Compare commits

..

3 Commits

Author SHA1 Message Date
514b643566 Update dependency react-router-dom to v7.13.1
All checks were successful
continuous-integration/drone/pr Build is passing
2026-02-24 22:10:52 +00:00
1523f7be2c add unit tests
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-24 17:55:26 +01:00
b664c99944 fix sonar issues
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-24 16:27:53 +01:00
8 changed files with 978 additions and 32 deletions

View File

@@ -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}

View File

@@ -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" />);

View File

@@ -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();
});
});

View 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);
});
});

View File

@@ -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);
}
});
});

View File

@@ -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();
});
});

View File

@@ -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();
});
});

View File

@@ -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"