From 81bac52b346d31f46e195048c614eeb2ea0a8423 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Jul 2026 14:36:37 -0400 Subject: [PATCH 1/3] fix(DatePicker): a11y polish for weekday headers, label, and calendar button - Announce full weekday names in calendar headers instead of abbreviations - Associate the field label with the segment shell via aria-labelledby - Convert the presentational calendar icon to an IconButton with an openCalendarLabel translation Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Calendar/CalendarBody.tsx | 7 +++++- .../Calendar/__tests__/CalendarBody.test.tsx | 15 ++++++++---- .../src/DatePicker/DatePickerInput/index.tsx | 23 ++++++++++--------- .../DatePicker/__tests__/DatePicker.test.tsx | 10 ++++---- .../src/DatePicker/utils/translations.ts | 3 +++ 5 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx index b2d69c5638..334e3dd3c3 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -171,7 +171,12 @@ export const CalendarBody: React.FC = ({ {weekdayLabels.map((label, i) => ( - + {label} ))} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx index 27dea15dbb..5e2ccd2ecc 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx @@ -6,7 +6,7 @@ import { createRef } from 'react'; import { getIsoFirstDayFromLocale } from '../../../utils/locale'; import { CalendarBody } from '../CalendarBody'; import { getMonthGrid } from '../utils/dateGrid'; -import { formatDateForAriaLabel } from '../utils/format'; +import { formatDateForAriaLabel, getWeekdayNames } from '../utils/format'; const displayDate = new Date(2024, 2, 1); const focusedDate = new Date(2024, 2, 15); @@ -203,14 +203,21 @@ describe('CalendarBody', () => { await waitFor(() => expect(march15).toHaveFocus()); }); - it('renders seven weekday column headers with scope and abbreviations', () => { + it('renders seven weekday column headers with full accessible names', () => { const { view } = renderView(); + const locale = new Intl.Locale('en-US'); + const firstWeekday = getIsoFirstDayFromLocale(locale); + const fullNames = getWeekdayNames({ + format: 'long', + locale, + firstWeekday, + }); const headers = view.getAllByRole('columnheader'); expect(headers).toHaveLength(7); - headers.forEach((th) => { + headers.forEach((th, i) => { expect(th).toHaveAttribute('scope', 'col'); - expect(th).toHaveAttribute('abbr'); + expect(th).toHaveAccessibleName(fullNames[i]); }); }); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index ebce7088a2..e557e6e7c0 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -11,6 +11,7 @@ import { } from 'react'; import { FlexBox } from '../../Box'; +import { IconButton } from '../../Button'; import { FormGroup } from '../../Form/elements/FormGroup'; import type { InputWrapperProps } from '../../Form/inputs/Input'; import { isSameDay } from '../DatePickerCalendar/Calendar/utils/dateGrid'; @@ -95,6 +96,7 @@ export const DatePickerInput = forwardRef( const isInputFocusedRef = useRef(false); const containerRef = useRef(null); + const buttonRef = useRef(null); const segmentElRefs = useRef< Partial> >({}); @@ -227,7 +229,7 @@ export const DatePickerInput = forwardRef( return ( ( width="fit-content" > ( type="hidden" value={hiddenValue} /> - - - + buttonRef.current?.blur()} + /> ); diff --git a/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx index 8079c9a453..c4490520e7 100644 --- a/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx +++ b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx @@ -151,14 +151,12 @@ describe('DatePicker', () => { expect(view.getAllByRole('group')).toHaveLength(2); }); - it('associates the field label with the segment shell via label `for` and shell `id` (DatePickerInput)', () => { + it('associates the field label with the segment shell via aria-labelledby (DatePickerInput)', () => { const { view } = renderSingle(); const shell = view.getByRole('group'); - const shellId = shell.getAttribute('id'); - expect(shellId).toBeTruthy(); - expect( - view.container.querySelector(`label[for="${shellId}"]`) - ).toBeInTheDocument(); + const labelledBy = shell.getAttribute('aria-labelledby'); + expect(labelledBy).toBeTruthy(); + expect(view.container.querySelector(`#${labelledBy}`)).toBeInTheDocument(); }); it('renders only children when the children prop is provided', () => { diff --git a/packages/gamut/src/DatePicker/utils/translations.ts b/packages/gamut/src/DatePicker/utils/translations.ts index ba5dceaa0e..6a6be48e7d 100644 --- a/packages/gamut/src/DatePicker/utils/translations.ts +++ b/packages/gamut/src/DatePicker/utils/translations.ts @@ -9,6 +9,8 @@ export interface DatePickerTranslations { endDateLabel?: string; /** aria-label for the calendar dialog (default: "Choose date"). */ calendarDialogAriaLabel?: string; + /** aria-label for the calendar icon trigger (default: "Open calendar"). */ + openCalendarLabel?: string; /** Label for the last 7 days quick action (default: "Last 7 days"). */ last7DaysDisplayText?: string; /** Label for the last 30 days quick action (default: "Last 30 days"). */ @@ -24,6 +26,7 @@ export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required startDateLabel: 'Start date', endDateLabel: 'End date', calendarDialogAriaLabel: 'Choose date', + openCalendarLabel: 'Open calendar', last7DaysDisplayText: 'Last 7 days', last30DaysDisplayText: 'Last 30 days', last90DaysDisplayText: 'Last 90 days', From 7f941b41920a1b34d8f2943bd5cf665d1cc4c41d Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 13 Jul 2026 17:08:29 -0400 Subject: [PATCH 2/3] feat(DatePicker): per-input error messages with stable layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move error state to each DatePickerInput so start/end inputs in range mode surface independent messages. Render the error via FormError's `absolute` variant so it sits out of layout flow — the input never changes height, keeping the range-mode arrow aligned and reserving no space when there is no error. The calendar popover still shifts down (y=0) only when open and an error exists, via a boolean `hasError` context signal. Validate typed dates (incomplete, invalid month/day, rollover, disabled dates) through a shared validateSegments util and localizable translation keys. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/gamut/src/DatePicker/DatePicker.tsx | 7 +- .../__tests__/mockContexts.ts | 4 + .../src/DatePicker/DatePickerContext/types.ts | 9 + .../src/DatePicker/DatePickerInput/index.tsx | 105 ++++++++-- .../DatePickerInput/utils/validation.ts | 194 ++++++++++++++++++ .../src/DatePicker/utils/translations.ts | 19 ++ 6 files changed, 319 insertions(+), 19 deletions(-) create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/utils/validation.ts diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index b334e4992d..822277fb54 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -42,6 +42,7 @@ export const DatePicker: React.FC = (props) => { const [gridFocusRequested, setGridFocusRequested] = useState(false); const [activeRangePart, setActiveRangePart] = useState(null); + const [hasError, setHasError] = useState(false); const inputRef = useRef(null); const dialogId = useId(); const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; @@ -106,6 +107,8 @@ export const DatePicker: React.FC = (props) => { disableDate, translations, quickActions: quickActions === null ? [] : resolvedQuickActions, + hasError, + setHasError, }; return mode === 'range' ? { @@ -141,6 +144,8 @@ export const DatePicker: React.FC = (props) => { disableDate, props, activeRangePart, + hasError, + setHasError, ]); const content = @@ -182,7 +187,7 @@ export const DatePicker: React.FC = (props) => { isOpen={isCalendarOpen} targetRef={inputRef} x={-20} - y={-16} + y={isCalendarOpen && hasError ? 0 : -16} onRequestClose={closeCalendar} >
* Discriminator: same meaning as the `mode` prop on `DatePicker` (`"single"` or `"range"`). */ mode: Mode; + /** + * Whether there is an error in any input. Used by DatePicker to adjust popover position + * when calendar is open. Only tracks IF error exists, not the message. + */ + hasError: boolean; + /** + * Callback to set whether error exists. Called by DatePickerInput when validation errors occur. + */ + setHasError: (hasError: boolean) => void; /** * Resolved `Intl.Locale` for the `locale` prop (or the runtime default). The same object is * passed to formatters and to APIs such as `getWeekInfo` where available. diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index e557e6e7c0..448a31f9ae 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -1,4 +1,6 @@ import { MiniCalendarIcon } from '@codecademy/gamut-icons'; +import { css } from '@codecademy/gamut-styles'; +import styled from '@emotion/styled'; import { type FocusEvent, forwardRef, @@ -12,6 +14,7 @@ import { import { FlexBox } from '../../Box'; import { IconButton } from '../../Button'; +import { FormError } from '../../Form/elements/FormError'; import { FormGroup } from '../../Form/elements/FormGroup'; import type { InputWrapperProps } from '../../Form/inputs/Input'; import { isSameDay } from '../DatePickerCalendar/Calendar/utils/dateGrid'; @@ -32,13 +35,30 @@ import { getDateFieldOrder, getDateFormatLayout, } from './utils'; +import { validateSegments } from './utils/validation'; + +/* FormError's `absolute` variant takes the message out of layout flow (anchored + to FormGroup's position: relative container), so the input never changes height: + the range-mode arrow stays aligned and no space is reserved when there's no error. */ +const AbsoluteFormError = styled(FormError)( + css({ + // max-content keeps the message on one line rather than wrapping into the + // narrow input width. + width: 'max-content', + // The `absolute` variant anchors at top: calc(100% - 8px), which overlaps the + // bottom of the input. Nudge it down below the input with a small gap. + mt: 12, + }) +); export type DatePickerInputProps = Omit< InputWrapperProps, - 'className' | 'type' | 'icon' | 'value' | 'onChange' | 'color' + 'className' | 'type' | 'icon' | 'value' | 'onChange' | 'color' | 'error' > & { /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */ rangePart?: 'start' | 'end'; + /** Error message to display. String for specific error, undefined for no error. */ + error?: string; }; export const DatePickerInput = forwardRef( @@ -62,6 +82,7 @@ export const DatePickerInput = forwardRef( isCalendarOpen, translations, disableDate, + setHasError, } = context; const isRange = mode === 'range'; @@ -88,6 +109,7 @@ export const DatePickerInput = forwardRef( [boundDate] ); const [segments, setSegments] = useState(segmentsFromBound); + const [inputError, setInputError] = useState(''); const parsedForHidden = parseSegmentsToDate(segments); const hiddenValue = parsedForHidden @@ -161,11 +183,25 @@ export const DatePickerInput = forwardRef( const onSegmentChange = useCallback( (next: SegmentValues) => { - const parsed = parseSegmentsToDate(next); - if (parsed) commitParsedDate(parsed); - else if (!next.month && !next.day && !next.year) clearSelection(); + const validationResult = validateSegments( + next, + translations, + disableDate + ); + if (validationResult.isValid) { + setInputError(''); + setHasError(false); + commitParsedDate(validationResult.date); + } else if (!next.month && !next.day && !next.year) { + setInputError(''); + setHasError(false); + clearSelection(); + } else if (validationResult.errorMessage) { + setInputError(validationResult.errorMessage); + setHasError(true); + } }, - [clearSelection, commitParsedDate] + [clearSelection, commitParsedDate, translations, disableDate, setHasError] ); const onContainerBlur = useCallback( @@ -173,24 +209,48 @@ export const DatePickerInput = forwardRef( if (containerRef.current?.contains(e.relatedTarget as Node)) return; isInputFocusedRef.current = false; setSegments((prev) => { - const normalized = normalizeSegmentValues(prev); - const parsed = parseSegmentsToDate(normalized); - if (parsed) { - const sameAsBound = isSameDay(parsed, boundDate); + // Check if all segments are empty + if (!prev.month && !prev.day && !prev.year) { + setInputError(''); + setHasError(false); + queueMicrotask(() => { + clearSelection(); + }); + return getDateSegmentsFromDate(null); + } + + // Validate raw input (without normalizing/clamping) + const validationResult = validateSegments( + prev, + translations, + disableDate + ); + + if (validationResult.isValid) { + // Valid complete date - normalize and commit + setInputError(''); + setHasError(false); + const normalized = normalizeSegmentValues(prev); + const sameAsBound = isSameDay(validationResult.date, boundDate); if (isCalendarOpen && !sameAsBound) { queueMicrotask(() => { - commitParsedDate(parsed); + commitParsedDate(validationResult.date); }); } return normalized; } - if (!normalized.month && !normalized.day && !normalized.year) { - queueMicrotask(() => { - clearSelection(); - }); - return getDateSegmentsFromDate(null); + + // Incomplete entry on blur - show the incomplete-date error + if (!validationResult.errorMessage) { + setInputError(translations.invalidDateIncomplete); + setHasError(true); + return prev; // Keep incomplete input visible so user can correct it } - return segmentsFromBound; + + // Invalid complete entry - show error and keep invalid input visible + setInputError(validationResult.errorMessage); + setHasError(true); + return prev; }); }, [ @@ -200,6 +260,9 @@ export const DatePickerInput = forwardRef( clearSelection, commitParsedDate, isCalendarOpen, + translations, + disableDate, + setHasError, ] ); @@ -238,11 +301,12 @@ export const DatePickerInput = forwardRef( width="fit-content" > ( applySegments={onSegmentChange} assignSegmentRef={assignSegmentRef} disabled={!!disabled} - error={!!error} + error={!!inputError} field={item.field} key={item.field} nextField={nextField} @@ -303,6 +367,11 @@ export const DatePickerInput = forwardRef( onClick={() => buttonRef.current?.blur()} /> + {inputError && ( + + {inputError} + + )} ); } diff --git a/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.ts b/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.ts new file mode 100644 index 0000000000..995c403dee --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.ts @@ -0,0 +1,194 @@ +import type { DatePickerTranslations } from '../../utils/translations'; +import type { SegmentValues } from '../Segment/utils'; +import { parseSegmentsToDate } from '../Segment/utils'; + +export type ErrorReason = + | 'incomplete' + | 'invalid-month' + | 'invalid-day' + | 'date-rollover' + | 'disabled-date' + | 'range-contains-disabled-date'; + +export type ValidationResult = + | { isValid: true; date: Date } + | { isValid: false; errorMessage: string; reason: ErrorReason }; + +/** + * Detects the specific reason why a date is invalid. + * Assumes segments are "complete enough" (all three fields have at least some input). + */ +function detectValidationIssue(segments: SegmentValues): ErrorReason | null { + const { month, day, year } = segments; + + // Parse to numbers for validation + const monthNumber = parseInt(month, 10); + const dayNumber = parseInt(day, 10); + const yearNumber = parseInt(year, 10); + + // Check month validity + if (monthNumber < 1 || monthNumber > 12) return 'invalid-month'; + + // Try to create the date - if it fails, it's either invalid day or date rollover + const parsed = new Date(yearNumber, monthNumber - 1, dayNumber); + if ( + parsed.getFullYear() !== yearNumber || + parsed.getMonth() !== monthNumber - 1 + ) { + return 'date-rollover'; + } + if (parsed.getDate() !== dayNumber) { + return 'invalid-day'; + } + + return null; +} + +/** + * Generates a human-readable error message based on the validation issue. + */ +export function generateErrorMessage( + reason: ErrorReason, + segments: SegmentValues, + translations: Required +): string { + const { month, day } = segments; + const monthNumber = parseInt(month, 10); + + switch (reason) { + case 'incomplete': + return translations.invalidDateIncomplete || 'Incomplete date'; + + case 'invalid-month': + return ( + translations.invalidDateInvalidMonth || 'Month must be between 1 and 12' + ); + + case 'invalid-day': { + const monthName = new Date(2024, monthNumber - 1, 1).toLocaleString( + 'en-US', + { month: 'long' } + ); + const message = + translations.invalidDateInvalidDay || + '{{month}} does not have {{day}} days'; + return message.replace('{{month}}', monthName).replace('{{day}}', day); + } + + case 'date-rollover': { + const monthName = new Date(2024, monthNumber - 1, 1).toLocaleString( + 'en-US', + { month: 'long' } + ); + const message = + translations.invalidDateRollover || + '{{month}} does not have {{day}} days'; + return message.replace('{{month}}', monthName).replace('{{day}}', day); + } + + case 'disabled-date': + return ( + translations.invalidDateNotAvailable || 'This date is not available' + ); + + case 'range-contains-disabled-date': + return ( + translations.invalidDateRangeContainsDisabledDate || + 'This date range contains unavailable dates' + ); + + default: + return 'Invalid date'; + } +} + +/** + * Validates a set of date segments and returns either a valid date or an error message. + * Requires: year to be exactly 4 digits, month and day to have digits and parse to valid numbers. + * Checks for structural validity (date exists), disabled dates, and generates specific error messages. + */ +export function validateSegments( + segments: SegmentValues, + translations: Required, + disableDate?: (date: Date) => boolean +): ValidationResult { + const { month, day, year } = segments; + + // Require complete year (4 digits), month, and day to have input + const hasCompleteYear = year.length === 4; + const hasMonthAndDay = month.length > 0 && day.length > 0; + + if (!hasCompleteYear || !hasMonthAndDay) { + // Incomplete entry - return as valid (no error), but with no date + return { + isValid: false, + errorMessage: '', + reason: 'incomplete', + }; + } + + // Entry is complete (year has 4 digits, month and day have input) - validate it + const parsed = parseSegmentsToDate(segments); + + if (parsed) { + // Date is structurally valid, now check if it's disabled + if (disableDate && disableDate(parsed)) { + return { + isValid: false, + errorMessage: generateErrorMessage( + 'disabled-date', + segments, + translations + ), + reason: 'disabled-date', + }; + } + // Date is valid and not disabled + return { isValid: true, date: parsed }; + } + + // Date is not valid - detect specific reason (must be one since entry is complete) + const reason = detectValidationIssue(segments) || 'date-rollover'; + + const errorMessage = generateErrorMessage(reason, segments, translations); + return { + isValid: false, + errorMessage, + reason, + }; +} + +/** + * Validates a date range to ensure no dates in the range are disabled. + */ +export function validateDateRange( + startDate: Date | null, + endDate: Date | null, + translations: Required, + disableDate?: (date: Date) => boolean +): ValidationResult | null { + if (!startDate || !endDate || !disableDate) return null; + + // Check if any date in the range is disabled + const current = new Date(startDate); + while (current <= endDate) { + if (disableDate(current)) { + return { + isValid: false, + errorMessage: generateErrorMessage( + 'range-contains-disabled-date', + { + month: '', + day: '', + year: '', + }, + translations + ), + reason: 'range-contains-disabled-date', + }; + } + current.setDate(current.getDate() + 1); + } + + return null; +} diff --git a/packages/gamut/src/DatePicker/utils/translations.ts b/packages/gamut/src/DatePicker/utils/translations.ts index 6a6be48e7d..a2d36dc419 100644 --- a/packages/gamut/src/DatePicker/utils/translations.ts +++ b/packages/gamut/src/DatePicker/utils/translations.ts @@ -17,6 +17,18 @@ export interface DatePickerTranslations { last30DaysDisplayText?: string; /** Label for the last 90 days quick action (default: "Last 90 days"). */ last90DaysDisplayText?: string; + /** Error message for incomplete date entry (default: "Incomplete date"). */ + invalidDateIncomplete?: string; + /** Error message for invalid month (default: "Month must be between 1 and 12"). */ + invalidDateInvalidMonth?: string; + /** Error message for invalid day (default: "{{month}} does not have {{day}} days"). Supports {{month}} and {{day}} template variables. */ + invalidDateInvalidDay?: string; + /** Error message for date rollover (default: "{{month}} does not have {{day}} days"). Supports {{month}} and {{day}} template variables. */ + invalidDateRollover?: string; + /** Error message for disabled/unavailable date (default: "This date is not available"). */ + invalidDateNotAvailable?: string; + /** Error message for date range containing disabled dates (default: "This date range contains unavailable dates"). */ + invalidDateRangeContainsDisabledDate?: string; } export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required = @@ -30,4 +42,11 @@ export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required last7DaysDisplayText: 'Last 7 days', last30DaysDisplayText: 'Last 30 days', last90DaysDisplayText: 'Last 90 days', + invalidDateIncomplete: 'Incomplete date', + invalidDateInvalidMonth: 'Month must be between 1 and 12', + invalidDateInvalidDay: '{{month}} does not have {{day}} days', + invalidDateRollover: '{{month}} does not have {{day}} days', + invalidDateNotAvailable: 'This date is not available', + invalidDateRangeContainsDisabledDate: + 'This date range contains unavailable dates', }; From 6e5803a863fe8b26a8ddeb61637ec0e9800e4ae9 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 5 Aug 2026 13:18:16 -0400 Subject: [PATCH 3/3] feat(DatePicker): surface range disabled-date error and fix lint Wire validateDateRange into the typed-commit path so a range spanning a disabled date shows an error instead of silently resetting; dedupe its span check onto rangeContainsDisabled. Also clean up pre-existing lint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../__tests__/DatePickerInput.test.tsx | 59 ++++++++++++++ .../src/DatePicker/DatePickerInput/index.tsx | 77 +++++++++++++++---- .../DatePickerInput/utils/validation.ts | 38 ++++----- 3 files changed, 138 insertions(+), 36 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx index 901d25d591..7181c68417 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx @@ -168,4 +168,63 @@ describe('DatePickerInput', () => { const hidden = view.container.querySelector('input[type="hidden"]')!; expect(hidden).toHaveValue('2024-03-15'); }); + + describe('range disabled-date validation', () => { + // Disables March 20, 2024 - a date that sits inside the range typed below. + const disableDate = (date: Date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 20; + + it('shows the range error and does not commit when a typed range spans a disabled date', async () => { + const user = userEvent.setup(); + const onRangeSelection = jest.fn(); + const { view } = renderInput({ + context: createMockRangeContext({ + startDate: new Date(2024, 2, 15), + endDate: null, + activeRangePart: 'end', + disableDate, + onRangeSelection, + }), + rangePart: 'end', + }); + + view.getByRole('spinbutton', { name: 'month' }).focus(); + await user.keyboard('03'); + await user.keyboard('25'); + await user.keyboard('2024'); + + view.getByText('This date range contains unavailable dates'); + expect(onRangeSelection).not.toHaveBeenCalled(); + }); + + it('commits and clears the error when the typed range avoids disabled dates', async () => { + const user = userEvent.setup(); + const onRangeSelection = jest.fn(); + const { view } = renderInput({ + context: createMockRangeContext({ + startDate: new Date(2024, 2, 15), + endDate: null, + activeRangePart: 'end', + disableDate, + onRangeSelection, + }), + rangePart: 'end', + }); + + view.getByRole('spinbutton', { name: 'month' }).focus(); + await user.keyboard('03'); + await user.keyboard('18'); + await user.keyboard('2024'); + + expect( + view.queryByText('This date range contains unavailable dates') + ).toBeNull(); + expect(onRangeSelection).toHaveBeenCalledWith( + new Date(2024, 2, 15), + new Date(2024, 2, 18) + ); + }); + }); }); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index 448a31f9ae..dd76bf7e4f 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -35,7 +35,7 @@ import { getDateFieldOrder, getDateFormatLayout, } from './utils'; -import { validateSegments } from './utils/validation'; +import { validateDateRange, validateSegments } from './utils/validation'; /* FormError's `absolute` variant takes the message out of layout flow (anchored to FormGroup's position: relative container), so the input never changes height: @@ -57,15 +57,10 @@ export type DatePickerInputProps = Omit< > & { /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */ rangePart?: 'start' | 'end'; - /** Error message to display. String for specific error, undefined for no error. */ - error?: string; }; export const DatePickerInput = forwardRef( - ( - { disabled, error, form, label, name, rangePart, size = 'base', ...rest }, - ref - ) => { + ({ disabled, form, label, name, rangePart, size = 'base', ...rest }, ref) => { const context = useDatePicker(); if (context === null) { @@ -171,6 +166,38 @@ export const DatePickerInput = forwardRef( [isRange, rangePart, context, endDate, date, disableDate] ); + // Returns the range-level error message for the range that *would* be + // committed if `parsed` were applied to this input, or null. Mirrors the + // endpoint conditions in handleDateSelectRange so we only flag spans that + // would actually become a committed start+end range (both set, in order). + const getRangeError = useCallback( + (parsed: Date): string | null => { + if (!isRange || !rangePart) return null; + const rangeStart = + rangePart === 'start' + ? parsed + : date && parsed.getTime() >= date.getTime() + ? date + : null; + const rangeEnd = + rangePart === 'end' + ? parsed + : endDate && parsed.getTime() <= endDate.getTime() + ? endDate + : null; + const rangeResult = validateDateRange( + rangeStart, + rangeEnd, + translations, + disableDate + ); + return rangeResult && !rangeResult.isValid + ? rangeResult.errorMessage + : null; + }, + [isRange, rangePart, date, endDate, translations, disableDate] + ); + const clearSelection = useCallback(() => { if (!isRange) { context.onSelection(null); @@ -189,9 +216,15 @@ export const DatePickerInput = forwardRef( disableDate ); if (validationResult.isValid) { - setInputError(''); - setHasError(false); - commitParsedDate(validationResult.date); + const rangeError = getRangeError(validationResult.date); + if (rangeError) { + setInputError(rangeError); + setHasError(true); + } else { + setInputError(''); + setHasError(false); + commitParsedDate(validationResult.date); + } } else if (!next.month && !next.day && !next.year) { setInputError(''); setHasError(false); @@ -201,7 +234,14 @@ export const DatePickerInput = forwardRef( setHasError(true); } }, - [clearSelection, commitParsedDate, translations, disableDate, setHasError] + [ + clearSelection, + commitParsedDate, + getRangeError, + translations, + disableDate, + setHasError, + ] ); const onContainerBlur = useCallback( @@ -227,6 +267,14 @@ export const DatePickerInput = forwardRef( ); if (validationResult.isValid) { + // Valid individual date, but the resulting range may span a + // disabled date - keep the raw input visible and surface the error. + const rangeError = getRangeError(validationResult.date); + if (rangeError) { + setInputError(rangeError); + setHasError(true); + return prev; + } // Valid complete date - normalize and commit setInputError(''); setHasError(false); @@ -256,9 +304,9 @@ export const DatePickerInput = forwardRef( [ containerRef, boundDate, - segmentsFromBound, clearSelection, commitParsedDate, + getRangeError, isCalendarOpen, translations, disableDate, @@ -301,7 +349,6 @@ export const DatePickerInput = forwardRef( width="fit-content" > ( value={hiddenValue} /> buttonRef.current?.blur()} /> diff --git a/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.ts b/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.ts index 995c403dee..1c766ceae1 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/utils/validation.ts @@ -1,3 +1,4 @@ +import { rangeContainsDisabled } from '../../DatePickerCalendar/utils/dateSelect'; import type { DatePickerTranslations } from '../../utils/translations'; import type { SegmentValues } from '../Segment/utils'; import { parseSegmentsToDate } from '../Segment/utils'; @@ -132,7 +133,7 @@ export function validateSegments( if (parsed) { // Date is structurally valid, now check if it's disabled - if (disableDate && disableDate(parsed)) { + if (disableDate?.(parsed)) { return { isValid: false, errorMessage: generateErrorMessage( @@ -159,7 +160,11 @@ export function validateSegments( } /** - * Validates a date range to ensure no dates in the range are disabled. + * Validates a committed date range to ensure no dates within it are disabled. + * Returns null when the range is complete and clear (or incomplete), or a + * `range-contains-disabled-date` result when any date in the span is disabled. + * Delegates the span walk to `rangeContainsDisabled` (the same check the + * calendar uses) so typed and clicked ranges stay consistent. */ export function validateDateRange( startDate: Date | null, @@ -169,25 +174,16 @@ export function validateDateRange( ): ValidationResult | null { if (!startDate || !endDate || !disableDate) return null; - // Check if any date in the range is disabled - const current = new Date(startDate); - while (current <= endDate) { - if (disableDate(current)) { - return { - isValid: false, - errorMessage: generateErrorMessage( - 'range-contains-disabled-date', - { - month: '', - day: '', - year: '', - }, - translations - ), - reason: 'range-contains-disabled-date', - }; - } - current.setDate(current.getDate() + 1); + if (rangeContainsDisabled({ startDate, endDate, disableDate })) { + return { + isValid: false, + errorMessage: generateErrorMessage( + 'range-contains-disabled-date', + { month: '', day: '', year: '' }, + translations + ), + reason: 'range-contains-disabled-date', + }; } return null;