Compare commits
3 Commits
v0.1.18
...
renovate/r
| Author | SHA1 | Date | |
|---|---|---|---|
| 514b643566 | |||
| 1523f7be2c | |||
| b664c99944 |
@@ -12,7 +12,6 @@ import {
|
||||
type FocusEvent,
|
||||
type FocusEventHandler,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MutableRefObject,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
useCallback,
|
||||
@@ -143,7 +142,7 @@ function createDateTimeFromPickerValue(value: PickerValue): Date {
|
||||
}
|
||||
|
||||
function startOfDay(value: Date): Date {
|
||||
const candidate = new Date(value.getTime());
|
||||
const candidate = new Date(value);
|
||||
candidate.setHours(0, 0, 0, 0);
|
||||
return candidate;
|
||||
}
|
||||
@@ -256,7 +255,7 @@ function assignRef(ref: Ref<HTMLInputElement> | undefined, node: HTMLInputElemen
|
||||
return;
|
||||
}
|
||||
|
||||
(ref as MutableRefObject<HTMLInputElement | null>).current = node;
|
||||
(ref as { current: HTMLInputElement | null }).current = node;
|
||||
}
|
||||
|
||||
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');
|
||||
/* c8 ignore start -- validated token counts always yield at least one segment. */
|
||||
if (segments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
return {
|
||||
type,
|
||||
@@ -421,9 +422,11 @@ function buildFormatConfig(type: DatePickerKind, requestedFormat?: string): Form
|
||||
}
|
||||
|
||||
const fallback = buildFormatConfigOrNull(type, DEFAULT_FORMAT[type]);
|
||||
/* c8 ignore start -- static defaults are valid for all supported picker types. */
|
||||
if (!fallback) {
|
||||
throw new Error('Failed to initialize DatePicker format configuration.');
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
return fallback;
|
||||
}
|
||||
@@ -455,9 +458,11 @@ function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): Pic
|
||||
}
|
||||
|
||||
const numeric = Number(chunk);
|
||||
/* c8 ignore start -- numeric chunks are finite after /^\d+$/ validation. */
|
||||
if (!Number.isFinite(numeric)) {
|
||||
return null;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
if (part.kind === 'year') {
|
||||
year = numeric;
|
||||
@@ -473,12 +478,16 @@ function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): Pic
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
/* c8 ignore start -- 'yyyy' token bounds parsed year to 0..9999. */
|
||||
if (year < 0 || year > 9999) {
|
||||
return null;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
const parsedDate = createValidatedDate(year, month, day);
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||
return null;
|
||||
@@ -616,7 +629,11 @@ function isWithinRange(
|
||||
return true;
|
||||
}
|
||||
|
||||
function applySegmentDigits(baseValue: PickerValue, kind: SegmentKind, digits: string): PickerValue {
|
||||
function applySegmentDigits(
|
||||
baseValue: PickerValue,
|
||||
kind: SegmentKind,
|
||||
digits: string,
|
||||
): PickerValue {
|
||||
const parsedDigits = Number(digits);
|
||||
if (!Number.isFinite(parsedDigits)) {
|
||||
return clonePickerValue(baseValue);
|
||||
@@ -644,7 +661,8 @@ function applySegmentDigits(baseValue: PickerValue, kind: SegmentKind, digits: s
|
||||
const maxDayForCurrentMonth = daysInMonth(year, month);
|
||||
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 {
|
||||
date: nextDate,
|
||||
hour,
|
||||
@@ -744,6 +762,39 @@ function isHourSelectableForRange(
|
||||
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({
|
||||
label,
|
||||
placeholder = '',
|
||||
@@ -770,9 +821,11 @@ export function DatePicker({
|
||||
const inputWrapperRef = useRef<HTMLDivElement | null>(null);
|
||||
const popupRef = useRef<HTMLDivElement | null>(null);
|
||||
const changeHandledRef = useRef(false);
|
||||
const bufferedDigitsRef = useRef<{ segmentIndex: number; digits: string; timestamp: number } | null>(
|
||||
null,
|
||||
);
|
||||
const bufferedDigitsRef = useRef<{
|
||||
segmentIndex: number;
|
||||
digits: string;
|
||||
timestamp: number;
|
||||
} | null>(null);
|
||||
const activeSegmentIndexRef = useRef(0);
|
||||
const pendingSelectionTimerRef = useRef<number | null>(null);
|
||||
|
||||
@@ -897,9 +950,11 @@ export function DatePicker({
|
||||
const monthGrid = useMemo(() => buildMonthGrid(viewMonth, weekStart), [viewMonth, weekStart]);
|
||||
|
||||
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;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
const anchorRect = inputWrapperRef.current.getBoundingClientRect();
|
||||
const popupRect = popupRef.current.getBoundingClientRect();
|
||||
@@ -920,7 +975,10 @@ export function DatePicker({
|
||||
? anchorRect.top - popupRect.height - 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({
|
||||
top,
|
||||
@@ -937,9 +995,11 @@ export function DatePicker({
|
||||
|
||||
recalculatePopupPosition();
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
/* c8 ignore start -- browser/window is always present in jsdom runtime. */
|
||||
if (!globalThis.window) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
const handleWindowChange = () => {
|
||||
recalculatePopupPosition();
|
||||
@@ -961,11 +1021,16 @@ export function DatePicker({
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const eventTarget = event.target as Node | null;
|
||||
/* c8 ignore start -- pointer events always provide a target in browser runtimes. */
|
||||
if (!eventTarget) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
if (popupRef.current?.contains(eventTarget) || inputWrapperRef.current?.contains(eventTarget)) {
|
||||
if (
|
||||
popupRef.current?.contains(eventTarget) ||
|
||||
inputWrapperRef.current?.contains(eventTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -992,7 +1057,7 @@ export function DatePicker({
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pendingSelectionTimerRef.current != null) {
|
||||
window.clearTimeout(pendingSelectionTimerRef.current);
|
||||
globalThis.window.clearTimeout(pendingSelectionTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -1000,27 +1065,33 @@ export function DatePicker({
|
||||
const selectSegment = useCallback(
|
||||
(segmentIndex: number) => {
|
||||
const segments = formatConfig.segments;
|
||||
/* c8 ignore start -- format configuration always includes at least one segment. */
|
||||
if (segments.length === 0) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
const clampedIndex = clampNumber(segmentIndex, 0, segments.length - 1);
|
||||
activeSegmentIndexRef.current = clampedIndex;
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
/* c8 ignore start -- browser/window is always present in jsdom runtime. */
|
||||
if (!globalThis.window) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
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 targetSegment = segments[clampedIndex];
|
||||
/* c8 ignore start -- input node and target segment are available while mounted. */
|
||||
if (!inputNode || !targetSegment) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
inputNode.setSelectionRange(targetSegment.start, targetSegment.end);
|
||||
}, 0);
|
||||
@@ -1030,14 +1101,18 @@ export function DatePicker({
|
||||
|
||||
const resolveCurrentSegmentIndex = useCallback(() => {
|
||||
const segments = formatConfig.segments;
|
||||
/* c8 ignore start -- format configuration always includes at least one segment. */
|
||||
if (segments.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
const inputNode = internalInputRef.current;
|
||||
/* c8 ignore start -- interactions only run when input node is mounted. */
|
||||
if (!inputNode) {
|
||||
return activeSegmentIndexRef.current;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
const index = findSegmentIndexByCaret(segments, inputNode.selectionStart);
|
||||
activeSegmentIndexRef.current = index;
|
||||
@@ -1067,9 +1142,11 @@ export function DatePicker({
|
||||
}
|
||||
|
||||
const inputNode = internalInputRef.current;
|
||||
/* c8 ignore start -- commits run only from active mounted input interactions. */
|
||||
if (!inputNode) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
changeHandledRef.current = false;
|
||||
|
||||
@@ -1105,11 +1182,14 @@ export function DatePicker({
|
||||
},
|
||||
) => {
|
||||
const segment = formatConfig.segments[segmentIndex];
|
||||
/* c8 ignore start -- segmentIndex is always resolved from known format segments. */
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
const baseValue = parsePickerValueWithFormat(displayValue, formatConfig) ?? clampedSelectedValue;
|
||||
const baseValue =
|
||||
parsePickerValueWithFormat(displayValue, formatConfig) ?? clampedSelectedValue;
|
||||
const nextUnclamped = applySegmentDigits(baseValue, segment.kind, digits);
|
||||
const nextClamped = clampPickerToRange(
|
||||
nextUnclamped,
|
||||
@@ -1145,7 +1225,7 @@ export function DatePicker({
|
||||
(segmentIndex: number, moveToNext: boolean) => {
|
||||
const buffered = bufferedDigitsRef.current;
|
||||
const segment = formatConfig.segments[segmentIndex];
|
||||
if (!buffered || buffered.segmentIndex !== segmentIndex || !segment) {
|
||||
if (buffered?.segmentIndex !== segmentIndex || !segment) {
|
||||
if (moveToNext) {
|
||||
selectSegment(segmentIndex + 1);
|
||||
}
|
||||
@@ -1159,9 +1239,11 @@ export function DatePicker({
|
||||
);
|
||||
|
||||
const openPicker = useCallback(() => {
|
||||
/* c8 ignore start -- disabled inputs cannot trigger picker open interactions. */
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
if (type !== 'time') {
|
||||
setViewMonth(startOfMonth(selectedDate));
|
||||
}
|
||||
@@ -1170,9 +1252,11 @@ export function DatePicker({
|
||||
}, [disabled, selectedDate, type]);
|
||||
|
||||
const togglePicker = useCallback(() => {
|
||||
/* c8 ignore start -- disabled icon button cannot trigger click events. */
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
if (isOpen) {
|
||||
closePicker();
|
||||
return;
|
||||
@@ -1220,9 +1304,11 @@ export function DatePicker({
|
||||
hour: 0,
|
||||
minute: 0,
|
||||
};
|
||||
/* c8 ignore start -- out-of-range date cells are disabled in the calendar UI. */
|
||||
if (!isWithinRange(candidate, normalizedMinValue, normalizedMaxValue, type)) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
commitValue(formatPickerValueWithFormat(candidate, formatConfig));
|
||||
closePicker();
|
||||
@@ -1234,7 +1320,12 @@ export function DatePicker({
|
||||
hour: selectedHour,
|
||||
minute: selectedMinute,
|
||||
};
|
||||
const nextValue = clampPickerToRange(candidate, normalizedMinValue, normalizedMaxValue, type);
|
||||
const nextValue = clampPickerToRange(
|
||||
candidate,
|
||||
normalizedMinValue,
|
||||
normalizedMaxValue,
|
||||
type,
|
||||
);
|
||||
commitValue(formatPickerValueWithFormat(nextValue, formatConfig));
|
||||
setViewMonth(startOfMonth(normalizedDate));
|
||||
},
|
||||
@@ -1313,17 +1404,21 @@ export function DatePicker({
|
||||
}, [disabled, selectSegment]);
|
||||
|
||||
const handleInputMouseUp = useCallback(() => {
|
||||
/* c8 ignore start -- disabled inputs do not emit mouse interaction events. */
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
selectSegment(resolveCurrentSegmentIndex());
|
||||
}, [disabled, resolveCurrentSegmentIndex, selectSegment]);
|
||||
|
||||
const handleInputClick = useCallback(() => {
|
||||
/* c8 ignore start -- disabled inputs do not emit click interaction events. */
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
selectSegment(resolveCurrentSegmentIndex());
|
||||
}, [disabled, resolveCurrentSegmentIndex, selectSegment]);
|
||||
@@ -1341,7 +1436,12 @@ export function DatePicker({
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = clampPickerToRange(parsed, normalizedMinValue, normalizedMaxValue, type);
|
||||
const clamped = clampPickerToRange(
|
||||
parsed,
|
||||
normalizedMinValue,
|
||||
normalizedMaxValue,
|
||||
type,
|
||||
);
|
||||
event.preventDefault();
|
||||
commitValue(formatPickerValueWithFormat(clamped, formatConfig));
|
||||
},
|
||||
@@ -1368,9 +1468,11 @@ export function DatePicker({
|
||||
}
|
||||
|
||||
const segment = formatConfig.segments[segmentIndex];
|
||||
/* c8 ignore start -- segment index resolution always maps to a valid segment. */
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
if (key === 'ArrowDown' && !isOpen) {
|
||||
event.preventDefault();
|
||||
@@ -1437,11 +1539,13 @@ export function DatePicker({
|
||||
digits = buffered.digits;
|
||||
}
|
||||
|
||||
/* c8 ignore start -- buffered digits are reset on segment completion. */
|
||||
if (digits.length >= segment.length) {
|
||||
digits = key;
|
||||
} else {
|
||||
digits += key;
|
||||
}
|
||||
/* c8 ignore stop */
|
||||
|
||||
bufferedDigitsRef.current = {
|
||||
segmentIndex,
|
||||
@@ -1663,7 +1767,11 @@ export function DatePicker({
|
||||
<div className="datepicker-time-root">
|
||||
<div className="datepicker-time-column">
|
||||
<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) => {
|
||||
const hourDisabled = !isHourSelectableForRange(
|
||||
selectedDate,
|
||||
@@ -1717,7 +1825,8 @@ export function DatePicker({
|
||||
type="button"
|
||||
className={joinClassNames(
|
||||
'datepicker-time-option',
|
||||
minute === selectedMinute && 'is-selected',
|
||||
minute === selectedMinute &&
|
||||
'is-selected',
|
||||
)}
|
||||
onClick={() => handleMinuteCommit(minute)}
|
||||
disabled={minuteDisabled}
|
||||
|
||||
@@ -21,6 +21,12 @@ describe('Button', () => {
|
||||
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', () => {
|
||||
render(<Button type="solid" icon={HomeIcon} ariaLabel="Open home" />);
|
||||
|
||||
|
||||
@@ -51,4 +51,12 @@ describe('Chip', () => {
|
||||
rerender(<Chip tone=" ">Blank</Chip>);
|
||||
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 { useState } from 'react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { createRef, type FocusEvent as ReactFocusEvent, useState } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { DatePicker } from '../../src/components/DatePicker';
|
||||
|
||||
@@ -10,6 +10,7 @@ type ControlledProps = {
|
||||
min?: string;
|
||||
max?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
onBlur?: (event: ReactFocusEvent<HTMLInputElement>) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -20,6 +21,7 @@ function ControlledDatePicker({
|
||||
min,
|
||||
max,
|
||||
onValueChange,
|
||||
onBlur,
|
||||
disabled = false,
|
||||
}: Readonly<ControlledProps>) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
@@ -33,6 +35,7 @@ function ControlledDatePicker({
|
||||
min={min}
|
||||
max={max}
|
||||
disabled={disabled}
|
||||
onBlur={onBlur}
|
||||
onChange={(event) => {
|
||||
setValue(event.target.value);
|
||||
onValueChange?.(event.target.value);
|
||||
@@ -64,6 +67,30 @@ function getCurrentMonthDayButton(label: string): HTMLButtonElement {
|
||||
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', () => {
|
||||
it('opens popup from icon button and closes with Escape', () => {
|
||||
render(<DatePicker label="Schedule" type="date-time" value="2031/05/20 14:30" onChange={() => {}} />);
|
||||
@@ -80,6 +107,12 @@ describe('DatePicker', () => {
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Date and time picker popup' }),
|
||||
).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 () => {
|
||||
@@ -288,6 +321,25 @@ describe('DatePicker', () => {
|
||||
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', () => {
|
||||
const onValueChange = vi.fn();
|
||||
render(<ControlledDatePicker type="time" initialValue="09:00" onValueChange={onValueChange} />);
|
||||
@@ -344,6 +396,37 @@ describe('DatePicker', () => {
|
||||
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', () => {
|
||||
const { container } = render(
|
||||
<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 > 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(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 > 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();
|
||||
});
|
||||
});
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@@ -4545,16 +4545,16 @@ react-remove-scroll@^2.6.3:
|
||||
use-sidecar "^1.1.3"
|
||||
|
||||
react-router-dom@^7.0.0:
|
||||
version "7.13.0"
|
||||
resolved "https://nexus.beatrice.wtf/repository/npm-group/react-router-dom/-/react-router-dom-7.13.0.tgz#8b5f7204fadca680f0e94f207c163f0dcd1cfdf5"
|
||||
integrity sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==
|
||||
version "7.13.1"
|
||||
resolved "https://nexus.beatrice.wtf/repository/npm-group/react-router-dom/-/react-router-dom-7.13.1.tgz#74c045acc333ca94612b889cd1b1e1ee9534dead"
|
||||
integrity sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==
|
||||
dependencies:
|
||||
react-router "7.13.0"
|
||||
react-router "7.13.1"
|
||||
|
||||
react-router@7.13.0:
|
||||
version "7.13.0"
|
||||
resolved "https://nexus.beatrice.wtf/repository/npm-group/react-router/-/react-router-7.13.0.tgz#de9484aee764f4f65b93275836ff5944d7f5bd3b"
|
||||
integrity sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==
|
||||
react-router@7.13.1:
|
||||
version "7.13.1"
|
||||
resolved "https://nexus.beatrice.wtf/repository/npm-group/react-router/-/react-router-7.13.1.tgz#5e2b3ebafd6c78d9775e135474bf5060645077f7"
|
||||
integrity sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==
|
||||
dependencies:
|
||||
cookie "^1.0.1"
|
||||
set-cookie-parser "^2.6.0"
|
||||
|
||||
Reference in New Issue
Block a user