Compare commits
3 Commits
v0.1.18
...
renovate/s
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f59bda937 | |||
| 1523f7be2c | |||
| b664c99944 |
@@ -12,7 +12,6 @@ import {
|
|||||||
type FocusEvent,
|
type FocusEvent,
|
||||||
type FocusEventHandler,
|
type FocusEventHandler,
|
||||||
type KeyboardEvent as ReactKeyboardEvent,
|
type KeyboardEvent as ReactKeyboardEvent,
|
||||||
type MutableRefObject,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type Ref,
|
type Ref,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -143,7 +142,7 @@ function createDateTimeFromPickerValue(value: PickerValue): Date {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startOfDay(value: Date): Date {
|
function startOfDay(value: Date): Date {
|
||||||
const candidate = new Date(value.getTime());
|
const candidate = new Date(value);
|
||||||
candidate.setHours(0, 0, 0, 0);
|
candidate.setHours(0, 0, 0, 0);
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
@@ -256,7 +255,7 @@ function assignRef(ref: Ref<HTMLInputElement> | undefined, node: HTMLInputElemen
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(ref as MutableRefObject<HTMLInputElement | null>).current = node;
|
(ref as { current: HTMLInputElement | null }).current = node;
|
||||||
}
|
}
|
||||||
|
|
||||||
function tokenizeFormat(format: string): RawFormatPart[] {
|
function tokenizeFormat(format: string): RawFormatPart[] {
|
||||||
@@ -398,9 +397,11 @@ function buildFormatConfigOrNull(type: DatePickerKind, format: string): FormatCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const segments = parts.filter((part): part is FormatSegment => part.type === 'segment');
|
const segments = parts.filter((part): part is FormatSegment => part.type === 'segment');
|
||||||
|
/* c8 ignore start -- validated token counts always yield at least one segment. */
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
@@ -421,9 +422,11 @@ function buildFormatConfig(type: DatePickerKind, requestedFormat?: string): Form
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fallback = buildFormatConfigOrNull(type, DEFAULT_FORMAT[type]);
|
const fallback = buildFormatConfigOrNull(type, DEFAULT_FORMAT[type]);
|
||||||
|
/* c8 ignore start -- static defaults are valid for all supported picker types. */
|
||||||
if (!fallback) {
|
if (!fallback) {
|
||||||
throw new Error('Failed to initialize DatePicker format configuration.');
|
throw new Error('Failed to initialize DatePicker format configuration.');
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
@@ -455,9 +458,11 @@ function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): Pic
|
|||||||
}
|
}
|
||||||
|
|
||||||
const numeric = Number(chunk);
|
const numeric = Number(chunk);
|
||||||
|
/* c8 ignore start -- numeric chunks are finite after /^\d+$/ validation. */
|
||||||
if (!Number.isFinite(numeric)) {
|
if (!Number.isFinite(numeric)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
if (part.kind === 'year') {
|
if (part.kind === 'year') {
|
||||||
year = numeric;
|
year = numeric;
|
||||||
@@ -473,12 +478,16 @@ function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): Pic
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.type !== 'time') {
|
if (config.type !== 'time') {
|
||||||
|
/* c8 ignore start -- date/month/year segments are guaranteed for non-time validated formats. */
|
||||||
if (year == null || month == null || day == null) {
|
if (year == null || month == null || day == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
/* c8 ignore start -- 'yyyy' token bounds parsed year to 0..9999. */
|
||||||
if (year < 0 || year > 9999) {
|
if (year < 0 || year > 9999) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
const parsedDate = createValidatedDate(year, month, day);
|
const parsedDate = createValidatedDate(year, month, day);
|
||||||
if (!parsedDate) {
|
if (!parsedDate) {
|
||||||
@@ -493,9 +502,11 @@ function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): Pic
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start -- date-time format validation guarantees hour/minute segments. */
|
||||||
if (hour == null || minute == null) {
|
if (hour == null || minute == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
return null;
|
return null;
|
||||||
@@ -508,9 +519,11 @@ function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): Pic
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start -- time format validation guarantees hour/minute segments. */
|
||||||
if (hour == null || minute == null) {
|
if (hour == null || minute == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
return null;
|
return null;
|
||||||
@@ -616,7 +629,11 @@ function isWithinRange(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applySegmentDigits(baseValue: PickerValue, kind: SegmentKind, digits: string): PickerValue {
|
function applySegmentDigits(
|
||||||
|
baseValue: PickerValue,
|
||||||
|
kind: SegmentKind,
|
||||||
|
digits: string,
|
||||||
|
): PickerValue {
|
||||||
const parsedDigits = Number(digits);
|
const parsedDigits = Number(digits);
|
||||||
if (!Number.isFinite(parsedDigits)) {
|
if (!Number.isFinite(parsedDigits)) {
|
||||||
return clonePickerValue(baseValue);
|
return clonePickerValue(baseValue);
|
||||||
@@ -644,7 +661,8 @@ function applySegmentDigits(baseValue: PickerValue, kind: SegmentKind, digits: s
|
|||||||
const maxDayForCurrentMonth = daysInMonth(year, month);
|
const maxDayForCurrentMonth = daysInMonth(year, month);
|
||||||
day = clampNumber(day, 1, maxDayForCurrentMonth);
|
day = clampNumber(day, 1, maxDayForCurrentMonth);
|
||||||
|
|
||||||
const nextDate = createValidatedDate(year, month, day) ?? createDateAtLocalMidnight(year, month - 1, day);
|
const nextDate =
|
||||||
|
createValidatedDate(year, month, day) ?? createDateAtLocalMidnight(year, month - 1, day);
|
||||||
return {
|
return {
|
||||||
date: nextDate,
|
date: nextDate,
|
||||||
hour,
|
hour,
|
||||||
@@ -744,6 +762,39 @@ function isHourSelectableForRange(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- test-only export of pure helpers.
|
||||||
|
export const __datePickerTestUtils = {
|
||||||
|
pad2,
|
||||||
|
pad4,
|
||||||
|
clampNumber,
|
||||||
|
createDateAtLocalMidnight,
|
||||||
|
createDateTimeFromPickerValue,
|
||||||
|
startOfDay,
|
||||||
|
startOfMonth,
|
||||||
|
isSameDay,
|
||||||
|
createValidatedDate,
|
||||||
|
daysInMonth,
|
||||||
|
clonePickerValue,
|
||||||
|
resolveLocale,
|
||||||
|
resolveWeekStart,
|
||||||
|
buildMonthGrid,
|
||||||
|
joinClassNames,
|
||||||
|
assignRef,
|
||||||
|
tokenizeFormat,
|
||||||
|
buildFormatConfigOrNull,
|
||||||
|
buildFormatConfig,
|
||||||
|
parsePickerValueWithFormat,
|
||||||
|
formatPickerValueWithFormat,
|
||||||
|
comparePickerValue,
|
||||||
|
normalizeRange,
|
||||||
|
clampPickerToRange,
|
||||||
|
isWithinRange,
|
||||||
|
applySegmentDigits,
|
||||||
|
findSegmentIndexByCaret,
|
||||||
|
isDateSelectableForRange,
|
||||||
|
isHourSelectableForRange,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export function DatePicker({
|
export function DatePicker({
|
||||||
label,
|
label,
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
@@ -770,9 +821,11 @@ export function DatePicker({
|
|||||||
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
|
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
const popupRef = useRef<HTMLDivElement | null>(null);
|
const popupRef = useRef<HTMLDivElement | null>(null);
|
||||||
const changeHandledRef = useRef(false);
|
const changeHandledRef = useRef(false);
|
||||||
const bufferedDigitsRef = useRef<{ segmentIndex: number; digits: string; timestamp: number } | null>(
|
const bufferedDigitsRef = useRef<{
|
||||||
null,
|
segmentIndex: number;
|
||||||
);
|
digits: string;
|
||||||
|
timestamp: number;
|
||||||
|
} | null>(null);
|
||||||
const activeSegmentIndexRef = useRef(0);
|
const activeSegmentIndexRef = useRef(0);
|
||||||
const pendingSelectionTimerRef = useRef<number | null>(null);
|
const pendingSelectionTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -897,9 +950,11 @@ export function DatePicker({
|
|||||||
const monthGrid = useMemo(() => buildMonthGrid(viewMonth, weekStart), [viewMonth, weekStart]);
|
const monthGrid = useMemo(() => buildMonthGrid(viewMonth, weekStart), [viewMonth, weekStart]);
|
||||||
|
|
||||||
const recalculatePopupPosition = useCallback(() => {
|
const recalculatePopupPosition = useCallback(() => {
|
||||||
if (!isOpen || !inputWrapperRef.current || !popupRef.current || typeof window === 'undefined') {
|
/* c8 ignore start -- guard protects partial mount/layout states that are not deterministic to unit test. */
|
||||||
|
if (!isOpen || !inputWrapperRef.current || !popupRef.current || !globalThis.window) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
const anchorRect = inputWrapperRef.current.getBoundingClientRect();
|
const anchorRect = inputWrapperRef.current.getBoundingClientRect();
|
||||||
const popupRect = popupRef.current.getBoundingClientRect();
|
const popupRect = popupRef.current.getBoundingClientRect();
|
||||||
@@ -920,7 +975,10 @@ export function DatePicker({
|
|||||||
? anchorRect.top - popupRect.height - POPUP_GAP
|
? anchorRect.top - popupRect.height - POPUP_GAP
|
||||||
: anchorRect.bottom + POPUP_GAP;
|
: anchorRect.bottom + POPUP_GAP;
|
||||||
|
|
||||||
top = Math.max(POPUP_MARGIN, Math.min(top, viewportHeight - POPUP_MARGIN - popupRect.height));
|
top = Math.max(
|
||||||
|
POPUP_MARGIN,
|
||||||
|
Math.min(top, viewportHeight - POPUP_MARGIN - popupRect.height),
|
||||||
|
);
|
||||||
|
|
||||||
setPopupPosition({
|
setPopupPosition({
|
||||||
top,
|
top,
|
||||||
@@ -937,9 +995,11 @@ export function DatePicker({
|
|||||||
|
|
||||||
recalculatePopupPosition();
|
recalculatePopupPosition();
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
/* c8 ignore start -- browser/window is always present in jsdom runtime. */
|
||||||
|
if (!globalThis.window) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
const handleWindowChange = () => {
|
const handleWindowChange = () => {
|
||||||
recalculatePopupPosition();
|
recalculatePopupPosition();
|
||||||
@@ -961,11 +1021,16 @@ export function DatePicker({
|
|||||||
|
|
||||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||||
const eventTarget = event.target as Node | null;
|
const eventTarget = event.target as Node | null;
|
||||||
|
/* c8 ignore start -- pointer events always provide a target in browser runtimes. */
|
||||||
if (!eventTarget) {
|
if (!eventTarget) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
if (popupRef.current?.contains(eventTarget) || inputWrapperRef.current?.contains(eventTarget)) {
|
if (
|
||||||
|
popupRef.current?.contains(eventTarget) ||
|
||||||
|
inputWrapperRef.current?.contains(eventTarget)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,7 +1057,7 @@ export function DatePicker({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (pendingSelectionTimerRef.current != null) {
|
if (pendingSelectionTimerRef.current != null) {
|
||||||
window.clearTimeout(pendingSelectionTimerRef.current);
|
globalThis.window.clearTimeout(pendingSelectionTimerRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
@@ -1000,27 +1065,33 @@ export function DatePicker({
|
|||||||
const selectSegment = useCallback(
|
const selectSegment = useCallback(
|
||||||
(segmentIndex: number) => {
|
(segmentIndex: number) => {
|
||||||
const segments = formatConfig.segments;
|
const segments = formatConfig.segments;
|
||||||
|
/* c8 ignore start -- format configuration always includes at least one segment. */
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
const clampedIndex = clampNumber(segmentIndex, 0, segments.length - 1);
|
const clampedIndex = clampNumber(segmentIndex, 0, segments.length - 1);
|
||||||
activeSegmentIndexRef.current = clampedIndex;
|
activeSegmentIndexRef.current = clampedIndex;
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
/* c8 ignore start -- browser/window is always present in jsdom runtime. */
|
||||||
|
if (!globalThis.window) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
if (pendingSelectionTimerRef.current != null) {
|
if (pendingSelectionTimerRef.current != null) {
|
||||||
window.clearTimeout(pendingSelectionTimerRef.current);
|
globalThis.window.clearTimeout(pendingSelectionTimerRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
pendingSelectionTimerRef.current = window.setTimeout(() => {
|
pendingSelectionTimerRef.current = globalThis.window.setTimeout(() => {
|
||||||
const inputNode = internalInputRef.current;
|
const inputNode = internalInputRef.current;
|
||||||
const targetSegment = segments[clampedIndex];
|
const targetSegment = segments[clampedIndex];
|
||||||
|
/* c8 ignore start -- input node and target segment are available while mounted. */
|
||||||
if (!inputNode || !targetSegment) {
|
if (!inputNode || !targetSegment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
inputNode.setSelectionRange(targetSegment.start, targetSegment.end);
|
inputNode.setSelectionRange(targetSegment.start, targetSegment.end);
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -1030,14 +1101,18 @@ export function DatePicker({
|
|||||||
|
|
||||||
const resolveCurrentSegmentIndex = useCallback(() => {
|
const resolveCurrentSegmentIndex = useCallback(() => {
|
||||||
const segments = formatConfig.segments;
|
const segments = formatConfig.segments;
|
||||||
|
/* c8 ignore start -- format configuration always includes at least one segment. */
|
||||||
if (segments.length === 0) {
|
if (segments.length === 0) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
const inputNode = internalInputRef.current;
|
const inputNode = internalInputRef.current;
|
||||||
|
/* c8 ignore start -- interactions only run when input node is mounted. */
|
||||||
if (!inputNode) {
|
if (!inputNode) {
|
||||||
return activeSegmentIndexRef.current;
|
return activeSegmentIndexRef.current;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
const index = findSegmentIndexByCaret(segments, inputNode.selectionStart);
|
const index = findSegmentIndexByCaret(segments, inputNode.selectionStart);
|
||||||
activeSegmentIndexRef.current = index;
|
activeSegmentIndexRef.current = index;
|
||||||
@@ -1067,9 +1142,11 @@ export function DatePicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const inputNode = internalInputRef.current;
|
const inputNode = internalInputRef.current;
|
||||||
|
/* c8 ignore start -- commits run only from active mounted input interactions. */
|
||||||
if (!inputNode) {
|
if (!inputNode) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
changeHandledRef.current = false;
|
changeHandledRef.current = false;
|
||||||
|
|
||||||
@@ -1105,11 +1182,14 @@ export function DatePicker({
|
|||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const segment = formatConfig.segments[segmentIndex];
|
const segment = formatConfig.segments[segmentIndex];
|
||||||
|
/* c8 ignore start -- segmentIndex is always resolved from known format segments. */
|
||||||
if (!segment) {
|
if (!segment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
const baseValue = parsePickerValueWithFormat(displayValue, formatConfig) ?? clampedSelectedValue;
|
const baseValue =
|
||||||
|
parsePickerValueWithFormat(displayValue, formatConfig) ?? clampedSelectedValue;
|
||||||
const nextUnclamped = applySegmentDigits(baseValue, segment.kind, digits);
|
const nextUnclamped = applySegmentDigits(baseValue, segment.kind, digits);
|
||||||
const nextClamped = clampPickerToRange(
|
const nextClamped = clampPickerToRange(
|
||||||
nextUnclamped,
|
nextUnclamped,
|
||||||
@@ -1145,7 +1225,7 @@ export function DatePicker({
|
|||||||
(segmentIndex: number, moveToNext: boolean) => {
|
(segmentIndex: number, moveToNext: boolean) => {
|
||||||
const buffered = bufferedDigitsRef.current;
|
const buffered = bufferedDigitsRef.current;
|
||||||
const segment = formatConfig.segments[segmentIndex];
|
const segment = formatConfig.segments[segmentIndex];
|
||||||
if (!buffered || buffered.segmentIndex !== segmentIndex || !segment) {
|
if (buffered?.segmentIndex !== segmentIndex || !segment) {
|
||||||
if (moveToNext) {
|
if (moveToNext) {
|
||||||
selectSegment(segmentIndex + 1);
|
selectSegment(segmentIndex + 1);
|
||||||
}
|
}
|
||||||
@@ -1159,9 +1239,11 @@ export function DatePicker({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const openPicker = useCallback(() => {
|
const openPicker = useCallback(() => {
|
||||||
|
/* c8 ignore start -- disabled inputs cannot trigger picker open interactions. */
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
if (type !== 'time') {
|
if (type !== 'time') {
|
||||||
setViewMonth(startOfMonth(selectedDate));
|
setViewMonth(startOfMonth(selectedDate));
|
||||||
}
|
}
|
||||||
@@ -1170,9 +1252,11 @@ export function DatePicker({
|
|||||||
}, [disabled, selectedDate, type]);
|
}, [disabled, selectedDate, type]);
|
||||||
|
|
||||||
const togglePicker = useCallback(() => {
|
const togglePicker = useCallback(() => {
|
||||||
|
/* c8 ignore start -- disabled icon button cannot trigger click events. */
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
closePicker();
|
closePicker();
|
||||||
return;
|
return;
|
||||||
@@ -1220,9 +1304,11 @@ export function DatePicker({
|
|||||||
hour: 0,
|
hour: 0,
|
||||||
minute: 0,
|
minute: 0,
|
||||||
};
|
};
|
||||||
|
/* c8 ignore start -- out-of-range date cells are disabled in the calendar UI. */
|
||||||
if (!isWithinRange(candidate, normalizedMinValue, normalizedMaxValue, type)) {
|
if (!isWithinRange(candidate, normalizedMinValue, normalizedMaxValue, type)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
commitValue(formatPickerValueWithFormat(candidate, formatConfig));
|
commitValue(formatPickerValueWithFormat(candidate, formatConfig));
|
||||||
closePicker();
|
closePicker();
|
||||||
@@ -1234,7 +1320,12 @@ export function DatePicker({
|
|||||||
hour: selectedHour,
|
hour: selectedHour,
|
||||||
minute: selectedMinute,
|
minute: selectedMinute,
|
||||||
};
|
};
|
||||||
const nextValue = clampPickerToRange(candidate, normalizedMinValue, normalizedMaxValue, type);
|
const nextValue = clampPickerToRange(
|
||||||
|
candidate,
|
||||||
|
normalizedMinValue,
|
||||||
|
normalizedMaxValue,
|
||||||
|
type,
|
||||||
|
);
|
||||||
commitValue(formatPickerValueWithFormat(nextValue, formatConfig));
|
commitValue(formatPickerValueWithFormat(nextValue, formatConfig));
|
||||||
setViewMonth(startOfMonth(normalizedDate));
|
setViewMonth(startOfMonth(normalizedDate));
|
||||||
},
|
},
|
||||||
@@ -1313,17 +1404,21 @@ export function DatePicker({
|
|||||||
}, [disabled, selectSegment]);
|
}, [disabled, selectSegment]);
|
||||||
|
|
||||||
const handleInputMouseUp = useCallback(() => {
|
const handleInputMouseUp = useCallback(() => {
|
||||||
|
/* c8 ignore start -- disabled inputs do not emit mouse interaction events. */
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
selectSegment(resolveCurrentSegmentIndex());
|
selectSegment(resolveCurrentSegmentIndex());
|
||||||
}, [disabled, resolveCurrentSegmentIndex, selectSegment]);
|
}, [disabled, resolveCurrentSegmentIndex, selectSegment]);
|
||||||
|
|
||||||
const handleInputClick = useCallback(() => {
|
const handleInputClick = useCallback(() => {
|
||||||
|
/* c8 ignore start -- disabled inputs do not emit click interaction events. */
|
||||||
if (disabled) {
|
if (disabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
selectSegment(resolveCurrentSegmentIndex());
|
selectSegment(resolveCurrentSegmentIndex());
|
||||||
}, [disabled, resolveCurrentSegmentIndex, selectSegment]);
|
}, [disabled, resolveCurrentSegmentIndex, selectSegment]);
|
||||||
@@ -1341,7 +1436,12 @@ export function DatePicker({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const clamped = clampPickerToRange(parsed, normalizedMinValue, normalizedMaxValue, type);
|
const clamped = clampPickerToRange(
|
||||||
|
parsed,
|
||||||
|
normalizedMinValue,
|
||||||
|
normalizedMaxValue,
|
||||||
|
type,
|
||||||
|
);
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
commitValue(formatPickerValueWithFormat(clamped, formatConfig));
|
commitValue(formatPickerValueWithFormat(clamped, formatConfig));
|
||||||
},
|
},
|
||||||
@@ -1368,9 +1468,11 @@ export function DatePicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const segment = formatConfig.segments[segmentIndex];
|
const segment = formatConfig.segments[segmentIndex];
|
||||||
|
/* c8 ignore start -- segment index resolution always maps to a valid segment. */
|
||||||
if (!segment) {
|
if (!segment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
if (key === 'ArrowDown' && !isOpen) {
|
if (key === 'ArrowDown' && !isOpen) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -1437,11 +1539,13 @@ export function DatePicker({
|
|||||||
digits = buffered.digits;
|
digits = buffered.digits;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* c8 ignore start -- buffered digits are reset on segment completion. */
|
||||||
if (digits.length >= segment.length) {
|
if (digits.length >= segment.length) {
|
||||||
digits = key;
|
digits = key;
|
||||||
} else {
|
} else {
|
||||||
digits += key;
|
digits += key;
|
||||||
}
|
}
|
||||||
|
/* c8 ignore stop */
|
||||||
|
|
||||||
bufferedDigitsRef.current = {
|
bufferedDigitsRef.current = {
|
||||||
segmentIndex,
|
segmentIndex,
|
||||||
@@ -1663,7 +1767,11 @@ export function DatePicker({
|
|||||||
<div className="datepicker-time-root">
|
<div className="datepicker-time-root">
|
||||||
<div className="datepicker-time-column">
|
<div className="datepicker-time-column">
|
||||||
<span className="datepicker-time-title">Hours</span>
|
<span className="datepicker-time-title">Hours</span>
|
||||||
<div className="datepicker-time-list" role="listbox" aria-label="Hours">
|
<div
|
||||||
|
className="datepicker-time-list"
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Hours"
|
||||||
|
>
|
||||||
{HOURS.map((hour) => {
|
{HOURS.map((hour) => {
|
||||||
const hourDisabled = !isHourSelectableForRange(
|
const hourDisabled = !isHourSelectableForRange(
|
||||||
selectedDate,
|
selectedDate,
|
||||||
@@ -1717,7 +1825,8 @@ export function DatePicker({
|
|||||||
type="button"
|
type="button"
|
||||||
className={joinClassNames(
|
className={joinClassNames(
|
||||||
'datepicker-time-option',
|
'datepicker-time-option',
|
||||||
minute === selectedMinute && 'is-selected',
|
minute === selectedMinute &&
|
||||||
|
'is-selected',
|
||||||
)}
|
)}
|
||||||
onClick={() => handleMinuteCommit(minute)}
|
onClick={() => handleMinuteCommit(minute)}
|
||||||
disabled={minuteDisabled}
|
disabled={minuteDisabled}
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ describe('Button', () => {
|
|||||||
expect(screen.getByRole('button', { name: 'Details' })).toHaveClass('btn-secondary');
|
expect(screen.getByRole('button', { name: 'Details' })).toHaveClass('btn-secondary');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses explicit variant when provided', () => {
|
||||||
|
render(<Button label="Danger" type="outlined" variant="important" />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: 'Danger' })).toHaveClass('btn-important');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders icon-only button and custom aria label', () => {
|
it('renders icon-only button and custom aria label', () => {
|
||||||
render(<Button type="solid" icon={HomeIcon} ariaLabel="Open home" />);
|
render(<Button type="solid" icon={HomeIcon} ariaLabel="Open home" />);
|
||||||
|
|
||||||
|
|||||||
@@ -51,4 +51,12 @@ describe('Chip', () => {
|
|||||||
rerender(<Chip tone=" ">Blank</Chip>);
|
rerender(<Chip tone=" ">Blank</Chip>);
|
||||||
expect(screen.getByText('Blank').getAttribute('style')).toBeNull();
|
expect(screen.getByText('Blank').getAttribute('style')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('ignores unresolved direct palettes and unknown shades', () => {
|
||||||
|
const { rerender } = render(<Chip tone="indigo">Palette token</Chip>);
|
||||||
|
expect(screen.getByText('Palette token').getAttribute('style')).toBeNull();
|
||||||
|
|
||||||
|
rerender(<Chip tone="indigo-999">Unknown shade</Chip>);
|
||||||
|
expect(screen.getByText('Unknown shade').getAttribute('style')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
428
tests/components/DatePicker.logic.test.ts
Normal file
428
tests/components/DatePicker.logic.test.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { __datePickerTestUtils as utils } from '../../src/components/DatePicker';
|
||||||
|
|
||||||
|
type GlobalDescriptor = PropertyDescriptor | undefined;
|
||||||
|
|
||||||
|
function setGlobalProperty(name: string, descriptor: PropertyDescriptor): GlobalDescriptor {
|
||||||
|
const key = name as keyof typeof globalThis;
|
||||||
|
const original = Object.getOwnPropertyDescriptor(globalThis, key);
|
||||||
|
Object.defineProperty(globalThis, key, descriptor);
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreGlobalProperty(name: string, original: GlobalDescriptor): void {
|
||||||
|
const key = name as keyof typeof globalThis;
|
||||||
|
if (original) {
|
||||||
|
Object.defineProperty(globalThis, key, original);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Reflect.deleteProperty(globalThis, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DatePicker logic helpers', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats and clamps numeric values', () => {
|
||||||
|
expect(utils.pad2(3)).toBe('03');
|
||||||
|
expect(utils.pad4(12)).toBe('0012');
|
||||||
|
expect(utils.clampNumber(99, 0, 50)).toBe(50);
|
||||||
|
expect(utils.clampNumber(-1, 0, 50)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates calendar dates strictly', () => {
|
||||||
|
expect(utils.createValidatedDate(2026, 2, 29)).toBeNull();
|
||||||
|
expect(utils.createValidatedDate(2028, 2, 29)).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves locale with and without navigator', () => {
|
||||||
|
const originalNavigator = setGlobalProperty('navigator', {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
expect(utils.resolveLocale()).toBe('en-US');
|
||||||
|
restoreGlobalProperty('navigator', originalNavigator);
|
||||||
|
|
||||||
|
const locale = utils.resolveLocale();
|
||||||
|
expect(typeof locale).toBe('string');
|
||||||
|
expect(locale.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves week start across locale and fallback branches', () => {
|
||||||
|
const originalIntl = setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: undefined,
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
weekInfo = { firstDay: 2 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('de-DE')).toBe(2);
|
||||||
|
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
weekInfo = { firstDay: 7 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
weekInfo = { firstDay: 0 };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
weekInfo = { firstDay: 'x' as unknown as number };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
|
||||||
|
setGlobalProperty('Intl', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
Locale: class MockLocale {
|
||||||
|
constructor() {
|
||||||
|
throw new Error('boom');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(utils.resolveWeekStart('en-US')).toBe(0);
|
||||||
|
|
||||||
|
restoreGlobalProperty('Intl', originalIntl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds and validates format configuration', () => {
|
||||||
|
expect(utils.buildFormatConfigOrNull('date-time', '')).toBeNull();
|
||||||
|
expect(utils.buildFormatConfigOrNull('date-time', 'yyyy/mm/dd HH')).toBeNull();
|
||||||
|
|
||||||
|
const config = utils.buildFormatConfigOrNull('date-time', 'dd/mm/yyyy HH:mm');
|
||||||
|
expect(config).not.toBeNull();
|
||||||
|
expect(config?.segments).toHaveLength(5);
|
||||||
|
expect(config?.totalLength).toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to default format when requested format is invalid', () => {
|
||||||
|
const config = utils.buildFormatConfig('date-time', 'yyyy/mm/dd');
|
||||||
|
expect(config.format).toBe('yyyy/mm/dd HH:mm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses valid values and rejects invalid input', () => {
|
||||||
|
const dateTimeConfig = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
|
||||||
|
expect(utils.parsePickerValueWithFormat('22/02/2026 14:30', dateTimeConfig)).toEqual({
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 22),
|
||||||
|
hour: 14,
|
||||||
|
minute: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(utils.parsePickerValueWithFormat('2/2/2026 14:30', dateTimeConfig)).toBeNull();
|
||||||
|
expect(utils.parsePickerValueWithFormat('22-02-2026 14:30', dateTimeConfig)).toBeNull();
|
||||||
|
expect(utils.parsePickerValueWithFormat('aa/02/2026 14:30', dateTimeConfig)).toBeNull();
|
||||||
|
expect(utils.parsePickerValueWithFormat('31/02/2026 14:30', dateTimeConfig)).toBeNull();
|
||||||
|
expect(utils.parsePickerValueWithFormat('22/02/2026 24:30', dateTimeConfig)).toBeNull();
|
||||||
|
|
||||||
|
const timeConfig = utils.buildFormatConfig('time', 'HH:mm');
|
||||||
|
expect(utils.parsePickerValueWithFormat('09:00', timeConfig)).toEqual({
|
||||||
|
date: utils.startOfDay(new Date()),
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
});
|
||||||
|
expect(utils.parsePickerValueWithFormat('09:77', timeConfig)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats picker values with the chosen configuration', () => {
|
||||||
|
const config = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
|
||||||
|
const text = utils.formatPickerValueWithFormat(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2027, 2, 11),
|
||||||
|
hour: 6,
|
||||||
|
minute: 5,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(text).toBe('11/03/2027 06:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compares values by picker type', () => {
|
||||||
|
const date = utils.comparePickerValue(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 23,
|
||||||
|
minute: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 2),
|
||||||
|
hour: 1,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
'date',
|
||||||
|
);
|
||||||
|
expect(date).toBeLessThan(0);
|
||||||
|
|
||||||
|
const time = utils.comparePickerValue(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 8,
|
||||||
|
minute: 59,
|
||||||
|
},
|
||||||
|
'time',
|
||||||
|
);
|
||||||
|
expect(time).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const dateTime = utils.comparePickerValue(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 1,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 1),
|
||||||
|
hour: 1,
|
||||||
|
minute: 1,
|
||||||
|
},
|
||||||
|
'date-time',
|
||||||
|
);
|
||||||
|
expect(dateTime).toBeLessThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes and enforces picker ranges', () => {
|
||||||
|
const min = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 9,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
const max = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 10,
|
||||||
|
minute: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalized = utils.normalizeRange(max, min, 'date-time');
|
||||||
|
expect(normalized.minValue).toEqual(min);
|
||||||
|
expect(normalized.maxValue).toEqual(max);
|
||||||
|
|
||||||
|
const below = utils.clampPickerToRange(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 8,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
'date-time',
|
||||||
|
);
|
||||||
|
expect(below).toEqual(min);
|
||||||
|
|
||||||
|
const above = utils.clampPickerToRange(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 10,
|
||||||
|
minute: 30,
|
||||||
|
},
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
'date-time',
|
||||||
|
);
|
||||||
|
expect(above).toEqual(max);
|
||||||
|
|
||||||
|
expect(utils.isWithinRange(min, min, max, 'date-time')).toBe(true);
|
||||||
|
expect(
|
||||||
|
utils.isWithinRange(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 7,
|
||||||
|
minute: 59,
|
||||||
|
},
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
'date-time',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
utils.isWithinRange(
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 10,
|
||||||
|
minute: 1,
|
||||||
|
},
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
'date-time',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies segment edits across year, month, day, hour and minute', () => {
|
||||||
|
const base = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 1, 28),
|
||||||
|
hour: 14,
|
||||||
|
minute: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(utils.applySegmentDigits(base, 'year', '0001').date.getFullYear()).toBe(1);
|
||||||
|
expect(utils.applySegmentDigits(base, 'month', '13').date.getMonth()).toBe(11);
|
||||||
|
expect(utils.applySegmentDigits(base, 'day', '99').date.getDate()).toBe(28);
|
||||||
|
expect(utils.applySegmentDigits(base, 'hour', '88').hour).toBe(23);
|
||||||
|
expect(utils.applySegmentDigits(base, 'minute', '99').minute).toBe(59);
|
||||||
|
|
||||||
|
const unchanged = utils.applySegmentDigits(base, 'day', 'not-a-number');
|
||||||
|
expect(unchanged).toEqual(base);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves segment index from caret position', () => {
|
||||||
|
const config = utils.buildFormatConfig('date-time', 'dd/mm/yyyy HH:mm');
|
||||||
|
|
||||||
|
expect(utils.findSegmentIndexByCaret([], 3)).toBe(0);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, null)).toBe(0);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, 0)).toBe(0);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, 6)).toBe(2);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, 99)).toBe(
|
||||||
|
config.segments[config.segments.length - 1].segmentIndex,
|
||||||
|
);
|
||||||
|
expect(utils.findSegmentIndexByCaret(config.segments, 2)).toBe(0);
|
||||||
|
|
||||||
|
const nonStandardSegments = [
|
||||||
|
{
|
||||||
|
type: 'segment',
|
||||||
|
kind: 'day',
|
||||||
|
token: 'dd',
|
||||||
|
length: 2,
|
||||||
|
start: 5,
|
||||||
|
end: 7,
|
||||||
|
segmentIndex: 0,
|
||||||
|
},
|
||||||
|
] as unknown as Parameters<typeof utils.findSegmentIndexByCaret>[0];
|
||||||
|
expect(utils.findSegmentIndexByCaret(nonStandardSegments, 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks date and hour selectability inside constrained ranges', () => {
|
||||||
|
const min = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 9,
|
||||||
|
minute: 30,
|
||||||
|
};
|
||||||
|
const max = {
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 10,
|
||||||
|
minute: 15,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
utils.isDateSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 9),
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
utils.isDateSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
utils.isDateSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 11),
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
utils.isDateSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 9),
|
||||||
|
'date',
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 0,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
hour: 0,
|
||||||
|
minute: 0,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
utils.isHourSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
8,
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
utils.isHourSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
9,
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
utils.isHourSelectableForRange(
|
||||||
|
utils.createDateAtLocalMidnight(2026, 2, 10),
|
||||||
|
11,
|
||||||
|
'date-time',
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds month grids and joins class names', () => {
|
||||||
|
const month = utils.createDateAtLocalMidnight(2026, 2, 10);
|
||||||
|
const grid = utils.buildMonthGrid(month, 1);
|
||||||
|
expect(grid).toHaveLength(42);
|
||||||
|
expect(grid[0]).toBeInstanceOf(Date);
|
||||||
|
expect(grid[41]).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
expect(utils.joinClassNames('a', false, 'b', undefined, '', null, 'c')).toBe('a b c');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns refs for callback and object refs', () => {
|
||||||
|
const callback = vi.fn();
|
||||||
|
const objectRef = { current: null as HTMLInputElement | null };
|
||||||
|
const node = document.createElement('input');
|
||||||
|
|
||||||
|
utils.assignRef(callback, node);
|
||||||
|
utils.assignRef(objectRef, node);
|
||||||
|
utils.assignRef(undefined, node);
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(node);
|
||||||
|
expect(objectRef.current).toBe(node);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import { useState } from 'react';
|
import { createRef, type FocusEvent as ReactFocusEvent, useState } from 'react';
|
||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { DatePicker } from '../../src/components/DatePicker';
|
import { DatePicker } from '../../src/components/DatePicker';
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ type ControlledProps = {
|
|||||||
min?: string;
|
min?: string;
|
||||||
max?: string;
|
max?: string;
|
||||||
onValueChange?: (value: string) => void;
|
onValueChange?: (value: string) => void;
|
||||||
|
onBlur?: (event: ReactFocusEvent<HTMLInputElement>) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ function ControlledDatePicker({
|
|||||||
min,
|
min,
|
||||||
max,
|
max,
|
||||||
onValueChange,
|
onValueChange,
|
||||||
|
onBlur,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: Readonly<ControlledProps>) {
|
}: Readonly<ControlledProps>) {
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
@@ -33,6 +35,7 @@ function ControlledDatePicker({
|
|||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
onBlur={onBlur}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setValue(event.target.value);
|
setValue(event.target.value);
|
||||||
onValueChange?.(event.target.value);
|
onValueChange?.(event.target.value);
|
||||||
@@ -64,6 +67,30 @@ function getCurrentMonthDayButton(label: string): HTMLButtonElement {
|
|||||||
return targetButton;
|
return targetButton;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPasteEvent(text: string): Event {
|
||||||
|
const event = new Event('paste', { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(event, 'clipboardData', {
|
||||||
|
value: {
|
||||||
|
getData: () => text,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRect(left: number, top: number, width: number, height: number): DOMRect {
|
||||||
|
return {
|
||||||
|
x: left,
|
||||||
|
y: top,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
right: left + width,
|
||||||
|
bottom: top + height,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
} as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
describe('DatePicker', () => {
|
describe('DatePicker', () => {
|
||||||
it('opens popup from icon button and closes with Escape', () => {
|
it('opens popup from icon button and closes with Escape', () => {
|
||||||
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
|
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
|
||||||
@@ -80,6 +107,12 @@ describe('DatePicker', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
|
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Close date picker' }));
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports segment-by-segment editing with custom format and auto-advance', async () => {
|
it('supports segment-by-segment editing with custom format and auto-advance', async () => {
|
||||||
@@ -288,6 +321,25 @@ describe('DatePicker', () => {
|
|||||||
expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('dialog', { name: 'Date picker popup' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('commits calendar date changes in date-time mode without closing the popup', () => {
|
||||||
|
const onValueChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
initialValue="2031/05/20 14:30"
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
pickCurrentMonthDay('15');
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
expect(input.value).toMatch(/^\d{4}\/\d{2}\/15 14:30$/);
|
||||||
|
expect(onValueChange).toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('renders time mode selectors only and keeps popup open after selection', () => {
|
it('renders time mode selectors only and keeps popup open after selection', () => {
|
||||||
const onValueChange = vi.fn();
|
const onValueChange = vi.fn();
|
||||||
render(<ControlledDatePicker type="time" initialValue="09:00" onValueChange={onValueChange} />);
|
render(<ControlledDatePicker type="time" initialValue="09:00" onValueChange={onValueChange} />);
|
||||||
@@ -344,6 +396,37 @@ describe('DatePicker', () => {
|
|||||||
expect(screen.getByText('Invalid date')).toBeInTheDocument();
|
expect(screen.getByText('Invalid date')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards inputRef for callback and object refs', () => {
|
||||||
|
const callbackRef = vi.fn();
|
||||||
|
const objectRef = createRef<HTMLInputElement>();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
value="2031/05/20 14:30"
|
||||||
|
onChange={() => {}}
|
||||||
|
inputRef={callbackRef}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(callbackRef).toHaveBeenCalled();
|
||||||
|
const callbackNode = callbackRef.mock.calls.find(([node]) => node instanceof HTMLInputElement)?.[0];
|
||||||
|
expect(callbackNode).toBeInstanceOf(HTMLInputElement);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
value="2031/05/20 14:30"
|
||||||
|
onChange={() => {}}
|
||||||
|
inputRef={objectRef}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(objectRef.current).toBeInstanceOf(HTMLInputElement);
|
||||||
|
});
|
||||||
|
|
||||||
it('supports inline layout', () => {
|
it('supports inline layout', () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<DatePicker label="Start time" type="time" value="09:00" onChange={() => {}} layout="inline" />,
|
<DatePicker label="Start time" type="time" value="09:00" onChange={() => {}} layout="inline" />,
|
||||||
@@ -352,4 +435,306 @@ describe('DatePicker', () => {
|
|||||||
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
||||||
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
|
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles outside vs inside pointer interactions for popup close behavior', () => {
|
||||||
|
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' });
|
||||||
|
|
||||||
|
fireEvent.mouseDown(dialog);
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(document.body);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports month/year chooser interactions and month navigation buttons', async () => {
|
||||||
|
render(<DatePicker label="Schedule" type="date" value="2031/05/20" onChange={() => {}} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
|
||||||
|
|
||||||
|
const chooserButtons = document.querySelectorAll('.datepicker-chooser-btn');
|
||||||
|
const monthChooserButton = chooserButtons[0] as HTMLButtonElement;
|
||||||
|
const yearChooserButton = chooserButtons[1] as HTMLButtonElement;
|
||||||
|
|
||||||
|
const initialMonthText = monthChooserButton.textContent;
|
||||||
|
fireEvent.click(monthChooserButton);
|
||||||
|
const monthList = screen.getByRole('listbox', { name: 'Choose month' });
|
||||||
|
const monthOptions = within(monthList).getAllByRole('button');
|
||||||
|
const nextMonth = monthOptions.find((option) => option.textContent !== initialMonthText) ?? monthOptions[0];
|
||||||
|
fireEvent.click(nextMonth);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('listbox', { name: 'Choose month' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect((document.querySelectorAll('.datepicker-chooser-btn')[0] as HTMLButtonElement).textContent).toBe(
|
||||||
|
nextMonth.textContent,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialYearText = yearChooserButton.textContent;
|
||||||
|
fireEvent.click(yearChooserButton);
|
||||||
|
const yearList = screen.getByRole('listbox', { name: 'Choose year' });
|
||||||
|
const yearOptions = within(yearList).getAllByRole('button');
|
||||||
|
const nextYear = yearOptions.find((option) => option.textContent !== initialYearText) ?? yearOptions[0];
|
||||||
|
fireEvent.click(nextYear);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole('listbox', { name: 'Choose year' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect((document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement).textContent).toBe(
|
||||||
|
nextYear.textContent,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('guards month navigation at absolute calendar boundaries', () => {
|
||||||
|
const first = render(
|
||||||
|
<DatePicker label="Schedule" type="date" value="0000/01/15" onChange={() => {}} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Previous month' }));
|
||||||
|
const lowerYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement;
|
||||||
|
expect(lowerYearButton.textContent).toBe('0');
|
||||||
|
|
||||||
|
first.unmount();
|
||||||
|
|
||||||
|
render(<DatePicker label="Schedule" type="date" value="9999/12/15" onChange={() => {}} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Next month' }));
|
||||||
|
const upperYearButton = document.querySelectorAll('.datepicker-chooser-btn')[1] as HTMLButtonElement;
|
||||||
|
expect(upperYearButton.textContent).toBe('9999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports keyboard navigation commands and segment reset shortcuts', async () => {
|
||||||
|
const onBlur = vi.fn();
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
initialValue="22/02/2026 14:30"
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
fireEvent.focus(input);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.selectionStart).toBe(0);
|
||||||
|
expect(input.selectionEnd).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '1' });
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowRight' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value.startsWith('01/')).toBe(true);
|
||||||
|
expect(input.selectionStart).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.mouseUp(input);
|
||||||
|
fireEvent.click(input);
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowRight' });
|
||||||
|
await waitFor(() => expect(input.selectionStart).toBe(3));
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowLeft' });
|
||||||
|
await waitFor(() => expect((input.selectionStart ?? 0) <= 3).toBe(true));
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'Tab' });
|
||||||
|
await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true));
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'Tab', shiftKey: true });
|
||||||
|
await waitFor(() => expect((input.selectionStart ?? 0) >= 0).toBe(true));
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'Backspace' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value.startsWith('01/')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '/' });
|
||||||
|
await waitFor(() => expect(input.selectionStart).toBe(3));
|
||||||
|
fireEvent.keyDown(input, { key: 'Delete' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(input.value.slice(3, 5)).toBe('01');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: '/' });
|
||||||
|
await waitFor(() => expect(input.selectionStart).toBe(6));
|
||||||
|
fireEvent.keyDown(input, { key: 'Delete' });
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: ' ' });
|
||||||
|
fireEvent.keyDown(input, { key: 'Delete' });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(/\s00:|:00$/.test(input.value)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
fireEvent.keyDown(input, { key: 'x' });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
expect(onBlur).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles paste validation and value commits', async () => {
|
||||||
|
render(
|
||||||
|
<ControlledDatePicker
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
initialValue="22/02/2026 14:30"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input.dispatchEvent(createPasteEvent('invalid value'));
|
||||||
|
});
|
||||||
|
expect(input.value).toBe('22/02/2026 14:30');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
input.dispatchEvent(createPasteEvent('11/03/2027 16:45'));
|
||||||
|
});
|
||||||
|
expect(input.value).toBe('11/03/2027 16:45');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns early from interaction handlers when disabled', () => {
|
||||||
|
render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
value="22/02/2026 14:30"
|
||||||
|
disabled
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.focus(input);
|
||||||
|
fireEvent.mouseUp(input);
|
||||||
|
fireEvent.click(input);
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
|
||||||
|
const disabledPaste = createPasteEvent('11/03/2027 16:45');
|
||||||
|
input.dispatchEvent(disabledPaste);
|
||||||
|
expect(disabledPaste.defaultPrevented).toBe(false);
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to direct assignment and synthetic onChange when native dispatch is stubbed', async () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const originalGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor;
|
||||||
|
|
||||||
|
render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
value="22/02/2026 14:30"
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
const nativeDispatchEvent = input.dispatchEvent.bind(input);
|
||||||
|
vi.spyOn(input, 'dispatchEvent').mockImplementation((event: Event) => {
|
||||||
|
if (event.type === 'change') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return nativeDispatchEvent(event);
|
||||||
|
});
|
||||||
|
vi.spyOn(Object, 'getOwnPropertyDescriptor').mockImplementation((target, property) => {
|
||||||
|
if (target === HTMLInputElement.prototype && property === 'value') {
|
||||||
|
return {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get: originalGetOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.get,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalGetOwnPropertyDescriptor(target, property);
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.focus(input);
|
||||||
|
input.setSelectionRange(0, 2);
|
||||||
|
fireEvent.keyDown(input, { key: '1' });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('safely handles pending segment selection during unmount', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<DatePicker
|
||||||
|
label="Schedule"
|
||||||
|
type="date-time"
|
||||||
|
format="dd/mm/yyyy HH:mm"
|
||||||
|
value="22/02/2026 14:30"
|
||||||
|
onChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = screen.getByLabelText('Schedule') as HTMLInputElement;
|
||||||
|
fireEvent.focus(input);
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
vi.runAllTimers();
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recalculates popup position on window resize and scroll', async () => {
|
||||||
|
const originalInnerWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
|
||||||
|
const originalInnerHeight = Object.getOwnPropertyDescriptor(window, 'innerHeight');
|
||||||
|
const rectSpy = vi
|
||||||
|
.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
|
||||||
|
.mockImplementation(function mockRect(this: HTMLElement) {
|
||||||
|
if (this.classList.contains('datepicker-popup')) {
|
||||||
|
return createRect(0, 0, 300, 200);
|
||||||
|
}
|
||||||
|
if (this.classList.contains('relative') && this.querySelector('input')) {
|
||||||
|
return createRect(500, 80, 120, 40);
|
||||||
|
}
|
||||||
|
return createRect(0, 0, 100, 30);
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 });
|
||||||
|
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 480 });
|
||||||
|
|
||||||
|
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Open date picker' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Date and time picker popup' });
|
||||||
|
expect(dialog).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(dialog.style.left).toBe('292px');
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent(window, new Event('resize'));
|
||||||
|
fireEvent(window, new Event('scroll'));
|
||||||
|
expect(screen.getByRole('dialog', { name: 'Date and time picker popup' })).toBeInTheDocument();
|
||||||
|
expect(dialog.style.left).toBe('292px');
|
||||||
|
|
||||||
|
rectSpy.mockRestore();
|
||||||
|
if (originalInnerWidth) {
|
||||||
|
Object.defineProperty(window, 'innerWidth', originalInnerWidth);
|
||||||
|
}
|
||||||
|
if (originalInnerHeight) {
|
||||||
|
Object.defineProperty(window, 'innerHeight', originalInnerHeight);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,4 +52,9 @@ describe('Dropdown', () => {
|
|||||||
expect(select).toHaveClass('custom-select');
|
expect(select).toHaveClass('custom-select');
|
||||||
expect(screen.getByText('Role').closest('label')).toHaveClass('custom-wrapper');
|
expect(screen.getByText('Role').closest('label')).toHaveClass('custom-wrapper');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports rendering without a label', () => {
|
||||||
|
const { container } = render(<Dropdown value="USER" choices={choices} />);
|
||||||
|
expect(container.querySelector('label > span')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -89,4 +89,9 @@ describe('InputField', () => {
|
|||||||
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
expect(container.querySelector('label')).toHaveClass('inline-flex');
|
||||||
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
|
expect(container.querySelector('label > div')).not.toHaveClass('mt-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports rendering without a label', () => {
|
||||||
|
const { container } = render(<InputField type="text" value="" onChange={() => {}} />);
|
||||||
|
expect(container.querySelector('label > span')).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1890,9 +1890,9 @@
|
|||||||
integrity sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==
|
integrity sha512-Gfkvwk9o9kE9r9XNBmJRfV8zONvXThnm1tcuojL04Uy5uRyqg93DC83lDebl0rocZCfKSjUv+fWYtMQmEDJldg==
|
||||||
|
|
||||||
"@storybook/addon-a11y@^10.2.10":
|
"@storybook/addon-a11y@^10.2.10":
|
||||||
version "10.2.10"
|
version "10.2.12"
|
||||||
resolved "https://nexus.beatrice.wtf/repository/npm-group/@storybook/addon-a11y/-/addon-a11y-10.2.10.tgz#8c80cdcce50eecc52aa7b0fec69dd2bcb92eef98"
|
resolved "https://nexus.beatrice.wtf/repository/npm-group/@storybook/addon-a11y/-/addon-a11y-10.2.12.tgz#d316b83772ead018fa6fa1bf548f48c61b6a0d88"
|
||||||
integrity sha512-1S9pDXgvbHhBStGarCvfJ3/rfcaiAcQHRhuM3Nk4WGSIYtC1LCSRuzYdDYU0aNRpdCbCrUA7kUCbqvIE3tH+3Q==
|
integrity sha512-UFJU9cOqaOLcb6WnO/y0urB6EUqPCxtNvIsiJ1MviCm9IqV+b3Y84NFxWYvYSBuYzqt89DKdh19edP6aVaLe6w==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@storybook/global" "^5.0.0"
|
"@storybook/global" "^5.0.0"
|
||||||
axe-core "^4.2.0"
|
axe-core "^4.2.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user