From 81bac52b346d31f46e195048c614eeb2ea0a8423 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Jul 2026 14:36:37 -0400 Subject: [PATCH 1/2] 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/2] 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', };