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(