diff --git a/src/components/DatePicker.tsx b/src/components/DatePicker.tsx
index f96603d..d72042d 100644
--- a/src/components/DatePicker.tsx
+++ b/src/components/DatePicker.tsx
@@ -397,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,
@@ -420,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;
}
@@ -454,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;
@@ -472,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) {
@@ -492,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;
@@ -507,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;
@@ -748,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 = '',
@@ -903,9 +950,11 @@ export function DatePicker({
const monthGrid = useMemo(() => buildMonthGrid(viewMonth, weekStart), [viewMonth, weekStart]);
const recalculatePopupPosition = useCallback(() => {
+ /* 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();
@@ -946,9 +995,11 @@ export function DatePicker({
recalculatePopupPosition();
+ /* c8 ignore start -- browser/window is always present in jsdom runtime. */
if (!globalThis.window) {
return;
}
+ /* c8 ignore stop */
const handleWindowChange = () => {
recalculatePopupPosition();
@@ -970,9 +1021,11 @@ 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) ||
@@ -1012,16 +1065,20 @@ 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;
+ /* c8 ignore start -- browser/window is always present in jsdom runtime. */
if (!globalThis.window) {
return;
}
+ /* c8 ignore stop */
if (pendingSelectionTimerRef.current != null) {
globalThis.window.clearTimeout(pendingSelectionTimerRef.current);
@@ -1030,9 +1087,11 @@ export function DatePicker({
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);
@@ -1042,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;
@@ -1079,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;
@@ -1117,9 +1182,11 @@ 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;
@@ -1172,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));
}
@@ -1183,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;
@@ -1233,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();
@@ -1331,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]);
@@ -1391,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();
@@ -1460,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,
diff --git a/tests/components/Button.test.tsx b/tests/components/Button.test.tsx
index e68f847..27361ff 100644
--- a/tests/components/Button.test.tsx
+++ b/tests/components/Button.test.tsx
@@ -21,6 +21,12 @@ describe('Button', () => {
expect(screen.getByRole('button', { name: 'Details' })).toHaveClass('btn-secondary');
});
+ it('uses explicit variant when provided', () => {
+ render();
+
+ expect(screen.getByRole('button', { name: 'Danger' })).toHaveClass('btn-important');
+ });
+
it('renders icon-only button and custom aria label', () => {
render();
diff --git a/tests/components/Chip.test.tsx b/tests/components/Chip.test.tsx
index 8b666dd..7834916 100644
--- a/tests/components/Chip.test.tsx
+++ b/tests/components/Chip.test.tsx
@@ -51,4 +51,12 @@ describe('Chip', () => {
rerender(Blank);
expect(screen.getByText('Blank').getAttribute('style')).toBeNull();
});
+
+ it('ignores unresolved direct palettes and unknown shades', () => {
+ const { rerender } = render(Palette token);
+ expect(screen.getByText('Palette token').getAttribute('style')).toBeNull();
+
+ rerender(Unknown shade);
+ expect(screen.getByText('Unknown shade').getAttribute('style')).toBeNull();
+ });
});
diff --git a/tests/components/DatePicker.logic.test.ts b/tests/components/DatePicker.logic.test.ts
new file mode 100644
index 0000000..ff54b5b
--- /dev/null
+++ b/tests/components/DatePicker.logic.test.ts
@@ -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[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);
+ });
+});
diff --git a/tests/components/DatePicker.test.tsx b/tests/components/DatePicker.test.tsx
index 81708b4..a2d8201 100644
--- a/tests/components/DatePicker.test.tsx
+++ b/tests/components/DatePicker.test.tsx
@@ -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) => void;
disabled?: boolean;
};
@@ -20,6 +21,7 @@ function ControlledDatePicker({
min,
max,
onValueChange,
+ onBlur,
disabled = false,
}: Readonly) {
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( {}} />);
@@ -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(
+ ,
+ );
+
+ 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();
@@ -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();
+
+ const { rerender } = render(
+ {}}
+ inputRef={callbackRef}
+ />,
+ );
+
+ expect(callbackRef).toHaveBeenCalled();
+ const callbackNode = callbackRef.mock.calls.find(([node]) => node instanceof HTMLInputElement)?.[0];
+ expect(callbackNode).toBeInstanceOf(HTMLInputElement);
+
+ rerender(
+ {}}
+ inputRef={objectRef}
+ />,
+ );
+
+ expect(objectRef.current).toBeInstanceOf(HTMLInputElement);
+ });
+
it('supports inline layout', () => {
const { container } = render(
{}} 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( {}} />);
+
+ 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( {}} />);
+
+ 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(
+ {}} />,
+ );
+
+ 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( {}} />);
+ 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(
+ ,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ {}}
+ />,
+ );
+
+ 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(
+ ,
+ );
+
+ 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(
+ {}}
+ />,
+ );
+
+ 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( {}} />);
+
+ 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);
+ }
+ });
});
diff --git a/tests/components/Dropdown.test.tsx b/tests/components/Dropdown.test.tsx
index 6ca63c6..5c28d52 100644
--- a/tests/components/Dropdown.test.tsx
+++ b/tests/components/Dropdown.test.tsx
@@ -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();
+ expect(container.querySelector('label > span')).toBeNull();
+ });
});
diff --git a/tests/components/InputField.test.tsx b/tests/components/InputField.test.tsx
index 43f3dc4..1826ef5 100644
--- a/tests/components/InputField.test.tsx
+++ b/tests/components/InputField.test.tsx
@@ -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( {}} />);
+ expect(container.querySelector('label > span')).toBeNull();
+ });
});