diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index b334e4992d..1d012b63ea 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -1,5 +1,3 @@ -import { MiniArrowLeftIcon, MiniArrowRightIcon } from '@codecademy/gamut-icons'; -import { useElementDir } from '@codecademy/gamut-styles'; import { useCallback, useEffect, @@ -9,7 +7,6 @@ import { useState, } from 'react'; -import { Box, FlexBox } from '../Box'; import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; import { @@ -22,6 +19,7 @@ import type { DatePickerRangeContextValue, } from './DatePickerContext/types'; import { DatePickerInput } from './DatePickerInput'; +import { DatePickerRangeInputWrapper } from './DatePickerInput/DatePickerRangeInputWrapper'; import type { DatePickerProps } from './types'; import { useResolvedLocale } from './utils/locale'; import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './utils/translations'; @@ -36,6 +34,7 @@ export const DatePicker: React.FC = (props) => { inputSize, quickActions, placement = 'inline', + description, } = props; const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [focusGridSignal, setFocusGridSignal] = useState(false); @@ -45,7 +44,6 @@ export const DatePicker: React.FC = (props) => { const inputRef = useRef(null); const dialogId = useId(); const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; - const isRtl = useElementDir() === 'rtl'; const clearGridFocusRequest = useCallback(() => { setGridFocusRequested(false); @@ -148,31 +146,19 @@ export const DatePicker: React.FC = (props) => { children ) : ( <> - - {mode === 'range' ? ( - <> - - - {isRtl ? : } - - - - ) : ( - - )} - + {mode === 'range' ? ( + + ) : ( + + )} = ({ {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/DatePickerInputShell/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/DatePickerInputShell/elements.tsx new file mode 100644 index 0000000000..52064147b5 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/DatePickerInputShell/elements.tsx @@ -0,0 +1,87 @@ +import { css, variant } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +import { Box, FlexBox } from '../../../Box'; +import { FormError } from '../../../Form/elements/FormError'; +import { + formFieldFocusStyles, + formFieldStyles, + inputSizeStyles, +} from '../../../Form/styles'; +import { + DATE_PICKER_ERROR_SLOT_HEIGHT, + DATE_PICKER_FIELD_WIDTH, +} from '../../constants'; + +export const DatePickerInputShellContainer = styled(Box)( + css({ + width: 'fit-content', + }) +); + +export const DatePickerInputShellField = styled(Box)( + css({ + position: 'relative', + width: 'fit-content', + }) +); + +export const DatePickerInputShellErrorSpacer = styled(Box)( + css({ + minHeight: DATE_PICKER_ERROR_SLOT_HEIGHT, + }) +); + +export const DatePickerInputShellError = styled(FormError)( + css({ + left: 0, + position: 'absolute', + top: '100%', + width: DATE_PICKER_FIELD_WIDTH, + whiteSpace: 'normal', + }) +); + +const shellFocusStyles = variant({ + variants: { + error: { + borderColor: 'feedback-error', + '&:hover': { + borderColor: 'feedback-error', + }, + '&:focus': { + borderColor: 'feedback-error', + boxShadow: `inset 0 0 0 1px feedback-error`, + }, + '&:focus-within': { + borderColor: 'feedback-error', + boxShadow: `inset 0 0 0 1px feedback-error`, + }, + }, + default: { + '&:focus-within': formFieldFocusStyles, + }, + }, +}); + +interface SegmentedShellProps + extends StyleProps, + StyleProps {} + +/** + * Shell uses the same styles as `Input`. `formFieldStyles` targets `&:focus`, but the host is a + * `div` — focus is on inner spinbuttons, so we mirror `Input` focus visuals with `&:focus-within`. + */ +export const SegmentedShell = styled(FlexBox)( + formFieldStyles, + inputSizeStyles, + shellFocusStyles, + css({ + flexGrow: 0, + flexShrink: 0, + maxWidth: DATE_PICKER_FIELD_WIDTH, + minWidth: DATE_PICKER_FIELD_WIDTH, + width: DATE_PICKER_FIELD_WIDTH, + }) +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/DatePickerInputShell/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/DatePickerInputShell/index.tsx new file mode 100644 index 0000000000..2a68e426c6 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/DatePickerInputShell/index.tsx @@ -0,0 +1,339 @@ +import { MiniCalendarIcon } from '@codecademy/gamut-icons'; +import { + type FocusEvent, + forwardRef, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { FlexBox } from '../../../Box'; +import { IconButton } from '../../../Button'; +import type { InputWrapperProps } from '../../../Form/inputs/Input'; +import { isSameDay } from '../../DatePickerCalendar/Calendar/utils/dateGrid'; +import { handleDateSelectRange } from '../../DatePickerCalendar/utils/dateSelect'; +import { useDatePicker } from '../../DatePickerContext'; +import { DatePickerInputSegment } from '../Segment'; +import { SegmentLiteral } from '../Segment/elements'; +import { + getDateSegmentsFromDate, + getSegmentValidationState, + parseSegmentsToDate, + resolveSegmentsOnBlur, + type SegmentValues, +} from '../Segment/utils'; +import { + DatePickerInputShellContainer, + DatePickerInputShellError, + DatePickerInputShellErrorSpacer, + DatePickerInputShellField, + SegmentedShell, +} from './elements'; +import { + type DatePartKind, + formatDateISO8601DateOnly, + getDateFieldOrder, + getDateFormatLayout, +} from './utils'; + +export type DatePickerInputShellProps = Omit< + InputWrapperProps, + 'className' | 'type' | 'icon' | 'value' | 'onChange' | 'color' | 'label' +> & { + labelledById: string; + rangePart?: 'start' | 'end'; + shellId: string; +}; + +export const DatePickerInputShell = forwardRef< + HTMLDivElement, + DatePickerInputShellProps +>( + ( + { + disabled, + error, + form, + labelledById, + name, + rangePart, + shellId, + size = 'base', + ...rest + }, + ref + ) => { + const context = useDatePicker(); + + const { + mode, + openCalendar, + focusCalendar, + locale, + isCalendarOpen, + disableDate, + translations, + } = context; + + const isRange = mode === 'range'; + const endDate = isRange ? context.endDate : null; + const date = isRange ? context.startDate : context.selectedDate; + + const buttonRef = useRef(null); + const errorId = useId(); + + const { layout, fieldOrder } = useMemo(() => { + const layout = getDateFormatLayout(locale); + return { layout, fieldOrder: getDateFieldOrder(layout) }; + }, [locale]); + + const boundDate = isRange && rangePart === 'end' ? endDate : date; + const segmentsFromBound = useMemo( + () => getDateSegmentsFromDate(boundDate), + [boundDate] + ); + const [segments, setSegments] = useState(segmentsFromBound); + const [validationError, setValidationError] = useState(null); + + const parsedForHidden = parseSegmentsToDate(segments); + const hiddenValue = parsedForHidden + ? formatDateISO8601DateOnly(parsedForHidden) + : ''; + const showError = Boolean(error) || Boolean(validationError); + + const isInputFocusedRef = useRef(false); + const segmentsRef = useRef(segments); + segmentsRef.current = segments; + const containerRef = useRef(null); + const segmentElRefs = useRef< + Partial> + >({}); + + const assignSegmentRef = useCallback( + (field: DatePartKind, el: HTMLSpanElement | null) => { + segmentElRefs.current[field] = el; + }, + [] + ); + + const onSiblingSegmentFocus = useCallback((field: DatePartKind) => { + segmentElRefs.current[field]?.focus(); + }, []); + + const shellRef = useCallback( + (el: HTMLDivElement | null) => { + containerRef.current = el; + if (typeof ref === 'function') ref(el); + else if (ref !== null) ref.current = el; + }, + [ref] + ); + + useEffect(() => { + if (!isInputFocusedRef.current) { + setSegments(segmentsFromBound); + setValidationError(null); + } + }, [segmentsFromBound]); + + const commitParsedDate = useCallback( + (parsed: Date) => { + if (!isRange) { + context.onSelection(parsed); + } + if (isRange && rangePart) { + handleDateSelectRange({ + date: parsed, + activeRangePart: rangePart, + startDate: date, + endDate, + onRangeSelection: context.onRangeSelection, + disableDate, + }); + } + }, + [isRange, rangePart, context, endDate, date, disableDate] + ); + + const clearSelection = useCallback(() => { + if (!isRange) { + context.onSelection(null); + } + if (isRange && rangePart) { + if (rangePart === 'start') context.onRangeSelection(null, endDate); + else context.onRangeSelection(date, null); + } + }, [isRange, rangePart, context, endDate, date]); + + const onSegmentChange = useCallback( + (next: SegmentValues) => { + const validation = getSegmentValidationState(next); + + if (validation?.parsedDate) { + setValidationError(null); + commitParsedDate(validation.parsedDate); + return; + } + + if (validation?.isInvalid) { + setValidationError(translations.invalidDateError); + return; + } + + if (!next.month && !next.day && !next.year) { + setValidationError(null); + clearSelection(); + return; + } + + setValidationError(null); + }, + [clearSelection, commitParsedDate, translations.invalidDateError] + ); + + const onContainerBlur = useCallback( + (e: FocusEvent) => { + if (containerRef.current?.contains(e.relatedTarget as Node)) return; + isInputFocusedRef.current = false; + + const resolution = resolveSegmentsOnBlur( + segmentsRef.current, + boundDate + ); + + setValidationError( + resolution.isInvalid ? translations.invalidDateError : null + ); + setSegments(resolution.segments); + + if (resolution.shouldClear) { + clearSelection(); + } else if ( + resolution.parsedDate && + isCalendarOpen && + !isSameDay(resolution.parsedDate, boundDate) + ) { + commitParsedDate(resolution.parsedDate); + } + }, + [ + boundDate, + clearSelection, + commitParsedDate, + isCalendarOpen, + translations.invalidDateError, + ] + ); + + const setActiveRangePartForField = useCallback(() => { + if (isRange && rangePart) context.setActiveRangePart(rangePart); + }, [isRange, rangePart, context]); + + const onSegmentFocus = useCallback(() => { + isInputFocusedRef.current = true; + setActiveRangePartForField(); + }, [setActiveRangePartForField]); + + const onShellFocus = useCallback(() => { + setActiveRangePartForField(); + }, [setActiveRangePartForField]); + + const onShellClick = useCallback(() => { + if (disabled) return; + setActiveRangePartForField(); + openCalendar(); + }, [disabled, setActiveRangePartForField, openCalendar]); + + const onSegmentAltArrowDown = useCallback(() => { + if (!isCalendarOpen) openCalendar(); + focusCalendar(); + }, [isCalendarOpen, openCalendar, focusCalendar]); + + return ( + + + + + {layout.map((item, index) => { + if (item.kind === 'literal') { + return ( + + {`${item.text}`} + + ); + } + const idx = fieldOrder.indexOf(item.field); + const prevField = idx > 0 ? fieldOrder[idx - 1] : null; + const nextField = + idx < fieldOrder.length - 1 ? fieldOrder[idx + 1] : null; + + return ( + + ); + })} + + + buttonRef.current?.blur()} + /> + + {validationError ? ( + + {validationError} + + ) : null} + + + + ); + } +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/utils.ts b/packages/gamut/src/DatePicker/DatePickerInput/DatePickerInputShell/utils.ts similarity index 96% rename from packages/gamut/src/DatePicker/DatePickerInput/utils.ts rename to packages/gamut/src/DatePicker/DatePickerInput/DatePickerInputShell/utils.ts index aaa7debf00..261c57b9cc 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/utils.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/DatePickerInputShell/utils.ts @@ -1,4 +1,4 @@ -import { stringifyLocale } from '../utils/locale'; +import { stringifyLocale } from '../../utils/locale'; export type DatePartKind = 'month' | 'day' | 'year'; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/DatePickerRangeInputWrapper/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/DatePickerRangeInputWrapper/index.tsx new file mode 100644 index 0000000000..beb1ad22b3 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/DatePickerRangeInputWrapper/index.tsx @@ -0,0 +1,110 @@ +import { MiniArrowLeftIcon, MiniArrowRightIcon } from '@codecademy/gamut-icons'; +import { useElementDir } from '@codecademy/gamut-styles'; +import { forwardRef, useId, useMemo } from 'react'; + +import { Box, FlexBox } from '../../../Box'; +import { FormGroupLabel } from '../../../Form/elements/FormGroupLabel'; +import { + DATE_PICKER_FIELD_WIDTH, + DATE_PICKER_RANGE_ARROW_WIDTH, + DATE_PICKER_SHELL_HEIGHT, +} from '../../constants'; +import { useDatePicker } from '../../DatePickerContext'; +import { createDatePickerFieldIds } from '../../utils/fieldIds'; +import type { DatePickerInputShellProps } from '../DatePickerInputShell'; +import { DatePickerInputShell } from '../DatePickerInputShell'; +import { DatePickerDescription } from '../elements'; + +export type DatePickerRangeInputWrapperProps = Omit< + DatePickerInputShellProps, + 'labelledById' | 'shellId' | 'rangePart' +> & { + description?: string; +}; + +export const DatePickerRangeInputWrapper = forwardRef< + HTMLDivElement, + DatePickerRangeInputWrapperProps +>(({ description, size = 'base', ...shellProps }, ref) => { + const { translations } = useDatePicker(); + const isRtl = useElementDir() === 'rtl'; + const columnGap = size === 'small' ? 4 : 8; + + const startUid = useId(); + const endUid = useId(); + + const startField = useMemo( + () => ({ + fieldIds: createDatePickerFieldIds(startUid, startUid), + label: translations.startDateLabel, + name: 'datePickerInputStart' as const, + }), + [startUid, translations.startDateLabel] + ); + + const endField = useMemo( + () => ({ + fieldIds: createDatePickerFieldIds(endUid, endUid), + label: translations.endDateLabel, + name: 'datePickerInputEnd' as const, + }), + [endUid, translations.endDateLabel] + ); + + return ( + + + + + {startField.label} + + + + + + {endField.label} + + + + {description ? ( + + {description} + + ) : null} + + + + {isRtl ? : } + + + + + ); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts index 49d1da3376..834818b99d 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts @@ -1,16 +1,18 @@ -import type { DateFormatLayoutItem } from '../../utils'; +import { DateFormatLayoutItem } from '../../DatePickerInputShell/utils'; import { appendSegmentDigit, buildCombinedFromSegments, digitsToSegments, getDateSegmentsFromDate, getSegmentSpinBounds, + getSegmentValidationState, getStrictSegmentDigits, isStrictlyCompleteDateEntry, normalizeSegmentValues, padSegmentNumber, parseSegmentNumericString, parseSegmentsToDate, + resolveSegmentsOnBlur, spinSegment, } from '../utils'; @@ -186,6 +188,111 @@ describe('normalizeSegmentValues', () => { }); }); +describe('getSegmentValidationState', () => { + it('returns invalid for a complete but impossible date', () => { + expect( + getSegmentValidationState({ month: '02', day: '30', year: '2023' }) + ).toEqual({ + isInvalid: true, + parsedDate: null, + segments: { month: '02', day: '30', year: '2023' }, + }); + }); + + it('returns valid for a complete date with single-digit month', () => { + expect( + getSegmentValidationState({ month: '2', day: '15', year: '2026' }) + ).toEqual({ + isInvalid: false, + parsedDate: new Date(2026, 1, 15), + segments: { month: '02', day: '15', year: '2026' }, + }); + }); + + it('returns null for incomplete input', () => { + expect( + getSegmentValidationState({ month: '02', day: '15', year: '20' }) + ).toBeNull(); + }); +}); + +describe('resolveSegmentsOnBlur', () => { + it('keeps strict invalid dates visible and marks them invalid', () => { + expect( + resolveSegmentsOnBlur( + { month: '02', day: '30', year: '2023' }, + new Date(2024, 0, 1) + ) + ).toEqual({ + isInvalid: true, + parsedDate: null, + segments: { month: '02', day: '30', year: '2023' }, + shouldClear: false, + }); + }); + + it('errors on invalid dates with single-digit month or day instead of clamping', () => { + expect( + resolveSegmentsOnBlur( + { month: '2', day: '30', year: '2026' }, + new Date(2024, 0, 1) + ) + ).toEqual({ + isInvalid: true, + parsedDate: null, + segments: { month: '02', day: '30', year: '2026' }, + shouldClear: false, + }); + }); + + it('accepts valid dates with single-digit month or day', () => { + expect( + resolveSegmentsOnBlur({ month: '2', day: '15', year: '2026' }, null) + ).toEqual({ + isInvalid: false, + parsedDate: new Date(2026, 1, 15), + segments: { month: '02', day: '15', year: '2026' }, + shouldClear: false, + }); + }); + + it('normalizes valid complete dates', () => { + expect( + resolveSegmentsOnBlur({ month: '03', day: '15', year: '2024' }, null) + ).toEqual({ + isInvalid: false, + parsedDate: new Date(2024, 2, 15), + segments: { month: '03', day: '15', year: '2024' }, + shouldClear: false, + }); + }); + + it('reverts incomplete input to the bound date', () => { + expect( + resolveSegmentsOnBlur( + { month: '03', day: '15', year: '20' }, + new Date(2024, 0, 1) + ) + ).toEqual({ + isInvalid: false, + parsedDate: null, + segments: { month: '01', day: '01', year: '2024' }, + shouldClear: false, + }); + }); + + it('clears empty input', () => { + expect( + resolveSegmentsOnBlur({ month: '', day: '', year: '' }, null) + ).toEqual({ + isInvalid: false, + parsedDate: null, + segments: { month: '', day: '', year: '' }, + shouldClear: true, + }); + }); +}); + describe('getSegmentSpinBounds', () => { it('bounds month to 1-12', () => { expect( diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx index eeef657ac0..8668bead1e 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx @@ -1,6 +1,6 @@ import { type Dispatch, type SetStateAction, useCallback, useId } from 'react'; -import type { DatePartKind } from '../utils'; +import type { DatePartKind } from '../DatePickerInputShell/utils'; import { Segment } from './elements'; import { appendSegmentDigit, diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts index 1cfb4a3b5b..5f344e3ccc 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts @@ -1,4 +1,7 @@ -import type { DateFormatLayoutItem, DatePartKind } from '../utils'; +import type { + DateFormatLayoutItem, + DatePartKind, +} from '../DatePickerInputShell/utils'; export type SegmentValues = { month: string; @@ -51,6 +54,20 @@ export const isStrictlyCompleteDateEntry = (strictSegments: SegmentValues) => { return year.length === 4 && month.length === 2 && day.length === 2; }; +/** Year is full length and both month and day have digits (e.g. 2/30/2026). */ +export const isCompleteDateEntryAttempt = (strictSegments: SegmentValues) => { + const { month, day, year } = strictSegments; + return year.length === 4 && month.length > 0 && day.length > 0; +}; + +export const padSegmentDigitsForParse = ( + strictSegments: SegmentValues +): SegmentValues => ({ + month: strictSegments.month.padStart(2, '0'), + day: strictSegments.day.padStart(2, '0'), + year: strictSegments.year, +}); + export const normalizeSegmentValues = ( segments: SegmentValues ): SegmentValues => { @@ -84,6 +101,89 @@ export const normalizeSegmentValues = ( return { month, day, year }; }; +export type SegmentBlurResolution = { + isInvalid: boolean; + parsedDate: Date | null; + segments: SegmentValues; + shouldClear: boolean; +}; + +export type SegmentValidationState = { + isInvalid: boolean; + parsedDate: Date | null; + segments: SegmentValues; +}; + +export const getSegmentValidationState = ( + segments: SegmentValues +): SegmentValidationState | null => { + const strictSegments = getStrictSegmentDigits(segments); + + if (isStrictlyCompleteDateEntry(strictSegments)) { + const parsed = parseSegmentsToDate(strictSegments); + return { + isInvalid: parsed === null, + parsedDate: parsed, + segments: parsed ? getDateSegmentsFromDate(parsed) : strictSegments, + }; + } + + if (isCompleteDateEntryAttempt(strictSegments)) { + const paddedSegments = padSegmentDigitsForParse(strictSegments); + const parsed = parseSegmentsToDate(paddedSegments); + return { + isInvalid: parsed === null, + parsedDate: parsed, + segments: parsed ? getDateSegmentsFromDate(parsed) : paddedSegments, + }; + } + + return null; +}; + +export const resolveSegmentsOnBlur = ( + segments: SegmentValues, + boundDate: Date | null +): SegmentBlurResolution => { + const validation = getSegmentValidationState(segments); + + if (validation) { + return { + isInvalid: validation.isInvalid, + parsedDate: validation.parsedDate, + segments: validation.segments, + shouldClear: false, + }; + } + + const normalized = normalizeSegmentValues(segments); + const parsed = parseSegmentsToDate(normalized); + if (parsed) { + return { + isInvalid: false, + parsedDate: parsed, + segments: normalized, + shouldClear: false, + }; + } + + if (!normalized.month && !normalized.day && !normalized.year) { + return { + isInvalid: false, + parsedDate: null, + segments: getDateSegmentsFromDate(null), + shouldClear: true, + }; + } + + return { + isInvalid: false, + parsedDate: null, + segments: getDateSegmentsFromDate(boundDate), + shouldClear: false, + }; +}; + export const getSegmentPlaceholder = (field: DatePartKind) => field === 'year' ? 'YYYY' : field === 'month' ? 'MM' : 'DD'; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx index 901d25d591..acb4e49a12 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx @@ -1,5 +1,5 @@ import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { ComponentProps, FC } from 'react'; @@ -9,6 +9,7 @@ import { createMockSingleContext, } from '../../DatePickerContext/__tests__/mockContexts'; import type { DatePickerContextValue } from '../../DatePickerContext/types'; +import { DatePickerRangeInputWrapper } from '../DatePickerRangeInputWrapper'; import { DatePickerInput } from '../index'; type HarnessProps = { context: DatePickerContextValue } & ComponentProps< @@ -48,6 +49,14 @@ const RangeLabelsHarness: FC = ({ const renderRange = setupRtl(RangeLabelsHarness, {}); +const RangeWrapperHarness: FC = () => ( + + + +); + +const renderRangeWrapper = setupRtl(RangeWrapperHarness, {}); + describe('DatePickerInput', () => { it('throws when rendered without DatePickerProvider', () => { expect(() => @@ -71,6 +80,14 @@ describe('DatePickerInput', () => { expect(openCalendar).toHaveBeenCalledTimes(1); }); + it('renders description between the label and the input', () => { + const { view } = renderInput({ + description: 'Use MM/DD/YYYY format.', + }); + + view.getByText('Use MM/DD/YYYY format.'); + }); + it('renders default Date label in single date mode', () => { const { view } = renderInput(); @@ -78,7 +95,7 @@ describe('DatePickerInput', () => { }); it('renders default Start date and End date labels in range mode', () => { - const { view } = renderRange(); + const { view } = renderRangeWrapper(); view.getByText('Start date'); view.getByText('End date'); @@ -168,4 +185,129 @@ describe('DatePickerInput', () => { const hidden = view.container.querySelector('input[type="hidden"]')!; expect(hidden).toHaveValue('2024-03-15'); }); + + it('shows an error and keeps invalid input after blur', async () => { + const user = userEvent.setup(); + const { view } = renderInput({ + context: createMockSingleContext({ selectedDate: null }), + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + const day = view.getByRole('spinbutton', { name: 'day' }); + const year = view.getByRole('spinbutton', { name: 'year' }); + + month.focus(); + await user.keyboard('02'); + await user.keyboard('30'); + await user.keyboard('2023'); + + fireEvent.blur(year, { relatedTarget: document.body }); + + view.getByText('Enter a valid date'); + expect(view.getByRole('spinbutton', { name: 'month' })).toHaveTextContent( + '02' + ); + expect(view.getByRole('spinbutton', { name: 'day' })).toHaveTextContent( + '30' + ); + expect(view.getByRole('spinbutton', { name: 'year' })).toHaveTextContent( + '2023' + ); + expect(view.container.querySelector('input[type="hidden"]')).toHaveValue( + '' + ); + expect(view.getByRole('group')).toHaveAttribute( + 'aria-describedby', + expect.stringMatching(/./) + ); + expect( + view.getAllByRole('spinbutton', { name: 'month' })[0] + ).toHaveAttribute('aria-invalid', 'true'); + }); + + it('shows an error for invalid single-digit month input after blur', async () => { + const user = userEvent.setup(); + const { view } = renderInput({ + context: createMockSingleContext({ selectedDate: null }), + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + const day = view.getByRole('spinbutton', { name: 'day' }); + const year = view.getByRole('spinbutton', { name: 'year' }); + + month.focus(); + await user.keyboard('2'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('30'); + await user.keyboard('2026'); + + fireEvent.blur(year, { relatedTarget: document.body }); + + view.getByText('Enter a valid date'); + expect(view.getByRole('spinbutton', { name: 'month' })).toHaveTextContent( + '02' + ); + expect(view.getByRole('spinbutton', { name: 'day' })).toHaveTextContent( + '30' + ); + expect(view.getByRole('spinbutton', { name: 'year' })).toHaveTextContent( + '2026' + ); + expect(view.container.querySelector('input[type="hidden"]')).toHaveValue( + '' + ); + }); + + it('shows an error immediately when a complete invalid date is typed', async () => { + const user = userEvent.setup(); + const { view } = renderInput({ + context: createMockSingleContext({ selectedDate: null }), + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + const day = view.getByRole('spinbutton', { name: 'day' }); + const year = view.getByRole('spinbutton', { name: 'year' }); + + month.focus(); + await user.keyboard('2'); + await user.keyboard('{ArrowRight}'); + await user.keyboard('30'); + await user.keyboard('2026'); + + await waitFor(() => { + view.getByText('Enter a valid date'); + }); + expect(document.activeElement).toBe(year); + }); + + it('clears the validation error when the user corrects the date', async () => { + const user = userEvent.setup(); + const { view } = renderInput({ + context: createMockSingleContext({ selectedDate: null }), + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + const day = view.getByRole('spinbutton', { name: 'day' }); + const year = view.getByRole('spinbutton', { name: 'year' }); + + month.focus(); + await user.keyboard('02'); + await user.keyboard('30'); + await user.keyboard('2023'); + + await waitFor(() => { + view.getByText('Enter a valid date'); + }); + + day.focus(); + await user.keyboard('{Backspace}{Backspace}'); + await user.keyboard('28'); + + await waitFor(() => { + expect(view.queryByText('Enter a valid date')).not.toBeInTheDocument(); + }); + + const hidden = view.container.querySelector('input[type="hidden"]')!; + expect(hidden).toHaveValue('2023-02-28'); + }); }); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx index 672f95d6b7..c6fd9a60a0 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx @@ -1,46 +1,10 @@ -import { variant } from '@codecademy/gamut-styles'; -import { StyleProps } from '@codecademy/variance'; +import { css } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; -import { FlexBox } from '../../Box'; -import { - formFieldFocusStyles, - formFieldStyles, - inputSizeStyles, -} from '../../Form/styles'; +import { FormGroupDescription } from '../../Form'; -const shellFocusStyles = variant({ - variants: { - error: { - borderColor: 'feedback-error', - '&:hover': { - borderColor: 'feedback-error', - }, - '&:focus': { - borderColor: 'feedback-error', - boxShadow: `inset 0 0 0 1px feedback-error`, - }, - '&:focus-within': { - borderColor: 'feedback-error', - boxShadow: `inset 0 0 0 1px feedback-error`, - }, - }, - default: { - '&:focus-within': formFieldFocusStyles, - }, - }, -}); - -interface SegmentedShellProps - extends StyleProps, - StyleProps {} - -/** - * Shell uses the same styles as `Input`. `formFieldStyles` targets `&:focus`, but the host is a - * `div` — focus is on inner spinbuttons, so we mirror `Input` focus visuals with `&:focus-within`. - */ -export const SegmentedShell = styled(FlexBox)( - formFieldStyles, - inputSizeStyles, - shellFocusStyles +export const DatePickerDescription = styled(FormGroupDescription)( + css({ + whiteSpace: 'nowrap', + }) ); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index ebce7088a2..0cae328ed2 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -1,36 +1,19 @@ -import { MiniCalendarIcon } from '@codecademy/gamut-icons'; -import { - type FocusEvent, - forwardRef, - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; +import { forwardRef, useId } from 'react'; -import { FlexBox } from '../../Box'; +import { Box, FlexBox } from '../../Box'; import { FormGroup } from '../../Form/elements/FormGroup'; +import { FormGroupLabel } from '../../Form/elements/FormGroupLabel'; import type { InputWrapperProps } from '../../Form/inputs/Input'; -import { isSameDay } from '../DatePickerCalendar/Calendar/utils/dateGrid'; -import { handleDateSelectRange } from '../DatePickerCalendar/utils/dateSelect'; +import { DATE_PICKER_FIELD_WIDTH } from '../constants'; import { useDatePicker } from '../DatePickerContext'; -import { SegmentedShell } from './elements'; -import { DatePickerInputSegment } from './Segment'; -import { SegmentLiteral } from './Segment/elements'; -import { - type SegmentValues, - getDateSegmentsFromDate, - normalizeSegmentValues, - parseSegmentsToDate, -} from './Segment/utils'; import { - type DatePartKind, - formatDateISO8601DateOnly, - getDateFieldOrder, - getDateFormatLayout, -} from './utils'; + createDatePickerFieldIds, + createDatePickerShellId, +} from '../utils/fieldIds'; +import { DatePickerInputShell } from './DatePickerInputShell'; +import { DatePickerDescription } from './elements'; + +export { DatePickerDescription } from './elements'; export type DatePickerInputProps = Omit< InputWrapperProps, @@ -38,11 +21,23 @@ 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'; + /** Description to display between the label and the input. */ + description?: string; }; export const DatePickerInput = forwardRef( ( - { disabled, error, form, label, name, rangePart, size = 'base', ...rest }, + { + disabled, + error, + form, + label, + name, + rangePart, + size = 'base', + description, + ...rest + }, ref ) => { const context = useDatePicker(); @@ -53,256 +48,69 @@ export const DatePickerInput = forwardRef( ); } - const { - mode, - openCalendar, - focusCalendar, - locale, - isCalendarOpen, - translations, - disableDate, - } = context; - - const isRange = mode === 'range'; - const endDate = isRange ? context.endDate : null; - const date = isRange ? context.startDate : context.selectedDate; - - const inputID = useId(); - const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; - - const { layout, fieldOrder } = useMemo(() => { - const layout = getDateFormatLayout(locale); - return { layout, fieldOrder: getDateFieldOrder(layout) }; - }, [locale]); - - const defaultLabel = !isRange - ? translations.dateLabel - : rangePart === 'end' - ? translations.endDateLabel - : translations.startDateLabel; - - const boundDate = isRange && rangePart === 'end' ? endDate : date; - const segmentsFromBound = useMemo( - () => getDateSegmentsFromDate(boundDate), - [boundDate] - ); - const [segments, setSegments] = useState(segmentsFromBound); - - const parsedForHidden = parseSegmentsToDate(segments); - const hiddenValue = parsedForHidden - ? formatDateISO8601DateOnly(parsedForHidden) - : ''; - - const isInputFocusedRef = useRef(false); - const containerRef = useRef(null); - const segmentElRefs = useRef< - Partial> - >({}); - - const assignSegmentRef = useCallback( - (field: DatePartKind, el: HTMLSpanElement | null) => { - segmentElRefs.current[field] = el; - }, - [segmentElRefs] - ); - - const onSiblingSegmentFocus = useCallback( - (field: DatePartKind) => { - segmentElRefs.current[field]?.focus(); - }, - [segmentElRefs] - ); - - const shellRef = useCallback( - (el: HTMLDivElement | null) => { - containerRef.current = el; - if (typeof ref === 'function') ref(el); - else if (ref !== null) ref.current = el; - }, - [ref] - ); - - useEffect(() => { - if (!isInputFocusedRef.current) { - setSegments(segmentsFromBound); - } - }, [segmentsFromBound]); - - const commitParsedDate = useCallback( - (parsed: Date) => { - if (!isRange) { - context.onSelection(parsed); - } - if (isRange && rangePart) { - handleDateSelectRange({ - date: parsed, - activeRangePart: rangePart, - startDate: date, - endDate, - onRangeSelection: context.onRangeSelection, - disableDate, - }); - } - }, - [isRange, rangePart, context, endDate, date, disableDate] - ); - - const clearSelection = useCallback(() => { - if (!isRange) { - context.onSelection(null); - } - if (isRange && rangePart) { - if (rangePart === 'start') context.onRangeSelection(null, endDate); - else context.onRangeSelection(date, null); - } - }, [isRange, rangePart, context, endDate, date]); - - const onSegmentChange = useCallback( - (next: SegmentValues) => { - const parsed = parseSegmentsToDate(next); - if (parsed) commitParsedDate(parsed); - else if (!next.month && !next.day && !next.year) clearSelection(); - }, - [clearSelection, commitParsedDate] - ); - - const onContainerBlur = useCallback( - (e: FocusEvent) => { - 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); - if (isCalendarOpen && !sameAsBound) { - queueMicrotask(() => { - commitParsedDate(parsed); - }); - } - return normalized; - } - if (!normalized.month && !normalized.day && !normalized.year) { - queueMicrotask(() => { - clearSelection(); - }); - return getDateSegmentsFromDate(null); - } - return segmentsFromBound; - }); - }, - [ - containerRef, - boundDate, - segmentsFromBound, - clearSelection, - commitParsedDate, - isCalendarOpen, - ] - ); - - const setActiveRangePartForField = useCallback(() => { - if (isRange && rangePart) context.setActiveRangePart(rangePart); - }, [isRange, rangePart, context]); - - const onSegmentFocus = useCallback(() => { - isInputFocusedRef.current = true; - setActiveRangePartForField(); - }, [isInputFocusedRef, setActiveRangePartForField]); - - const onShellFocus = useCallback(() => { - setActiveRangePartForField(); - }, [setActiveRangePartForField]); - - const onShellClick = useCallback(() => { - if (disabled) return; - setActiveRangePartForField(); - openCalendar(); - }, [disabled, setActiveRangePartForField, openCalendar]); + const { translations } = context; + const labelUid = useId(); + const inputUid = useId(); + const shellProps = { disabled, error, form, size, ...rest }; + + if (rangePart) { + const shellId = createDatePickerShellId(inputUid); + const defaultLabel = + rangePart === 'end' + ? translations.endDateLabel + : translations.startDateLabel; + + return ( + + {description ? ( + + {description} + + ) : null} + + + ); + } - const onSegmentAltArrowDown = useCallback(() => { - if (!isCalendarOpen) openCalendar(); - focusCalendar(); - }, [isCalendarOpen, openCalendar, focusCalendar]); + const fieldIds = createDatePickerFieldIds(inputUid, labelUid); return ( - - - - {layout.map((item, index) => { - if (item.kind === 'literal') { - return ( - - {`${item.text}`} - - ); - } - const idx = fieldOrder.indexOf(item.field); - const prevField = idx > 0 ? fieldOrder[idx - 1] : null; - const nextField = - idx < fieldOrder.length - 1 ? fieldOrder[idx + 1] : null; - - return ( - - ); - })} - - + + + {label ?? translations.dateLabel} + + + {description ? ( + + {description} + + ) : null} + + - - - - - + + ); } ); diff --git a/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx index 8079c9a453..b15e531563 100644 --- a/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx +++ b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx @@ -46,13 +46,15 @@ type RangeHarnessProps = { onEndSelected: (date: Date | null) => void; }; -const RangeHarness: FC = ({ +const RangeHarness: FC = ({ startDate, endDate, onStartSelected, onEndSelected, + description, }) => ( { expect(view.getAllByRole('group')).toHaveLength(2); }); - it('associates the field label with the segment shell via label `for` and shell `id` (DatePickerInput)', () => { + it('renders one shared description spanning both range fields', () => { + const { view } = renderRange({ + description: 'Insert any rules or instructions here.', + }); + + expect( + view.getAllByText('Insert any rules or instructions here.') + ).toHaveLength(1); + }); + + 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/constants.ts b/packages/gamut/src/DatePicker/constants.ts new file mode 100644 index 0000000000..a0d974dbfc --- /dev/null +++ b/packages/gamut/src/DatePicker/constants.ts @@ -0,0 +1,14 @@ +/** Fixed width of each segmented date field shell (matches design spec). */ +export const DATE_PICKER_FIELD_WIDTH = '170px'; + +/** Horizontal space reserved for the range arrow between start and end fields. */ +export const DATE_PICKER_RANGE_ARROW_WIDTH = 24; + +/** Vertical space reserved below each field shell for validation error text. */ +export const DATE_PICKER_ERROR_SLOT_HEIGHT = 20; + +/** Height of the segmented shell row (used to align the range arrow with inputs). */ +export const DATE_PICKER_SHELL_HEIGHT = { + base: 48, + small: 32, +} as const; diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index c35cf19d35..dc536520c9 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -67,6 +67,8 @@ interface DatePickerBaseProps * @default "inline" */ placement?: 'inline' | 'floating'; + /** Description to display between the label and the input(s). In range mode, spans both fields. */ + description?: string; } export interface DatePickerSingleProps extends DatePickerBaseProps<'single'> { diff --git a/packages/gamut/src/DatePicker/utils/fieldIds.ts b/packages/gamut/src/DatePicker/utils/fieldIds.ts new file mode 100644 index 0000000000..a2590c929b --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/fieldIds.ts @@ -0,0 +1,22 @@ +import type { InputWrapperProps } from '../../Form/inputs/Input'; + +export type DatePickerFieldLabelIds = { + shellId: string; + labelledById: string; +}; + +export type DatePickerFieldProps = Pick< + InputWrapperProps, + 'disabled' | 'error' | 'form' | 'size' +>; + +export const createDatePickerShellId = (uid: string) => + `datepicker-input-${uid.replace(/:/g, '')}`; + +export const createDatePickerFieldIds = ( + inputUid: string, + labelUid: string +): DatePickerFieldLabelIds => ({ + shellId: createDatePickerShellId(inputUid), + labelledById: `datepicker-label-${labelUid.replace(/:/g, '')}`, +}); diff --git a/packages/gamut/src/DatePicker/utils/translations.ts b/packages/gamut/src/DatePicker/utils/translations.ts index ba5dceaa0e..9adf448306 100644 --- a/packages/gamut/src/DatePicker/utils/translations.ts +++ b/packages/gamut/src/DatePicker/utils/translations.ts @@ -9,6 +9,10 @@ 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; + /** Error message when typed segments do not form a valid date (default: "Enter a valid date"). */ + invalidDateError?: 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 +28,8 @@ export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required startDateLabel: 'Start date', endDateLabel: 'End date', calendarDialogAriaLabel: 'Choose date', + openCalendarLabel: 'Open calendar', + invalidDateError: 'Enter a valid date', last7DaysDisplayText: 'Last 7 days', last30DaysDisplayText: 'Last 30 days', last90DaysDisplayText: 'Last 90 days', diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx index dc26b4bb61..65e4e4adf0 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx @@ -335,6 +335,7 @@ export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required startDateLabel: 'Start date', endDateLabel: 'End date', calendarDialogAriaLabel: 'Choose date', + invalidDateError: 'Enter a valid date', last7DaysDisplayText: 'Last 7 days', last30DaysDisplayText: 'Last 30 days', last90DaysDisplayText: 'Last 90 days', @@ -348,7 +349,7 @@ export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required `DatePicker` can be rendered with `children` and composed for more flexibility. The following components/hooks are exported and available to use for composition: - `useDatePicker()` exposes shared shell state. Always narrow on `mode` when mode-specific fields are needed. -- `DatePickerInput`: month/day/year `role="spinbutton"` segments. In range mode use `rangePart="start"` or `"end"` on each instance. +- `DatePickerInput`: month/day/year `role="spinbutton"` segments. In range mode use `rangePart="start"` or `"end"` on each instance for custom layouts. - `DatePickerCalendar`: calendar UI wired to context; pass a stable `dialogId` that matches the `id` on the `role="dialog"` wrapper for `aria-labelledby` / `aria-controls` wiring. Example component to be passed as `children` to `DatePicker`: diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx index 79019732d9..fd1bf16e69 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -100,6 +100,8 @@ export const Default: Story = { ), ], render: function DatePickerStory(args) { + const description = + 'Select a date from the calendar. Insert any rules or instructions here. Select a date from the calendar. Insert any rules or instructions here. Select a date from the calendar. Insert any rules or instructions here.'; const [selectedDate, setSelectedDate] = useState(null); const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); @@ -108,11 +110,17 @@ export const Default: Story = { return ( ); } @@ -120,6 +128,7 @@ export const Default: Story = { return (