diff --git a/.nx/version-plans/version-plan-1775495905156.md b/.nx/version-plans/version-plan-1775495905156.md new file mode 100644 index 00000000000..6cc40f05adb --- /dev/null +++ b/.nx/version-plans/version-plan-1775495905156.md @@ -0,0 +1,5 @@ +--- +gamut: minor +--- + +New DatePicker component diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 962e8667d61..7bdb1824d89 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -33,6 +33,10 @@ exports[`Gamut Exported Keys 1`] = ` "CTAButton", "DataList", "DataTable", + "DatePicker", + "DatePickerCalendar", + "DatePickerInput", + "DatePickerProvider", "DelayedRenderWrapper", "Dialog", "Disclosure", @@ -69,6 +73,7 @@ exports[`Gamut Exported Keys 1`] = ` "ListCol", "ListRow", "Markdown", + "matchDisabledDates", "Menu", "MenuItem", "MenuSeparator", @@ -113,6 +118,7 @@ exports[`Gamut Exported Keys 1`] = ` "ToolTip", "USE_DEBOUNCED_FIELD_DIRTY_KEY", "useConnectedForm", + "useDatePicker", "useDebouncedField", "useField", "useFormState", diff --git a/packages/gamut/jest.config.ts b/packages/gamut/jest.config.ts index 89cacf4d8c9..23d35b69523 100644 --- a/packages/gamut/jest.config.ts +++ b/packages/gamut/jest.config.ts @@ -8,5 +8,5 @@ export default base('gamut', { setupFiles: ['/../../script/jest/base-setup.js'], setupFilesAfterEnv: ['/../../script/jest/rtl-setup.js'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], - transformIgnorePatterns: ['node_modules/(?!(@vidstack/react)/)'], + transformIgnorePatterns: ['node_modules/(?!(@vidstack/react|@formatjs)/)'], }); diff --git a/packages/gamut/package.json b/packages/gamut/package.json index 58f68247ae9..3089fa01fab 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -9,6 +9,7 @@ "@codecademy/gamut-patterns": "0.10.29", "@codecademy/gamut-styles": "17.14.0", "@codecademy/variance": "0.26.1", + "@formatjs/intl-locale": "^5.3.1", "@react-aria/interactions": "3.25.0", "@types/marked": "^4.0.8", "@vidstack/react": "^1.12.12", diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000..b334e4992df --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -0,0 +1,202 @@ +import { MiniArrowLeftIcon, MiniArrowRightIcon } from '@codecademy/gamut-icons'; +import { useElementDir } from '@codecademy/gamut-styles'; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { Box, FlexBox } from '../Box'; +import { PopoverContainer } from '../PopoverContainer'; +import { DatePickerCalendar } from './DatePickerCalendar'; +import { + getDefaultRangeQuickActions, + getDefaultSingleQuickActions, +} from './DatePickerCalendar/utils/quickActions'; +import { DatePickerProvider } from './DatePickerContext'; +import type { + DatePickerContextValue, + DatePickerRangeContextValue, +} from './DatePickerContext/types'; +import { DatePickerInput } from './DatePickerInput'; +import type { DatePickerProps } from './types'; +import { useResolvedLocale } from './utils/locale'; +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './utils/translations'; + +export const DatePicker: React.FC = (props) => { + const { + locale, + disableDate, + children, + mode, + translations: translationsProp, + inputSize, + quickActions, + placement = 'inline', + } = props; + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [focusGridSignal, setFocusGridSignal] = useState(false); + const [gridFocusRequested, setGridFocusRequested] = useState(false); + const [activeRangePart, setActiveRangePart] = + useState(null); + const inputRef = useRef(null); + const dialogId = useId(); + const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; + const isRtl = useElementDir() === 'rtl'; + + const clearGridFocusRequest = useCallback(() => { + setGridFocusRequested(false); + }, []); + + const resolvedLocale = useResolvedLocale(locale); + + const openCalendar = useCallback(() => { + setIsCalendarOpen(true); + }, []); + + const focusCalendar = useCallback(() => { + setGridFocusRequested(true); + setFocusGridSignal((signal) => !signal); + }, []); + + const closeCalendar = useCallback(() => { + setIsCalendarOpen(false); + setActiveRangePart(null); + setGridFocusRequested(false); + const shell = inputRef.current; + const toFocus = + shell?.querySelector('[role="spinbutton"]') ?? shell; + toFocus?.focus(); + }, []); + + useEffect(() => { + if (!isCalendarOpen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + e.preventDefault(); + e.stopPropagation(); + closeCalendar(); + }; + document.addEventListener('keydown', onKeyDown, true); + return () => document.removeEventListener('keydown', onKeyDown, true); + }, [isCalendarOpen, closeCalendar]); + + const contextValue = useMemo(() => { + const translations = { + ...DEFAULT_DATE_PICKER_TRANSLATIONS, + ...translationsProp, + }; + const resolvedQuickActions = + quickActions ?? + (mode === 'range' + ? getDefaultRangeQuickActions(translations) + : getDefaultSingleQuickActions(resolvedLocale)); + const base = { + isCalendarOpen, + openCalendar, + focusCalendar, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, + closeCalendar, + locale: resolvedLocale, + disableDate, + translations, + quickActions: quickActions === null ? [] : resolvedQuickActions, + }; + return mode === 'range' + ? { + ...base, + mode: 'range', + startDate: props.startDate, + endDate: props.endDate, + activeRangePart, + setActiveRangePart, + onRangeSelection: (startDate: Date | null, endDate: Date | null) => { + props.onStartSelected(startDate); + props.onEndSelected(endDate); + }, + } + : { + ...base, + mode: 'single', + selectedDate: props.selectedDate, + onSelection: props.onSelected, + }; + }, [ + translationsProp, + quickActions, + mode, + resolvedLocale, + isCalendarOpen, + openCalendar, + focusCalendar, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, + closeCalendar, + disableDate, + props, + activeRangePart, + ]); + + const content = + children !== undefined ? ( + children + ) : ( + <> + + {mode === 'range' ? ( + <> + + + {isRtl ? : } + + + + ) : ( + + )} + + + + + + ); + + return ( + {content} + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx new file mode 100644 index 00000000000..8ef8181ad6e --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -0,0 +1,245 @@ +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; +import * as React from 'react'; + +import { useIsoFirstWeekday, useResolvedLocale } from '../../utils/locale'; +import { CalendarBodyProps } from './types'; +import { + getDatesWithRow, + getMonthGrid, + isDateDisabled, + isDateInRange, + isSameDay, + normalizeDate, +} from './utils/dateGrid'; +import { CalendarTable, DateCell, TableHeader } from './utils/elements'; +import { formatDateForAriaLabel, getWeekdayNames } from './utils/format'; +import { keyHandler } from './utils/keyHandler'; + +export const CalendarBody: React.FC = ({ + displayDate, + selectedDate, + endDate = null, + disableDate, + onDateSelect, + locale, + weekStartsOn, + labelledById, + focusedDate, + onFocusedDateChange, + onDisplayDateChange, + onEscapeKeyPress, + hasAdjacentMonthRight, + hasAdjacentMonthLeft, + focusGridSync, + pauseGridRoving, +}) => { + const resolvedLocale = useResolvedLocale(locale); + const firstWeekday = useIsoFirstWeekday(resolvedLocale, weekStartsOn); + const year = displayDate.getFullYear(); + const month = displayDate.getMonth(); + const weeks = getMonthGrid({ year, month, firstWeekday }); + const weekdayLabels = getWeekdayNames({ + format: 'short', + locale: resolvedLocale, + firstWeekday, + }); + const weekdayFullNames = getWeekdayNames({ + format: 'long', + locale: resolvedLocale, + firstWeekday, + }); + const buttonRefs = useRef>(new Map()); + const tableRef = useRef(null); + + const datesWithRow = useMemo(() => getDatesWithRow(weeks), [weeks]); + const focusTarget = focusedDate ?? selectedDate; + + const isToday = useCallback( + (date: Date | null) => date !== null && isSameDay(date, new Date()), + [] + ); + + const focusButton = useCallback((date: Date | null): boolean => { + if (date === null) return false; + const key = normalizeDate(date); + const el = buttonRefs.current.get(key); + if (!el) return false; + el.focus(); + return true; + }, []); + + useLayoutEffect(() => { + if (focusTarget === null) return; + + if (!focusGridSync) { + focusButton(focusTarget); + return; + } + + const activeEl = document.activeElement; + const inThisGrid = tableRef.current?.contains(activeEl) ?? false; + const containerEl = focusGridSync.calendarContainerRef.current; + const focusInCalendarContainer = containerEl?.contains(activeEl) ?? false; + const requested = focusGridSync.gridFocusRequested; + const focusOnNavChevron = + activeEl instanceof Element && + activeEl.closest('[data-calendar-month-nav]') != null; + + if (!requested && (pauseGridRoving || focusOnNavChevron)) { + return; + } + + // Month navigation unmounts the active cell; focus often lands on , the dialog shell, + // or another non-grid node — not inside the container, so we must still sync. + const focusLostFromCellUnmount = + activeEl === document.body || + activeEl === document.documentElement || + (activeEl instanceof HTMLElement && + containerEl != null && + containerEl.contains(activeEl) === false && + activeEl.contains(containerEl)); + + // Sync DOM focus when: navigating inside this table; first focus from input (keyboard open); + // focus is in the multi-month strip (cross-grid arrows); or focus was lost after the grid updated. + // Do not pull focus from the input when the user opened with the mouse and never entered the surface. + const shouldSyncFocus = + inThisGrid || + requested || + focusInCalendarContainer || + (focusLostFromCellUnmount && containerEl != null); + + if (!shouldSyncFocus) return; + + const finish = (success: boolean) => { + if (success && requested) { + focusGridSync.onGridFocusRequestHandled(); + } + }; + + let success = focusButton(focusTarget); + if (success) { + finish(true); + return; + } + + // New cells may not have refs until after this layout pass (e.g. display month just changed). + if (shouldSyncFocus) { + requestAnimationFrame(() => { + success = focusButton(focusTarget); + if (success) finish(true); + }); + } + }, [focusTarget, focusButton, focusGridSync, year, month, pauseGridRoving]); + + const onKeyDown = useCallback( + (e: React.KeyboardEvent, date: Date) => + keyHandler({ + e, + date, + onFocusedDateChange, + datesWithRow, + month, + year, + disableDate, + onDateSelect, + onEscapeKeyPress, + onDisplayDateChange, + hasAdjacentMonthRight, + hasAdjacentMonthLeft, + }), + [ + onFocusedDateChange, + datesWithRow, + month, + year, + disableDate, + onDateSelect, + onEscapeKeyPress, + onDisplayDateChange, + hasAdjacentMonthLeft, + hasAdjacentMonthRight, + ] + ); + + const setButtonRef = useCallback((date: Date, el: HTMLElement | null) => { + const normalizedDateTime = normalizeDate(date); + if (el) buttonRefs.current.set(normalizedDateTime, el); + else buttonRefs.current.delete(normalizedDateTime); + }, []); + + return ( + + + + {weekdayLabels.map((label, i) => ( + + {label} + + ))} + + + + {weeks.map((week, rowIndex) => ( + + {week.map((date, colIndex) => { + if (date === null) { + return ( + // eslint-disable-next-line jsx-a11y/control-has-associated-label -- this is a false positive + + ); + } + const selected = + isSameDay(date, selectedDate) || isSameDay(date, endDate); + const range = !!selectedDate && !!endDate; + const inRange = + range && + isDateInRange({ + date, + startDate: selectedDate, + endDate, + }); + const disabled = isDateDisabled({ date, disableDate }); + const today = isToday(date); + const isFocused = + focusTarget !== null && isSameDay(date, focusTarget); + const rovingTabIndex = pauseGridRoving ? -1 : isFocused ? 0 : -1; + + return ( + setButtonRef(date, el as HTMLElement | null)} + role="gridcell" + tabIndex={rovingTabIndex} + onClick={() => { + if (!disabled) onDateSelect(date); + }} + onFocus={() => onFocusedDateChange?.(date)} + onKeyDown={(e: React.KeyboardEvent) => onKeyDown(e, date)} + > + {date.getDate()} + + ); + })} + + ))} + + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx new file mode 100644 index 00000000000..fc6591ae3fd --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; + +import { FlexBox } from '../../../Box'; +import { TextButton } from '../../../Button'; +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../utils/translations'; +import { CalendarFooterProps } from './types'; + +export const CalendarFooter: React.FC = ({ + clearButton, + quickActions = [], +}) => { + if (quickActions.length === 0 && !clearButton) return null; + + const actions = quickActions.slice(0, 3); + + return ( + + {clearButton && ( + + clearButton.onClick?.()} + > + {clearButton.text ?? + DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText} + + + )} + + {actions.map((action, index) => ( + + {action.displayText} + + ))} + + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx new file mode 100644 index 00000000000..9b7e03011b1 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; + +import { FlexBox } from '../../../Box'; +import { Text } from '../../../Typography'; +import { useResolvedLocale } from '../../utils/locale'; +import { CalendarNavLastMonth } from './CalendarNavLastMonth'; +import { CalendarNavNextMonth } from './CalendarNavNextMonth'; +import { CalendarHeaderProps } from './types'; +import { formatMonthYear } from './utils/format'; + +export const CalendarHeader: React.FC = ({ + displayDate, + locale, + headingId, + onDisplayDateChange, + hideLastNav, + hideNextNav, + onLastMonthClick, + onNextMonthClick, + interceptTabToGrid, + onTabIntoGrid, +}) => { + const resolvedLocale = useResolvedLocale(locale); + + return ( + + {!hideLastNav && ( + + )} + + + {formatMonthYear({ date: displayDate, locale: resolvedLocale })} + + + {!hideNextNav && ( + + )} + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx new file mode 100644 index 00000000000..026930ea63c --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx @@ -0,0 +1,59 @@ +import { + MiniChevronLeftIcon, + MiniChevronRightIcon, +} from '@codecademy/gamut-icons'; +import { useElementDir } from '@codecademy/gamut-styles'; +import * as React from 'react'; + +import { IconButton } from '../../../Button'; +import { useResolvedLocale } from '../../utils/locale'; +import { CalendarNavProps } from './types'; +import { getRelativeMonthLabels } from './utils/format'; + +export const CalendarNavLastMonth: React.FC = ({ + displayDate, + onDisplayDateChange, + onLastMonthClick, + onTabIntoGrid, + interceptTabToGrid, + locale, +}) => { + const resolvedLocale = useResolvedLocale(locale); + const { lastMonth } = getRelativeMonthLabels(resolvedLocale); + const buttonRef = React.useRef(null); + const isRtl = useElementDir(buttonRef) === 'rtl'; + + const handleClick = (e: React.MouseEvent) => { + const lastMonth = new Date( + displayDate.getFullYear(), + displayDate.getMonth() - 1, + 1 + ); + onDisplayDateChange?.(lastMonth); + onLastMonthClick?.(); + if (e.detail > 0) { + buttonRef.current?.blur(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && !e.shiftKey && interceptTabToGrid && onTabIntoGrid) { + e.preventDefault(); + onTabIntoGrid(); + } + }; + + return ( + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx new file mode 100644 index 00000000000..8d69aa2ff21 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx @@ -0,0 +1,59 @@ +import { + MiniChevronLeftIcon, + MiniChevronRightIcon, +} from '@codecademy/gamut-icons'; +import { useElementDir } from '@codecademy/gamut-styles'; +import * as React from 'react'; + +import { IconButton } from '../../../Button'; +import { useResolvedLocale } from '../../utils/locale'; +import { CalendarNavProps } from './types'; +import { getRelativeMonthLabels } from './utils/format'; + +export const CalendarNavNextMonth: React.FC = ({ + displayDate, + onDisplayDateChange, + onNextMonthClick, + onTabIntoGrid, + interceptTabToGrid, + locale, +}) => { + const resolvedLocale = useResolvedLocale(locale); + const { nextMonth } = getRelativeMonthLabels(resolvedLocale); + const buttonRef = React.useRef(null); + const isRtl = useElementDir(buttonRef) === 'rtl'; + + const handleClick = (e: React.MouseEvent) => { + const nextMonth = new Date( + displayDate.getFullYear(), + displayDate.getMonth() + 1, + 1 + ); + onDisplayDateChange?.(nextMonth); + onNextMonthClick?.(); + if (e.detail > 0) { + buttonRef.current?.blur(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && !e.shiftKey && interceptTabToGrid && onTabIntoGrid) { + e.preventDefault(); + onTabIntoGrid(); + } + }; + + return ( + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx new file mode 100644 index 00000000000..425348fa6dd --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx @@ -0,0 +1,20 @@ +import { CheckerDense } from '@codecademy/gamut-patterns'; +import * as React from 'react'; + +import { Box } from '../../../Box'; +import { WithChildrenProp } from '../../../utils'; + +export const CalendarWrapper: React.FC = ({ children }) => ( + + + + {children} + + +); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx new file mode 100644 index 00000000000..b63007f5bc3 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx @@ -0,0 +1,326 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; + +import { actKeyboard } from '../../../__tests__/actKeyboard'; +import { getIsoFirstDayFromLocale } from '../../../utils/locale'; +import { CalendarBody } from '../CalendarBody'; +import { getMonthGrid } from '../utils/dateGrid'; +import { formatDateForAriaLabel } from '../utils/format'; + +const displayDate = new Date(2024, 2, 1); +const focusedDate = new Date(2024, 2, 15); +const mockOnDateSelect = jest.fn(); +const mockOnFocusedDateChange = jest.fn(); +const mockOnDisplayDateChange = jest.fn(); +const mockOnGridFocusRequestHandled = jest.fn(); +const mockOnEscapeKeyPress = jest.fn(); + +const defaultCalendarContainerRef = createRef(); + +const renderView = setupRtl(CalendarBody, { + displayDate, + selectedDate: null, + focusedDate, + labelledById: 'cal-heading', + locale: 'en-US', + onDateSelect: mockOnDateSelect, + onFocusedDateChange: mockOnFocusedDateChange, + onDisplayDateChange: mockOnDisplayDateChange, + onEscapeKeyPress: mockOnEscapeKeyPress, + focusGridSync: { + gridFocusRequested: false, + signal: false, + onGridFocusRequestHandled: mockOnGridFocusRequestHandled, + calendarContainerRef: defaultCalendarContainerRef, + }, +}); + +describe('CalendarBody', () => { + it('renders a grid labelled by the heading id', () => { + const { view } = renderView(); + + const grid = view.getByRole('grid'); + expect(grid).toHaveAttribute('aria-labelledby', 'cal-heading'); + }); + + it('selects a day when a gridcell is clicked', async () => { + const { view } = renderView(); + + const day15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + await userEvent.click(day15); + + expect(mockOnDateSelect).toHaveBeenCalledTimes(1); + expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 15)); + }); + + it('fires onFocusedDateChange when a day receives focus', () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + + expect(mockOnFocusedDateChange).toHaveBeenCalledTimes(1); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(new Date(2024, 2, 20)); + }); + + it('renders a DateCell with isToday true when the date is today', () => { + const today = new Date(); + const { view } = renderView({ + selectedDate: today, + displayDate: today, + focusedDate: today, + }); + + const todayCell = view.getByLabelText( + formatDateForAriaLabel({ + date: today, + locale: new Intl.Locale('en-US'), + }) + ); + expect(todayCell).toHaveAttribute('aria-current', 'date'); + }); + + it('renders a DateCell with isDisabled true when the date is disabled', () => { + const { view } = renderView({ + disableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const disabledCell = view.getByRole('gridcell', { name: /March 1, 2024/i }); + expect(disabledCell).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not select a day when a disabled gridcell is clicked', async () => { + const { view } = renderView({ + disableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const disabledCell = view.getByRole('gridcell', { name: /March 1, 2024/i }); + await userEvent.click(disabledCell); + + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange when a disabled day cell receives focus', async () => { + const { view } = renderView({ + disableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const day1 = view.getByRole('gridcell', { name: /March 1, 2024/i }); + day1.focus(); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(new Date(2024, 2, 1)); + }); + + it('renders a DateCell with isSelected true when the date is selected', () => { + const { view } = renderView({ selectedDate: new Date(2024, 2, 15) }); + + const selected = view.getByRole('gridcell', { name: /March 15, 2024/i }); + expect(selected).toHaveAttribute('aria-selected', 'true'); + }); + + it('renders a DateCell with isInRange true when the date is in the range', () => { + const { view } = renderView({ + selectedDate: new Date(2024, 2, 15), + endDate: new Date(2024, 2, 20), + }); + + const inRange = view.getByRole('gridcell', { name: /March 17, 2024/i }); + expect(inRange).toHaveAttribute('aria-selected', 'true'); + }); + + it('renders a DateCell with isRangeStart true when the date is the start of the range', () => { + const { view } = renderView({ + selectedDate: new Date(2024, 2, 15), + endDate: new Date(2024, 2, 20), + }); + + const rangeStart = view.getByRole('gridcell', { name: /March 15, 2024/i }); + expect(rangeStart).toHaveAttribute('aria-selected', 'true'); + }); + + it('renders a DateCell with isRangeEnd true when the date is the end of the range', () => { + const { view } = renderView({ + selectedDate: new Date(2024, 2, 15), + endDate: new Date(2024, 2, 20), + }); + + const rangeEnd = view.getByRole('gridcell', { name: /March 20, 2024/i }); + expect(rangeEnd).toHaveAttribute('aria-selected', 'true'); + }); + + it('moves DOM focus to the focus target and calls onGridFocusRequestHandled when grid focus is requested', async () => { + const { view } = renderView({ + focusGridSync: { + gridFocusRequested: true, + signal: false, + onGridFocusRequestHandled: mockOnGridFocusRequestHandled, + calendarContainerRef: createRef(), + }, + }); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + await waitFor(() => expect(march15).toHaveFocus()); + expect(mockOnGridFocusRequestHandled).toHaveBeenCalled(); + }); + + it('sets tabIndex 0 on the focus target day and -1 on other day cells', () => { + const { view } = renderView(); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + const march10 = view.getByRole('gridcell', { name: /March 10, 2024/i }); + expect(march15).toHaveAttribute('tabIndex', '0'); + expect(march10).toHaveAttribute('tabIndex', '-1'); + }); + + it('uses selectedDate as the roving focus target when focusedDate is null', () => { + const { view } = renderView({ + focusedDate: null, + selectedDate: new Date(2024, 2, 22), + }); + + const march22 = view.getByRole('gridcell', { name: /March 22, 2024/i }); + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + expect(march22).toHaveAttribute('tabIndex', '0'); + expect(march15).toHaveAttribute('tabIndex', '-1'); + }); + + it('moves DOM focus to the focus target when focusGridSync is omitted (standalone)', async () => { + const { view } = renderView({ + focusGridSync: undefined, + }); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + await waitFor(() => expect(march15).toHaveFocus()); + }); + + it('renders seven weekday column headers with scope and abbreviations', () => { + const { view } = renderView(); + + const headers = view.getAllByRole('columnheader'); + expect(headers).toHaveLength(7); + headers.forEach((th) => { + expect(th).toHaveAttribute('scope', 'col'); + expect(th).toHaveAttribute('abbr'); + }); + }); + + it('renders one gridcell per calendar slot (days plus leading and trailing padding)', () => { + const { view } = renderView(); + + const firstWeekday = getIsoFirstDayFromLocale(new Intl.Locale('en-US')); + const weeks = getMonthGrid({ + year: 2024, + month: 2, + firstWeekday, + }); + const expectedCellCount = weeks.flat().length; + + expect(view.getAllByRole('gridcell')).toHaveLength(expectedCellCount); + }); + + describe('keyboard navigation (integration)', () => { + it('moves focus via ArrowLeft through keyHandler', async () => { + const user = userEvent.setup(); + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await actKeyboard(user, '{ArrowLeft}'); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 2, 19) + ); + }); + + it('updates focus and the visible month via PageDown through keyHandler', async () => { + const user = userEvent.setup(); + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await actKeyboard(user, '{PageDown}'); + + expect(mockOnFocusedDateChange).toHaveBeenLastCalledWith( + new Date(2024, 3, 20) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(2024, 3, 1) + ); + }); + + it('selects the focused day on Enter', async () => { + const user = userEvent.setup(); + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await actKeyboard(user, '{Enter}'); + + expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 20)); + }); + + it('selects the focused day when key is Space', () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + fireEvent.keyDown(day20, { key: ' ' }); + + expect(mockOnDateSelect).toHaveBeenCalledWith(new Date(2024, 2, 20)); + }); + + it('does not select a disabled day on Enter', async () => { + const user = userEvent.setup(); + const { view } = renderView({ + disableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const day1 = view.getByRole('gridcell', { name: /March 1, 2024/i }); + day1.focus(); + await actKeyboard(user, '{Enter}'); + + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('calls onEscapeKeyPress on Escape', async () => { + const user = userEvent.setup(); + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await actKeyboard(user, '{Escape}'); + + expect(mockOnEscapeKeyPress).toHaveBeenCalled(); + }); + + it('does not move focus after an unhandled key (beyond focus sync)', async () => { + const user = userEvent.setup(); + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 2, 20) + ); + mockOnFocusedDateChange.mockClear(); + + await actKeyboard(user, '{x}'); + + expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx new file mode 100644 index 00000000000..fde752b22cb --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx @@ -0,0 +1,95 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import userEvent from '@testing-library/user-event'; + +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../../utils/translations'; +import { CalendarFooter } from '../CalendarFooter'; + +const mockOnClearDate = jest.fn(); +const mockOnQuickActionClick = jest.fn(); + +const renderView = setupRtl(CalendarFooter, { + clearButton: { + onClick: mockOnClearDate, + }, + quickActions: [ + { + num: 0, + timePeriod: 'day', + displayText: 'Today', + onClick: mockOnQuickActionClick, + }, + ], +}); + +describe('CalendarFooter', () => { + it('renders null when there are no quick actions and no clear button', () => { + const { view } = renderView({ + clearButton: undefined, + quickActions: undefined, + }); + + expect(view.queryByRole('button')).toBeNull(); + }); + + describe('clear button', () => { + it('renders clear button when clearButton object is passed and calls onClick when clicked', async () => { + const { view } = renderView(); + + view.getByRole('button', { + name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, + }); + }); + + it('renders clear button with custom text when text is passed', () => { + const { view } = renderView({ + clearButton: { onClick: mockOnClearDate, text: 'Custom text' }, + }); + + view.getByRole('button', { name: 'Custom text' }); + }); + + it('calls onClick when clear button is clicked', async () => { + const { view } = renderView(); + + await userEvent.click( + view.getByRole('button', { + name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, + }) + ); + + expect(mockOnClearDate).toHaveBeenCalledTimes(1); + }); + + it('does not render clear button when clearButton object is not passed', () => { + const { view } = renderView({ clearButton: undefined }); + + expect( + view.queryByRole('button', { + name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText, + }) + ).toBeNull(); + }); + }); + + describe('quick actions', () => { + it('renders quick actions when quickActions array is passed', () => { + const { view } = renderView(); + + view.getByRole('button', { name: 'Today' }); + }); + + it('does not render quick actions when quickActions array is not passed', () => { + const { view } = renderView({ quickActions: undefined }); + + expect(view.queryByRole('button', { name: 'Today' })).toBeNull(); + }); + + it('calls onClick when a quick action is clicked', async () => { + const { view } = renderView(); + + await userEvent.click(view.getByRole('button', { name: 'Today' })); + + expect(mockOnQuickActionClick).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx new file mode 100644 index 00000000000..c606e262759 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx @@ -0,0 +1,48 @@ +import { setupRtl } from '@codecademy/gamut-tests'; + +import { CalendarHeader } from '../CalendarHeader'; + +const displayDate = new Date(2024, 2, 1); +const mockOnDisplayDateChange = jest.fn(); +const mockOnLastMonthClick = jest.fn(); +const mockOnNextMonthClick = jest.fn(); + +const renderView = setupRtl(CalendarHeader, { + displayDate, + headingId: 'cal-heading', + locale: 'en-US', + onDisplayDateChange: mockOnDisplayDateChange, + onLastMonthClick: mockOnLastMonthClick, + onNextMonthClick: mockOnNextMonthClick, +}); + +describe('CalendarHeader', () => { + it('renders the month/year title with the given id', () => { + const { view } = renderView(); + + const title = view.container.querySelector('#cal-heading'); + expect(title).toBeInTheDocument(); + expect(title).toHaveTextContent('March 2024'); + }); + + it('renders last month and next month nav buttons when hideLastNav and hideNextNav are false', () => { + const { view } = renderView(); + + view.getByRole('button', { name: 'Last month' }); + view.getByRole('button', { name: 'Next month' }); + }); + + it('hides last nav when hideLastNav is true', () => { + const { view } = renderView({ hideLastNav: true }); + + expect(view.queryByRole('button', { name: 'Last month' })).toBeNull(); + view.getByRole('button', { name: 'Next month' }); + }); + + it('hides next nav when hideNextNav is true', () => { + const { view } = renderView({ hideNextNav: true }); + + expect(view.queryByRole('button', { name: 'Next month' })).toBeNull(); + view.queryByRole('button', { name: 'Last month' }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx new file mode 100644 index 00000000000..ec2eeea83d5 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx @@ -0,0 +1,47 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import userEvent from '@testing-library/user-event'; + +import { CalendarNavLastMonth } from '../CalendarNavLastMonth'; +import { CalendarNavNextMonth } from '../CalendarNavNextMonth'; + +const onLastDisplayChange = jest.fn(); +const mockOnLastMonthClick = jest.fn(); +const renderLast = setupRtl(CalendarNavLastMonth, { + displayDate: new Date(2024, 5, 15), + locale: 'en-US', + onDisplayDateChange: onLastDisplayChange, + onLastMonthClick: mockOnLastMonthClick, +}); + +const onNextDisplayChange = jest.fn(); +const mockOnNextMonthClick = jest.fn(); +const renderNext = setupRtl(CalendarNavNextMonth, { + displayDate: new Date(2024, 5, 15), + locale: 'en-US', + onDisplayDateChange: onNextDisplayChange, + onNextMonthClick: mockOnNextMonthClick, +}); + +describe('CalendarNavLastMonth', () => { + it('moves displayDate to the first day of the previous month', async () => { + const { view } = renderLast(); + + await userEvent.click(view.getByRole('button')); + + expect(onLastDisplayChange).toHaveBeenCalledTimes(1); + expect(onLastDisplayChange).toHaveBeenCalledWith(new Date(2024, 4, 1)); + expect(mockOnLastMonthClick).toHaveBeenCalledTimes(1); + }); +}); + +describe('CalendarNavNextMonth', () => { + it('moves displayDate to the first day of the next month', async () => { + const { view } = renderNext(); + + await userEvent.click(view.getByRole('button')); + + expect(onNextDisplayChange).toHaveBeenCalledTimes(1); + expect(onNextDisplayChange).toHaveBeenCalledWith(new Date(2024, 6, 1)); + expect(mockOnNextMonthClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/index.tsx new file mode 100644 index 00000000000..e1fcffcbdc0 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/index.tsx @@ -0,0 +1,7 @@ +export * from './CalendarWrapper'; +export * from './CalendarHeader'; +export * from './CalendarBody'; +export * from './CalendarFooter'; +export * from './CalendarNavLastMonth'; +export * from './CalendarNavNextMonth'; +export * from './types'; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts new file mode 100644 index 00000000000..771ce5f5b1f --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts @@ -0,0 +1,153 @@ +import type { RefObject } from 'react'; + +import type { + CalendarQuickAction, + DatePickerSharedProps, +} from '../../sharedTypes'; +import type { IsoWeekday } from '../../utils/locale'; + +interface CalendarBaseProps extends DatePickerSharedProps { + /** + * Date used to display month and year in the calendar header. + */ + displayDate: Date; + /** + * Callback when the visible month changes. Pass the new `Date` to your `displayDate` state (or + * equivalent) so the calendar stays in sync. + */ + onDisplayDateChange: (newDate: Date) => void; +} + +export interface CalendarNavProps + extends Omit { + /** Callback called after the user navigates to the previous month. */ + onLastMonthClick?: () => void; + /** Callback called after the user navigates to the next month. */ + onNextMonthClick?: () => void; + /** + * Whether to intercept focus from the month chevron buttons. Used when the day grid is temporarily "paused" after a + * month change until the user moves into the grid. + * + */ + interceptTabToGrid?: boolean; + /** + * Callback called when {@link interceptTabToGrid} is set to move focus into the day grid + * and restore roving focus. + */ + onTabIntoGrid?: () => void; +} + +export interface CalendarHeaderProps extends CalendarNavProps { + /** Hides the control that moves to the previous month. */ + hideLastNav?: boolean; + /** Hides the control that moves to the next month. */ + hideNextNav?: boolean; + /** + * Date used to display month and year in the second month's calendar header. + */ + secondDisplayDate?: Date; + /** + * `id` of the visible month heading, used for the grid `aria-labelledby` association. + */ + headingId: string; +} + +export interface CalendarBodyProps extends CalendarBaseProps { + /** + * Start of the selected range, or the single selected date. Pass `null` when nothing is + * selected. + */ + selectedDate: Date | null; + /** + * End of the range. Omit in single-date mode, or pass `null` for no end date in range mode. + */ + endDate?: Date | null; + /** + * Callback when the user chooses a day cell + */ + onDateSelect: (date: Date) => void; + /** + * First column of the grid as an ISO weekday (`1` = Monday through `7` = Sunday), matching + * `Intl.Locale.prototype.getWeekInfo().firstDay`. Omit to use the active locale. + */ + weekStartsOn?: IsoWeekday; + /** + * `id` of the month heading, used for the grid `aria-labelledby` association. + */ + labelledById: string; + /** + * Which day should hold roving `tabindex` in the grid. + */ + focusedDate: Date | null; + /** + * Callback when the focused day changes, including from arrow keys, click, and programmatic + * updates to `focusedDate`. + */ + onFocusedDateChange: (date: Date | null) => void; + /** + * Callback when the user presses Escape while a day is focused. The + * `DatePicker` shell uses this to close the calendar popover. + */ + onEscapeKeyPress?: () => void; + /** + * Whether the current month has a second grid to the right. + */ + hasAdjacentMonthRight?: boolean; + /** + * Whether the current month has a second grid to the left. + */ + hasAdjacentMonthLeft?: boolean; + /** + * Focus management contract for the `DatePicker` shell. Programmatically moves DOM + * focus to a day only when this grid, or a wider `calendarContainerRef` region, already has + * focus, or when `gridFocusRequested` is set (e.g. keyboard open or ArrowDown from + * the field). + * + */ + focusGridSync?: { + /** + * Whether the shell requested a one-shot move of focus into the grid (e.g. from the + * input or trigger). + */ + gridFocusRequested: boolean; + /** + * Whether a grid-focus request is issued with an unchanged `focusedDate`, so layout + * effects that depend on focus can still re-run. + */ + signal: boolean; + /** + * Call after DOM focus was successfully moved into the grid in response to + * `gridFocusRequested` so the shell can clear the request. + */ + onGridFocusRequestHandled: () => void; + /** + * Ref to an element that wraps the calendar (e.g. the two month tables). + */ + calendarContainerRef: RefObject; + }; + /** + * Whether to pause grid roving, i.e. should all day cells use `tabIndex={-1}` until the user moves into the grid + * so the grid does not steal focus during month transitions. + */ + pauseGridRoving?: boolean; +} + +export interface CalendarFooterProps { + /** + * "Clear" action in the footer. + */ + clearButton?: { + /** Whether the clear button is disabled. */ + disabled?: boolean; + /** Callback called when the clear button is clicked. */ + onClick?: () => void; + /** Text to display for the clear button. `DatePickerCalendar` uses `translations.clearButtonText` */ + text?: string; + }; + /** + * Shortcut actions. See {@link CalendarQuickAction} for the + * object shape. The `DatePicker` shell provides defaults for single and range mode unless you + * override. + */ + quickActions?: CalendarQuickAction[]; +} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts new file mode 100644 index 00000000000..023667dc1f0 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts @@ -0,0 +1,167 @@ +import { + getDatesWithRow, + getMonthGrid, + getWeekdayOffsetInGrid, + isDateDisabled, + isDateInRange, + isDateWithinVisibleMonths, + isSameDay, + matchDisabledDates, +} from '../dateGrid'; + +describe('getWeekdayOffsetInGrid', () => { + it('returns 0 when the 1st matches the grid first weekday (Monday)', () => { + const first = new Date(2024, 0, 1); + expect(getWeekdayOffsetInGrid({ date: first, firstWeekday: 1 })).toBe(0); + }); + + it('returns a positive offset when the 1st is later in the week than firstWeekday', () => { + const first = new Date(2024, 0, 1); + expect( + getWeekdayOffsetInGrid({ date: first, firstWeekday: 7 }) + ).toBeGreaterThan(0); + }); +}); + +describe('getMonthGrid', () => { + it('includes exactly the number of days in the month', () => { + const weeks = getMonthGrid({ year: 2024, month: 2, firstWeekday: 1 }); + const days = weeks.flat().filter((d): d is Date => d !== null); + expect(days).toHaveLength(31); + }); + + it('pads leading and trailing cells with null so each row has 7 cells', () => { + const weeks = getMonthGrid({ year: 2024, month: 2, firstWeekday: 1 }); + weeks.forEach((row) => { + expect(row).toHaveLength(7); + }); + }); +}); + +describe('isSameDay', () => { + it('returns true for the same calendar day in local time', () => { + const a = new Date(2024, 5, 15, 8, 0); + const b = new Date(2024, 5, 15, 22, 0); + expect(isSameDay(a, b)).toBe(true); + }); + + it('returns false for different days or null', () => { + expect(isSameDay(new Date(2024, 5, 15), new Date(2024, 5, 16))).toBe(false); + expect(isSameDay(new Date(), null)).toBe(false); + }); +}); + +describe('isDateInRange', () => { + const startDate = new Date(2024, 2, 10); + const endDate = new Date(2024, 2, 20); + + it('returns true strictly between startDate and endDate', () => { + expect( + isDateInRange({ date: new Date(2024, 2, 15), startDate, endDate }) + ).toBe(true); + }); + + it('returns false on startDate, endDate, or outside', () => { + expect(isDateInRange({ date: startDate, startDate, endDate })).toBe(false); + expect(isDateInRange({ date: endDate, startDate, endDate })).toBe(false); + expect( + isDateInRange({ date: new Date(2024, 2, 5), startDate, endDate }) + ).toBe(false); + }); + + it('returns false when startDate is null', () => { + expect( + isDateInRange({ date: new Date(2024, 2, 15), startDate: null, endDate }) + ).toBe(false); + }); +}); + +describe('matchDisabledDates', () => { + it('returns true when any listed day matches the calendar day', () => { + const target = new Date(2024, 4, 10); + const shouldDisable = matchDisabledDates([new Date(2024, 4, 10, 15, 30)]); + expect(shouldDisable(target)).toBe(true); + }); + + it('returns false when the list is empty or no day matches', () => { + expect(matchDisabledDates([])(new Date(2024, 4, 10))).toBe(false); + expect( + matchDisabledDates([new Date(2024, 4, 11)])(new Date(2024, 4, 10)) + ).toBe(false); + }); +}); + +describe('isDateDisabled', () => { + it('returns true when disableDate returns true', () => { + expect( + isDateDisabled({ + date: new Date(2024, 4, 10), + disableDate: () => true, + }) + ).toBe(true); + expect( + isDateDisabled({ + date: new Date(2024, 4, 10), + disableDate: (d) => d.getDate() === 10, + }) + ).toBe(true); + }); + + it('returns false when disableDate is omitted or returns false', () => { + expect(isDateDisabled({ date: new Date(2024, 4, 10) })).toBe(false); + expect( + isDateDisabled({ + date: new Date(2024, 4, 10), + disableDate: () => false, + }) + ).toBe(false); + }); +}); + +describe('isDateWithinVisibleMonths', () => { + const march2024 = new Date(2024, 2, 1); + const april2024 = new Date(2024, 3, 15); + + it('returns true when the date is in the left visible month', () => { + expect( + isDateWithinVisibleMonths({ + date: new Date(2024, 2, 20), + startOfLeftVisibleMonth: march2024, + showSecondMonth: false, + }) + ).toBe(true); + }); + + it('returns true when the date is in the second column month in a two-month layout', () => { + expect( + isDateWithinVisibleMonths({ + date: april2024, + startOfLeftVisibleMonth: march2024, + showSecondMonth: true, + }) + ).toBe(true); + }); + + it('returns false when the date is outside the visible month(s)', () => { + expect( + isDateWithinVisibleMonths({ + date: new Date(2024, 4, 1), + startOfLeftVisibleMonth: march2024, + showSecondMonth: true, + }) + ).toBe(false); + }); +}); + +describe('getDatesWithRow', () => { + it('lists only non-null dates with row indices', () => { + const weeks = getMonthGrid({ year: 2024, month: 0, firstWeekday: 1 }); + const withRow = getDatesWithRow(weeks); + expect(withRow.length).toBe(31); + expect(withRow[0].rowIndex).toBe(0); + withRow.forEach(({ date }) => { + expect(date.getMonth()).toBe(0); + expect(date.getFullYear()).toBe(2024); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts new file mode 100644 index 00000000000..3fe4353b50a --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts @@ -0,0 +1,142 @@ +import { + capitalizeFirst, + formatDateForAriaLabel, + formatMonthYear, + getRelativeMonthLabels, + getRelativeTodayLabel, + getWeekdayNames, +} from '../format'; + +const enUS = new Intl.Locale('en-US'); +const frFR = new Intl.Locale('fr-FR'); + +describe('capitalizeFirst', () => { + it('uppercases the first character per locale', () => { + expect(capitalizeFirst({ str: 'hello', locale: enUS })).toBe('Hello'); + }); + + it('returns empty string unchanged', () => { + expect(capitalizeFirst({ str: '', locale: enUS })).toBe(''); + }); +}); + +describe('formatMonthYear', () => { + it('formats month in long format and year in numeric format', () => { + const text = formatMonthYear({ date: new Date(2026, 0, 15), locale: enUS }); + expect(text).toMatch(/2026/); + expect(text.toLowerCase()).toContain('january'); + }); + + it('formats month and year based on the given locale', () => { + const text = formatMonthYear({ date: new Date(2026, 0, 15), locale: frFR }); + expect(text).toMatch(/2026/); + expect(text.toLowerCase()).toContain('janvier'); + }); +}); + +describe('getWeekdayNames', () => { + it('returns short weekday names when format is short', () => { + const short = getWeekdayNames({ + format: 'short', + locale: enUS, + firstWeekday: 1, + }); + expect(short).toHaveLength(7); + expect(short).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + }); + + it('returns long weekday names when format is long', () => { + const long = getWeekdayNames({ + format: 'long', + locale: enUS, + firstWeekday: 7, + }); + expect(long).toHaveLength(7); + expect(long).toEqual([ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', + ]); + }); + + it('returns the correct weekday name order for the given firstWeekday', () => { + const short = getWeekdayNames({ + format: 'short', + locale: frFR, + firstWeekday: 1, + }); + expect(short).toEqual([ + 'lun.', + 'mar.', + 'mer.', + 'jeu.', + 'ven.', + 'sam.', + 'dim.', + ]); + const long = getWeekdayNames({ + format: 'long', + locale: frFR, + firstWeekday: 7, + }); + expect(long).toEqual([ + 'dimanche', + 'lundi', + 'mardi', + 'mercredi', + 'jeudi', + 'vendredi', + 'samedi', + ]); + }); +}); + +describe('getRelativeMonthLabels', () => { + it('returns next and last month strings in the given locale (en-US)', () => { + const { nextMonth, lastMonth } = getRelativeMonthLabels(enUS); + expect(nextMonth).toEqual('Next month'); + expect(lastMonth).toEqual('Last month'); + }); + + it('returns next and last month strings in the given locale (fr-FR)', () => { + const { nextMonth, lastMonth } = getRelativeMonthLabels(frFR); + expect(nextMonth).toEqual('Le mois prochain'); + expect(lastMonth).toEqual('Le mois dernier'); + }); +}); + +describe('getRelativeTodayLabel', () => { + it('returns today string in the given locale (en-US)', () => { + expect(getRelativeTodayLabel(enUS)).toEqual('Today'); + }); + + it('returns today string in the given locale (fr-FR)', () => { + expect(getRelativeTodayLabel(frFR)).toEqual('Aujourd’hui'); + }); +}); + +describe('formatDateForAriaLabel', () => { + it('formats month in long format, day in numeric format, and year in numeric format in the given locale (en-US)', () => { + const label = formatDateForAriaLabel({ + date: new Date(2026, 1, 14), + locale: enUS, + }); + expect(label).toMatch(/2026/); + expect(label.toLowerCase()).toContain('february'); + expect(label).toMatch(/14/); + }); + + it('formats month in long format, day in numeric format, and year in numeric format in the given locale (fr-FR)', () => { + const label = formatDateForAriaLabel({ + date: new Date(2026, 1, 14), + locale: frFR, + }); + expect(label).toMatch(/2026/); + expect(label.toLowerCase()).toContain('février'); + expect(label).toMatch(/14/); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx new file mode 100644 index 00000000000..a404686ec54 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx @@ -0,0 +1,410 @@ +import type { KeyboardEvent } from 'react'; + +import { getDatesWithRow, getMonthGrid, matchDisabledDates } from '../dateGrid'; +import { keyHandler, KeyHandlerParams } from '../keyHandler'; + +const makeEvent = ( + key: string, + opts: Partial> = {} +) => + ({ + key, + preventDefault: jest.fn(), + shiftKey: opts.shiftKey ?? false, + } as unknown as React.KeyboardEvent); + +const mockOnFocusedDateChange = jest.fn(); +const mockOnDateSelect = jest.fn(); +const mockOnEscapeKeyPress = jest.fn(); +const mockOnDisplayDateChange = jest.fn(); + +const year = 2024; +const month = 2; +const firstWeekday = 1 as const; +const weeks = getMonthGrid({ year, month, firstWeekday }); +const datesWithRow = getDatesWithRow(weeks); +const midIdx = Math.floor(datesWithRow.length / 2); +const { date } = datesWithRow[midIdx]; +const firstIdx = 0; +const lastIdx = datesWithRow.length - 1; + +const baseParams: Omit = { + date, + datesWithRow, + month, + year, + hasAdjacentMonthRight: false, + hasAdjacentMonthLeft: false, + onDisplayDateChange: mockOnDisplayDateChange, + onFocusedDateChange: mockOnFocusedDateChange, + onDateSelect: mockOnDateSelect, + onEscapeKeyPress: mockOnEscapeKeyPress, +}; + +describe('keyHandler', () => { + it('calls onFocusedDateChange for ArrowLeft to previous day in grid', () => { + keyHandler({ + ...baseParams, + e: makeEvent('ArrowLeft'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + datesWithRow[midIdx - 1].date + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange for ArrowRight to next day in grid', () => { + keyHandler({ + ...baseParams, + e: makeEvent('ArrowRight'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + datesWithRow[midIdx + 1].date + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange for ArrowUp to previous week in grid', () => { + keyHandler({ + ...baseParams, + e: makeEvent('ArrowUp'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + datesWithRow[midIdx - 7].date + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange for ArrowDown to next week in grid', () => { + keyHandler({ + ...baseParams, + e: makeEvent('ArrowDown'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + datesWithRow[midIdx + 7].date + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('moves focus to the last day of the previous month when ArrowLeft is pressed on the first day of the month', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[firstIdx].date, + e: makeEvent('ArrowLeft'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(year, month, 0) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year, month - 1, 1) + ); + }); + + it('moves focus to the first day of the next month when ArrowRight is pressed on the last day of the month', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[lastIdx].date, + e: makeEvent('ArrowRight'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(year, month + 1, 1) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year, month + 1, 1) + ); + }); + + it('moves focus up a week and updates the month view when ArrowUp crosses into the previous month', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[firstIdx].date, + e: makeEvent('ArrowUp'), + }); + const expected = new Date(datesWithRow[firstIdx].date); + expected.setDate(expected.getDate() - 7); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expected); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(expected.getFullYear(), expected.getMonth(), 1) + ); + }); + + it('moves focus down a week and updates the month view when ArrowDown crosses into the next month', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[lastIdx].date, + e: makeEvent('ArrowDown'), + }); + const expected = new Date(datesWithRow[lastIdx].date); + expected.setDate(expected.getDate() + 7); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expected); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(expected.getFullYear(), expected.getMonth(), 1) + ); + }); + + it('calls onDateSelect on Enter when not disabled', () => { + keyHandler({ + ...baseParams, + e: makeEvent('Enter'), + }); + expect(mockOnDateSelect).toHaveBeenCalledWith(date); + }); + + it('does not call onDateSelect on Enter when date is disabled', () => { + keyHandler({ + ...baseParams, + e: makeEvent('Enter'), + disableDate: matchDisabledDates([date]), + }); + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('calls onEscapeKeyPress on Escape', () => { + keyHandler({ + ...baseParams, + e: makeEvent('Escape'), + }); + expect(mockOnEscapeKeyPress).toHaveBeenCalled(); + }); + + it('calls onFocusedDateChange and onDisplayDateChange on PageDown for next month', () => { + keyHandler({ + ...baseParams, + e: makeEvent('PageDown'), + }); + const expectedFocus = new Date(year, month + 1, date.getDate()); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expectedFocus); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year, month + 1, 1) + ); + }); + + it('calls onFocusedDateChange and onDisplayDateChange on Shift+PageDown for next year', () => { + keyHandler({ + ...baseParams, + e: makeEvent('PageDown', { shiftKey: true }), + }); + const expectedFocus = new Date(year + 1, month, date.getDate()); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expectedFocus); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year + 1, month, 1) + ); + }); + + it('calls onFocusedDateChange and onDisplayDateChange on PageUp for previous month', () => { + keyHandler({ + ...baseParams, + e: makeEvent('PageUp'), + }); + const expectedFocus = new Date(year, month - 1, date.getDate()); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expectedFocus); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year, month - 1, 1) + ); + }); + + it('calls onFocusedDateChange and onDisplayDateChange on Shift+PageUp for previous year', () => { + keyHandler({ + ...baseParams, + e: makeEvent('PageUp', { shiftKey: true }), + }); + const expectedFocus = new Date(year - 1, month, date.getDate()); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expectedFocus); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(year - 1, month, 1) + ); + }); + + it('moves focus to the first day of the current week row when Home key is pressed', () => { + const { rowIndex } = datesWithRow[midIdx]; + const firstInRow = datesWithRow.find((d) => d.rowIndex === rowIndex)!.date; + + keyHandler({ + ...baseParams, + e: makeEvent('Home'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(firstInRow); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('moves focus to the last day of the current week row when End key is pressed', () => { + const { rowIndex } = datesWithRow[midIdx]; + const lastInRow = [...datesWithRow] + .reverse() + .find((d) => d.rowIndex === rowIndex)!.date; + + keyHandler({ + ...baseParams, + e: makeEvent('End'), + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(lastInRow); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does nothing for unhandled keys', () => { + keyHandler({ + ...baseParams, + e: makeEvent('x'), + }); + expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + describe('edge cases', () => { + it('returns early when the active date is not in datesWithRow', () => { + keyHandler({ + ...baseParams, + date: new Date(2024, 2, 99), + e: makeEvent('ArrowLeft'), + }); + expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDisplayDateChange when ArrowLeft leaves the month but a second month is visible on the left', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[firstIdx].date, + e: makeEvent('ArrowLeft'), + hasAdjacentMonthLeft: true, + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(year, month, 0) + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDisplayDateChange when ArrowRight leaves the month but a second month is visible on the right', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[lastIdx].date, + e: makeEvent('ArrowRight'), + hasAdjacentMonthRight: true, + }); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(year, month + 1, 1) + ); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDisplayDateChange when ArrowUp crosses months but a second month is visible on the left', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[firstIdx].date, + e: makeEvent('ArrowUp'), + hasAdjacentMonthLeft: true, + }); + const expected = new Date(datesWithRow[firstIdx].date); + expected.setDate(expected.getDate() - 7); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expected); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDisplayDateChange when ArrowDown crosses months but a second month is visible on the right', () => { + keyHandler({ + ...baseParams, + date: datesWithRow[lastIdx].date, + e: makeEvent('ArrowDown'), + hasAdjacentMonthRight: true, + }); + const expected = new Date(datesWithRow[lastIdx].date); + expected.setDate(expected.getDate() + 7); + expect(mockOnFocusedDateChange).toHaveBeenCalledWith(expected); + expect(mockOnDisplayDateChange).not.toHaveBeenCalled(); + }); + + it('clamps PageDown from Jan 31 to Feb 29 in a leap year', () => { + const janWeeks = getMonthGrid({ year: 2024, month: 0, firstWeekday }); + const janDates = getDatesWithRow(janWeeks); + const jan31 = janDates.find((d) => d.date.getDate() === 31)!.date; + + keyHandler({ + ...baseParams, + date: jan31, + datesWithRow: janDates, + month: 0, + year: 2024, + e: makeEvent('PageDown'), + }); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 1, 29) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(2024, 1, 1) + ); + }); + + it('clamps PageUp from Mar 31 to Feb 29 in a leap year', () => { + const marWeeks = getMonthGrid({ year: 2024, month: 2, firstWeekday }); + const marDates = getDatesWithRow(marWeeks); + const mar31 = marDates.find((d) => d.date.getDate() === 31)!.date; + + keyHandler({ + ...baseParams, + date: mar31, + datesWithRow: marDates, + month: 2, + year: 2024, + e: makeEvent('PageUp'), + }); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 1, 29) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(2024, 1, 1) + ); + }); + + it('clamps Shift+PageUp from Feb 29 to Feb 28 when the previous year is not a leap year', () => { + const febWeeksLeap = getMonthGrid({ year: 2024, month: 1, firstWeekday }); + const febDates = getDatesWithRow(febWeeksLeap); + const feb29 = febDates.find((d) => d.date.getDate() === 29)!.date; + + keyHandler({ + ...baseParams, + date: feb29, + datesWithRow: febDates, + month: 1, + year: 2024, + e: makeEvent('PageUp', { shiftKey: true }), + }); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2023, 1, 28) + ); + expect(mockOnDisplayDateChange).toHaveBeenCalledWith( + new Date(2023, 1, 1) + ); + }); + + it('calls onDateSelect on Space when not disabled', () => { + keyHandler({ + ...baseParams, + e: makeEvent(' '), + }); + expect(mockOnDateSelect).toHaveBeenCalledWith(date); + expect(mockOnFocusedDateChange).not.toHaveBeenCalled(); + }); + + it('does not call onDateSelect on Space when the date is disabled', () => { + keyHandler({ + ...baseParams, + e: makeEvent(' '), + disableDate: matchDisabledDates([date]), + }); + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('does not throw when Escape is pressed and onEscapeKeyPress is omitted', () => { + expect(() => + keyHandler({ + ...baseParams, + onEscapeKeyPress: undefined, + e: makeEvent('Escape'), + }) + ).not.toThrow(); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts new file mode 100644 index 00000000000..7d6e0917c6b --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts @@ -0,0 +1,195 @@ +import type { IsoWeekday } from '../../../utils/locale'; + +const DAYS_PER_WEEK = 7; + +export const normalizeDate = (date: Date) => { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ).getTime(); +}; + +/** + * Number of empty cells before the 1st of the month, for a grid whose first column is + * `firstWeekday` (ISO: 1 = Monday … 7 = Sunday from `Intl.Locale#getWeekInfo`). + */ +export const getWeekdayOffsetInGrid = ({ + date, + firstWeekday, +}: { + date: Date; + firstWeekday: IsoWeekday; +}) => { + const dayOfWeek = date.getDay(); + const iso = dayOfWeek === 0 ? 7 : dayOfWeek; + return (iso - firstWeekday + 14) % 7; +}; + +export const getFirstOfMonth = (date: Date) => { + return new Date(date.getFullYear(), date.getMonth(), 1); +}; + +/** + * Returns an array of weeks for the given month. Each week is an array of 7 items: + * each item is either a Date (that day) or null (padding from previous/next month). + * + * @param year - Full year (e.g. 2026) + * @param month - Month 0-11 (0 = January) + * @param firstWeekday - First day of the week for the calendar row (ISO 1–7, from `getWeekInfo().firstDay`) + */ +export const getMonthGrid = ({ + year, + month, + firstWeekday, +}: { + year: number; + month: number; + firstWeekday: IsoWeekday; +}) => { + const first = getFirstOfMonth(new Date(year, month, 1)); + const last = new Date(year, month + 1, 0); + const firstDayOfWeek = getWeekdayOffsetInGrid({ date: first, firstWeekday }); + const daysInMonth = last.getDate(); + + const weeks: (Date | null)[][] = []; + let currentWeek: (Date | null)[] = []; + + for (let i = 0; i < firstDayOfWeek; i += 1) { + currentWeek.push(null); + } + + for (let day = 1; day <= daysInMonth; day += 1) { + currentWeek.push(new Date(year, month, day)); + if (currentWeek.length === DAYS_PER_WEEK) { + weeks.push(currentWeek); + currentWeek = []; + } + } + + if (currentWeek.length > 0) { + while (currentWeek.length < DAYS_PER_WEEK) { + currentWeek.push(null); + } + weeks.push(currentWeek); + } + + return weeks; +}; + +export const isSameDay = (a: Date | null, b: Date | null) => { + if (a === null || b === null) return false; + return normalizeDate(a) === normalizeDate(b); +}; + +export const getOrderedCalendarEndpoints = ({ + startDate, + endDate, +}: { + startDate: Date; + endDate: Date; +}) => { + const normalizedStartDate = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate() + ); + const normalizedEndDate = new Date( + endDate.getFullYear(), + endDate.getMonth(), + endDate.getDate() + ); + return normalizedStartDate <= normalizedEndDate + ? { low: normalizedStartDate, high: normalizedEndDate } + : { low: normalizedEndDate, high: normalizedStartDate }; +}; + +/** + * Check if `date` is between `startDate` and `endDate` (exclusive), ignoring time. + */ +export const isDateInRange = ({ + date, + startDate, + endDate, +}: { + date: Date; + startDate: Date | null; + endDate: Date | null; +}) => { + if (startDate === null) return false; + const endBound = endDate ?? startDate; + const { low, high } = getOrderedCalendarEndpoints({ + startDate, + endDate: endBound, + }); + const normalizedDate = normalizeDate(date); + return ( + normalizedDate > normalizeDate(low) && normalizedDate < normalizeDate(high) + ); +}; + +/** + * Returns a `disableDate` callback: for each calendar `date`, `true` if it’s the same day as + * any in `dates` + * + * @example + * ```tsx + * {}} + * disableDate={matchDisabledDates([new Date(2026, 3, 14)])} + * /> + * ``` + */ +export const matchDisabledDates = + (dates: readonly Date[] = []) => + (date: Date): boolean => + dates.some((d) => isSameDay(date, d)); + +export const isDateDisabled = ({ + date, + disableDate, +}: { + date: Date; + disableDate?: (date: Date) => boolean; +}) => Boolean(disableDate?.(date)); + +export type DateWithRow = { date: Date; rowIndex: number }; + +export const getDatesWithRow = (weeks: (Date | null)[][]) => { + const result: DateWithRow[] = []; + weeks.forEach((week, rowIndex) => { + week.forEach((date) => { + if (date !== null) result.push({ date, rowIndex }); + }); + }); + return result; +}; + +export const addMonths = ({ date, n }: { date: Date; n: number }) => + new Date(date.getFullYear(), date.getMonth() + n, 1); + +export const isDateWithinVisibleMonths = ({ + date, + startOfLeftVisibleMonth, + showSecondMonth, +}: { + date: Date; + /** First day of the month rendered in the left calendar column (`displayDate`). */ + startOfLeftVisibleMonth: Date; + showSecondMonth: boolean; +}) => { + const year = date.getFullYear(); + const month = date.getMonth(); + const leftYear = startOfLeftVisibleMonth.getFullYear(); + const leftMonth = startOfLeftVisibleMonth.getMonth(); + if (year === leftYear && month === leftMonth) return true; + if (showSecondMonth) { + const rightMonthStart = addMonths({ date: startOfLeftVisibleMonth, n: 1 }); + const rightYear = rightMonthStart.getFullYear(); + const rightMonth = rightMonthStart.getMonth(); + return year === rightYear && month === rightMonth; + } + return false; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx new file mode 100644 index 00000000000..b5a7354e8d7 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx @@ -0,0 +1,129 @@ +import { css, states, transitionConcat } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +export const CalendarTable = styled.table( + css({ + /** Row gaps only: 0 between columns, 8px token between rows (incl. under header). */ + borderCollapse: 'separate', + borderSpacing: '0 8px', + }) +); + +export const TableHeader = styled.th( + css({ + fontSize: 14, + fontWeight: 'base', + color: 'text-disabled', + textAlign: 'center', + }) +); + +const datecellStates = states({ + isToday: { + position: 'relative', + '&::after': { + content: '""', + position: 'absolute', + bottom: 4, + /** Half of dot width (4px) so the marker sits under the centered date numeral. */ + insetInlineStart: 'calc(50% - 2px)', + width: 4, + height: 4, + borderRadius: 'full', + bg: 'hyper', + }, + }, + isSelected: { + bg: 'text', + color: 'background', + '&:hover, &:focus': { + bg: 'secondary-hover', + color: 'background', + }, + '&::after': { + bg: 'background', + }, + }, + isRangeStart: { + borderRadiusRight: 'none', + }, + isRangeEnd: { + borderRadiusLeft: 'none', + }, + isInRange: { + bg: 'text-disabled', + color: 'background', + borderRadius: 'none', + '&:hover, &:focus': { + bg: 'secondary-hover', + color: 'background', + }, + '&::after': { + bg: 'background', + }, + }, + isDisabled: { + color: 'text-disabled', + bg: 'transparent', + cursor: 'not-allowed', + userSelect: 'none', + textDecoration: 'line-through', + '&:hover, &:focus': { + color: 'text-disabled', + bg: 'transparent', + textDecoration: 'line-through', + cursor: 'not-allowed', + userSelect: 'none', + }, + }, +}); + +type DateCellProps = StyleProps; + +export const DateCell = styled.td( + css({ + fontWeight: 'base', + width: '32px', + height: '32px', + textAlign: 'center', + padding: 0, + position: 'relative', + borderRadius: 'md', + transition: transitionConcat( + ['border-color', 'color', 'background-color', 'box-shadow'], + 'fast', + 'ease-in' + ), + '&:hover': { + color: 'secondary', + bg: 'background-hover', + transition: transitionConcat( + ['background-color', 'box-shadow'], + 'fast', + 'ease-in' + ), + cursor: 'pointer', + }, + '&:focus-visible': { + color: 'secondary', + outline: 'none', + }, + '&:active': { + color: 'text', + }, + '&::before': { + content: '""', + position: 'absolute', + inset: -3, + borderRadius: 'lg', + border: 2, + opacity: 0, + zIndex: 0, + }, + '&:focus-visible::before': { + opacity: 1, + }, + }), + datecellStates +); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts new file mode 100644 index 00000000000..5f4c33feefa --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts @@ -0,0 +1,86 @@ +import type { IsoWeekday } from '../../../utils/locale'; +import { stringifyLocale } from '../../../utils/locale'; + +export const capitalizeFirst = ({ + str, + locale, +}: { + str: string; + locale: Intl.Locale; +}) => + str.length === 0 + ? str + : str[0].toLocaleUpperCase(stringifyLocale(locale)) + str.slice(1); + +export const formatMonthYear = ({ + date, + locale, +}: { + date: Date; + locale: Intl.Locale; +}) => { + return new Intl.DateTimeFormat(stringifyLocale(locale), { + month: 'long', + year: 'numeric', + }).format(date); +}; + +/** + * Get weekday names for column headers or abbr attributes. + * Column order follows `firstWeekday` (ISO 1 = Monday … 7 = Sunday), matching `Intl.Locale#getWeekInfo().firstDay`. + * @param format - 'short' for abbreviated (e.g. "Su", "Mo"), 'long' for full (e.g. "Sunday", "Monday") + */ +export const getWeekdayNames = ({ + format, + locale, + firstWeekday, +}: { + format: 'short' | 'long'; + locale: Intl.Locale; + firstWeekday: IsoWeekday; +}) => { + const formatter = new Intl.DateTimeFormat(stringifyLocale(locale), { + weekday: format, + }); + const monday = new Date(2024, 0, 8); + const namesMonToSun = Array.from({ length: 7 }, (_, i) => { + const date = new Date(monday); + date.setDate(monday.getDate() + i); + return formatter.format(date); + }); + return Array.from({ length: 7 }, (_, j) => { + const iso = ((firstWeekday - 1 + j) % 7) + 1; + return namesMonToSun[iso - 1]; + }); +}; + +export const getRelativeMonthLabels = (locale: Intl.Locale) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); + return { + nextMonth: capitalizeFirst({ str: rtf.format(1, 'month'), locale }), + lastMonth: capitalizeFirst({ str: rtf.format(-1, 'month'), locale }), + }; +}; + +export const getRelativeTodayLabel = (locale: Intl.Locale) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); + return capitalizeFirst({ str: rtf.format(0, 'day'), locale }); +}; + +export const formatDateForAriaLabel = ({ + date, + locale, +}: { + date: Date; + locale: Intl.Locale; +}) => { + return new Intl.DateTimeFormat(stringifyLocale(locale), { + month: 'long', + day: 'numeric', + year: 'numeric', + }).format(date); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts new file mode 100644 index 00000000000..e2e7cdf2732 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts @@ -0,0 +1,156 @@ +import type { CalendarBodyProps } from '../types'; +import { type DateWithRow, isDateDisabled } from './dateGrid'; + +export type KeyHandlerParams = Pick< + CalendarBodyProps, + | 'onFocusedDateChange' + | 'onDateSelect' + | 'onDisplayDateChange' + | 'onEscapeKeyPress' + | 'hasAdjacentMonthRight' + | 'hasAdjacentMonthLeft' + | 'disableDate' +> & { + e: React.KeyboardEvent; + date: Date; + datesWithRow: DateWithRow[]; + month: number; + year: number; +}; + +/** + * Clamp a day to the last day of the given month (e.g. Jan 31 -> Feb 28). + */ +const clampToMonth = (year: number, month: number, day: number) => { + const last = new Date(year, month + 1, 0).getDate(); + return new Date(year, month, Math.min(day, last)); +}; + +export const keyHandler = ({ + e, + date, + onFocusedDateChange, + datesWithRow, + month, + year, + disableDate, + onDateSelect, + onEscapeKeyPress, + onDisplayDateChange, + hasAdjacentMonthRight, + hasAdjacentMonthLeft, +}: KeyHandlerParams) => { + const idx = datesWithRow.findIndex( + ({ date: dateWithRow }) => dateWithRow.getTime() === date.getTime() + ); + if (idx < 0) return; + + const currentRow = datesWithRow[idx].rowIndex; + const day = date.getDate(); + const hasRight = !!hasAdjacentMonthRight; + const hasLeft = !!hasAdjacentMonthLeft; + + let newDate: Date | null = null; + let newDisplayDate: Date | null = null; + + switch (e.key) { + case 'ArrowLeft': + e.preventDefault(); + if (idx > 0) { + newDate = datesWithRow[idx - 1].date; + } else { + const lastDayPrevMonth = new Date(year, month, 0); + newDate = lastDayPrevMonth; + if (!hasLeft) { + newDisplayDate = new Date(year, month - 1, 1); + } + } + break; + case 'ArrowRight': + e.preventDefault(); + if (idx < datesWithRow.length - 1) { + newDate = datesWithRow[idx + 1].date; + } else { + newDate = new Date(year, month + 1, 1); + if (!hasRight) { + newDisplayDate = new Date(year, month + 1, 1); + } + } + break; + case 'ArrowUp': + e.preventDefault(); + newDate = new Date(date); + newDate.setDate(newDate.getDate() - 7); + if (newDate.getMonth() !== month || newDate.getFullYear() !== year) { + if (!hasLeft) { + newDisplayDate = new Date( + newDate.getFullYear(), + newDate.getMonth(), + 1 + ); + } + } + break; + case 'ArrowDown': + e.preventDefault(); + newDate = new Date(date); + newDate.setDate(newDate.getDate() + 7); + if (newDate.getMonth() !== month || newDate.getFullYear() !== year) { + if (!hasRight) { + newDisplayDate = new Date( + newDate.getFullYear(), + newDate.getMonth(), + 1 + ); + } + } + break; + case 'Home': + e.preventDefault(); + newDate = + datesWithRow.find(({ rowIndex }) => rowIndex === currentRow)?.date ?? + date; + break; + case 'End': + e.preventDefault(); + newDate = + [...datesWithRow] + .reverse() + .find(({ rowIndex }) => rowIndex === currentRow)?.date ?? date; + break; + case 'PageDown': + e.preventDefault(); + if (e.shiftKey) { + newDate = clampToMonth(year + 1, month, day); + } else { + newDate = clampToMonth(year, month + 1, day); + } + newDisplayDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); + break; + case 'PageUp': + e.preventDefault(); + if (e.shiftKey) { + newDate = clampToMonth(year - 1, month, day); + } else { + newDate = clampToMonth(year, month - 1, day); + } + newDisplayDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (!isDateDisabled({ date, disableDate })) onDateSelect(date); + return; + case 'Escape': + e.preventDefault(); + onEscapeKeyPress?.(); + return; + default: + return; + } + + if (newDate !== null) { + onFocusedDateChange(newDate); + if (newDisplayDate !== null) onDisplayDateChange?.(newDisplayDate); + } +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx new file mode 100644 index 00000000000..77d5b8529e8 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx @@ -0,0 +1,179 @@ +import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { FC } from 'react'; + +import { actKeyboard } from '../../__tests__/actKeyboard'; +import { DatePickerProvider } from '../../DatePickerContext'; +import { + createMockRangeContext, + createMockSingleContext, +} from '../../DatePickerContext/__tests__/mockContexts'; +import type { DatePickerContextValue } from '../../DatePickerContext/types'; +import { DatePickerCalendar } from '..'; + +jest.mock('react-use', () => { + const actual = jest.requireActual('react-use'); + return { + ...actual, + /** One-month layout in tests (stable gridcell queries). */ + useMedia: jest.fn(() => false), + }; +}); + +type CalendarHarnessProps = { context: DatePickerContextValue }; + +const DatePickerCalendarHarness: FC = ({ context }) => ( + + + +); + +const renderCalendar = setupRtl(DatePickerCalendarHarness, { + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: new Date(2024, 2, 1), + }), +}); + +describe('DatePickerCalendar', () => { + it('throws when rendered without DatePickerProvider', () => { + expect(() => + render( + + + + ) + ).toThrow(/useDatePickerContext must be used within a DatePicker/); + }); + + it('renders a calendar grid when the picker context is open', () => { + const { view } = renderCalendar(); + + expect(view.getByRole('grid')).toBeInTheDocument(); + }); + + it('calls setSelection when a day cell is activated in single mode', async () => { + const onSelection = jest.fn(); + const { view } = renderCalendar({ + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: new Date(2024, 2, 1), + onSelection, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(onSelection).toHaveBeenCalledWith(new Date(2024, 2, 20)); + }); + + it('calls onSelection(null) when the already-selected day is clicked again in single mode', async () => { + const onSelection = jest.fn(); + const selected = new Date(2024, 2, 20); + const { view } = renderCalendar({ + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: selected, + onSelection, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(onSelection).toHaveBeenCalledWith(null); + }); + + it('calls closeCalendar after selecting a date in single mode', async () => { + const closeCalendar = jest.fn(); + const { view } = renderCalendar({ + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: new Date(2024, 2, 1), + closeCalendar, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(closeCalendar).toHaveBeenCalled(); + }); + + it('invokes closeCalendar when Escape is pressed on the grid', async () => { + const user = userEvent.setup(); + const closeCalendar = jest.fn(); + const { view } = renderCalendar({ + context: createMockSingleContext({ + isCalendarOpen: true, + selectedDate: new Date(2024, 2, 1), + closeCalendar, + }), + }); + + const march15 = view.getByRole('gridcell', { name: /March 15, 2024/i }); + march15.focus(); + await actKeyboard(user, '{Escape}'); + + expect(closeCalendar).toHaveBeenCalled(); + }); + + it('calls onRangeSelection with a new start when the start field is active and a day is chosen', async () => { + const onRangeSelection = jest.fn(); + const { view } = renderCalendar({ + context: createMockRangeContext({ + isCalendarOpen: true, + startDate: new Date(2024, 2, 1), + endDate: null, + activeRangePart: 'start', + onRangeSelection, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(onRangeSelection).toHaveBeenCalledWith(new Date(2024, 2, 20), null); + }); + + it('calls setSelection in range mode when choosing an end date with the end field active', async () => { + const onRangeSelection = jest.fn(); + const { view } = renderCalendar({ + context: createMockRangeContext({ + isCalendarOpen: true, + startDate: new Date(2024, 2, 1), + endDate: null, + activeRangePart: 'end', + onRangeSelection, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(onRangeSelection).toHaveBeenCalledWith( + new Date(2024, 2, 1), + new Date(2024, 2, 20) + ); + }); + + it('calls closeCalendar in range mode when a full range is selected', async () => { + const closeCalendar = jest.fn(); + const { view } = renderCalendar({ + context: createMockRangeContext({ + isCalendarOpen: true, + startDate: new Date(2024, 2, 1), + endDate: null, + activeRangePart: 'end', + closeCalendar, + }), + }); + + const march20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + await userEvent.click(march20); + + expect(closeCalendar).toHaveBeenCalled(); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx new file mode 100644 index 00000000000..5839ee6004f --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -0,0 +1,331 @@ +import { breakpoints } from '@codecademy/gamut-styles'; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; +import { useMedia } from 'react-use'; + +import { Box, FlexBox } from '../../Box'; +import { useDatePicker } from '../DatePickerContext'; +import { CalendarQuickAction } from '../sharedTypes'; +import { + CalendarBody, + CalendarFooter, + CalendarHeader, + CalendarWrapper, +} from './Calendar'; +import type { CalendarBodyProps } from './Calendar/types'; +import { + addMonths, + getFirstOfMonth, + isDateWithinVisibleMonths, +} from './Calendar/utils/dateGrid'; +import { + applyRangeOrNewStart, + handleDateSelectRange, + handleDateSelectSingle, + rangeContainsDisabled, +} from './utils/dateSelect'; +import { computeQuickAction } from './utils/quickActions'; + +export type DatePickerCalendarProps = Pick< + CalendarBodyProps, + 'weekStartsOn' +> & { + /** id for the dialog (for aria-controls from input). */ + dialogId: string; +}; + +export const DatePickerCalendar: React.FC = ({ + dialogId, + weekStartsOn, +}) => { + const context = useDatePicker(); + const generatedId = useId(); + const headingLeftId = `datepicker-calendar-left-month-heading-${generatedId.replace( + /:/g, + '' + )}`; + const headingRightId = `datepicker-calendar-right-month-heading-${generatedId.replace( + /:/g, + '' + )}`; + + if (context === null) { + throw new Error( + 'DatePickerCalendar must be used inside a DatePicker (it reads shared state from context).' + ); + } + + const { + mode, + disableDate, + locale, + closeCalendar, + isCalendarOpen, + translations, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, + quickActions, + } = context; + + /** Wraps both month grids so cross-grid roving and tab-from-nav still count as in-calendar. */ + const calendarContainerRef = useRef(null); + + const focusGridSync = useMemo( + () => ({ + gridFocusRequested, + signal: focusGridSignal, + onGridFocusRequestHandled: clearGridFocusRequest, + calendarContainerRef, + }), + [ + gridFocusRequested, + focusGridSignal, + clearGridFocusRequest, + calendarContainerRef, + ] + ); + + const isRange = mode === 'range'; + const selectedDate = isRange ? context.startDate : context.selectedDate; + const endDate = isRange ? context.endDate : undefined; + const setActiveRangePart = isRange ? context.setActiveRangePart : undefined; + const activeRangePart = isRange ? context.activeRangePart : null; + + const anchorDate = useMemo((): Date | null => { + if (!isRange) return selectedDate ?? null; + if (activeRangePart === 'end') return endDate ?? selectedDate ?? null; + return selectedDate ?? endDate ?? null; + }, [isRange, selectedDate, endDate, activeRangePart]); + + const [displayDate, setDisplayDate] = useState(() => + getFirstOfMonth(anchorDate ?? selectedDate ?? endDate ?? new Date()) + ); + const [focusedDate, setFocusedDate] = useState( + () => selectedDate ?? endDate ?? new Date() + ); + + const focusTarget = focusedDate ?? selectedDate ?? endDate ?? new Date(); + const secondMonthDate = addMonths({ date: displayDate, n: 1 }); + const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.sm})`); + /** Current left-column month; read in the anchor sync effect without listing `displayDate` in deps (month nav would retrigger and snap back). */ + const startOfLeftVisibleMonthRef = useRef(displayDate); + startOfLeftVisibleMonthRef.current = displayDate; + + const [pauseGridRoving, setPauseGridRoving] = useState(false); + + const resumeGridRoving = useCallback(() => setPauseGridRoving(false), []); + + const beginHeaderMonthChange = useCallback( + (nextDisplay: (prev: Date) => Date) => { + setPauseGridRoving(true); + setDisplayDate(nextDisplay); + }, + [] + ); + + const onFocusedDateChangeFromGrid = useCallback( + (next: Date | null) => { + resumeGridRoving(); + setFocusedDate(next); + }, + [resumeGridRoving] + ); + + useEffect(() => { + if (gridFocusRequested) resumeGridRoving(); + }, [gridFocusRequested, resumeGridRoving]); + + const onTabFromMonthNav = useCallback(() => { + setFocusedDate(getFirstOfMonth(displayDate)); + resumeGridRoving(); + requestAnimationFrame(() => { + calendarContainerRef.current + ?.querySelector('[role="gridcell"][tabindex="0"]') + ?.focus(); + }); + }, [displayDate, resumeGridRoving]); + + useEffect(() => { + if (!isCalendarOpen || !anchorDate) { + return; + } + + const alreadyVisible = isDateWithinVisibleMonths({ + date: anchorDate, + startOfLeftVisibleMonth: startOfLeftVisibleMonthRef.current, + showSecondMonth: isTwoMonthsVisible, + }); + + if (!alreadyVisible) { + setDisplayDate(getFirstOfMonth(anchorDate)); + } + setFocusedDate(anchorDate); + }, [isCalendarOpen, anchorDate, isTwoMonthsVisible]); + + const onDateSelect = useCallback( + (date: Date) => { + if (!isRange) { + handleDateSelectSingle({ + date, + selectedDate: context.selectedDate, + onSelection: context.onSelection, + }); + queueMicrotask(closeCalendar); + return; + } + setActiveRangePart?.(null); + const shouldClose = handleDateSelectRange({ + date, + activeRangePart: context.activeRangePart, + startDate: context.startDate, + endDate: context.endDate, + onRangeSelection: context.onRangeSelection, + disableDate, + }); + if (shouldClose) queueMicrotask(closeCalendar); + }, + [isRange, setActiveRangePart, context, disableDate, closeCalendar] + ); + + const clearDate = useCallback(() => { + if (isRange) context.onRangeSelection(null, null); + else context.onSelection(null); + resumeGridRoving(); + setFocusedDate(displayDate); + }, [isRange, context, setFocusedDate, displayDate, resumeGridRoving]); + + const computedQuickActions: CalendarQuickAction[] = useMemo(() => { + return quickActions.slice(0, 3).map((action) => ({ + ...action, + onClick: () => { + action.onClick?.(); + setActiveRangePart?.(null); + const { startDate, endDate } = computeQuickAction({ + num: action.num, + timePeriod: action.timePeriod, + isRange, + }); + if (isRange) { + if ( + rangeContainsDisabled({ + startDate, + endDate, + disableDate, + }) + ) { + applyRangeOrNewStart({ + startDate, + endDate, + clickedDate: endDate, + disableDate, + onRangeSelection: context.onRangeSelection, + }); + } else { + context.onRangeSelection(startDate, endDate); + } + setDisplayDate(getFirstOfMonth(endDate)); + setFocusedDate(endDate); + queueMicrotask(closeCalendar); + } else { + context.onSelection(startDate); + setDisplayDate(getFirstOfMonth(startDate)); + setFocusedDate(startDate); + queueMicrotask(closeCalendar); + } + }, + })); + }, [ + closeCalendar, + disableDate, + isRange, + quickActions, + setActiveRangePart, + context, + ]); + + return ( + + + + beginHeaderMonthChange(() => next)} + onTabIntoGrid={onTabFromMonthNav} + /> + + + + + beginHeaderMonthChange((prev) => addMonths({ date: prev, n: 1 })) + } + onTabIntoGrid={onTabFromMonthNav} + /> + + setDisplayDate((prev) => addMonths({ date: prev, n: 1 })) + } + onEscapeKeyPress={closeCalendar} + onFocusedDateChange={onFocusedDateChangeFromGrid} + /> + + + + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts new file mode 100644 index 00000000000..3c4a14c70d1 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts @@ -0,0 +1,660 @@ +import { matchDisabledDates } from '../../Calendar/utils/dateGrid'; +import { + applyRangeOrNewStart, + handleDateSelectRange, + handleDateSelectSingle, + rangeContainsDisabled, +} from '../dateSelect'; + +const createDate = (y: number, month: number, day: number) => + new Date(y, month, day); + +const mockOnSelection = jest.fn(); +const mockOnRangeSelection = jest.fn(); +describe('rangeContainsDisabled', () => { + const startDate = createDate(2024, 0, 10); + const endDate = createDate(2024, 0, 20); + + it('returns true when a disabled date is the start date', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: matchDisabledDates([createDate(2024, 0, 10)]), + }) + ).toBe(true); + }); + + it('returns true when a disabled date is the end date', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: matchDisabledDates([createDate(2024, 0, 20)]), + }) + ).toBe(true); + }); + + it('returns true when a disabled date is strictly between startDate and endDate', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: matchDisabledDates([createDate(2024, 0, 15)]), + }) + ).toBe(true); + }); + + it('returns false when no disabled date touches the inclusive range', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: matchDisabledDates([ + createDate(2024, 0, 5), + createDate(2024, 0, 25), + ]), + }) + ).toBe(false); + }); + + it('returns true when disableDate marks a day inside the inclusive range', () => { + expect( + rangeContainsDisabled({ + startDate, + endDate, + disableDate: (d) => d.getDate() === 15, + }) + ).toBe(true); + }); +}); + +describe('handleDateSelectSingle', () => { + it('clears selection when the same date is clicked again', () => { + const selected = createDate(2024, 5, 15); + handleDateSelectSingle({ + date: selected, + selectedDate: selected, + onSelection: mockOnSelection, + }); + expect(mockOnSelection).toHaveBeenCalledWith(null); + }); + + it('sets selection when no date was previously selected', () => { + const newSelected = createDate(2024, 5, 10); + handleDateSelectSingle({ + date: newSelected, + selectedDate: null, + onSelection: mockOnSelection, + }); + expect(mockOnSelection).toHaveBeenCalledWith(newSelected); + }); + + it('sets selection to a new day when a date was previously selected', () => { + const newSelected = createDate(2024, 5, 20); + handleDateSelectSingle({ + date: newSelected, + selectedDate: createDate(2024, 5, 15), + onSelection: mockOnSelection, + }); + expect(mockOnSelection).toHaveBeenCalledWith(newSelected); + }); +}); + +describe('applyRangeOrNewStart', () => { + it('sets selection to the start and end date when the range does not contain a disabled date', () => { + const startDate = createDate(2024, 5, 10); + const endDate = createDate(2024, 5, 20); + const clicked = createDate(2024, 5, 10); + expect( + applyRangeOrNewStart({ + startDate, + endDate, + clickedDate: clicked, + disableDate: matchDisabledDates([createDate(2024, 5, 30)]), + onRangeSelection: mockOnRangeSelection, + }) + ).toBe(true); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, endDate); + }); + + it('sets selection to the clicked date as start and null as end when the range contains a disabled date', () => { + const startDate = createDate(2024, 5, 10); + const endDate = createDate(2024, 5, 20); + const clicked = createDate(2024, 5, 10); + expect( + applyRangeOrNewStart({ + startDate, + endDate, + clickedDate: clicked, + disableDate: matchDisabledDates([createDate(2024, 5, 12)]), + onRangeSelection: mockOnRangeSelection, + }) + ).toBe(false); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); +}); + +describe('handleDateSelectRange', () => { + describe('close calendar return value', () => { + it('returns false when only a start date is chosen (calendar mode)', () => { + expect( + handleDateSelectRange({ + date: createDate(2024, 5, 10), + activeRangePart: null, + startDate: null, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }) + ).toBe(false); + expect(mockOnRangeSelection).toHaveBeenCalledWith( + createDate(2024, 5, 10), + null + ); + }); + + it('returns true when end date is chosen after start (calendar mode)', () => { + expect( + handleDateSelectRange({ + date: createDate(2024, 5, 20), + activeRangePart: null, + startDate: createDate(2024, 5, 10), + endDate: null, + onRangeSelection: mockOnRangeSelection, + }) + ).toBe(true); + expect(mockOnRangeSelection).toHaveBeenCalledWith( + createDate(2024, 5, 10), + createDate(2024, 5, 20) + ); + }); + }); + + describe('activeRangePart === start', () => { + describe('start date is set', () => { + it('clears start when the start date is clicked again', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 20); + handleDateSelectRange({ + date: startDate, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, endDate); + }); + + it('sets start date when no end date is set', () => { + const startDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when new start is after end', () => { + const startDate = createDate(2024, 2, 2); + const endDate = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 20); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and keeps end date when new start is before end', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('sets start date and keeps end date when new start is the same as end', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 25); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const startDate = createDate(2024, 2, 2); + const endDate = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + describe('start date is not set', () => { + it('sets start date when no end date is set', () => { + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when new start is after end', () => { + const endDate = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 20); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and keeps end date when new start is before end', () => { + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('sets start date and keeps end date when new start is the same as end', () => { + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 25); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const endDate = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + }); + + describe('activeRangePart === end', () => { + describe('end date is set', () => { + it('clears end date when the end date is clicked again', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 18); + handleDateSelectRange({ + date: endDate, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, null); + }); + + it('sets end date when no start date is set', () => { + const endDate = createDate(2024, 2, 18); + const clicked = createDate(2024, 2, 19); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and clears start date when new end is before start', () => { + const startDate = createDate(2024, 2, 20); + const endDate = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and keeps start date when new end is after start', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('sets end date and keeps start date when new end is the same as start', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + describe('end date is not set', () => { + it('sets end date when no start date is set', () => { + const clicked = createDate(2024, 2, 19); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: null, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and clears start date when new end is before start', () => { + const startDate = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and keeps start date when new end is after start', () => { + const startDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('sets end date and keeps start date when new end is the same as start', () => { + const startDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const startDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + }); + + describe('selection mode (activeRangePart null) with start date and end date set', () => { + it('clears when a single-day range is clicked again', () => { + const day = createDate(2024, 2, 14); + handleDateSelectRange({ + date: day, + activeRangePart: null, + startDate: day, + endDate: day, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, null); + }); + + it('end date becomes start date when start date is clicked', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 28); + handleDateSelectRange({ + date: startDate, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(endDate, null); + }); + + it('clears end date when end date is clicked', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 28); + handleDateSelectRange({ + date: endDate, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, null); + }); + + it('updates end date when a date after start date is clicked', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('updates start date to the clicked date and clears end date when the range extending to right would contain a disabled date', () => { + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('updates start date when a date before start date is clicked', () => { + const startDate = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); + }); + + it('updates start date to the clicked date and clears end date when the range extending to left would contain a disabled date', () => { + const startDate = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + + describe('start date set, end date empty, selection mode', () => { + it('updates start date when clicked date is before start date', () => { + const startDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets end date when clicked date is on or after start date', () => { + const startDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 22); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); + }); + + it('updates start date to the clicked date and does not set end date when the range would contain a disabled date', () => { + const startDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate, + endDate: null, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + + describe('start date empty, end date set, selection mode', () => { + it('sets start date and clears end datewhen clicked date is before end date', () => { + const endDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when clicked date is after end date', () => { + const endDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 22); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('updates start date to the clicked date and clears end date when the range would contain a disabled date', () => { + const endDate = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate, + onRangeSelection: mockOnRangeSelection, + disableDate: matchDisabledDates([createDate(2024, 2, 12)]), + }); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts new file mode 100644 index 00000000000..bd5a664e3ab --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts @@ -0,0 +1,90 @@ +import { computeQuickAction } from '../quickActions'; + +describe('computeQuickAction', () => { + const fixed = new Date(2026, 4, 15); // May 15, 2026 local (“today” anchor) + + describe('range mode (isRange: true)', () => { + it('returns [startDate, endDate] with endDate = anchor day when range is entirely in the past', () => { + const { startDate, endDate } = computeQuickAction({ + num: -30, + timePeriod: 'day', + isRange: true, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 3, 15)); + }); + + it('applies month as num * 30 rolling days', () => { + const { startDate, endDate } = computeQuickAction({ + num: -1, + timePeriod: 'month', + isRange: true, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 3, 15)); + }); + + it('applies multiple months as additional 30-day steps', () => { + const { startDate, endDate } = computeQuickAction({ + num: -2, + timePeriod: 'month', + isRange: true, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + const expected = new Date(2026, 4, 15); + expected.setDate(expected.getDate() - 60); + expect(startDate).toEqual(expected); + }); + + it('applies year as num * 365 rolling days', () => { + const { startDate, endDate } = computeQuickAction({ + num: -1, + timePeriod: 'year', + isRange: true, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + const expected = new Date(2026, 4, 15); + expected.setDate(expected.getDate() - 365); + expect(startDate).toEqual(expected); + }); + + it('when computed start is after anchor day, orders as [anchor, computed] so the range is forward', () => { + const { startDate, endDate } = computeQuickAction({ + num: 5, + timePeriod: 'day', + isRange: true, + now: fixed, + }); + expect(startDate).toEqual(new Date(2026, 4, 15)); + expect(endDate).toEqual(new Date(2026, 4, 20)); + }); + }); + + describe('single mode (isRange: false)', () => { + it('does not swap when computed day is after anchor; endDate is still the anchor day', () => { + const { startDate, endDate } = computeQuickAction({ + num: 1, + timePeriod: 'day', + isRange: false, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 4, 16)); + }); + + it('returns past startDate with endDate = anchor for yesterday', () => { + const { startDate, endDate } = computeQuickAction({ + num: -1, + timePeriod: 'day', + isRange: false, + now: fixed, + }); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 4, 14)); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts new file mode 100644 index 00000000000..77425c45b03 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts @@ -0,0 +1,200 @@ +import type { + DatePickerRangeContextValue, + DatePickerSingleContextValue, +} from '../../DatePickerContext/types'; +import { DatePickerSharedProps } from '../../sharedTypes'; +import type { DatePickerProps, DatePickerRangeProps } from '../../types'; +import { + getOrderedCalendarEndpoints, + isDateDisabled, + isDateInRange, + isSameDay, +} from '../Calendar/utils/dateGrid'; + +export const isRangeProps = ( + props: DatePickerProps +): props is DatePickerRangeProps => props.mode === 'range'; + +type RangeContainsDisabledParams = { + startDate: Date; + endDate: Date; +} & Pick; + +export const rangeContainsDisabled = ({ + startDate, + endDate, + disableDate, +}: RangeContainsDisabledParams) => { + const { low, high } = getOrderedCalendarEndpoints({ startDate, endDate }); + + if ( + isDateDisabled({ date: low, disableDate }) || + isDateDisabled({ date: high, disableDate }) + ) { + return true; + } + + let date = new Date(low.getFullYear(), low.getMonth(), low.getDate() + 1); + while (isDateInRange({ date, startDate, endDate })) { + if (isDateDisabled({ date, disableDate })) { + return true; + } + date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1); + } + return false; +}; + +type HandleDateSelectSingleParams = { + date: Date; +} & Pick; + +export const handleDateSelectSingle = ({ + date, + selectedDate, + onSelection, +}: HandleDateSelectSingleParams) => { + if (isSameDay(date, selectedDate)) { + onSelection(null); + return; + } + onSelection(date); +}; + +type ApplyRangeOrNewStartParams = { + startDate: Date; + endDate: Date; + clickedDate: Date; +} & Pick; + +/** @returns whether a full startDate+endDate range was committed (calendar may close). */ +export const applyRangeOrNewStart = ({ + startDate, + endDate, + clickedDate, + disableDate, + onRangeSelection, +}: ApplyRangeOrNewStartParams) => { + if (rangeContainsDisabled({ startDate, endDate, disableDate })) { + onRangeSelection(clickedDate, null); + return false; + } + onRangeSelection(startDate, endDate); + return true; +}; + +type HandleDateSelectRangeParams = { + date: Date; +} & Pick< + DatePickerRangeContextValue, + | 'activeRangePart' + | 'endDate' + | 'onRangeSelection' + | 'disableDate' + | 'startDate' +>; + +/** @returns whether the calendar should close (full range selected and committed). */ +export const handleDateSelectRange = ({ + date, + activeRangePart, + startDate, + endDate, + onRangeSelection, + disableDate, +}: HandleDateSelectRangeParams) => { + // Field targeting: start or end input was focused + if (activeRangePart === 'start') { + if (isSameDay(date, startDate)) { + onRangeSelection(null, endDate); + return false; + } + const newEndDate = + endDate !== null && date.getTime() <= endDate.getTime() ? endDate : null; + if (newEndDate !== null) { + return applyRangeOrNewStart({ + startDate: date, + endDate: newEndDate, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + onRangeSelection(date, newEndDate); + return false; + } + if (activeRangePart === 'end') { + if (isSameDay(date, endDate)) { + onRangeSelection(startDate, null); + return false; + } + const newStartDate = + startDate !== null && date.getTime() >= startDate.getTime() + ? startDate + : null; + if (newStartDate !== null) { + return applyRangeOrNewStart({ + startDate: newStartDate, + endDate: date, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + onRangeSelection(newStartDate, date); + return false; + } + + // Selection mode (no field focused: calendar drives both) + if (startDate && endDate) { + if (isSameDay(startDate, endDate) && isSameDay(date, startDate)) { + onRangeSelection(null, null); + return false; + } + // if clicked on start date, end date becomes start date + if (isSameDay(date, startDate)) { + onRangeSelection(endDate, null); + return false; + } + // if clicked on end date, clears end date and start remains + if (isSameDay(date, endDate)) { + onRangeSelection(startDate, null); + return false; + } + // If clicked date > Start: Updates End Date to new date (Start remains) + if (date.getTime() > startDate.getTime()) { + return applyRangeOrNewStart({ + startDate, + endDate: date, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left + return applyRangeOrNewStart({ + startDate: date, + endDate, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + // Start is Set, End is Empty + if (startDate && !endDate) { + // If clicked date < Start: Restarts selection with clicked date as new Start + if (date.getTime() < startDate.getTime()) { + onRangeSelection(date, null); + return false; + } + // If clicked date > Start: Sets it as End Date (if range valid) + return applyRangeOrNewStart({ + startDate, + endDate: date, + clickedDate: date, + disableDate, + onRangeSelection, + }); + } + onRangeSelection(date, null); + return false; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts new file mode 100644 index 00000000000..9cb87101ea7 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts @@ -0,0 +1,106 @@ +import { CalendarQuickAction } from '../../sharedTypes'; +import { stringifyLocale } from '../../utils/locale'; +import { DatePickerTranslations } from '../../utils/translations'; +import { capitalizeFirst } from '../Calendar/utils/format'; + +const getRelativeDisplayText = ({ + num, + timePeriod, + locale, +}: { + num: number; + timePeriod: CalendarQuickAction['timePeriod']; + locale: Intl.Locale; +}) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); + return capitalizeFirst({ str: rtf.format(num, timePeriod), locale }); +}; + +export const getDefaultSingleQuickActions = ( + locale: Intl.Locale +): CalendarQuickAction[] => [ + { + num: -1, + timePeriod: 'day', + displayText: getRelativeDisplayText({ + num: -1, + timePeriod: 'day', + locale, + }), + }, + { + num: 0, + timePeriod: 'day', + displayText: getRelativeDisplayText({ num: 0, timePeriod: 'day', locale }), + }, + { + num: 1, + timePeriod: 'day', + displayText: getRelativeDisplayText({ num: 1, timePeriod: 'day', locale }), + }, +]; + +export const getDefaultRangeQuickActions = ( + translations: Required +): CalendarQuickAction[] => [ + { + num: -7, + timePeriod: 'day', + displayText: translations.last7DaysDisplayText, + }, + { + num: -30, + timePeriod: 'day', + displayText: translations.last30DaysDisplayText, + }, + { + num: -90, + timePeriod: 'day', + displayText: translations.last90DaysDisplayText, + }, +]; + +export const computeQuickAction = ({ + num, + timePeriod, + isRange, + now = new Date(), +}: { + num: number; + timePeriod: CalendarQuickAction['timePeriod']; + isRange: boolean; + now?: Date; +}) => { + const anchorDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let startDate: Date; + + switch (timePeriod) { + case 'day': { + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num); + break; + } + case 'week': { + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 7); + break; + } + case 'month': { + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 30); + break; + } + case 'year': { + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 365); + break; + } + } + + if (isRange && startDate.getTime() > anchorDate.getTime()) { + return { startDate: anchorDate, endDate: startDate }; + } + return { startDate, endDate: anchorDate }; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx new file mode 100644 index 00000000000..a3db97457f7 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx @@ -0,0 +1,78 @@ +import { MockGamutProvider } from '@codecademy/gamut-tests'; +import { render } from '@testing-library/react'; + +import { DatePickerProvider, useDatePicker } from '..'; +import { + createMockRangeContext, + createMockSingleContext, +} from './mockContexts'; + +const Consumer = () => { + const ctx = useDatePicker(); + return {ctx.mode}; +}; + +const RangeDatesConsumer = () => { + const ctx = useDatePicker(); + if (ctx.mode !== 'range') return null; + return ( + + {ctx.startDate?.toDateString() ?? 'no-start'}| + {ctx.endDate?.toDateString() ?? 'no-end'} + + ); +}; + +describe('DatePickerContext', () => { + it('throws when useDatePicker is used outside DatePickerProvider', () => { + expect(() => + render( + + + + ) + ).toThrow('useDatePickerContext must be used within a DatePicker.'); + }); + + it('returns single-mode context from useDatePicker when wrapped in DatePickerProvider', () => { + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('mode')).toHaveTextContent('single'); + }); + + it('exposes range fields when the provider value is range mode', () => { + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('mode')).toHaveTextContent('range'); + }); + + it('passes startDate and endDate through to consumers in range mode', () => { + const start = new Date(2026, 3, 10); + const end = new Date(2026, 3, 20); + const { getByTestId } = render( + + + + + + ); + + expect(getByTestId('range-dates')).toHaveTextContent( + `${start.toDateString()}|${end.toDateString()}` + ); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts new file mode 100644 index 00000000000..33ea42ef641 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts @@ -0,0 +1,50 @@ +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../utils/translations'; +import type { + DatePickerRangeContextValue, + DatePickerSingleContextValue, +} from '../types'; + +export function createMockSingleContext( + overrides: Partial = {} +): DatePickerSingleContextValue { + return { + mode: 'single', + locale: new Intl.Locale('en-US'), + isCalendarOpen: false, + openCalendar: jest.fn(), + focusCalendar: jest.fn(), + focusGridSignal: false, + gridFocusRequested: false, + clearGridFocusRequest: jest.fn(), + closeCalendar: jest.fn(), + translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, + quickActions: [], + selectedDate: new Date(2024, 2, 15), + onSelection: jest.fn(), + ...overrides, + }; +} + +export function createMockRangeContext( + overrides: Partial = {} +): DatePickerRangeContextValue { + return { + mode: 'range', + locale: new Intl.Locale('en-US'), + isCalendarOpen: false, + openCalendar: jest.fn(), + focusCalendar: jest.fn(), + focusGridSignal: false, + gridFocusRequested: false, + clearGridFocusRequest: jest.fn(), + closeCalendar: jest.fn(), + translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, + quickActions: [], + startDate: null, + endDate: null, + onRangeSelection: jest.fn(), + activeRangePart: null, + setActiveRangePart: jest.fn(), + ...overrides, + }; +} diff --git a/packages/gamut/src/DatePicker/DatePickerContext/index.tsx b/packages/gamut/src/DatePicker/DatePickerContext/index.tsx new file mode 100644 index 00000000000..725c4badd16 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/index.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; + +import { DatePickerContext, DatePickerContextValue } from './types'; + +export const DatePickerProvider = DatePickerContext.Provider; + +export const useDatePicker = (): DatePickerContextValue => { + const value = useContext(DatePickerContext); + if (value === null) { + throw new Error('useDatePickerContext must be used within a DatePicker.'); + } + return value; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerContext/types.ts b/packages/gamut/src/DatePicker/DatePickerContext/types.ts new file mode 100644 index 00000000000..cc389358e0e --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/types.ts @@ -0,0 +1,109 @@ +import { createContext } from 'react'; + +import { CalendarQuickAction, DatePickerSharedProps } from '../sharedTypes'; +import type { DatePickerTranslations } from '../utils/translations'; + +interface DatePickerBaseContextValue + extends Pick { + /** + * Discriminator: same meaning as the `mode` prop on `DatePicker` (`"single"` or `"range"`). + */ + mode: Mode; + /** + * 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. + */ + locale: Intl.Locale; + /** + * Whether the calendar popover (dialog) is open. + * + */ + isCalendarOpen: boolean; + /** + * Function to open the calendar popover. Use within callbacks that should open the calendar. + */ + openCalendar: () => void; + /** Function to move focus into the calendar grid. */ + focusCalendar: () => void; + /** + * Whether a grid-focus request is issued with an unchanged `focusedDate`, so layout + * effects that depend on focus can still re-run. + */ + focusGridSignal: boolean; + /** + * Whether the shell should run a one-shot move of focus into the grid. + * Clear with {@link clearGridFocusRequest} after focus moves. + */ + gridFocusRequested: boolean; + /** + * Mark the grid focus request as handled. Call after the calendar has moved focus into the + * grid, or to reset state without closing. + */ + clearGridFocusRequest: () => void; + /** + * Function to close the calendar popover and return focus to the input. Use within callbacks that should close the calendar. + */ + closeCalendar: () => void; + /** + * Merged `translations` for the `DatePicker` component. The `DatePicker` `translations` prop is merged onto + * the default strings so every key is present. See {@link DatePickerTranslations} for the shape of the translations and default values. + */ + translations: Required; + /** + * Footer quick actions. The shell uses an empty array if the + * `DatePicker` `quickActions` prop is `null`. When the prop is omitted, built-in defaults + * apply. See {@link CalendarQuickAction} for the shape of the quick actions. + */ + quickActions: CalendarQuickAction[]; +} + +export interface DatePickerSingleContextValue + extends DatePickerBaseContextValue<'single'> { + /** + * The controlled selected date. Same as the `DatePicker` `selectedDate` prop. + */ + selectedDate: Date | null; + /** + * Callback to update the selected date. Forwards to the `onSelected` callback on `DatePicker`. + */ + onSelection: (date: Date | null) => void; +} + +type ActiveRangePart = 'start' | 'end' | null; + +export interface DatePickerRangeContextValue + extends DatePickerBaseContextValue<'range'> { + /** + * Controlled start of the range. Same as the `DatePicker` `startDate` prop. + */ + startDate: Date | null; + /** + * Controlled end of the range. Same as the `DatePicker` `endDate` prop. + */ + endDate: Date | null; + /** + * Updates both `startDate` and `endDate` by calling the `onStartSelected` and `onEndSelected` + * props on `DatePicker` in one step. + */ + onRangeSelection: (startDate: Date | null, endDate: Date | null) => void; + /** + * `"start"` or `"end"` when that segment of the field drives the next interaction, or `null` + * in a combined "selection" state (for example when extending or replacing the range from the + * grid). Affects the visible anchor month in the open calendar and how a day pick updates + * start vs. end in range mode. + */ + activeRangePart: ActiveRangePart; + /** + * Set {@link activeRangePart} (for example when focus moves between the start and end + * spinbutton inputs). + */ + setActiveRangePart: (part: ActiveRangePart) => void; +} + +export type DatePickerContextValue = + | DatePickerSingleContextValue + | DatePickerRangeContextValue; + +export const DatePickerContext = createContext( + null +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx new file mode 100644 index 00000000000..c597503ef4e --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx @@ -0,0 +1,184 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import userEvent from '@testing-library/user-event'; +import { type FC, useCallback, useState } from 'react'; + +import { actKeyboard } from '../../../__tests__/actKeyboard'; +import type { DatePartKind } from '../../utils'; +import { type AssignSegmentRef, DatePickerInputSegment } from '..'; +import type { SegmentValues } from '../utils'; + +const noop = () => undefined; + +const noopAssignSegmentRef: AssignSegmentRef = () => undefined; + +const noopFocusSegmentField = () => undefined; + +type HarnessProps = { + field?: DatePartKind; + segment?: SegmentValues; + disabled?: boolean; + error?: boolean; + focusOrOpenCalendarGrid?: () => void; +}; + +/** + * Real `useState` + `setSegments` so functional updaters from ArrowUp / typing run and re-render. + */ +const SegmentHarness: FC = ({ + field = 'month', + segment = { month: '', day: '', year: '' }, + disabled = false, + error = false, + focusOrOpenCalendarGrid = noop, +}) => { + const [segments, setSegments] = useState(segment); + const applySegments = useCallback(() => { + // Parent would commit parsed date; segment tests only need state updates via setSegments. + }, []); + + return ( + + ); +}; + +const renderView = setupRtl(SegmentHarness, {}); + +describe('DatePickerInputSegment', () => { + it.each([ + ['month', 'MM'], + ['day', 'DD'], + ['year', 'YYYY'], + ] as const)('shows placeholder for %s', (field, expected) => { + const { view } = renderView({ field }); + + expect(view.getByRole('spinbutton', { name: field })).toHaveAttribute( + 'aria-valuetext', + expected + ); + }); + + it('sets aria-invalid when error is true', () => { + const { view } = renderView({ error: true }); + + expect(view.getByRole('spinbutton', { name: 'month' })).toHaveAttribute( + 'aria-invalid', + 'true' + ); + }); + + it('sets aria-disabled and tabIndex -1 when disabled', () => { + const { view } = renderView({ disabled: true }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + expect(month).toHaveAttribute('aria-disabled', 'true'); + expect(month).toHaveAttribute('tabIndex', '-1'); + }); + + it('ignores digit input when disabled', async () => { + const user = userEvent.setup(); + const { view } = renderView({ disabled: true }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await actKeyboard(user, '5'); + + expect(month).toHaveAttribute('aria-valuetext', 'MM'); + }); + + it('increments month with ArrowUp from empty', async () => { + const user = userEvent.setup(); + const { view } = renderView({}); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await actKeyboard(user, '{ArrowUp}'); + + expect(month).toHaveAttribute('aria-valuetext', '01'); + }); + + it('increments month with ArrowUp from 01', async () => { + const user = userEvent.setup(); + const { view } = renderView({ + segment: { month: '01', day: '', year: '' }, + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await actKeyboard(user, '{ArrowUp}'); + + expect(month).toHaveAttribute('aria-valuetext', '02'); + }); + + it('decrements month with ArrowDown from empty', async () => { + const user = userEvent.setup(); + const { view } = renderView({}); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await actKeyboard(user, '{ArrowDown}'); + + expect(month).toHaveAttribute('aria-valuetext', '12'); + }); + + it('decrements month with ArrowDown from 02', async () => { + const user = userEvent.setup(); + const { view } = renderView({ + segment: { month: '02', day: '', year: '' }, + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await actKeyboard(user, '{ArrowDown}'); + + expect(month).toHaveAttribute('aria-valuetext', '01'); + }); + + it('calls focusOrOpenCalendarGrid on Alt+ArrowDown', async () => { + const user = userEvent.setup(); + const focusOrOpenCalendarGrid = jest.fn(); + const { view } = renderView({ focusOrOpenCalendarGrid }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await actKeyboard(user, '{Alt>}{ArrowDown}{/Alt}'); + + expect(focusOrOpenCalendarGrid).toHaveBeenCalledTimes(1); + }); + + it('appends typed digits until the segment is full', async () => { + const user = userEvent.setup(); + const { view } = renderView({}); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await actKeyboard(user, '03'); + + expect(month).toHaveAttribute('aria-valuetext', '03'); + }); + + it('removes the last digit with Backspace', async () => { + const user = userEvent.setup(); + const { view } = renderView({ + segment: { month: '03', day: '', year: '' }, + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + await user.click(month); + await actKeyboard(user, '{Backspace}'); + + expect(month).toHaveAttribute('aria-valuetext', '0'); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts new file mode 100644 index 00000000000..49d1da3376f --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts @@ -0,0 +1,450 @@ +import type { DateFormatLayoutItem } from '../../utils'; +import { + appendSegmentDigit, + buildCombinedFromSegments, + digitsToSegments, + getDateSegmentsFromDate, + getSegmentSpinBounds, + getStrictSegmentDigits, + isStrictlyCompleteDateEntry, + normalizeSegmentValues, + padSegmentNumber, + parseSegmentNumericString, + parseSegmentsToDate, + spinSegment, +} from '../utils'; + +describe('getDateSegmentsFromDate', () => { + it('returns empty strings for null', () => { + expect(getDateSegmentsFromDate(null)).toEqual({ + month: '', + day: '', + year: '', + }); + }); + + it('pads month and day to two digits', () => { + expect(getDateSegmentsFromDate(new Date(2024, 2, 5))).toEqual({ + month: '03', + day: '05', + year: '2024', + }); + }); +}); + +describe('parseSegmentsToDate', () => { + it('returns date when all segments are valid', () => { + expect( + parseSegmentsToDate({ month: '03', day: '15', year: '2024' }) + ).toEqual(new Date(2024, 2, 15)); + }); + + it('returns null unless year has four digits', () => { + expect( + parseSegmentsToDate({ month: '03', day: '15', year: '24' }) + ).toBeNull(); + }); + + it('returns null when month or day is empty', () => { + expect( + parseSegmentsToDate({ month: '', day: '15', year: '2024' }) + ).toBeNull(); + expect( + parseSegmentsToDate({ month: '03', day: '', year: '2024' }) + ).toBeNull(); + }); + + it('returns null for invalid month', () => { + expect( + parseSegmentsToDate({ month: '13', day: '01', year: '2024' }) + ).toBeNull(); + }); + + it('returns null for invalid calendar day (rollover)', () => { + expect( + parseSegmentsToDate({ month: '02', day: '30', year: '2023' }) + ).toBeNull(); + }); + + it('accepts Feb 29 in a leap year', () => { + expect( + parseSegmentsToDate({ month: '02', day: '29', year: '2024' }) + ).toEqual(new Date(2024, 1, 29)); + }); +}); + +describe('getStrictSegmentDigits', () => { + it('strips non-digits and truncates to field widths', () => { + expect( + getStrictSegmentDigits({ month: '1a2', day: '3-4', year: '20xx24' }) + ).toEqual({ month: '12', day: '34', year: '2024' }); + }); +}); + +describe('isStrictlyCompleteDateEntry', () => { + it('is true for 2+2+4 digit strings', () => { + expect( + isStrictlyCompleteDateEntry({ month: '03', day: '15', year: '2024' }) + ).toBe(true); + }); + + it('is false for incomplete strings', () => { + expect( + isStrictlyCompleteDateEntry({ month: '3', day: '15', year: '2024' }) + ).toBe(false); + expect( + isStrictlyCompleteDateEntry({ month: '03', day: '15', year: '24' }) + ).toBe(false); + }); +}); + +describe('normalizeSegmentValues', () => { + describe('when entry is strictly complete', () => { + it('returns canonical segments when strict entry is a valid date', () => { + expect( + normalizeSegmentValues({ month: '03', day: '15', year: '2024' }) + ).toEqual({ + month: '03', + day: '15', + year: '2024', + }); + }); + + it('returns empty segments when strict entry is an invalid calendar date', () => { + expect( + normalizeSegmentValues({ month: '02', day: '30', year: '2023' }) + ).toEqual({ month: '', day: '', year: '' }); + }); + }); + + describe('when entry is not strictly complete', () => { + it('pads a partial month', () => { + expect( + normalizeSegmentValues({ month: '3', day: '19', year: '2024' }) + ).toEqual({ + month: '03', + day: '19', + year: '2024', + }); + }); + + it('pads a partial day when year and month are complete', () => { + expect( + normalizeSegmentValues({ month: '03', day: '9', year: '2024' }) + ).toEqual({ + month: '03', + day: '09', + year: '2024', + }); + }); + + it('pads a partial day when year and month are not complete', () => { + expect( + normalizeSegmentValues({ month: '03', day: '9', year: '20' }) + ).toEqual({ + month: '03', + day: '09', + year: '20', + }); + }); + + it('clamps partial month to 1-12', () => { + expect( + normalizeSegmentValues({ month: '99', day: '', year: '' }) + ).toEqual(expect.objectContaining({ month: '12' })); + expect( + normalizeSegmentValues({ month: '00', day: '', year: '' }) + ).toEqual(expect.objectContaining({ month: '01' })); + }); + + it('clamps day to the last day of the month', () => { + // Single-digit month avoids strict 2/2/4 path; Feb 31 → 29 in 2024. + expect( + normalizeSegmentValues({ month: '2', day: '31', year: '2024' }) + ).toEqual({ + month: '02', + day: '29', + year: '2024', + }); + }); + + it('clamps partial day to 1-31 when year/month not both complete', () => { + expect( + normalizeSegmentValues({ month: '06', day: '99', year: '20' }) + ).toEqual(expect.objectContaining({ day: '31' })); + }); + + it('strips non-digit characters from partial input', () => { + expect( + normalizeSegmentValues({ month: '1a', day: '2b', year: '20c24' }) + ).toEqual({ + month: '01', + day: '02', + year: '2024', + }); + }); + }); +}); + +describe('getSegmentSpinBounds', () => { + it('bounds month to 1-12', () => { + expect( + getSegmentSpinBounds({ + field: 'month', + segments: { month: '', day: '', year: '' }, + }) + ).toEqual({ min: 1, max: 12 }); + }); + + it('bounds year to 1-9999', () => { + expect( + getSegmentSpinBounds({ + field: 'year', + segments: { month: '', day: '', year: '' }, + }) + ).toEqual({ min: 1, max: 9999 }); + }); + + it('bounds day using parsed month and four-digit year', () => { + expect( + getSegmentSpinBounds({ + field: 'day', + segments: { month: '02', year: '2024', day: '' }, + }) + ).toEqual({ min: 1, max: 29 }); + expect( + getSegmentSpinBounds({ + field: 'day', + segments: { month: '02', year: '2023', day: '' }, + }) + ).toEqual({ min: 1, max: 28 }); + }); + + it('uses default year 2024 when year segment is incomplete', () => { + expect( + getSegmentSpinBounds({ + field: 'day', + segments: { month: '02', year: '20', day: '' }, + }) + ).toEqual({ min: 1, max: 29 }); + }); +}); + +describe('parseSegmentNumericString', () => { + it('returns null for empty or non-numeric', () => { + expect(parseSegmentNumericString('')).toBeNull(); + expect(parseSegmentNumericString('abc')).toBeNull(); + }); + + it('parses first digit run', () => { + expect(parseSegmentNumericString('12')).toBe(12); + expect(parseSegmentNumericString('3x4')).toBe(34); + }); +}); + +describe('padSegmentNumber', () => { + it('pads year to four digits', () => { + expect(padSegmentNumber({ field: 'year', numericValue: 123 })).toBe('0123'); + }); + it('pads month to two digits', () => { + expect(padSegmentNumber({ field: 'month', numericValue: 3 })).toBe('03'); + }); + + it('pads day to two digits', () => { + expect(padSegmentNumber({ field: 'day', numericValue: 1 })).toBe('01'); + }); +}); + +describe('appendSegmentDigit', () => { + it('ignores non-digit characters', () => { + expect(appendSegmentDigit({ field: 'month', prev: '01', digit: 'x' })).toBe( + '01' + ); + }); + + it('appends until max length', () => { + expect(appendSegmentDigit({ field: 'month', prev: '', digit: '1' })).toBe( + '1' + ); + expect(appendSegmentDigit({ field: 'month', prev: '1', digit: '2' })).toBe( + '12' + ); + }); + + it('replaces when segment is already full', () => { + expect(appendSegmentDigit({ field: 'month', prev: '12', digit: '5' })).toBe( + '5' + ); + expect( + appendSegmentDigit({ field: 'year', prev: '2024', digit: '9' }) + ).toBe('9'); + }); + + it('strips non-digits from previous value before appending', () => { + expect(appendSegmentDigit({ field: 'day', prev: '1a', digit: '2' })).toBe( + '12' + ); + }); +}); + +describe('spinSegment', () => { + const empty = { month: '', day: '', year: '' }; + + beforeEach(() => { + jest.spyOn(Date.prototype, 'getFullYear').mockReturnValue(2024); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('uses current calendar year when stepping up', () => { + expect(spinSegment({ field: 'year', segments: empty, delta: 1 })).toBe( + '2024' + ); + }); + + it('uses max year when stepping down', () => { + expect(spinSegment({ field: 'year', segments: empty, delta: -1 })).toBe( + '9999' + ); + }); + + it('steps month up from empty to min', () => { + expect( + spinSegment({ + field: 'month', + segments: { month: '', day: '', year: '' }, + delta: 1, + }) + ).toBe('01'); + }); + + it('steps month down from empty to max', () => { + expect( + spinSegment({ + field: 'month', + segments: { month: '', day: '', year: '' }, + delta: -1, + }) + ).toBe('12'); + }); + + it('steps day up from empty to min', () => { + expect( + spinSegment({ + field: 'day', + segments: { month: '', day: '', year: '' }, + delta: 1, + }) + ).toBe('01'); + }); + + it('steps day down from empty to max', () => { + expect( + spinSegment({ + field: 'day', + segments: { month: '', day: '', year: '' }, + delta: -1, + }) + ).toBe('31'); + }); + + it('increments within bounds', () => { + expect( + spinSegment({ + field: 'month', + segments: { month: '06', day: '', year: '' }, + delta: 1, + }) + ).toBe('07'); + expect( + spinSegment({ + field: 'month', + segments: { month: '12', day: '', year: '' }, + delta: 1, + }) + ).toBe('12'); + }); + + it('decrements within bounds', () => { + expect( + spinSegment({ + field: 'month', + segments: { month: '06', day: '', year: '' }, + delta: -1, + }) + ).toBe('05'); + expect( + spinSegment({ + field: 'month', + segments: { month: '01', day: '', year: '' }, + delta: -1, + }) + ).toBe('01'); + }); +}); + +describe('buildCombinedFromSegments', () => { + it('joins fields and literals in layout order (US)', () => { + const usLayout: DateFormatLayoutItem[] = [ + { kind: 'field', field: 'month' }, + { kind: 'literal', text: '/' }, + { kind: 'field', field: 'day' }, + { kind: 'literal', text: '/' }, + { kind: 'field', field: 'year' }, + ]; + + expect( + buildCombinedFromSegments({ + segments: { month: '03', day: '15', year: '2024' }, + layout: usLayout, + }) + ).toBe('03/15/2024'); + }); + + it('joins fields and literals in layout order (UK)', () => { + const ukLayout: DateFormatLayoutItem[] = [ + { kind: 'field', field: 'day' }, + { kind: 'literal', text: '/' }, + { kind: 'field', field: 'month' }, + { kind: 'literal', text: '/' }, + { kind: 'field', field: 'year' }, + ]; + + expect( + buildCombinedFromSegments({ + segments: { month: '03', day: '15', year: '2024' }, + layout: ukLayout, + }) + ).toBe('15/03/2024'); + }); +}); + +describe('digitsToSegments', () => { + it('splits digit string by field order (MDY)', () => { + expect( + digitsToSegments({ + digits: '03152024', + fieldOrder: ['month', 'day', 'year'], + }) + ).toEqual({ + month: '03', + day: '15', + year: '2024', + }); + }); + + it('splits digit string by field order (DMY)', () => { + expect( + digitsToSegments({ + digits: '15032024', + fieldOrder: ['day', 'month', 'year'], + }) + ).toEqual({ + month: '03', + day: '15', + year: '2024', + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx new file mode 100644 index 00000000000..2428e02116a --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx @@ -0,0 +1,43 @@ +import { css, states } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +const segmentStyles = states({ + isEmpty: { + color: 'text-secondary', + }, + isYear: { + minWidth: '4ch', + }, +}); + +type SegmentStyleProps = StyleProps; + +export const Segment = styled.span( + css({ + display: 'inline-block', + textAlign: 'center', + minWidth: '2ch', + padding: 0, + margin: 0, + color: 'text', + cursor: 'text', + '&:focus': { + bg: 'primary', + color: 'background', + borderRadius: 'md', + }, + '&:focus-visible': { + outline: 'none', + }, + }), + segmentStyles +); + +export const SegmentLiteral = styled.span( + css({ + color: 'text-secondary', + userSelect: 'none', + px: 4, + }) +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx new file mode 100644 index 00000000000..12b8bb313c9 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx @@ -0,0 +1,195 @@ +import { type Dispatch, type SetStateAction, useCallback, useId } from 'react'; + +import type { DatePartKind } from '../utils'; +import { Segment } from './elements'; +import { + appendSegmentDigit, + getSegmentPlaceholder, + getSegmentSpinBounds, + parseSegmentNumericString, + segmentMaxLength, + SegmentValues, + spinSegment, +} from './utils'; + +export type AssignSegmentRef = ( + field: DatePartKind, + el: HTMLSpanElement | null +) => void; + +export type DatePickerInputSegmentProps = { + field: DatePartKind; + segments: SegmentValues; + disabled: boolean; + error: boolean; + onFocus: () => void; + onAltArrowDown: () => void; + /** Focus a sibling segment; must use refs registered via `assignSegmentRef` (owned by parent). */ + onSiblingFocus: (field: DatePartKind) => void; + assignSegmentRef: AssignSegmentRef; + setSegments: Dispatch>; + prevField: DatePartKind | null; + nextField: DatePartKind | null; + applySegments: (next: SegmentValues) => void; +}; + +export const DatePickerInputSegment: React.FC = ({ + field, + segments, + disabled, + error, + onFocus, + onAltArrowDown, + onSiblingFocus, + assignSegmentRef, + setSegments, + prevField, + nextField, + applySegments, +}) => { + const { min, max } = getSegmentSpinBounds({ field, segments }); + const numericValue = parseSegmentNumericString(segments[field]); + const ariaValue = + segments[field].length > 0 && numericValue != null + ? numericValue + : undefined; + const display = + segments[field].length > 0 ? segments[field] : getSegmentPlaceholder(field); + const inputID = useId(); + const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; + + const onKeyDown = useCallback( + (field: DatePartKind) => (e: React.KeyboardEvent) => { + if (disabled) return; + + if (e.altKey && (e.key === 'ArrowDown' || e.key === 'Down')) { + e.preventDefault(); + e.stopPropagation(); + onAltArrowDown(); + return; + } + + if (e.key === 'ArrowLeft') { + if (prevField) { + e.preventDefault(); + onSiblingFocus(prevField); + } + return; + } + + if (e.key === 'ArrowRight') { + if (nextField) { + e.preventDefault(); + onSiblingFocus(nextField); + } + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSegments((prev) => { + const next = { + ...prev, + [field]: spinSegment({ field, segments: prev, delta: 1 }), + }; + queueMicrotask(() => { + applySegments(next); + }); + return next; + }); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSegments((prev) => { + const next = { + ...prev, + [field]: spinSegment({ field, segments: prev, delta: -1 }), + }; + queueMicrotask(() => { + applySegments(next); + }); + return next; + }); + return; + } + + if (e.key === 'Backspace' || e.key === 'Delete') { + e.preventDefault(); + setSegments((prev) => { + if (prev[field].length > 0) { + const next = { + ...prev, + [field]: prev[field].slice(0, -1), + }; + queueMicrotask(() => { + applySegments(next); + }); + return next; + } + if (prevField) { + queueMicrotask(() => onSiblingFocus(prevField)); + } + return prev; + }); + return; + } + + if (e.key.length === 1 && /^\d$/.test(e.key)) { + e.preventDefault(); + e.stopPropagation(); + setSegments((prev) => { + const next = { + ...prev, + [field]: appendSegmentDigit({ + field, + prev: prev[field], + digit: e.key, + }), + }; + queueMicrotask(() => { + applySegments(next); + }); + const maxLen = segmentMaxLength(field); + if (next[field].length >= maxLen && nextField) { + queueMicrotask(() => onSiblingFocus(nextField)); + } + return next; + }); + } + }, + [ + disabled, + onAltArrowDown, + prevField, + onSiblingFocus, + nextField, + setSegments, + applySegments, + ] + ); + + return ( + assignSegmentRef(field, el)} + role="spinbutton" + tabIndex={disabled ? -1 : 0} + onFocus={onFocus} + onKeyDown={onKeyDown(field)} + > + {display} + + ); +}; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts new file mode 100644 index 00000000000..1cfb4a3b5b1 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts @@ -0,0 +1,216 @@ +import type { DateFormatLayoutItem, DatePartKind } from '../utils'; + +export type SegmentValues = { + month: string; + day: string; + year: string; +}; + +export const getDateSegmentsFromDate = (date: Date | null): SegmentValues => { + if (date === null) return { month: '', day: '', year: '' }; + return { + month: String(date.getMonth() + 1).padStart(2, '0'), + day: String(date.getDate()).padStart(2, '0'), + year: String(date.getFullYear()), + }; +}; + +export const parseSegmentsToDate = (segments: SegmentValues) => { + const { month, day, year } = segments; + if (year.length !== 4) return null; + if (month.length === 0 || day.length === 0) return null; + const monthNumber = parseInt(month, 10); + const dayNumber = parseInt(day, 10); + const yearNumber = parseInt(year, 10); + if ( + !Number.isFinite(monthNumber) || + !Number.isFinite(dayNumber) || + !Number.isFinite(yearNumber) + ) + return null; + if (monthNumber < 1 || monthNumber > 12) return null; + const parsed = new Date(yearNumber, monthNumber - 1, dayNumber); + if ( + parsed.getFullYear() !== yearNumber || + parsed.getMonth() !== monthNumber - 1 || + parsed.getDate() !== dayNumber + ) { + return null; + } + return parsed; +}; + +export const getStrictSegmentDigits = (segments: SegmentValues) => ({ + month: segments.month.replace(/\D/g, '').slice(0, 2), + day: segments.day.replace(/\D/g, '').slice(0, 2), + year: segments.year.replace(/\D/g, '').slice(0, 4), +}); + +export const isStrictlyCompleteDateEntry = (strictSegments: SegmentValues) => { + const { month, day, year } = strictSegments; + return year.length === 4 && month.length === 2 && day.length === 2; +}; + +export const normalizeSegmentValues = ( + segments: SegmentValues +): SegmentValues => { + const strictSegments = getStrictSegmentDigits(segments); + if (isStrictlyCompleteDateEntry(strictSegments)) { + const parsed = parseSegmentsToDate(strictSegments); + if (parsed) { + return getDateSegmentsFromDate(parsed); + } + return { month: '', day: '', year: '' }; + } + + const year = segments.year.replace(/\D/g, '').slice(0, 4); + let month = segments.month.replace(/\D/g, '').slice(0, 2); + let day = segments.day.replace(/\D/g, '').slice(0, 2); + + if (month.length > 0) { + const m = Math.min(12, Math.max(1, parseInt(month, 10))); + month = Number.isFinite(m) ? String(m).padStart(2, '0') : ''; + } + if (year.length === 4 && month.length === 2 && day.length > 0) { + const y = parseInt(year, 10); + const m = parseInt(month, 10); + const dmax = new Date(y, m, 0).getDate(); + const d = Math.min(dmax, Math.max(1, parseInt(day, 10))); + day = Number.isFinite(d) ? String(d).padStart(2, '0') : ''; + } else if (day.length > 0) { + const d = Math.min(31, Math.max(1, parseInt(day, 10))); + day = Number.isFinite(d) ? String(d).padStart(2, '0') : ''; + } + return { month, day, year }; +}; + +export const getSegmentPlaceholder = (field: DatePartKind) => + field === 'year' ? 'YYYY' : field === 'month' ? 'MM' : 'DD'; + +export const segmentMaxLength = (field: DatePartKind) => + field === 'year' ? 4 : 2; + +export const getSegmentSpinBounds = ({ + field, + segments, +}: { + field: DatePartKind; + segments: SegmentValues; +}): { min: number; max: number } => { + switch (field) { + case 'month': + return { min: 1, max: 12 }; + case 'day': { + const year = + segments.year.length === 4 ? parseInt(segments.year, 10) : 2024; + const month = + segments.month.length >= 1 + ? Math.min(12, Math.max(1, parseInt(segments.month, 10) || 1)) + : 1; + const maxDay = new Date(year, month, 0).getDate(); + return { min: 1, max: Number.isFinite(maxDay) ? maxDay : 31 }; + } + case 'year': + return { min: 1, max: 9999 }; + default: + return { min: 1, max: 9999 }; + } +}; + +export const parseSegmentNumericString = (str: string) => { + const digits = str.replace(/\D/g, ''); + if (digits.length === 0) return null; + const numericValue = parseInt(digits, 10); + return Number.isFinite(numericValue) ? numericValue : null; +}; + +export const padSegmentNumber = ({ + field, + numericValue, +}: { + field: DatePartKind; + numericValue: number; +}) => { + if (field === 'year') { + const clamped = Math.min(9999, Math.max(1, numericValue)); + return String(clamped).padStart(4, '0'); + } + const clamped = Math.min(99, Math.max(0, numericValue)); + return String(clamped).padStart(2, '0').slice(-2); +}; + +export const appendSegmentDigit = ({ + field, + prev, + digit, +}: { + field: DatePartKind; + prev: string; + digit: string; +}) => { + if (!/^\d$/.test(digit)) return prev; + const maxLen = segmentMaxLength(field); + const digitsOnly = prev.replace(/\D/g, ''); + // If full, appending would truncate to the same value — use the new digit as a fresh start. + if (digitsOnly.length >= maxLen) { + return digit.slice(0, maxLen); + } + return (digitsOnly + digit).slice(0, maxLen); +}; + +export const spinSegment = ({ + field, + segments, + delta, +}: { + field: DatePartKind; + segments: SegmentValues; + delta: 1 | -1; +}) => { + const { min, max } = getSegmentSpinBounds({ field, segments }); + let currentSegementValue = parseSegmentNumericString(segments[field]); + + if (currentSegementValue === null) { + currentSegementValue = + field === 'year' + ? delta > 0 + ? new Date().getFullYear() + : max + : delta > 0 + ? min + : max; + } else { + currentSegementValue += delta; + } + + currentSegementValue = Math.min(max, Math.max(min, currentSegementValue)); + return padSegmentNumber({ field, numericValue: currentSegementValue }); +}; + +export const buildCombinedFromSegments = ({ + segments, + layout, +}: { + segments: SegmentValues; + layout: DateFormatLayoutItem[]; +}) => + layout + .map((item) => (item.kind === 'literal' ? item.text : segments[item.field])) + .join(''); + +export const digitsToSegments = ({ + digits, + fieldOrder, +}: { + digits: string; + fieldOrder: DatePartKind[]; +}): SegmentValues => { + let rest = digits; + const segments: SegmentValues = { month: '', day: '', year: '' }; + for (const field of fieldOrder) { + const maxLen = field === 'year' ? 4 : 2; + segments[field] = rest.slice(0, maxLen); + rest = rest.slice(maxLen); + } + return segments; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx new file mode 100644 index 00000000000..d2b793ec801 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx @@ -0,0 +1,172 @@ +import { MockGamutProvider, setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent, render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ComponentProps, FC } from 'react'; + +import { actKeyboard } from '../../__tests__/actKeyboard'; +import { DatePickerProvider } from '../../DatePickerContext'; +import { + createMockRangeContext, + createMockSingleContext, +} from '../../DatePickerContext/__tests__/mockContexts'; +import type { DatePickerContextValue } from '../../DatePickerContext/types'; +import { DatePickerInput } from '../index'; + +type HarnessProps = { context: DatePickerContextValue } & ComponentProps< + typeof DatePickerInput +>; + +const DatePickerInputHarness: FC = ({ + context, + ...inputProps +}) => ( + + + +); + +const renderInput = setupRtl(DatePickerInputHarness, { + context: createMockSingleContext(), +}); + +type RangeLabelsHarnessProps = { + startLabel?: string; + endLabel?: string; +}; + +const RangeLabelsHarness: FC = ({ + startLabel, + endLabel, +}) => { + const context = createMockRangeContext(); + return ( + + + + + ); +}; + +const renderRange = setupRtl(RangeLabelsHarness, {}); + +describe('DatePickerInput', () => { + it('throws when rendered without DatePickerProvider', () => { + expect(() => + render( + + + + ) + ).toThrow(/useDatePickerContext must be used within a DatePicker/); + }); + + it('calls openCalendar when the shell is clicked', async () => { + const user = userEvent.setup(); + const openCalendar = jest.fn(); + const { view } = renderInput({ + context: createMockSingleContext({ openCalendar, isCalendarOpen: false }), + }); + + await user.click(view.getByRole('group')); + + expect(openCalendar).toHaveBeenCalledTimes(1); + }); + + it('renders default Date label in single date mode', () => { + const { view } = renderInput(); + + view.getByText('Date'); + }); + + it('renders default Start date and End date labels in range mode', () => { + const { view } = renderRange(); + + view.getByText('Start date'); + view.getByText('End date'); + }); + + it('renders a custom label when provided in single date mode', () => { + const { view } = renderInput({ label: 'Ship date' }); + + view.getByText('Ship date'); + }); + + it('renders a custom label when provided in range mode', () => { + const { view } = renderRange({ + startLabel: 'The Beginning', + endLabel: 'The End', + }); + + view.getByText('The Beginning'); + view.getByText('The End'); + }); + + it('syncs hidden input to the context selected date (ISO date-only)', () => { + const { view } = renderInput(); + + const hidden = view.container.querySelector('input[type="hidden"]')!; + expect(hidden).toHaveValue('2024-03-15'); + }); + + it('moves focus between segments with ArrowLeft and ArrowRight', 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 actKeyboard(user, '{ArrowRight}'); + expect(document.activeElement).toBe(day); + + await actKeyboard(user, '{ArrowRight}'); + expect(document.activeElement).toBe(year); + + await actKeyboard(user, '{ArrowLeft}'); + expect(document.activeElement).toBe(day); + }); + + it('updates the hidden input when a full date is typed (auto-advance between segments)', 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 actKeyboard(user, '03'); + expect(document.activeElement).toBe(day); + await actKeyboard(user, '15'); + expect(document.activeElement).toBe(year); + await actKeyboard(user, '2024'); + + const hidden = view.container.querySelector('input[type="hidden"]')!; + expect(hidden).toHaveValue('2024-03-15'); + }); + + it('normalizes and keeps a valid date after blur', async () => { + const user = userEvent.setup(); + const { view } = renderInput({ + context: createMockSingleContext({ selectedDate: null }), + }); + + const month = view.getByRole('spinbutton', { name: 'month' }); + const year = view.getByRole('spinbutton', { name: 'year' }); + + month.focus(); + await actKeyboard(user, '03'); + await actKeyboard(user, '15'); + await actKeyboard(user, '2024'); + + fireEvent.blur(year, { relatedTarget: document.body }); + + const hidden = view.container.querySelector('input[type="hidden"]')!; + expect(hidden).toHaveValue('2024-03-15'); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx new file mode 100644 index 00000000000..672f95d6b7f --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx @@ -0,0 +1,46 @@ +import { variant } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +import { FlexBox } from '../../Box'; +import { + formFieldFocusStyles, + formFieldStyles, + inputSizeStyles, +} from '../../Form/styles'; + +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 +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx new file mode 100644 index 00000000000..dcb34747b40 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -0,0 +1,308 @@ +import { MiniCalendarIcon } from '@codecademy/gamut-icons'; +import { + type FocusEvent, + forwardRef, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { FlexBox } from '../../Box'; +import { FormGroup } from '../../Form/elements/FormGroup'; +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 { 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'; + +export type DatePickerInputProps = Omit< + InputWrapperProps, + 'className' | 'type' | 'icon' | 'value' | 'onChange' | 'color' +> & { + /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */ + rangePart?: 'start' | 'end'; +}; + +export const DatePickerInput = forwardRef( + ( + { disabled, error, form, label, name, rangePart, size = 'base', ...rest }, + ref + ) => { + const context = useDatePicker(); + + if (context === null) { + throw new Error( + 'DatePickerInput must be used inside a DatePicker (it reads shared state from context).' + ); + } + + 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 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 ( + + ); + })} + + + + + + + + ); + } +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/utils.ts b/packages/gamut/src/DatePicker/DatePickerInput/utils.ts new file mode 100644 index 00000000000..aaa7debf00c --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/utils.ts @@ -0,0 +1,44 @@ +import { stringifyLocale } from '../utils/locale'; + +export type DatePartKind = 'month' | 'day' | 'year'; + +export type DateFormatLayoutItem = + | { kind: 'field'; field: DatePartKind } + | { kind: 'literal'; text: string }; + +export const getDateFormatLayout = (locale: Intl.Locale) => { + const parts = new Intl.DateTimeFormat(stringifyLocale(locale), { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(2025, 10, 15)); + + const items: DateFormatLayoutItem[] = []; + for (const part of parts) { + if (part.type === 'month') items.push({ kind: 'field', field: 'month' }); + else if (part.type === 'day') items.push({ kind: 'field', field: 'day' }); + else if (part.type === 'year') items.push({ kind: 'field', field: 'year' }); + else if (part.type === 'literal') + items.push({ kind: 'literal', text: part.value }); + } + return items; +}; + +export const getDateFieldOrder = (layout: DateFormatLayoutItem[]) => { + const order: DatePartKind[] = []; + for (const item of layout) { + if (item.kind === 'field' && !order.includes(item.field)) { + order.push(item.field); + } + } + return order.length === 3 + ? order + : (['month', 'day', 'year'] as DatePartKind[]); +}; + +export const formatDateISO8601DateOnly = (date: Date) => { + const y = date.getFullYear(); + const m = date.getMonth() + 1; + const d = date.getDate(); + return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`; +}; diff --git a/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx new file mode 100644 index 00000000000..f418508e89a --- /dev/null +++ b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx @@ -0,0 +1,346 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { FC, FormEvent, FormEventHandler } from 'react'; + +import { DatePicker } from '../DatePicker'; +import type { DatePickerTranslations } from '../utils/translations'; +import { actKeyboard } from './actKeyboard'; + +jest.mock('react-use', () => { + const actual = jest.requireActual('react-use'); + return { + ...actual, + useMedia: jest.fn(() => false), + }; +}); + +type SingleHarnessProps = { + selectedDate: Date | null; + onSelected: (date: Date | null) => void; + translations?: DatePickerTranslations; +}; + +const SingleHarness: FC = ({ + selectedDate, + onSelected, + translations, +}) => ( + +); + +const renderSingle = setupRtl(SingleHarness, { + selectedDate: new Date(2024, 2, 1), + onSelected: jest.fn(), +}); + +type RangeHarnessProps = { + startDate: Date | null; + endDate: Date | null; + onStartSelected: (date: Date | null) => void; + onEndSelected: (date: Date | null) => void; +}; + +const RangeHarness: FC = ({ + startDate, + endDate, + onStartSelected, + onEndSelected, +}) => ( + +); + +const renderRange = setupRtl(RangeHarness, { + startDate: null, + endDate: null, + onStartSelected: jest.fn(), + onEndSelected: jest.fn(), +}); + +const composedSetSelected = jest.fn(); + +const ComposedOnlyHarness: FC = () => ( + +
composed
+
+); + +const renderComposedOnly = setupRtl(ComposedOnlyHarness, {}); + +type SingleInFormProps = { + onFormSubmit: FormEventHandler; +} & SingleHarnessProps; + +const SingleInForm: FC = ({ + onFormSubmit, + ...harnessProps +}) => ( +
+ + +); + +const renderSingleInForm = setupRtl(SingleInForm, { + onFormSubmit: (e: FormEvent) => { + e.preventDefault(); + }, + selectedDate: new Date(2024, 2, 1), + onSelected: jest.fn(), +}); + +type RangeInFormProps = { + onFormSubmit: FormEventHandler; +} & RangeHarnessProps; + +const RangeInForm: FC = ({ onFormSubmit, ...rest }) => ( +
+ + +); + +const renderRangeInForm = setupRtl(RangeInForm, { + onFormSubmit: (e: FormEvent) => { + e.preventDefault(); + }, + startDate: null, + endDate: null, + onStartSelected: jest.fn(), + onEndSelected: jest.fn(), +}); + +describe('DatePicker', () => { + it('does not show the calendar dialog until the input group opens it', async () => { + const user = userEvent.setup(); + const { view } = renderSingle(); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + + await user.click(view.getByRole('group')); + + expect(view.getByRole('dialog')).toBeVisible(); + }); + + it('renders a custom date label when translations override dateLabel', () => { + const { view } = renderSingle({ + translations: { dateLabel: 'Ship date' }, + }); + + view.getByText('Ship date'); + }); + + it('renders two input groups in range mode', () => { + const { view } = renderRange(); + + expect(view.getAllByRole('group')).toHaveLength(2); + }); + + it('associates the field label with the segment shell via label `for` and shell `id` (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(); + }); + + it('renders only children when the children prop is provided', () => { + const { view } = renderComposedOnly(); + + expect(view.getByTestId('composed')).toHaveTextContent('composed'); + expect(view.queryByText('Date')).not.toBeInTheDocument(); + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes the calendar on Escape from the input when the popover is open', async () => { + const user = userEvent.setup(); + const { view } = renderSingle(); + + await user.click(view.getByRole('group')); + expect(view.getByRole('dialog')).toBeVisible(); + + const spinbutton = view.getByRole('spinbutton', { name: 'month' }); + spinbutton.focus(); + await actKeyboard(user, '{Escape}'); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('closes the calendar on Escape from a month nav chevron (not only the day grid)', async () => { + const user = userEvent.setup(); + const { view } = renderSingle(); + + await user.click(view.getByRole('group')); + expect(view.getByRole('dialog')).toBeVisible(); + + view.getByRole('button', { name: 'Last month' }).focus(); + await actKeyboard(user, '{Escape}'); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + }); +}); + +describe('DatePicker inside a form', () => { + it('renders the field inside the form and can open the dialog', async () => { + const user = userEvent.setup(); + const { view } = renderSingleInForm(); + + const form = view.getByTestId('datepicker-form'); + expect(form).toBeInstanceOf(HTMLFormElement); + expect(form.querySelector('[role="group"]')).toBeInTheDocument(); + + await user.click(view.getByRole('group')); + expect(view.getByRole('dialog')).toBeVisible(); + }); + + it('does not submit the form when Escape closes the calendar from the field', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + const { view } = renderSingleInForm({ onFormSubmit }); + + await user.click(view.getByRole('group')); + view.getByRole('spinbutton', { name: 'month' }).focus(); + await actKeyboard(user, '{Escape}'); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onFormSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit the form when Escape closes the calendar from a month chevron', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + const { view } = renderSingleInForm({ onFormSubmit }); + + await user.click(view.getByRole('group')); + view.getByRole('button', { name: 'Last month' }).focus(); + await actKeyboard(user, '{Escape}'); + + expect(onFormSubmit).not.toHaveBeenCalled(); + }); + + it('does not submit the form when selecting a day in the grid', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + const { view } = renderSingleInForm({ onFormSubmit }); + + await user.click(view.getByRole('group')); + await user.click(view.getByRole('gridcell', { name: /March 20, 2024/i })); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onFormSubmit).not.toHaveBeenCalled(); + }); + + it('still submits the form when a real submit control is used', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + + const SingleInFormWithSubmit: FC = ({ + onFormSubmit: onSubmit, + ...harness + }) => ( +
+ + + + ); + + const renderWithButton = setupRtl(SingleInFormWithSubmit, { + selectedDate: new Date(2024, 2, 1), + onSelected: jest.fn(), + }); + + renderWithButton({ + onFormSubmit: (e) => { + e.preventDefault(); + onFormSubmit(e); + }, + }); + await user.click(screen.getByRole('button', { name: 'Save' })); + expect(onFormSubmit).toHaveBeenCalledTimes(1); + }); + + it('exposes a hidden field whose name and value match FormData on submit (default DatePicker passes `name` through)', () => { + const onFormSubmit = jest.fn((e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const hidden = form.querySelector( + 'input[type="hidden"]' + )!; + expect(hidden.name).toBeTruthy(); + expect(hidden).toHaveValue('2024-03-01'); + expect(new FormData(form).get(hidden.name)).toBe(hidden.value); + }); + + const { view } = renderSingleInForm({ onFormSubmit }); + fireEvent.submit(view.getByTestId('datepicker-form') as HTMLFormElement); + expect(onFormSubmit).toHaveBeenCalledTimes(1); + }); + + it('sends the selected date as the ISO 8601 value in FormData on submit', () => { + const selected = new Date(2024, 5, 15); + const onFormSubmit = jest.fn((e: FormEvent) => { + e.preventDefault(); + const form = e.currentTarget; + const hidden = form.querySelector( + 'input[type="hidden"]' + )!; + const key = hidden.name; + const fromFormData = new FormData(form).get(key); + expect(fromFormData).toBe('2024-06-15'); + expect(fromFormData).toBe(hidden.value); + }); + + const { view } = renderSingleInForm({ + onFormSubmit, + selectedDate: selected, + }); + fireEvent.submit(view.getByTestId('datepicker-form') as HTMLFormElement); + expect(onFormSubmit).toHaveBeenCalledTimes(1); + }); + + it('in range mode, has two named hidden fields for start and end', () => { + const { view } = renderRangeInForm(); + const hiddens = view.container.querySelectorAll('input[type="hidden"]'); + expect(hiddens).toHaveLength(2); + const start = hiddens[0] as HTMLInputElement; + const end = hiddens[1] as HTMLInputElement; + expect(start.name).toBeTruthy(); + expect(end.name).toBeTruthy(); + expect(start.name).not.toBe(end.name); + }); + + it('in range mode, does not submit the form when opening and closing the calendar on the start field', async () => { + const user = userEvent.setup(); + const onFormSubmit = jest.fn((e) => e.preventDefault()); + const { view } = renderRangeInForm({ onFormSubmit }); + + const groups = view.getAllByRole('group'); + expect(groups).toHaveLength(2); + await user.click(groups[0]!); + expect(view.getByRole('dialog')).toBeVisible(); + + view.getByRole('button', { name: 'Next month' }).focus(); + await actKeyboard(user, '{Escape}'); + + expect(view.queryByRole('dialog')).not.toBeInTheDocument(); + expect(onFormSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/gamut/src/DatePicker/__tests__/actKeyboard.ts b/packages/gamut/src/DatePicker/__tests__/actKeyboard.ts new file mode 100644 index 00000000000..c6958263d58 --- /dev/null +++ b/packages/gamut/src/DatePicker/__tests__/actKeyboard.ts @@ -0,0 +1,18 @@ +import { act } from '@testing-library/react'; + +/** + * Wraps `userEvent.keyboard` in `act` so key handlers that call `setState` in + * DatePicker segments do not trigger "not wrapped in act(...)" in tests. + * Flushes one microtask for Segment `queueMicrotask(applySegments)` work. + */ +export async function actKeyboard( + user: { keyboard: (text: string) => Promise }, + text: string +) { + await act(async () => { + await user.keyboard(text); + }); + await act(async () => { + await Promise.resolve(); + }); +} diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx new file mode 100644 index 00000000000..53bcb00c74d --- /dev/null +++ b/packages/gamut/src/DatePicker/index.tsx @@ -0,0 +1,6 @@ +export * from './DatePicker'; +export * from './DatePickerContext'; +export * from './DatePickerCalendar'; +export * from './DatePickerInput'; +export * from './types'; +export { matchDisabledDates } from './DatePickerCalendar/Calendar/utils/dateGrid'; diff --git a/packages/gamut/src/DatePicker/sharedTypes.ts b/packages/gamut/src/DatePicker/sharedTypes.ts new file mode 100644 index 00000000000..00cbd62acb7 --- /dev/null +++ b/packages/gamut/src/DatePicker/sharedTypes.ts @@ -0,0 +1,41 @@ +export interface DatePickerSharedProps { + /** + * Return `true` to disable that calendar day. Use `matchDisabledDates` from `./utils/dateGrid` + * to disable a fixed list of days. + * + * @example Disable anything older than three calendar months + * ```tsx + * const cutoff = new Date(); + * cutoff.setMonth(cutoff.getMonth() - 3); + * const startOfCutoff = new Date( + * cutoff.getFullYear(), + * cutoff.getMonth(), + * cutoff.getDate() + * ); + * {}} + * disableDate={(d) => d < startOfCutoff} + * /> + * ``` + */ + disableDate?: (date: Date) => boolean; + + /** + * Locale for formatting and `Intl.Locale` APIs. Accepts `Intl.LocalesArgument` (e.g. `'en-US'`, + * `['en-GB', 'en']`, or a prebuilt `Intl.Locale`). Omitted → runtime default (user agent). + */ + locale?: Intl.LocalesArgument; +} + +export interface CalendarQuickAction { + /** Number of days, weeks, months, or years to add or subtract from the current date. */ + num: number; + /** Time period to add or subtract from the current date. */ + timePeriod: 'day' | 'week' | 'month' | 'year'; + /** Text to display for the quick action. */ + displayText: string; + /** Callback when the quick action is clicked. */ + onClick?: () => void; +} diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts new file mode 100644 index 00000000000..c35cf19d353 --- /dev/null +++ b/packages/gamut/src/DatePicker/types.ts @@ -0,0 +1,169 @@ +import { ComponentProps } from 'react'; + +import { Input } from '../Form/inputs/Input'; +import { CalendarQuickAction, DatePickerSharedProps } from './sharedTypes'; +import { DatePickerTranslations } from './utils/translations'; + +interface DatePickerBaseProps + extends DatePickerSharedProps { + /** Discriminator: set to `"single"` or `"range"` to determine the mode of the DatePicker */ + mode: Mode; + /** When provided, only the provider is rendered and children compose Input + Calendar. */ + children?: React.ReactNode; + /** Override default UI strings for internationalization. + * + * @default DEFAULT_DATE_PICKER_TRANSLATIONS + * @see {@link DatePickerTranslations} for the shape of the translations and default values + * @example + * ```tsx + * + * ``` + */ + translations?: DatePickerTranslations; + /** Size of the input. + * @default "base" + * @see `size` on {@link Input} + */ + inputSize?: ComponentProps['size']; + /** + * Calendar footer quick actions. Default values are provided based on the mode, but you can pass your own. Only the first 3 quick actions will be displayed. + * Pass `null` to omit quick actions. + * + * @default for single mode: Yesterday, Today, Tomorrow + * for range mode: Last 7 days, Last 30 days, Last 90 days + * + * @see {@link CalendarQuickAction} for the shape of the quick actions. + * + * @example single mode: + * ```tsx + * + * ``` + * @example range mode: + * ```tsx + * { + /** Controlled selected date. Pass `null` to not have a default selected date. Pass a `Date` to have a default selected date. + * + * @example + * ```tsx + * const [selectedDate, setSelectedDate] = useState(null); + * + * ``` + */ + selectedDate: Date | null; + /** Callback called when the user selects a date. Pass the new date to the callback so the component can update the state. + * + * @example + * ```tsx + * const [selectedDate, setSelectedDate] = useState(null); + * + * ``` + */ + onSelected: (date: Date | null) => void; +} + +export interface DatePickerRangeProps extends DatePickerBaseProps<'range'> { + /** Controlled start date. Pass `null` to not have a default start date. Pass a `Date` to have a default start date. + * + * @example + * ```tsx + * const [startDate, setStartDate] = useState(null); + * const [endDate, setEndDate] = useState(null); + * + * + * ``` + */ + startDate: Date | null; + /** Controlled end date. Pass `null` to not have a default end date. Pass a `Date` to have a default end date. + * + * @example + * ```tsx + * const [endDate, setEndDate] = useState(null); + * const [startDate, setStartDate] = useState(null); + * + * ``` + */ + endDate: Date | null; + /** Callback called when the user changes the start date. Pass the new start date to the callback so the component can update the state. + * + * @example + * ```tsx + * const [startDate, setStartDate] = useState(null); + * const [endDate, setEndDate] = useState(null); + * + * ``` + */ + onStartSelected: (date: Date | null) => void; + /** Callback called when the user changes the end date. Pass the new end date to the callback so the component can update the state. + * + * @example + * ```tsx + * const [endDate, setEndDate] = useState(null); + * const [startDate, setStartDate] = useState(null); + * + * ``` + */ + onEndSelected: (date: Date | null) => void; +} + +export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; diff --git a/packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts b/packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts new file mode 100644 index 00000000000..26d6e765b37 --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts @@ -0,0 +1,72 @@ +import { + getDefaultLocaleTag, + getIsoFirstDayFromLocale, + resolveLocale, + stringifyLocale, +} from '../locale'; + +describe('getDefaultLocaleTag', () => { + it('returns a non-empty BCP 47 tag from the runtime', () => { + const tag = getDefaultLocaleTag(); + expect(typeof tag).toBe('string'); + expect(tag.length).toBeGreaterThan(0); + }); +}); + +describe('resolveLocale', () => { + it('uses the runtime default when locales is undefined', () => { + const loc = resolveLocale(undefined); + expect(loc).toBeInstanceOf(Intl.Locale); + expect(loc.toString()).toBe(getDefaultLocaleTag()); + }); + + it('returns the same Intl.Locale instance when passed in', () => { + const input = new Intl.Locale('en-CA'); + expect(resolveLocale(input)).toBe(input); + }); + + it('parses a string tag', () => { + const loc = resolveLocale('de-DE'); + expect(loc.toString()).toBe('de-DE'); + }); + + it('uses the first entry of a locale array', () => { + const loc = resolveLocale(['fr-FR', 'fr']); + expect(loc.toString()).toBe('fr-FR'); + }); + + it('falls back to default when the locale array is empty', () => { + const loc = resolveLocale([]); + expect(loc.toString()).toBe(getDefaultLocaleTag()); + }); +}); + +describe('stringifyLocale', () => { + it('returns locale.toString()', () => { + const loc = new Intl.Locale('en-US'); + expect(stringifyLocale(loc)).toBe(loc.toString()); + }); +}); + +describe('getIsoFirstDayFromLocale', () => { + it('returns the override when provided', () => { + const locale = new Intl.Locale('en-US'); + expect(getIsoFirstDayFromLocale(locale, 1)).toBe(1); + expect(getIsoFirstDayFromLocale(locale, 7)).toBe(7); + }); + + it('uses getWeekInfo().firstDay when it is between 1 and 7', () => { + const locale = { + getWeekInfo: () => ({ firstDay: 3 }), + } as unknown as Intl.Locale; + expect(getIsoFirstDayFromLocale(locale)).toBe(3); + }); + + it('falls back to Sunday (7) when getWeekInfo is missing or firstDay is invalid', () => { + expect(getIsoFirstDayFromLocale({} as unknown as Intl.Locale)).toBe(7); + const badFirstDay = { + getWeekInfo: () => ({ firstDay: 0 }), + } as unknown as Intl.Locale; + expect(getIsoFirstDayFromLocale(badFirstDay)).toBe(7); + }); +}); diff --git a/packages/gamut/src/DatePicker/utils/locale.ts b/packages/gamut/src/DatePicker/utils/locale.ts new file mode 100644 index 00000000000..b0cfb94f66a --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/locale.ts @@ -0,0 +1,98 @@ +// Replaces `Intl.Locale` when missing or incomplete (e.g. no `getWeekInfo` in Firefox). +// https://formatjs.github.io/docs/polyfills/intl-locale/ +import '@formatjs/intl-locale/polyfill.js'; + +import { useEffect, useMemo, useState } from 'react'; + +/** + * The runtime default locale string (user agent), matching what `Intl` uses when no locale is passed. + */ +export const getDefaultLocaleTag = () => + new Intl.DateTimeFormat().resolvedOptions().locale; + +/** + * Resolves `Intl.LocalesArgument` (or `undefined`) to a stable `Intl.Locale` instance for formatting + * and locale metadata. + * + * - `undefined` → default runtime locale via {@link getDefaultLocaleTag} + * - `Intl.Locale` → returned as-is (no duplicate allocation) + * - `string` / `readonly string[]` → `new Intl.Locale(...)` + */ +export const resolveLocale = (locales?: Intl.LocalesArgument) => { + if (locales === undefined) { + return new Intl.Locale(getDefaultLocaleTag()); + } + if (locales instanceof Intl.Locale) { + return locales; + } + if (typeof locales === 'string') { + return new Intl.Locale(locales); + } + const first = locales[0]; + if (first === undefined) { + return new Intl.Locale(getDefaultLocaleTag()); + } + if (typeof first === 'string') { + return new Intl.Locale(first); + } + return first instanceof Intl.Locale ? first : new Intl.Locale(String(first)); +}; + +export const useResolvedLocale = (locale?: Intl.LocalesArgument) => + useMemo(() => resolveLocale(locale), [locale]); + +export const stringifyLocale = (locale: Intl.Locale) => locale.toString(); + +/** ISO weekday: 1 = Monday … 7 = Sunday (matches `Intl.Locale#getWeekInfo().firstDay`). */ +export type IsoWeekday = 1 | 2 | 3 | 4 | 5 | 6 | 7; + +/** `getWeekInfo` is stage-3; typings may lag behind runtime / polyfill. */ +type LocaleWithWeekInfo = Intl.Locale & { + getWeekInfo?: () => { firstDay: number }; +}; + +/** + * First calendar column weekday from `locale` (via `getWeekInfo()`), or explicit override. + */ +export const getIsoFirstDayFromLocale = ( + locale: Intl.Locale, + weekStartsOnOverride?: IsoWeekday +) => { + if (weekStartsOnOverride) return weekStartsOnOverride; + + try { + const getWeekInfo = (locale as LocaleWithWeekInfo).getWeekInfo?.bind( + locale + ); + if (typeof getWeekInfo === 'function') { + const { firstDay } = getWeekInfo(); + if (typeof firstDay === 'number' && firstDay >= 1 && firstDay <= 7) { + return firstDay as IsoWeekday; + } + } + } catch {} + return 7; +}; + +/** + * Resolved first weekday for the calendar grid. Re-reads after mount so async polyfills + * (e.g. Firefox) can install `getWeekInfo` before the first paint in some bundles. + */ +export const useIsoFirstWeekday = ( + locale: Intl.Locale, + weekStartsOnOverride?: IsoWeekday +) => { + const [firstDay, setFirstDay] = useState(() => + getIsoFirstDayFromLocale(locale, weekStartsOnOverride) + ); + + useEffect(() => { + setFirstDay(getIsoFirstDayFromLocale(locale, weekStartsOnOverride)); + const t = setTimeout(() => { + setFirstDay(getIsoFirstDayFromLocale(locale, weekStartsOnOverride)); + }, 0); + return () => clearTimeout(t); + }, [locale, weekStartsOnOverride]); + + return firstDay; +}; diff --git a/packages/gamut/src/DatePicker/utils/translations.ts b/packages/gamut/src/DatePicker/utils/translations.ts new file mode 100644 index 00000000000..ba5dceaa0e7 --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/translations.ts @@ -0,0 +1,30 @@ +export interface DatePickerTranslations { + /** Label for the clear date button (default: "Clear"). */ + clearButtonText?: string; + /** Label for the date input in single mode (default: "Date"). */ + dateLabel?: string; + /** Label for the start date input in range mode (default: "Start date"). */ + startDateLabel?: string; + /** Label for the end date input in range mode (default: "End date"). */ + endDateLabel?: string; + /** aria-label for the calendar dialog (default: "Choose date"). */ + calendarDialogAriaLabel?: 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"). */ + last30DaysDisplayText?: string; + /** Label for the last 90 days quick action (default: "Last 90 days"). */ + last90DaysDisplayText?: string; +} + +export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required = + { + clearButtonText: 'Clear', + dateLabel: 'Date', + startDateLabel: 'Start date', + endDateLabel: 'End date', + calendarDialogAriaLabel: 'Choose date', + last7DaysDisplayText: 'Last 7 days', + last30DaysDisplayText: 'Last 30 days', + last90DaysDisplayText: 'Last 90 days', + }; diff --git a/packages/gamut/src/FocusTrap/index.tsx b/packages/gamut/src/FocusTrap/index.tsx index 94efeebd9d7..2d7174b2e02 100644 --- a/packages/gamut/src/FocusTrap/index.tsx +++ b/packages/gamut/src/FocusTrap/index.tsx @@ -33,9 +33,9 @@ export interface FocusTrapProps extends WithChildrenProp { */ allowPageInteraction?: boolean; /** - * Passthrough for react-focus-on library props + * Passthrough for react-focus-on library props (partial; only override what you need). */ - focusOnProps?: ReactFocusOnProps; + focusOnProps?: Partial; } export const FocusTrap: React.FC = ({ diff --git a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx index 377e8529453..c93f1965bc8 100644 --- a/packages/gamut/src/PopoverContainer/PopoverContainer.tsx +++ b/packages/gamut/src/PopoverContainer/PopoverContainer.tsx @@ -38,6 +38,7 @@ export const PopoverContainer: React.FC = ({ targetRef, allowPageInteraction, closeOnViewportExit = false, + focusOnProps, ...rest }) => { const popoverRef = useRef(null); @@ -259,6 +260,7 @@ export const PopoverContainer: React.FC = ({ const content = ( diff --git a/packages/gamut/src/PopoverContainer/types.ts b/packages/gamut/src/PopoverContainer/types.ts index f61efb13a0f..8f958c921d0 100644 --- a/packages/gamut/src/PopoverContainer/types.ts +++ b/packages/gamut/src/PopoverContainer/types.ts @@ -1,5 +1,6 @@ import { RefObject } from 'react'; +import { FocusTrapProps } from '../FocusTrap'; import { WithChildrenProp } from '../utils'; export type Alignments = @@ -68,7 +69,8 @@ export interface PopoverPositionConfig extends PopoverAlignment { export interface PopoverContainerProps extends PopoverAlignment, - WithChildrenProp { + WithChildrenProp, + Pick { className?: string; /** * Whether the popover is rendered. diff --git a/packages/gamut/src/index.tsx b/packages/gamut/src/index.tsx index 2720269977a..55192cf5ecc 100644 --- a/packages/gamut/src/index.tsx +++ b/packages/gamut/src/index.tsx @@ -16,6 +16,7 @@ export * from './Card'; export * from './Coachmark'; export * from './ConnectedForm'; export * from './ContentContainer'; +export * from './DatePicker'; export * from './DelayedRenderWrapper'; export * from './Disclosure'; export * from './DataList'; diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx new file mode 100644 index 00000000000..dc26b4bb61b --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx @@ -0,0 +1,398 @@ +import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; + +import { + ComponentHeader, + ImageWrapper, + KeyboardKey, + LinkTo, +} from '~styleguide/blocks'; + +import * as DatePickerStories from './DatePicker.stories'; + +export const parameters = { + title: 'DatePicker', + design: { + type: 'figma', + url: 'https://www.figma.com/design/ReGfRNillGABAj5SlITalN/%F0%9F%93%90-Gamut?node-id=127461-42132', + }, + subtitle: `Single-date or range selection with a segmented date field, calendar popover, footer quick actions, and shared React context for custom layouts.`, + status: 'updating', + source: { + repo: 'gamut', + githubLink: + 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/DatePicker/DatePicker.tsx', + }, +}; + + + + + +## Usage + +Use `DatePicker` to allow users to select a single date or a date range via manual input or an interactive calendar. + +### Best practices: + +- Include quick actions when users are likely to select common or recurring dates or ranges +- Include disabled dates to prevent selection of dates that are unavailable or invalid in your context + +### When NOT to use: + +- **Predefined options** - for choosing from a fixed set of date-related options (e.g., "This month", "Last quarter"), use the Select or SelectDropdown component instead + +## Anatomy + + + +1. **Start date / Date** + +- The first date in a range, or the only date when `mode="single"` +- Label reads "Date" for single date; "Start date" for range +- Inputting a date updates the calendar selection immediately + +2. **End date** + +- The last date in the selected range +- Label reads "End date" +- Inputting a date updates the calendar selection immediately +- Only shown if `mode="range"` + +3. **Calendar** + +- Popover opens on click of either date input, or on Alt + or Option + when an input is focused +- Closes automatically once the selection is complete +- Selecting a date in the calendar updates the manual inputs immediately +- Clicking an already-selected date clears it +- Displays 2 months by default; collapses to 1 month at viewports below 768px + +4. **Today** + +- Indicates the current date on the calendar to orient the user +- Indicator is primary-colored when the date is not selected; switches to the background token when selected + +5. **Disabled dates** _(optional)_ + +- Use to mark specific dates as non-selectable +- If a selected range overlaps any disabled date, the range is invalid +- An invalid range clears the End date and treats the click as a new Start date selection + +6. **Clear** + +- Clears all current date selections +- Only shown if `mode="range"` + +7. **Quick actions** _(optional)_ + +- Use when you expect users to frequently select common dates or ranges +- Defaults: "Last 7 days," "Last 30 days," "Last 90 days" for `mode="range"`; "Yesterday," "Today," "Tomorrow" for `mode="single"` +- Include up to 3 actions +- Use range quick actions when `mode="range"`; use single-date quick actions when `mode="single"` +- For past date ranges, use "Last" as the label prefix (e.g., "Last 7 days") +- Quick action ranges are inclusive of today +- If the resulting range overlaps any disabled dates, no action is taken + +## Variants + +### Single date + +Use `mode="single"` when users need to select a single date. + +`selectedDate` and `onSelected` are the controlled value and updater and are required props. + +```tsx +const [selectedDate, setSelectedDate] = useState(null); +return ( + +); +``` + + + +### Range + +Use `mode="range"` when users need to select a range of dates. + +`startDate`, `onStartSelected`, `endDate`, and `onEndSelected` are the controlled values and updaters and are required props. + +```tsx +const [startDate, setStartDate] = useState(null); +const [endDate, setEndDate] = useState(null); +return ( + +); +``` + + + +### With initial date + +Set the initial date using the `selectedDate` (single) or `startDate` and `endDate` (range) props. + + + + + +### Placement + +Use the `placement` prop to control whether the calendar popover renders inside the current DOM context (inline) or escapes with a portal (floating). The default placement is `"inline"`. + +#### Inline + + + +#### Floating + + + +### Input size + +Use the `inputSize` prop to adjust the size of the input. The default size is `"base"`. `"small"` can be used to reduce the padding of the input: + + + +## Quick actions + +Quick actions are a set of predefined options that allow users to quickly select a date or range of dates. At most, 3 quick actions can be set for a calendar. + +### Default values + +There are default quick actions for both single and range modes for convenience. + +#### Single date: yesterday / today / tomorrow + + + +#### Range: last 7 / 30 / 90 days + + + +### Disable quick actions + +Disable quick actions by passing `quickActions={null}`. + + + +When quick actions are disabled in single mode, the footer is not shown. + + + +### Custom values + +Use the `quickActions` prop to pass custom quick actions. `quickActions` is an array of quick actions, each quick action is an object with the following properties: + +- `num`: the number of days to offset from today. +- `timePeriod`: the time period to offset from today. +- `displayText`: the text to display for the quick action. +- `onClick`: the callback to call when the quick action is clicked. + + + + + +## Disable dates + +Use the `disableDate` callback prop to disable specific dates in the calendar. The callback should return `true` if the date should be disabled and `false` if it should be enabled. Dates can be disabled based on a fixed list of dates, the current date, the start date, or any other date property. Below are some common examples that showcase how versatile `disableDate` can be: + +### Disable a fixed list of dates + +Use the `matchDisabledDates` helper in `disableDate` to disable a fixed list of dates. Here we are disabling the date April 22, 2026. + +```tsx + {}} + endDate={null} + onEndSelected={() => {}} + disableDate={matchDisabledDates([new Date(2026, 3, 22)])} +/> +``` + + + +### Disable dates based on the current date + +Example of disabling dates that are before today: + +```tsx + {}} + endDate={null} + onEndSelected={() => {}} + disableDate={(date) => date < new Date()} +/> +``` + + + +Example of disabling dates that are more than 30 days before today: + +```tsx + {}} + endDate={null} + onEndSelected={() => {}} + disableDate={(date) => { + const today = new Date(); + const thirtyDaysAgo = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 30 + ); + return date < thirtyDaysAgo; + }} +/> +``` + + + +### Disable dates based on the start date + +We can limit the range of dates that can be selected based on the start date. For example, here we are disabling dates that are more than 30 days before or after the start date, effectively limiting the range to 30 days. + +```tsx + {}} + endDate={null} + onEndSelected={() => {}} + disableDate={(date) => { + if (startDate === null) { + return false; + } + const start = calendarDate(startDate); + const min = new Date(start); + min.setDate(min.getDate() - 30); + const max = new Date(start); + max.setDate(max.getDate() + 30); + const day = calendarDate(date); + return day < min || day > max; + }} +/> +``` + + + +### Disable weekends + +```tsx + {}} + endDate={null} + disableDate={(date) => date.getDay() === 0 || date.getDay() === 6} +/> +``` + + + +## Locale + +Use the `locale` prop to set the locale of the DatePicker component. The default locale is the runtime default +locale. A [BCP 47 language tag](https://developer.mozilla.org/en-US/docs/Glossary/BCP_47_language_tag) (e.g. `"en-US"`, `"fr-FR"`) or a [`Intl.Locale` object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale) can also be passed. + +DatePicker uses the `Intl` API to internationalize a lot of the component automatically: + +- `Intl.Locale` + `getWeekInfo`: which day starts the calendar. +- `Intl.DateTimeFormat`: segment order and separators for the inputs; month + year in the header; short/long weekday names in the grid; formatted day cell `aria-label`. +- `Intl.RelativeTimeFormat`: the default single-mode quick actions (e.g. yesterday / today / tomorrow) and the previous / next month navigation tip text. +- `String.prototype.toLocaleUpperCase`: first character of some relative-time strings, using the active locale. + +Here we are setting the locale to 'de-DE' (German): + + + +### Translations + +Use the `translations` prop to internationalize any text that is not handled automatically by the `Intl` API. The passed translations are merged with the default translations from `DEFAULT_DATE_PICKER_TRANSLATIONS`. + +```tsx +export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required = + { + clearButtonText: 'Clear', + dateLabel: 'Date', + startDateLabel: 'Start date', + endDateLabel: 'End date', + calendarDialogAriaLabel: 'Choose date', + last7DaysDisplayText: 'Last 7 days', + last30DaysDisplayText: 'Last 30 days', + last90DaysDisplayText: 'Last 90 days', + }; +``` + + + +## Composed with context + +`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. +- `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`: + +```tsx +export const ComposedDatePickerLayout: React.FC = () => { + const { isCalendarOpen, closeCalendar } = useDatePicker(); + const inputRef = useRef(null); + + return ( + <> + + + + + + + + ); +}; +``` + + + +## Playground + + + + + +## Accessibility considerations + +- The calendar lives in a `role="dialog"` region; pair its `id` with `DatePickerCalendar`'s `dialogId` and input `aria-controls` from the default implementation. +- Segments use `role="spinbutton"`; arrow keys adjust values, `Arrow Left/Right` move between segments, and `Alt+Arrow Down` opens the calendar or moves focus into the grid when appropriate. +- `Escape` closes the popover. diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000000..79019732d98 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,618 @@ +import { + Box, + DatePicker, + DatePickerCalendar, + DatePickerInput, + matchDisabledDates, + PopoverContainer, + useDatePicker, +} from '@codecademy/gamut'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, useState } from 'react'; + +const storybookLocaleOptions = [ + undefined, + 'en', + 'en-US', + 'en-GB', + 'de-DE', + 'es', + 'es-ES', + 'fr-FR', + 'ja-JP', + 'pt-BR', + 'zh-CN', + 'ko-KR', + 'it-IT', + 'nl-NL', + 'pl-PL', + 'ru-RU', + 'sv-SE', + 'tr-TR', + 'hi-IN', + 'ar', + 'ar-SA', + 'he-IL', + 'th-TH', +] as const; + +const meta: Meta = { + component: DatePicker, + title: 'Organisms/DatePicker', + args: { + mode: 'single', + locale: 'en-US', + }, + argTypes: { + mode: { + description: 'Pick a single date or a range of dates.', + type: { + name: 'enum', + value: ['single', 'range'], + required: true, + }, + control: { type: 'radio' }, + table: { type: { summary: "'single' | 'range'" } }, + }, + locale: { + description: + 'BCP 47 language tag for `Intl` segment order, month names, and quick-action relative dates. `undefined` uses runtime default.', + control: { type: 'select' }, + options: [...storybookLocaleOptions], + }, + selectedDate: { + if: { arg: 'mode', eq: 'single' }, + control: false, + }, + onSelected: { + if: { arg: 'mode', eq: 'single' }, + control: false, + }, + startDate: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + endDate: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + onStartSelected: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + onEndSelected: { + if: { arg: 'mode', eq: 'range' }, + control: false, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + decorators: [ + (Story) => ( + + + + ), + ], + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + if (args.mode === 'range') { + return ( + + ); + } + + return ( + + ); + }, +}; + +const fixedMode = (mode: 'single' | 'range') => + ({ + args: { mode }, + argTypes: { + mode: { control: false }, + }, + } as const); + +export const SingleDate: Story = { + ...fixedMode('single'), + decorators: [ + (Story) => ( + + + + ), + ], + args: { placement: 'inline' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const Range: Story = { + ...fixedMode('range'), + decorators: [ + (Story) => ( + + + + ), + ], + args: { placement: 'inline' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const WithInitialDateSingle: Story = { + ...fixedMode('single'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState( + () => new Date(2026, 1, 15) + ); + return ( + + ); + }, +}; + +export const WithInitialDateRange: Story = { + ...fixedMode('range'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState( + () => new Date(2026, 1, 15) + ); + const [endDate, setEndDate] = useState( + () => new Date(2026, 1, 20) + ); + return ( + + ); + }, +}; + +export const FloatingPlacement: Story = { + ...fixedMode('range'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeSmall: Story = { + ...fixedMode('range'), + args: { inputSize: 'small', placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const SingleDefaultQuickActions: Story = { + ...fixedMode('single'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeDefaultQuickActions: Story = { + ...fixedMode('range'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const SingleNoQuickActions: Story = { + ...fixedMode('single'), + args: { quickActions: null, placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeNoQuickActions: Story = { + ...fixedMode('range'), + args: { quickActions: null, placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const SingleCustomQuickActions: Story = { + ...fixedMode('single'), + args: { + placement: 'floating', + quickActions: [ + { num: -3, timePeriod: 'day', displayText: '3 days ago' }, + { num: 0, timePeriod: 'day', displayText: 'Today' }, + { num: 3, timePeriod: 'day', displayText: 'In 3 days' }, + ], + }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeCustomQuickActions: Story = { + ...fixedMode('range'), + args: { + placement: 'floating', + quickActions: [ + { num: -7, timePeriod: 'day', displayText: 'Last 7 days' }, + { num: -14, timePeriod: 'day', displayText: 'Last 14 days' }, + { num: -30, timePeriod: 'day', displayText: 'Last 30 days' }, + ], + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const MatchDisabledDates: Story = { + ...fixedMode('range'), + args: { + disableDate: matchDisabledDates([new Date(2026, 3, 22)]), + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +const calendarDate = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), date.getDate()); + +export const Range30DayWindowFromStart: Story = { + ...fixedMode('range'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + return ( + { + if (startDate === null) { + return false; + } + const start = calendarDate(startDate); + const min = new Date(start); + min.setDate(min.getDate() - 30); + const max = new Date(start); + max.setDate(max.getDate() + 30); + const day = calendarDate(date); + return day < min || day > max; + }} + endDate={endDate} + mode="range" + startDate={startDate} + onEndSelected={setEndDate} + onStartSelected={setStartDate} + /> + ); + }, +}; + +export const RangeDisabledBeforeToday: Story = { + ...fixedMode('range'), + args: { + disableDate: (date) => { + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); + return date < startOfToday; + }, + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeDisabledMoreThan30DaysBeforeToday: Story = { + ...fixedMode('range'), + args: { + disableDate: (date) => { + const today = new Date(); + const thirtyDaysAgo = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() - 30 + ); + return date < thirtyDaysAgo; + }, + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const RangeDisabledWeekends: Story = { + ...fixedMode('range'), + args: { + disableDate: (date) => date.getDay() === 0 || date.getDay() === 6, + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + ); + }, +}; + +export const Locale: Story = { + ...fixedMode('single'), + args: { locale: 'de-DE', placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + ); + }, +}; + +export const Translations: Story = { + ...fixedMode('range'), + args: { + locale: 'es', + translations: { + clearButtonText: 'Borrar', + dateLabel: 'Fecha', + startDateLabel: 'Fecha de inicio', + endDateLabel: 'Fecha de fin', + calendarDialogAriaLabel: 'Elegir fecha', + last7DaysDisplayText: 'Últimos 7 días', + last30DaysDisplayText: 'Últimos 30 días', + last90DaysDisplayText: 'Últimos 90 días', + }, + placement: 'floating', + }, + render: function DatePickerStory(args) { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + + return ( + + ); + }, +}; + +export const ComposedWithContext: Story = { + ...fixedMode('single'), + args: { placement: 'floating' }, + render: function DatePickerStory(args) { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + + + ); + }, +}; + +const ComposedDatePickerLayout: React.FC = () => { + const { isCalendarOpen, closeCalendar } = useDatePicker(); + const inputRef = useRef(null); + + return ( + <> + + + + + + + + ); +}; diff --git a/packages/styleguide/src/static/organisms/datepicker.png b/packages/styleguide/src/static/organisms/datepicker.png new file mode 100644 index 00000000000..11d0c49b40d Binary files /dev/null and b/packages/styleguide/src/static/organisms/datepicker.png differ diff --git a/yarn.lock b/yarn.lock index 6f53106d5a6..62b8b6cfb80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -283,7 +283,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.22.5, @babel/helper-plugin-utils@npm:^7.25.9, @babel/helper-plugin-utils@npm:^7.28.6, @babel/helper-plugin-utils@npm:^7.8.0": +"@babel/helper-plugin-utils@npm:^7.0.0, @babel/helper-plugin-utils@npm:^7.10.4, @babel/helper-plugin-utils@npm:^7.12.13, @babel/helper-plugin-utils@npm:^7.14.5, @babel/helper-plugin-utils@npm:^7.18.6, @babel/helper-plugin-utils@npm:^7.22.5, @babel/helper-plugin-utils@npm:^7.25.9, @babel/helper-plugin-utils@npm:^7.27.1, @babel/helper-plugin-utils@npm:^7.8.0": version: 7.28.6 resolution: "@babel/helper-plugin-utils@npm:7.28.6" checksum: 10c0/3f5f8acc152fdbb69a84b8624145ff4f9b9f6e776cb989f9f968f8606eb7185c5c3cfcf3ba08534e37e1e0e1c118ac67080610333f56baa4f7376c99b5f1143d @@ -594,13 +594,13 @@ __metadata: linkType: hard "@babel/plugin-syntax-jsx@npm:^7.25.9, @babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.7.2": - version: 7.28.6 - resolution: "@babel/plugin-syntax-jsx@npm:7.28.6" + version: 7.27.1 + resolution: "@babel/plugin-syntax-jsx@npm:7.27.1" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.27.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/b98fc3cd75e4ca3d5ca1162f610c286e14ede1486e0d297c13a5eb0ac85680ac9656d17d348bddd9160a54d797a08cea5eaac02b9330ddebb7b26732b7b99fb5 + checksum: 10c0/bc5afe6a458d5f0492c02a54ad98c5756a0c13bd6d20609aae65acd560a9e141b0876da5f358dce34ea136f271c1016df58b461184d7ae9c4321e0f98588bc84 languageName: node linkType: hard @@ -693,13 +693,13 @@ __metadata: linkType: hard "@babel/plugin-syntax-typescript@npm:^7.25.9, @babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.3.3, @babel/plugin-syntax-typescript@npm:^7.7.2": - version: 7.28.6 - resolution: "@babel/plugin-syntax-typescript@npm:7.28.6" + version: 7.27.1 + resolution: "@babel/plugin-syntax-typescript@npm:7.27.1" dependencies: - "@babel/helper-plugin-utils": "npm:^7.28.6" + "@babel/helper-plugin-utils": "npm:^7.27.1" peerDependencies: "@babel/core": ^7.0.0-0 - checksum: 10c0/b0c392a35624883ac480277401ac7d92d8646b66e33639f5d350de7a6723924265985ae11ab9ebd551740ded261c443eaa9a87ea19def9763ca1e0d78c97dea8 + checksum: 10c0/11589b4c89c66ef02d57bf56c6246267851ec0c361f58929327dc3e070b0dab644be625bbe7fb4c4df30c3634bfdfe31244e1f517be397d2def1487dbbe3c37d languageName: node linkType: hard @@ -1714,6 +1714,7 @@ __metadata: "@codecademy/gamut-patterns": "npm:0.10.29" "@codecademy/gamut-styles": "npm:17.14.0" "@codecademy/variance": "npm:0.26.1" + "@formatjs/intl-locale": "npm:^5.3.1" "@react-aria/interactions": "npm:3.25.0" "@types/marked": "npm:^4.0.8" "@vidstack/react": "npm:^1.12.12" @@ -2318,6 +2319,13 @@ __metadata: languageName: node linkType: hard +"@formatjs/bigdecimal@npm:0.2.0": + version: 0.2.0 + resolution: "@formatjs/bigdecimal@npm:0.2.0" + checksum: 10c0/dec607e3d9d4b8c5d0474862e867726cbf322a24d543d5b2cbc3cab6fea187ac787a8e1a0e3df5ceef85a1ab9d58112a08bb7af40b1b3a3b00670431b0603510 + languageName: node + linkType: hard + "@formatjs/ecma402-abstract@npm:2.3.4": version: 2.3.4 resolution: "@formatjs/ecma402-abstract@npm:2.3.4" @@ -2330,6 +2338,17 @@ __metadata: languageName: node linkType: hard +"@formatjs/ecma402-abstract@npm:3.2.0": + version: 3.2.0 + resolution: "@formatjs/ecma402-abstract@npm:3.2.0" + dependencies: + "@formatjs/bigdecimal": "npm:0.2.0" + "@formatjs/fast-memoize": "npm:3.1.1" + "@formatjs/intl-localematcher": "npm:0.8.2" + checksum: 10c0/b3c8ac881c3d7533fb4127ca3d771d2a32cb89e6efbbcc72d80b1dcc6a798494ace9ca5ee822b25eb08ebdc7ee2885a9e33496a436b40271ffc915ece605a3ce + languageName: node + linkType: hard + "@formatjs/fast-memoize@npm:2.2.7": version: 2.2.7 resolution: "@formatjs/fast-memoize@npm:2.2.7" @@ -2339,6 +2358,13 @@ __metadata: languageName: node linkType: hard +"@formatjs/fast-memoize@npm:3.1.1": + version: 3.1.1 + resolution: "@formatjs/fast-memoize@npm:3.1.1" + checksum: 10c0/79b24dc1389a49b2b2fb9e90a2ba922a4057d4b74e7bc33a3811f0dc94a5a868d28e8e37917b68c2f831070d11dfd0889de686f269bf5214085a44efc1c25a8c + languageName: node + linkType: hard + "@formatjs/icu-messageformat-parser@npm:2.11.2": version: 2.11.2 resolution: "@formatjs/icu-messageformat-parser@npm:2.11.2" @@ -2360,6 +2386,24 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-getcanonicallocales@npm:3.2.2": + version: 3.2.2 + resolution: "@formatjs/intl-getcanonicallocales@npm:3.2.2" + checksum: 10c0/3b0235c0752a1db8d92502211d048822711d9141217f679f54a0914426e2516685b468f2711f0d27a18f3db3d1908c7e2655746af3fb34e660b1126450c3f42a + languageName: node + linkType: hard + +"@formatjs/intl-locale@npm:^5.3.1": + version: 5.3.1 + resolution: "@formatjs/intl-locale@npm:5.3.1" + dependencies: + "@formatjs/ecma402-abstract": "npm:3.2.0" + "@formatjs/intl-getcanonicallocales": "npm:3.2.2" + "@formatjs/intl-supportedvaluesof": "npm:2.3.0" + checksum: 10c0/19e00dde293d2cfda7357420957062e319a041bc3aaa8bd9b834463074e6e7bf2ae2374818a01c24d659c4373b1954a671460299258568775d30875aabbeaf90 + languageName: node + linkType: hard + "@formatjs/intl-localematcher@npm:0.6.1": version: 0.6.1 resolution: "@formatjs/intl-localematcher@npm:0.6.1" @@ -2369,6 +2413,25 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-localematcher@npm:0.8.2": + version: 0.8.2 + resolution: "@formatjs/intl-localematcher@npm:0.8.2" + dependencies: + "@formatjs/fast-memoize": "npm:3.1.1" + checksum: 10c0/3bf838a018184837b167964849dafdcdeac95531a24f4df7d868638d4ad716854a250e9bccac9ab4568264c0db7470e70b99363da1db308fdc882b87f3eca651 + languageName: node + linkType: hard + +"@formatjs/intl-supportedvaluesof@npm:2.3.0": + version: 2.3.0 + resolution: "@formatjs/intl-supportedvaluesof@npm:2.3.0" + dependencies: + "@formatjs/ecma402-abstract": "npm:3.2.0" + "@formatjs/fast-memoize": "npm:3.1.1" + checksum: 10c0/132a44cf6922f8eaa58fef47f74179d75614a9c68343defc07f6d42080e29205b2a8663db599e404da4dd657790aca0b2312733abfeec56d3524593d6e8a4f6e + languageName: node + linkType: hard + "@hapi/address@npm:^5.1.1": version: 5.1.1 resolution: "@hapi/address@npm:5.1.1" @@ -3094,12 +3157,12 @@ __metadata: linkType: hard "@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": - version: 0.3.31 - resolution: "@jridgewell/trace-mapping@npm:0.3.31" + version: 0.3.30 + resolution: "@jridgewell/trace-mapping@npm:0.3.30" dependencies: "@jridgewell/resolve-uri": "npm:^3.1.0" "@jridgewell/sourcemap-codec": "npm:^1.4.14" - checksum: 10c0/4b30ec8cd56c5fd9a661f088230af01e0c1a3888d11ffb6b47639700f71225be21d1f7e168048d6d4f9449207b978a235c07c8f15c07705685d16dc06280e9d9 + checksum: 10c0/3a1516c10f44613b9ba27c37a02ff8f410893776b2b3dad20a391b51b884dd60f97bbb56936d65d2ff8fe978510a0000266654ab8426bdb9ceb5fb4585b19e23 languageName: node linkType: hard @@ -8638,11 +8701,11 @@ __metadata: linkType: hard "acorn-walk@npm:^8.0.2, acorn-walk@npm:^8.1.1": - version: 8.3.5 - resolution: "acorn-walk@npm:8.3.5" + version: 8.3.4 + resolution: "acorn-walk@npm:8.3.4" dependencies: acorn: "npm:^8.11.0" - checksum: 10c0/e31bf5b5423ed1349437029d66d708b9fbd1b77a644b031501e2c753b028d13b56348210ed901d5b1d0d86eb3381c0a0fc0d0998511a9d546d1194936266a332 + checksum: 10c0/76537ac5fb2c37a64560feaf3342023dadc086c46da57da363e64c6148dc21b57d49ace26f949e225063acb6fb441eabffd89f7a3066de5ad37ab3e328927c62 languageName: node linkType: hard @@ -10165,9 +10228,9 @@ __metadata: linkType: hard "collect-v8-coverage@npm:^1.0.0, collect-v8-coverage@npm:^1.0.2": - version: 1.0.3 - resolution: "collect-v8-coverage@npm:1.0.3" - checksum: 10c0/bc62ba251bcce5e3354a8f88fa6442bee56e3e612fec08d4dfcf66179b41ea0bf544b0f78c4ebc0f8050871220af95bb5c5578a6aef346feea155640582f09dc + version: 1.0.2 + resolution: "collect-v8-coverage@npm:1.0.2" + checksum: 10c0/ed7008e2e8b6852c5483b444a3ae6e976e088d4335a85aa0a9db2861c5f1d31bd2d7ff97a60469b3388deeba661a619753afbe201279fb159b4b9548ab8269a1 languageName: node linkType: hard @@ -11094,14 +11157,14 @@ __metadata: linkType: hard "dedent@npm:^1.0.0, dedent@npm:^1.6.0": - version: 1.7.2 - resolution: "dedent@npm:1.7.2" + version: 1.7.0 + resolution: "dedent@npm:1.7.0" peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: babel-plugin-macros: optional: true - checksum: 10c0/acaff07cac355b93f17b1b17ebbb84d3cc55af6ab4b7814c3f505e061903e168bc6bf9ddce331552d64dee1525f0b4c549c9ade46aebfac6f69caaed74e90751 + checksum: 10c0/c5e8a8beb5072bd5e520cb64b27a82d7ec3c2a63ee5ce47dbc2a05d5b7700cefd77a992a752cd0a8b1d979c1db06b14fb9486e805f3ad6088eda6e07cd9bf2d5 languageName: node linkType: hard @@ -17588,9 +17651,9 @@ __metadata: linkType: hard "nwsapi@npm:^2.2.2": - version: 2.2.23 - resolution: "nwsapi@npm:2.2.23" - checksum: 10c0/e44bfc9246baf659581206ed716d291a1905185247795fb8a302cb09315c943a31023b4ac4d026a5eaf32b2def51d77b3d0f9ebf4f3d35f70e105fcb6447c76e + version: 2.2.22 + resolution: "nwsapi@npm:2.2.22" + checksum: 10c0/b6a0e5ea6754aacfdfe551c8c0f1b374eaf94d48b0a4e7eac666f879ecbc1892ef1d7c457e9b02eefad3fa1323ea1faebcba533eeab6582e24c9c503411bf879 languageName: node linkType: hard @@ -23507,8 +23570,8 @@ __metadata: linkType: hard "ws@npm:^8.11.0, ws@npm:^8.18.0": - version: 8.19.0 - resolution: "ws@npm:8.19.0" + version: 8.18.3 + resolution: "ws@npm:8.18.3" peerDependencies: bufferutil: ^4.0.1 utf-8-validate: ">=5.0.2" @@ -23517,7 +23580,7 @@ __metadata: optional: true utf-8-validate: optional: true - checksum: 10c0/4741d9b9bc3f9c791880882414f96e36b8b254e34d4b503279d6400d9a4b87a033834856dbdd94ee4b637944df17ea8afc4bce0ff4a1560d2166be8855da5b04 + checksum: 10c0/eac918213de265ef7cb3d4ca348b891a51a520d839aa51cdb8ca93d4fa7ff9f6ccb339ccee89e4075324097f0a55157c89fa3f7147bde9d8d7e90335dc087b53 languageName: node linkType: hard