From fd2fbcedc7f83df64f51eb165a5ba0f7d6759b9f Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 2 Mar 2026 17:05:01 -0500 Subject: [PATCH 001/110] calendar phase 1 --- .../src/DatePicker/Calendar/Calendar.tsx | 16 +++ .../src/DatePicker/Calendar/CalendarBody.tsx | 107 ++++++++++++++++++ .../DatePicker/Calendar/CalendarFooter.tsx | 63 +++++++++++ .../DatePicker/Calendar/CalendarHeader.tsx | 65 +++++++++++ .../gamut/src/DatePicker/Calendar/index.tsx | 6 + .../gamut/src/DatePicker/Calendar/types.ts | 58 ++++++++++ .../src/DatePicker/Calendar/utils/dateGrid.ts | 103 +++++++++++++++++ .../src/DatePicker/Calendar/utils/format.ts | 60 ++++++++++ .../src/DatePicker/Calendar/utils/index.ts | 2 + packages/gamut/src/DatePicker/index.tsx | 17 +++ packages/gamut/src/index.tsx | 1 + .../src/lib/Molecules/DatePicker/Calendar.mdx | 26 +++++ .../Molecules/DatePicker/Calendar.stories.tsx | 47 ++++++++ 13 files changed, 571 insertions(+) create mode 100644 packages/gamut/src/DatePicker/Calendar/Calendar.tsx create mode 100644 packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx create mode 100644 packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx create mode 100644 packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx create mode 100644 packages/gamut/src/DatePicker/Calendar/index.tsx create mode 100644 packages/gamut/src/DatePicker/Calendar/types.ts create mode 100644 packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts create mode 100644 packages/gamut/src/DatePicker/Calendar/utils/format.ts create mode 100644 packages/gamut/src/DatePicker/Calendar/utils/index.ts create mode 100644 packages/gamut/src/DatePicker/index.tsx create mode 100644 packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx create mode 100644 packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/Calendar.tsx b/packages/gamut/src/DatePicker/Calendar/Calendar.tsx new file mode 100644 index 00000000000..95452d589b1 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/Calendar.tsx @@ -0,0 +1,16 @@ +import { css } from '@codecademy/gamut-styles'; +import styled from '@emotion/styled'; + +/** + * Outer wrapper for the calendar (header + body + footer). + * Used by DatePickerCalendar to group the calendar content. + */ +export const Calendar = styled.div( + css({ + backgroundColor: 'background', + borderRadius: 'lg', + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)', + width: 'max-content', + p: 24, + }) +); diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx new file mode 100644 index 00000000000..3dbde289782 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -0,0 +1,107 @@ +import { states } from '@codecademy/gamut-styles'; +import styled from '@emotion/styled'; +import { useCallback } from 'react'; +import * as React from 'react'; + +import { CalendarBodyProps } from './types'; +import { + getMonthGrid, + isDateDisabled, + isDateInRange, + isSameDay, +} from './utils/dateGrid'; +import { getWeekdayFullNames, getWeekdayLabels } from './utils/format'; +import { TextButton } from '../../Button'; + +const DateButton = styled(TextButton)( + states({ + isToday: { + bg: 'navy-200', + }, + isSelected: { + color: 'background', + bg: 'text', + // '&:hover': { bg: 'shadow-secondary' }, + }, + isInRange: { + bg: 'border-secondary', + }, + }) +); + +export const CalendarBody: React.FC = ({ + visibleDate, + selectedDate, + endDate = null, + disabledDates = [], + onDateSelect, + locale, + weekStartsOn = 0, + labelledById, +}) => { + const year = visibleDate.getFullYear(); + const month = visibleDate.getMonth(); + const weeks = getMonthGrid(year, month, weekStartsOn); + const weekdayLabels = getWeekdayLabels(locale, weekStartsOn); + const weekdayFullNames = getWeekdayFullNames(locale, weekStartsOn); + + const isToday = useCallback( + (d: Date | null) => d !== null && isSameDay(d, new Date()), + [] + ); + + return ( + + + + {weekdayLabels.map((label, i) => ( + + ))} + + + + {weeks.map((week, rowIndex) => ( + + {week.map((date, colIndex) => { + if (date === null) { + return ( + + ); + })} + + ))} + +
+ {label} +
+ ); + } + const selected = + isSameDay(date, selectedDate) || isSameDay(date, endDate); + const inRange = + (selectedDate !== null || endDate !== null) && + isDateInRange(date, selectedDate, endDate); + const disabled = isDateDisabled(date, disabledDates); + const today = isToday(date); + + return ( + + onDateSelect(date)} + tabIndex={selected ? 0 : -1} // isnt entirely right + > + {date.getDate()} + +
+ ); +}; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx new file mode 100644 index 00000000000..b59c1405755 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -0,0 +1,63 @@ +import * as React from 'react'; + +import { FlexBox } from '../../Box'; +import { TextButton } from '../../Button'; +import { CalendarFooterProps, QuickAction } from './types'; + +// function formatQuickActionLabel(action: QuickAction): string { +// const { num, timePeriod } = action; +// const period = +// timePeriod === 'day' +// ? num === 1 +// ? 'day' +// : 'days' +// : timePeriod === 'week' +// ? num === 1 +// ? 'week' +// : 'weeks' +// : timePeriod === 'month' +// ? num === 1 +// ? 'month' +// : 'months' +// : num === 1 +// ? 'year' +// : 'years'; +// return `${num} ${period}`; +// } + +export const CalendarFooter: React.FC = ({ + onClearDate, + onTodayClick, + onSelectedDateChange, + onCurrentMonthYearChange, + quickActions = [], +}) => { + const handleClearDate = () => { + onSelectedDateChange(null); + onClearDate?.(); + }; + + const handleTodayClick = () => { + const today = new Date(); + onSelectedDateChange(today); + onCurrentMonthYearChange( + new Date(today.getFullYear(), today.getMonth(), 1) + ); + onTodayClick?.(); + }; + // const actions = quickActions.slice(0, 3); + + return ( + + Clear + + Today + + + ); +}; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx new file mode 100644 index 00000000000..b709e6d985a --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx @@ -0,0 +1,65 @@ +import { + MiniChevronLeftIcon, + MiniChevronRightIcon, +} from '@codecademy/gamut-icons'; +import * as React from 'react'; + +import { FlexBox } from '../../Box'; +import { IconButton } from '../../Button'; +import { Text } from '../../Typography'; +import { CalendarHeaderProps } from './types'; +import { formatMonthYear } from './utils/format'; + +export const CalendarHeader: React.FC = ({ + currentMonthYear, + onCurrentMonthYearChange, + onPreviousMonthClick, + onNextMonthClick, + locale, + headingId, +}) => { + const monthYear = formatMonthYear(currentMonthYear, locale); + + const handlePreviousMonth = () => { + const previousMonth = new Date( + currentMonthYear.getFullYear(), + currentMonthYear.getMonth() - 1, + 1 + ); + onCurrentMonthYearChange?.(previousMonth); + onPreviousMonthClick?.(); + }; + + const handleNextMonth = () => { + const nextMonth = new Date( + currentMonthYear.getFullYear(), + currentMonthYear.getMonth() + 1, + 1 + ); + onCurrentMonthYearChange?.(nextMonth); + onNextMonthClick?.(); + }; + + return ( + + + + + {monthYear} + + + + ); +}; diff --git a/packages/gamut/src/DatePicker/Calendar/index.tsx b/packages/gamut/src/DatePicker/Calendar/index.tsx new file mode 100644 index 00000000000..f01bca09a2d --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/index.tsx @@ -0,0 +1,6 @@ +export { Calendar } from './Calendar'; +export { CalendarHeader } from './CalendarHeader'; +export { CalendarBody } from './CalendarBody'; +export { CalendarFooter } from './CalendarFooter'; +export type { CalendarHeaderProps, CalendarBodyProps, CalendarFooterProps, QuickAction } from './types'; +export * from './utils'; diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts new file mode 100644 index 00000000000..ee1593add4a --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -0,0 +1,58 @@ +/** + * Internal types for the Calendar subcomponents (used by DatePickerCalendar). + */ + +export interface CalendarHeaderProps { + /** Currently displayed month and year (used for heading and prev/next range) */ + currentMonthYear: Date; + /** Called when the user navigates to a different month. Pass the new date (e.g. setVisibleDate) so the calendar updates. */ + onCurrentMonthYearChange: (newDate: Date) => void; + /** Optional. Called after navigating to previous month; use for click tracking. */ + onPreviousMonthClick?: () => void; + /** Optional. Called after navigating to next month; use for click tracking. */ + onNextMonthClick?: () => void; + /** Locale for month/year formatting (e.g. 'en-US') */ + locale?: string; + /** Optional id for the heading (for grid aria-labelledby) */ + headingId?: string; +} + +export interface CalendarBodyProps { + /** The month to display (typically first day of that month) */ + visibleDate: Date; + /** Selected start date (single or range start) */ + selectedDate: Date | null; + /** Selected end date (range only; null for single-date mode) */ + endDate?: Date | null; + /** Dates that should be disabled (unselectable) */ + disabledDates?: Date[]; + /** Called when a date cell is selected */ + onDateSelect: (date: Date) => void; + /** Locale for weekday names and week start */ + locale?: string; + /** 0 = Sunday, 1 = Monday (default from locale if not set) */ + weekStartsOn?: 0 | 1; + /** For keyboard nav: which cell has focus (roving tabindex) */ + focusedDate?: Date | null; + /** Callback when focused date changes (e.g. arrow keys) */ + onFocusedDateChange?: (date: Date | null) => void; + /** Id of the month/year heading (aria-labelledby on grid) */ + labelledById?: string; +} + +export interface QuickAction { + num: number; + timePeriod: 'day' | 'week' | 'month' | 'year'; + onClick: () => void; +} + +export interface CalendarFooterProps { + onClearDate?: () => void; + onTodayClick?: () => void; + /** Called when the user navigates to a different month. Pass the new date (e.g. setVisibleDate) so the calendar updates. */ + onSelectedDateChange: (newDate: Date | null) => void; + onCurrentMonthYearChange: (newDate: Date) => void; + + /** Max 3 quick actions (e.g. "7 days", "1 month") */ + quickActions?: QuickAction[]; +} diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts new file mode 100644 index 00000000000..745dfbc407a --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts @@ -0,0 +1,103 @@ +/** + * Builds a grid of days for a calendar month using native Date and Intl. + * Each row has 7 cells; leading/trailing cells may be null (padding from adjacent months). + */ + +const DAYS_PER_WEEK = 7; + +/** + * Normalize to start of day in local time for comparison. + */ +function toDateOnly(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()); +} + +/** + * Get the weekday for a date (0 = Sunday, 6 = Saturday). + * Optionally use weekStartsOn to compute "offset" for display (e.g. Monday = 0). + */ +export function getDayOfWeek(date: Date, weekStartsOn: 0 | 1 = 0): number { + const sundayBased = date.getDay(); + if (weekStartsOn === 0) return sundayBased; + return (sundayBased + 6) % 7; // Monday = 0 +} + +/** + * 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 weekStartsOn - 0 = Sunday, 1 = Monday + */ +export function getMonthGrid( + year: number, + month: number, + weekStartsOn: 0 | 1 = 0 +): (Date | null)[][] { + const first = new Date(year, month, 1); + const last = new Date(year, month + 1, 0); + const firstDayOfWeek = getDayOfWeek(first, weekStartsOn); + const daysInMonth = last.getDate(); + + const weeks: (Date | null)[][] = []; + let currentWeek: (Date | null)[] = []; + + // Pad start of first week with nulls + for (let i = 0; i < firstDayOfWeek; i++) { + currentWeek.push(null); + } + + for (let day = 1; day <= daysInMonth; day++) { + currentWeek.push(new Date(year, month, day)); + if (currentWeek.length === DAYS_PER_WEEK) { + weeks.push(currentWeek); + currentWeek = []; + } + } + + // Pad end of last week with nulls + if (currentWeek.length > 0) { + while (currentWeek.length < DAYS_PER_WEEK) { + currentWeek.push(null); + } + weeks.push(currentWeek); + } + + return weeks; +} + +/** + * Check if two dates are the same calendar day (ignoring time). + */ +export function isSameDay(a: Date | null, b: Date | null): boolean { + if (a === null || b === null) return false; + return toDateOnly(a).getTime() === toDateOnly(b).getTime(); +} + +/** + * Check if `date` is between `start` and `end` (inclusive), ignoring time. + */ +export function isDateInRange( + date: Date, + start: Date | null, + end: Date | null +): boolean { + if (start === null) return false; + const d = toDateOnly(date).getTime(); + const s = toDateOnly(start).getTime(); + const e = end !== null ? toDateOnly(end).getTime() : s; + const low = Math.min(s, e); + const high = Math.max(s, e); + return d >= low && d <= high; +} + +/** + * Check if `date` is in the `disabledDates` list (by calendar day). + */ +export function isDateDisabled( + date: Date, + disabledDates: Date[] = [] +): boolean { + return disabledDates.some((d) => isSameDay(date, d)); +} diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts new file mode 100644 index 00000000000..78430f01a8e --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -0,0 +1,60 @@ +/** + * Date formatting for the calendar using Intl.DateTimeFormat. + */ + +/** + * Format month and year for the calendar header (e.g. "February 2026"). + */ +export function formatMonthYear(date: Date, locale?: string): string { + return new Intl.DateTimeFormat(locale ?? 'en-US', { + month: 'long', + year: 'numeric', + }).format(date); +} + +/** + * Get short weekday labels for column headers (e.g. ["Su", "Mo", ...]). + * Order depends on weekStartsOn: 0 = Sunday first, 1 = Monday first. + */ +export function getWeekdayLabels( + locale?: string, + weekStartsOn: 0 | 1 = 0 +): string[] { + const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { + weekday: 'short', + }); + // Jan 7, 2024 is a Sunday; add 0..6 days to get Sun..Sat + const sunday = new Date(2024, 0, 7); + const labels = Array.from({ length: 7 }, (_, i) => { + const d = new Date(sunday); + d.setDate(sunday.getDate() + i); + return formatter.format(d); + }); + if (weekStartsOn === 1) { + return [...labels.slice(1), labels[0]]; + } + return labels; +} + +/** + * Get full weekday names for abbr attributes (e.g. "Sunday", "Monday"). + * Same order as getWeekdayLabels. + */ +export function getWeekdayFullNames( + locale?: string, + weekStartsOn: 0 | 1 = 0 +): string[] { + const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { + weekday: 'long', + }); + const sunday = new Date(2024, 0, 7); + const names = Array.from({ length: 7 }, (_, i) => { + const d = new Date(sunday); + d.setDate(sunday.getDate() + i); + return formatter.format(d); + }); + if (weekStartsOn === 1) { + return [...names.slice(1), names[0]]; + } + return names; +} diff --git a/packages/gamut/src/DatePicker/Calendar/utils/index.ts b/packages/gamut/src/DatePicker/Calendar/utils/index.ts new file mode 100644 index 00000000000..e6f25ce9da3 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/utils/index.ts @@ -0,0 +1,2 @@ +export * from './dateGrid'; +export * from './format'; diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx new file mode 100644 index 00000000000..a9653fd9d21 --- /dev/null +++ b/packages/gamut/src/DatePicker/index.tsx @@ -0,0 +1,17 @@ +/** + * DatePicker – Calendar components for use by DatePickerCalendar. + * Full DatePicker provider and DatePickerInput will be added in a follow-up. + */ +export { + Calendar, + CalendarHeader, + CalendarBody, + CalendarFooter, +} from './Calendar'; +export type { + CalendarHeaderProps, + CalendarBodyProps, + CalendarFooterProps, + QuickAction, +} from './Calendar/types'; +export * from './Calendar/utils'; diff --git a/packages/gamut/src/index.tsx b/packages/gamut/src/index.tsx index 0537494d5db..3742d5afa77 100644 --- a/packages/gamut/src/index.tsx +++ b/packages/gamut/src/index.tsx @@ -15,6 +15,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/Molecules/DatePicker/Calendar.mdx b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx new file mode 100644 index 00000000000..b107fda00dc --- /dev/null +++ b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx @@ -0,0 +1,26 @@ +import { Canvas, Controls, Meta } from '@storybook/blocks'; + +import { ComponentHeader } from '~styleguide/blocks'; + +import * as CalendarStories from './Calendar.stories'; + +export const parameters = { + title: 'DatePicker/Calendar', + subtitle: `Calendar grid with header (month/year + prev/next), body (day grid), and footer (Clear, Today, quick actions). Used inside DatePickerCalendar.`, + status: 'current', + source: { + repo: 'gamut', + githubLink: + 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/DatePicker/Calendar', + }, +}; + + + + + +## Playground + + + + diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx new file mode 100644 index 00000000000..8643423dd9a --- /dev/null +++ b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx @@ -0,0 +1,47 @@ +import { + Calendar, + CalendarBody, + CalendarFooter, + CalendarHeader, +} from '@codecademy/gamut'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useId, useState } from 'react'; + +const meta: Meta = { + component: Calendar, + title: 'Molecules/DatePicker/Calendar', +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: function CalendarStory() { + const headingId = useId(); + const [visibleDate, setVisibleDate] = useState(() => new Date()); + const [selectedDate, setSelectedDate] = useState(null); + + return ( + + + + + + ); + }, +}; From 0ffb4244682058cade4567fcb41ed16f7f608605 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 4 Mar 2026 12:57:14 -0500 Subject: [PATCH 002/110] add focus management --- .../src/DatePicker/Calendar/CalendarBody.tsx | 146 +++++++++++++++++- .../gamut/src/DatePicker/Calendar/types.ts | 6 +- .../src/DatePicker/Calendar/utils/dateGrid.ts | 8 + .../Molecules/DatePicker/Calendar.stories.tsx | 4 + 4 files changed, 159 insertions(+), 5 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 3dbde289782..f39eb7be30e 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -1,10 +1,11 @@ import { states } from '@codecademy/gamut-styles'; import styled from '@emotion/styled'; -import { useCallback } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import * as React from 'react'; import { CalendarBodyProps } from './types'; import { + clampToMonth, getMonthGrid, isDateDisabled, isDateInRange, @@ -21,7 +22,6 @@ const DateButton = styled(TextButton)( isSelected: { color: 'background', bg: 'text', - // '&:hover': { bg: 'shadow-secondary' }, }, isInRange: { bg: 'border-secondary', @@ -29,6 +29,19 @@ const DateButton = styled(TextButton)( }) ); +/** Flat list of dates in grid order (row-major, non-null only) with row index for Home/End */ +function getDatesWithRow( + weeks: (Date | null)[][] +): { date: Date; rowIndex: number }[] { + const result: { date: Date; rowIndex: number }[] = []; + weeks.forEach((week, rowIndex) => { + week.forEach((date) => { + if (date !== null) result.push({ date, rowIndex }); + }); + }); + return result; +} + export const CalendarBody: React.FC = ({ visibleDate, selectedDate, @@ -38,18 +51,141 @@ export const CalendarBody: React.FC = ({ locale, weekStartsOn = 0, labelledById, + focusedDate, + onFocusedDateChange, + onVisibleDateChange, }) => { const year = visibleDate.getFullYear(); const month = visibleDate.getMonth(); const weeks = getMonthGrid(year, month, weekStartsOn); const weekdayLabels = getWeekdayLabels(locale, weekStartsOn); const weekdayFullNames = getWeekdayFullNames(locale, weekStartsOn); + const buttonRefs = useRef>(new Map()); + + const datesWithRow = useMemo(() => getDatesWithRow(weeks), [weeks]); + const focusTarget = focusedDate ?? selectedDate; const isToday = useCallback( (d: Date | null) => d !== null && isSameDay(d, new Date()), [] ); + const focusButton = useCallback((date: Date | null) => { + if (date === null) return; + const key = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + buttonRefs.current.get(key)?.focus(); + }, []); + + useEffect(() => { + if (focusTarget !== null) focusButton(focusTarget); + }, [focusTarget, focusButton]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent, date: Date) => { + if (!onFocusedDateChange) return; + const key = date.getTime(); + const idx = datesWithRow.findIndex(({ date: d }) => d.getTime() === key); + if (idx < 0) return; + + const currentRow = datesWithRow[idx].rowIndex; + const day = date.getDate(); + + let newDate: Date | null = null; + let newVisibleDate: 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; + newVisibleDate = 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); + newVisibleDate = 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) { + newVisibleDate = 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) { + newVisibleDate = 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); + } + newVisibleDate = 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); + } + newVisibleDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (!isDateDisabled(date, disabledDates)) onDateSelect(date); + return; + default: + return; + } + + if (newDate !== null) { + onFocusedDateChange(newDate); + if (newVisibleDate !== null) onVisibleDateChange?.(newVisibleDate); + } + }, + [ + datesWithRow, + disabledDates, + month, + year, + onDateSelect, + onFocusedDateChange, + onVisibleDateChange, + ] + ); + + const setButtonRef = useCallback((date: Date, el: HTMLElement | null) => { + const k = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime(); + if (el) buttonRefs.current.set(k, el); + else buttonRefs.current.delete(k); + }, []); + return ( @@ -77,6 +213,7 @@ export const CalendarBody: React.FC = ({ isDateInRange(date, selectedDate, endDate); const disabled = isDateDisabled(date, disabledDates); const today = isToday(date); + const isFocused = focusTarget !== null && isSameDay(date, focusTarget); return ( {weeks.map((week, rowIndex) => ( - + {week.map((date, colIndex) => { if (date === null) { return ( @@ -213,7 +247,8 @@ export const CalendarBody: React.FC = ({ isDateInRange(date, selectedDate, endDate); const disabled = isDateDisabled(date, disabledDates); const today = isToday(date); - const isFocused = focusTarget !== null && isSameDay(date, focusTarget); + const isFocused = + focusTarget !== null && isSameDay(date, focusTarget); return ( {weeks.map((week, rowIndex) => ( - + {week.map((date, colIndex) => { if (date === null) { return ( + // fix this error + // eslint-disable-next-line react/no-array-index-key, jsx-a11y/control-has-associated-label {weekdayLabels.map((label, i) => ( - + ))} @@ -246,10 +289,12 @@ export const CalendarBody: React.FC = ({ const selected = isSameDay(date, selectedDate) || isSameDay(date, endDate); const inRange = - (selectedDate !== null || endDate !== null) && + !!selectedDate && + !!endDate && isDateInRange(date, selectedDate, endDate); const disabled = isDateDisabled(date, disabledDates); const today = isToday(date); + // this is making the selected date a differnet color bc it is focused, look into further const isFocused = focusTarget !== null && isSameDay(date, focusTarget); @@ -267,7 +312,6 @@ export const CalendarBody: React.FC = ({ ref={(el) => setButtonRef(date, el as HTMLElement | null)} tabIndex={isFocused ? 0 : -1} variant="secondary" - width="36px" onClick={() => onDateSelect(date)} onFocus={() => onFocusedDateChange?.(date)} onKeyDown={(e: React.KeyboardEvent) => diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index 58318278d4d..2b35c13e646 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -51,7 +51,7 @@ export const CalendarFooter: React.FC = ({ alignItems="center" borderTop={1} justifyContent="space-between" - py={12} + p={12} > Clear diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts index 693b1fbe057..e39529ca3b7 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts @@ -79,7 +79,7 @@ export function isSameDay(a: Date | null, b: Date | null): boolean { } /** - * Check if `date` is between `start` and `end` (inclusive), ignoring time. + * Check if `date` is between `start` and `end` (exclusive), ignoring time. */ export function isDateInRange( date: Date, @@ -92,7 +92,7 @@ export function isDateInRange( const e = end !== null ? toDateOnly(end).getTime() : s; const low = Math.min(s, e); const high = Math.max(s, e); - return d >= low && d <= high; + return d > low && d < high; } /** diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx index 567b4e9092f..15b326cbcad 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput.tsx @@ -144,7 +144,7 @@ export const DatePickerInput = forwardRef< aria-controls={calendarDialogId} aria-expanded={isCalendarOpen} aria-haspopup="dialog" - icon={CalendarIcon} + icon={CalendarIcon} // add mini calendar icon and update id={inputId} label={label ?? defaultLabel} // this isnt actually adding a label placeholder={placeholder ?? 'MM/DD/YYYY'} From e615f9f56c1daa9760c7db1a709769c94173a21f Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 17 Mar 2026 10:53:42 -0400 Subject: [PATCH 023/110] PR feedback --- .../src/DatePicker/Calendar/CalendarBody.tsx | 144 ++---------------- .../DatePicker/Calendar/CalendarHeader.tsx | 22 +-- .../gamut/src/DatePicker/Calendar/types.ts | 18 +-- .../src/DatePicker/Calendar/utils/dateGrid.ts | 54 +++---- .../src/DatePicker/Calendar/utils/format.ts | 41 ++--- .../DatePicker/Calendar/utils/keyHandler.ts | 128 ++++++++++++++++ .../DatePicker/Calendar/utils/validation.ts | 14 +- packages/gamut/src/DatePicker/DatePicker.tsx | 1 + .../src/DatePicker/DatePickerCalendar.tsx | 104 +++---------- packages/gamut/src/DatePicker/index.tsx | 1 - packages/gamut/src/DatePicker/types.ts | 23 --- packages/gamut/src/DatePicker/utils.ts | 91 +++++++++++ 12 files changed, 317 insertions(+), 324 deletions(-) create mode 100644 packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts create mode 100644 packages/gamut/src/DatePicker/utils.ts diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index b3ec25912c0..9acfe07ce31 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -6,13 +6,13 @@ import * as React from 'react'; import { TextButton } from '../../Button'; import { CalendarBodyProps } from './types'; import { - clampToMonth, getMonthGrid, isDateDisabled, isDateInRange, isSameDay, } from './utils/dateGrid'; import { getWeekdayFullNames, getWeekdayLabels } from './utils/format'; +import { getDatesWithRow, keyHandler } from './utils/keyHandler'; const TableHeader = styled.th( css({ @@ -66,25 +66,9 @@ const DateButton = styled(TextButton)( css({ fontWeight: 'base', width: '32px', - // '&:hover, &:focus': { - // bg: 'background-hover', - // }, }) ); -/** Flat list of dates in grid order (row-major, non-null only) with row index for Home/End */ -function getDatesWithRow( - weeks: (Date | null)[][] -): { date: Date; rowIndex: number }[] { - const result: { date: Date; rowIndex: number }[] = []; - weeks.forEach((week, rowIndex) => { - week.forEach((date) => { - if (date !== null) result.push({ date, rowIndex }); - }); - }); - return result; -} - export const CalendarBody: React.FC = ({ visibleDate, selectedDate, @@ -129,119 +113,19 @@ export const CalendarBody: React.FC = ({ }, [focusTarget, focusButton]); const handleKeyDown = useCallback( - (e: React.KeyboardEvent, date: Date) => { - if (!onFocusedDateChange) return; - const key = date.getTime(); - const idx = datesWithRow.findIndex(({ date: d }) => d.getTime() === key); - if (idx < 0) return; - - const currentRow = datesWithRow[idx].rowIndex; - const day = date.getDate(); - - let newDate: Date | null = null; - let newVisibleDate: 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; - newVisibleDate = 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); - newVisibleDate = 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) { - newVisibleDate = 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) { - newVisibleDate = 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); - } - newVisibleDate = 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); - } - newVisibleDate = new Date( - newDate.getFullYear(), - newDate.getMonth(), - 1 - ); - break; - case 'Enter': - case ' ': - e.preventDefault(); - if (!isDateDisabled(date, disabledDates)) onDateSelect(date); - return; - case 'Escape': - e.preventDefault(); - onEscapeKeyPress?.(); - return; - default: - return; - } - - if (newDate !== null) { - onFocusedDateChange(newDate); - if (newVisibleDate !== null) onVisibleDateChange?.(newVisibleDate); - } - }, + (e: React.KeyboardEvent, date: Date) => + keyHandler( + e, + date, + onFocusedDateChange, + datesWithRow, + month, + year, + disabledDates, + onDateSelect, + onEscapeKeyPress, + onVisibleDateChange + ), [ onFocusedDateChange, datesWithRow, diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx index e6af0849ce3..d08bff4287c 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx @@ -57,16 +57,18 @@ export const CalendarHeader: React.FC = ({ > {formatMonthYear(currentMonthYear, locale)} - - {formatMonthYear(secondMonthYear, locale)} - + {secondMonthYear && ( + + {formatMonthYear(secondMonthYear, locale)} + + )} void; /** Currently displayed second month and year (used for heading and prev/next range) */ - secondMonthYear: Date; - /** Optional. Called after navigating to previous month; use for click tracking. */ + secondMonthYear?: Date; + /** Called after navigating to previous month; use for click tracking. */ onPreviousMonthClick?: () => void; - /** Optional. Called after navigating to next month; use for click tracking. */ + /** Called after navigating to next month; use for click tracking. */ onNextMonthClick?: () => void; /** Locale for month/year formatting (e.g. 'en-US') */ locale?: string; - /** Optional id for the heading (for grid aria-labelledby) */ - headingId?: string; + /** id for the heading (for grid aria-labelledby) */ + headingId: string; } export interface CalendarBodyProps { @@ -35,13 +35,13 @@ export interface CalendarBodyProps { /** 0 = Sunday, 1 = Monday (default from locale if not set) */ weekStartsOn?: 0 | 1; /** Id of the month/year heading (aria-labelledby on grid) */ - labelledById?: string; + labelledById: string; /** For keyboard nav: which cell has focus (roving tabindex) */ - focusedDate?: Date | null; + focusedDate: Date | null; /** Callback when focused date changes (e.g. arrow keys) */ - onFocusedDateChange?: (date: Date | null) => void; + onFocusedDateChange: (date: Date | null) => void; /** Called when grid keyboard nav changes month (e.g. Page Up/Down). Pass setVisibleDate so the calendar updates. */ - onVisibleDateChange?: (newDate: Date) => void; + onVisibleDateChange: (newDate: Date) => void; /** Called when the escape key is pressed */ onEscapeKeyPress?: () => void; } diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts index e39529ca3b7..160de67c976 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts @@ -8,19 +8,19 @@ const DAYS_PER_WEEK = 7; /** * Normalize to start of day in local time for comparison. */ -function toDateOnly(d: Date): Date { - return new Date(d.getFullYear(), d.getMonth(), d.getDate()); -} +const toDateOnly = (date: Date) => { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +}; /** * Get the weekday for a date (0 = Sunday, 6 = Saturday). * Optionally use weekStartsOn to compute "offset" for display (e.g. Monday = 0). */ -export function getDayOfWeek(date: Date, weekStartsOn: 0 | 1 = 0): number { +export const getDayOfWeek = (date: Date, weekStartsOn: 0 | 1 = 0) => { const sundayBased = date.getDay(); if (weekStartsOn === 0) return sundayBased; return (sundayBased + 6) % 7; // Monday = 0 -} +}; /** * Returns an array of weeks for the given month. Each week is an array of 7 items: @@ -30,11 +30,11 @@ export function getDayOfWeek(date: Date, weekStartsOn: 0 | 1 = 0): number { * @param month - Month 0-11 (0 = January) * @param weekStartsOn - 0 = Sunday, 1 = Monday */ -export function getMonthGrid( +export const getMonthGrid = ( year: number, month: number, weekStartsOn: 0 | 1 = 0 -): (Date | null)[][] { +) => { const first = new Date(year, month, 1); const last = new Date(year, month + 1, 0); const firstDayOfWeek = getDayOfWeek(first, weekStartsOn); @@ -68,47 +68,37 @@ export function getMonthGrid( } return weeks; -} +}; /** * Check if two dates are the same calendar day (ignoring time). */ -export function isSameDay(a: Date | null, b: Date | null): boolean { +export const isSameDay = (a: Date | null, b: Date | null) => { if (a === null || b === null) return false; return toDateOnly(a).getTime() === toDateOnly(b).getTime(); -} +}; /** * Check if `date` is between `start` and `end` (exclusive), ignoring time. */ -export function isDateInRange( +export const isDateInRange = ( date: Date, start: Date | null, end: Date | null -): boolean { +) => { if (start === null) return false; - const d = toDateOnly(date).getTime(); - const s = toDateOnly(start).getTime(); - const e = end !== null ? toDateOnly(end).getTime() : s; - const low = Math.min(s, e); - const high = Math.max(s, e); - return d > low && d < high; -} + const normalizedDateTime = toDateOnly(date).getTime(); + const normalizedStartDateTime = toDateOnly(start).getTime(); + const normalizedEndDateTime = + end !== null ? toDateOnly(end).getTime() : normalizedStartDateTime; + const low = Math.min(normalizedStartDateTime, normalizedEndDateTime); + const high = Math.max(normalizedStartDateTime, normalizedEndDateTime); + return normalizedDateTime > low && normalizedDateTime < high; +}; /** * Check if `date` is in the `disabledDates` list (by calendar day). */ -export function isDateDisabled( - date: Date, - disabledDates: Date[] = [] -): boolean { +export const isDateDisabled = (date: Date, disabledDates: Date[] = []) => { return disabledDates.some((d) => isSameDay(date, d)); -} - -/** - * Clamp a day to the last day of the given month (e.g. Jan 31 -> Feb 28). - */ -export function clampToMonth(year: number, month: number, day: number): Date { - const last = new Date(year, month + 1, 0).getDate(); - return new Date(year, month, Math.min(day, last)); -} +}; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index d1eb0543224..d857e8392ff 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -5,21 +5,18 @@ /** * Format month and year for the calendar header (e.g. "February 2026"). */ -export function formatMonthYear(date: Date, locale?: string): string { +export const formatMonthYear = (date: Date, locale?: string) => { return new Intl.DateTimeFormat(locale ?? 'en-US', { month: 'long', year: 'numeric', }).format(date); -} +}; /** * Get short weekday labels for column headers (e.g. ["Su", "Mo", ...]). * Order depends on weekStartsOn: 0 = Sunday first, 1 = Monday first. */ -export function getWeekdayLabels( - locale?: string, - weekStartsOn: 0 | 1 = 0 -): string[] { +export const getWeekdayLabels = (locale?: string, weekStartsOn: 0 | 1 = 0) => { const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { weekday: 'short', }); @@ -34,16 +31,16 @@ export function getWeekdayLabels( return [...labels.slice(1), labels[0]]; } return labels; -} +}; /** * Get full weekday names for abbr attributes (e.g. "Sunday", "Monday"). * Same order as getWeekdayLabels. */ -export function getWeekdayFullNames( +export const getWeekdayFullNames = ( locale?: string, weekStartsOn: 0 | 1 = 0 -): string[] { +) => { const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { weekday: 'long', }); @@ -57,18 +54,18 @@ export function getWeekdayFullNames( return [...names.slice(1), names[0]]; } return names; -} +}; /** * Format a date for display in the date picker input (e.g. "2/15/2026"). */ -export function formatDateForInput(date: Date, locale?: string): string { +export const formatDateForInput = (date: Date, locale?: string) => { return new Intl.DateTimeFormat(locale ?? 'en-US', { month: 'numeric', day: 'numeric', year: 'numeric', }).format(date); -} +}; /** * Parse a string from the date input into a Date, or null if invalid. @@ -77,10 +74,7 @@ export function formatDateForInput(date: Date, locale?: string): string { */ // this logic needs some work -export function parseDateFromInput( - value: string, - locale?: string -): Date | null { +export const parseDateFromInput = (value: string, locale?: string) => { const trimmed = value.trim(); if (!trimmed) return null; const parsed = new Date(trimmed); @@ -90,18 +84,18 @@ export function parseDateFromInput( const parts = trimmed.split(/[/-]/); if (parts.length >= 3) return parsed; return null; -} +}; const RANGE_SEPARATOR = ' – '; /** * Format a date range for the input (e.g. "2/15/2026 – 2/20/2026"). */ -export function formatDateRangeForInput( +export const formatDateRangeForInput = ( startDate: Date | null, endDate: Date | null, locale?: string -): string { +) => { if (!startDate && !endDate) return ''; if (!startDate) return formatDateForInput(endDate!, locale); if (!endDate) return formatDateForInput(startDate, locale); @@ -109,16 +103,13 @@ export function formatDateRangeForInput( startDate, locale )}${RANGE_SEPARATOR}${formatDateForInput(endDate, locale)}`; -} +}; /** * Parse a range string (e.g. "2/15/2026 – 2/20/2026") into { startDate, endDate }. * Returns null if invalid. Single date is allowed and yields startDate = endDate. */ -export function parseDateRangeFromInput( - value: string, - locale?: string -): { startDate: Date; endDate: Date } | null { +export const parseDateRangeFromInput = (value: string, locale?: string) => { const trimmed = value.trim(); if (!trimmed) return null; const parts = trimmed.split(RANGE_SEPARATOR).map((s) => s.trim()); @@ -136,4 +127,4 @@ export function parseDateRangeFromInput( : { startDate: end, endDate: start }; } return null; -} +}; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts b/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts new file mode 100644 index 00000000000..5f136870ae1 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts @@ -0,0 +1,128 @@ +import { isDateDisabled } from './dateGrid'; + +/** + * Clamp a day to the last day of the given month (e.g. Jan 31 -> Feb 28). + */ +export 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)); +}; + +/** Flat list of dates in grid order (row-major, non-null only) with row index for Home/End */ +export const getDatesWithRow = (weeks: (Date | null)[][]) => { + const result: { date: Date; rowIndex: number }[] = []; + weeks.forEach((week, rowIndex) => { + week.forEach((date) => { + if (date !== null) result.push({ date, rowIndex }); + }); + }); + return result; +}; + +export const keyHandler = ( + e: React.KeyboardEvent, + date: Date, + onFocusedDateChange: (date: Date | null) => void, + datesWithRow: { date: Date; rowIndex: number }[], + month: number, + year: number, + disabledDates: Date[], + onDateSelect: (date: Date) => void, + onEscapeKeyPress?: () => void, + onVisibleDateChange?: (newDate: Date) => void +) => { + const key = date.getTime(); + const idx = datesWithRow.findIndex(({ date: d }) => d.getTime() === key); + if (idx < 0) return; + + const currentRow = datesWithRow[idx].rowIndex; + const day = date.getDate(); + + let newDate: Date | null = null; + let newVisibleDate: 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; + newVisibleDate = 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); + newVisibleDate = 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) { + newVisibleDate = 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) { + newVisibleDate = 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); + } + newVisibleDate = 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); + } + newVisibleDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (!isDateDisabled(date, disabledDates)) onDateSelect(date); + return; + case 'Escape': + e.preventDefault(); + onEscapeKeyPress?.(); + return; + default: + return; + } + + if (newDate !== null) { + onFocusedDateChange(newDate); + if (newVisibleDate !== null) onVisibleDateChange?.(newVisibleDate); + } +}; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/validation.ts b/packages/gamut/src/DatePicker/Calendar/utils/validation.ts index 86bdd63adc2..034e8a7c0ac 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/validation.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/validation.ts @@ -7,17 +7,17 @@ * Check if a date is in the past (before today at start of day). * Useful for disabling past dates in the calendar. */ -export function isPastDate(date: Date): boolean { +export const isPastDate = (date: Date) => { const today = new Date(); today.setHours(0, 0, 0, 0); - const d = new Date(date); - d.setHours(0, 0, 0, 0); - return d.getTime() < today.getTime(); -} + const normalizedDate = new Date(date); + normalizedDate.setHours(0, 0, 0, 0); + return normalizedDate.getTime() < today.getTime(); +}; /** * Check if a date is valid (finite and not NaN). */ -export function isValidDate(date: Date): boolean { +export const isValidDate = (date: Date) => { return date instanceof Date && !Number.isNaN(date.getTime()); -} +}; diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 7bbab2e7680..3b6ea05e6e8 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -59,6 +59,7 @@ export const DatePicker: React.FC = (props) => { [props] ); + // discriminated union type const contextValue = useMemo( () => ({ mode: mode ?? 'single', diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index b024a45cb85..02e86c62651 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -8,11 +8,12 @@ import { CalendarHeader, } from './Calendar'; import { useDatePicker } from './DatePickerContext'; +import { handleDateSelectRange, handleDateSelectSingle } from './utils'; export type DatePickerCalendarProps = { - /** Optional id for the dialog (for aria-controls from input). */ - dialogId?: string; - /** When outside DatePicker, pass weekStartsOn (0 or 1). */ + /** id for the dialog (for aria-controls from input). */ + dialogId: string; + /** Whether to start the calendar on Sunday (0) or Monday (1). Default is Sunday. */ weekStartsOn?: 0 | 1; }; @@ -42,6 +43,7 @@ export const DatePickerCalendar: React.FC = ({ const { mode, startDate, + selectedDate, setSelection, endDate, activeRangePart, @@ -77,91 +79,19 @@ export const DatePickerCalendar: React.FC = ({ } }, [isCalendarOpen, startDate, endDate]); - // Clicking Start Date: Clears it. If End exists, End becomes new Start - // Clicking End Date: Clears End Date (Start remains) - // If Start == End (single day range): Clicking it clears everything - - const handleDateSelect = (date: Date) => { + const onDateSelect = (date: Date) => { setActiveRangePart(null); - // single date select if (!isRange) { - // If clicked date is the same as Start Date: Clear Start Date - if (startDate && date.getTime() === startDate.getTime()) { - setSelection(null); - return; - } - // If clicked date is not the same as Start Date: Set Start Date to clicked date - setSelection(date); - return; - } - - // Range mode: field targeting (start or end input was focused) - if (activeRangePart === 'start') { - if (date.getTime() === startDate?.getTime()) { - setSelection(null, endDate); - return; - } - const newEnd = - endDate != null && date.getTime() <= endDate.getTime() ? endDate : null; - setSelection(date, newEnd); - return; - } - if (activeRangePart === 'end') { - if (date.getTime() === endDate?.getTime()) { - setSelection(startDate, null); - return; - } - const newStart = - startDate != null && date.getTime() >= startDate.getTime() - ? startDate - : null; - setSelection(newStart, date); - return; - } - - // Range selection mode (no field focused: calendar drives both) - if (startDate && endDate) { - // if start date is end date and is clicked, clears everything - if ( - startDate.getTime() === endDate.getTime() && - date.getTime() === startDate.getTime() - ) { - setSelection(null, null); - return; - } - // if clicked on start date, end date becomes start date - if (date.getTime() === startDate.getTime()) { - setSelection(endDate, null); - return; - } - // if clicked on end date, clears end date and start remains - if (date.getTime() === endDate.getTime()) { - setSelection(startDate, null); - return; - } - // If clicked date > Start: Updates End Date to new date (Start remains) - if (date.getTime() > startDate.getTime()) { - setSelection(startDate, date); - return; - } - // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left - - setSelection(date, endDate); - return; - } - // 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()) { - setSelection(date, null); - } - // If clicked date > Start: Sets it as End Date - else { - setSelection(startDate, date); - } - return; + handleDateSelectSingle(date, selectedDate, setSelection); + } else { + handleDateSelectRange( + date, + activeRangePart, + startDate, + endDate, + setSelection + ); } - setSelection(date, null); }; const handleClearDate = () => { @@ -202,7 +132,7 @@ export const DatePickerCalendar: React.FC = ({ selectedDate={startDate} visibleDate={visibleDate} weekStartsOn={weekStartsOn} - onDateSelect={handleDateSelect} + onDateSelect={onDateSelect} onEscapeKeyPress={closeCalendar} onFocusedDateChange={setFocusedDate} onVisibleDateChange={setVisibleDate} @@ -217,7 +147,7 @@ export const DatePickerCalendar: React.FC = ({ selectedDate={startDate} visibleDate={secondMonthDate} weekStartsOn={weekStartsOn} - onDateSelect={handleDateSelect} + onDateSelect={onDateSelect} onEscapeKeyPress={closeCalendar} onFocusedDateChange={setFocusedDate} onVisibleDateChange={setVisibleDate} diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index 93a62e869bb..da22954a486 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -6,7 +6,6 @@ export type { DatePickerProps, DatePickerRangeProps, DatePickerSingleProps, - UseDatePickerReturn, } from './types'; export { DatePicker } from './DatePicker'; diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index e59c971b41e..5739212da69 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -76,26 +76,3 @@ export interface DatePickerContextValue { disabledDates: Date[]; calendarDialogId: string; } - -/** Input props returned by useDatePicker for DatePickerInput. */ -export interface UseDatePickerInputProps { - value: string; - onChange: (e: React.ChangeEvent) => void; - onKeyDown?: (e: React.KeyboardEvent) => void; - onFocus?: (e: React.FocusEvent) => void; - onBlur?: (e: React.FocusEvent) => void; - onClick?: (e: React.MouseEvent) => void; - label?: string; - id?: string; - placeholder?: string; - error?: boolean; - inputRef: React.RefObject; - role?: 'combobox'; - 'aria-expanded'?: boolean; - 'aria-controls'?: string; - 'aria-haspopup'?: 'dialog'; - 'aria-autocomplete'?: 'none'; -} - -/** Return value of useDatePicker() (same as context value). */ -export type UseDatePickerReturn = DatePickerContextValue; diff --git a/packages/gamut/src/DatePicker/utils.ts b/packages/gamut/src/DatePicker/utils.ts new file mode 100644 index 00000000000..32b9eba8fb1 --- /dev/null +++ b/packages/gamut/src/DatePicker/utils.ts @@ -0,0 +1,91 @@ +import { ActiveRangePart } from './types'; + +export const handleDateSelectSingle = ( + date: Date, + selectedDate: Date | null, + setSelection: (date: Date | null) => void +) => { + // If clicked date is the same as Start Date: Clear Start Date + if (selectedDate && date.getTime() === selectedDate.getTime()) { + setSelection(null); + return; + } + // If clicked date is not the same as Start Date: Set Start Date to clicked date + setSelection(date); +}; + +export const handleDateSelectRange = ( + date: Date, + activeRangePart: ActiveRangePart, + startDate: Date | null, + endDate: Date | null, + setSelection: (startDate: Date | null, endDate?: Date | null) => void +) => { + // Range mode: field targeting (start or end input was focused) + if (activeRangePart === 'start') { + if (date.getTime() === startDate?.getTime()) { + setSelection(null, endDate); + return; + } + const newEnd = + endDate != null && date.getTime() <= endDate.getTime() ? endDate : null; + setSelection(date, newEnd); + return; + } + if (activeRangePart === 'end') { + if (date.getTime() === endDate?.getTime()) { + setSelection(startDate, null); + return; + } + const newStart = + startDate != null && date.getTime() >= startDate.getTime() + ? startDate + : null; + setSelection(newStart, date); + return; + } + + // Range selection mode (no field focused: calendar drives both) + if (startDate && endDate) { + // if start date is end date and is clicked, clears everything + if ( + startDate.getTime() === endDate.getTime() && + date.getTime() === startDate.getTime() + ) { + setSelection(null, null); + return; + } + // if clicked on start date, end date becomes start date + if (date.getTime() === startDate.getTime()) { + setSelection(endDate, null); + return; + } + // if clicked on end date, clears end date and start remains + if (date.getTime() === endDate.getTime()) { + setSelection(startDate, null); + return; + } + // If clicked date > Start: Updates End Date to new date (Start remains) + if (date.getTime() > startDate.getTime()) { + setSelection(startDate, date); + return; + } + // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left + + setSelection(date, endDate); + return; + } + // 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()) { + setSelection(date, null); + } + // If clicked date > Start: Sets it as End Date + else { + setSelection(startDate, date); + } + return; + } + setSelection(date, null); +}; From a19fc7be1220a1631c21ffa5f7c30d385eb1415e Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 17 Mar 2026 11:00:20 -0400 Subject: [PATCH 024/110] fix today in range color --- packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 9acfe07ce31..f9d2a542ee9 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -57,6 +57,9 @@ const DateButton = styled(TextButton)( bg: 'secondary-hover', color: 'background', }, + '&::after': { + bg: 'background', + }, }, disabled: { color: 'text-disabled', From 9c30d34b50bc8ba0c88bd22dac05ecdd24b287a2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 17 Mar 2026 12:23:02 -0400 Subject: [PATCH 025/110] clean up context --- .../gamut/src/DatePicker/Calendar/types.ts | 2 +- packages/gamut/src/DatePicker/DatePicker.tsx | 60 ++++++++++--------- .../src/DatePicker/DatePickerCalendar.tsx | 34 +++++------ .../gamut/src/DatePicker/DatePickerInput.tsx | 17 +++--- packages/gamut/src/DatePicker/types.ts | 42 ++++++++----- 5 files changed, 84 insertions(+), 71 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index 91c5c32a4ee..0889b4d4497 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -24,7 +24,7 @@ export interface CalendarBodyProps { visibleDate: Date; /** Selected start date (single or range start) */ selectedDate: Date | null; - /** Selected end date (range only; null for single-date mode) */ + /** Selected end date (range only; undefined for single-date mode) */ endDate?: Date | null; /** Dates that should be disabled (unselectable) */ disabledDates?: Date[]; diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 3b6ea05e6e8..cb15ee8c10b 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -43,9 +43,10 @@ export const DatePicker: React.FC = (props) => { inputRef.current?.focus(); }, []); - // do we want to refer to this as startDate or selectedDate internally? its the selected date in single mode and the start date in range mode - const startDate = isRangeProps(props) ? props.startDate : props.selectedDate; - const endDate = isRangeProps(props) ? props.endDate : null; // null vs undefined? + const startOrSelectedDate = isRangeProps(props) + ? props.startDate + : props.selectedDate; + const endDate = isRangeProps(props) ? props.endDate : null; const setSelection = useCallback( (start: Date | null, end?: Date | null) => { @@ -59,39 +60,40 @@ export const DatePicker: React.FC = (props) => { [props] ); - // discriminated union type - const contextValue = useMemo( - () => ({ - mode: mode ?? 'single', - selectedDate: startDate, - startDate, - endDate, + const contextValue = useMemo(() => { + const base = { + startOrSelectedDate, setSelection, - activeRangePart: mode === 'range' ? activeRangePart : null, - // fix this - // eslint-disable-next-line @typescript-eslint/no-empty-function - setActiveRangePart: mode === 'range' ? setActiveRangePart : () => {}, isCalendarOpen, openCalendar, closeCalendar, locale, disabledDates, calendarDialogId, - }), - [ - mode, - startDate, - endDate, - setSelection, - activeRangePart, - isCalendarOpen, - openCalendar, - closeCalendar, - locale, - disabledDates, - calendarDialogId, - ] - ); + }; + return mode === 'range' + ? { + ...base, + mode: 'range', + endDate, + activeRangePart, + setActiveRangePart, + } + : { ...base, mode: 'single' }; + }, [ + mode, + startOrSelectedDate, + endDate, + setSelection, + activeRangePart, + setActiveRangePart, + isCalendarOpen, + openCalendar, + closeCalendar, + locale, + disabledDates, + calendarDialogId, + ]); // what is this doing // useEffect(() => { diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 02e86c62651..ba3bf693b2c 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -42,12 +42,8 @@ export const DatePickerCalendar: React.FC = ({ const { mode, - startDate, - selectedDate, + startOrSelectedDate, setSelection, - endDate, - activeRangePart, - setActiveRangePart, disabledDates, locale, closeCalendar, @@ -55,14 +51,15 @@ export const DatePickerCalendar: React.FC = ({ } = context; const isRange = mode === 'range'; + const endDate = isRange ? context.endDate : undefined; const firstOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); const [visibleDate, setVisibleDate] = useState(() => - firstOfMonth(startDate ?? new Date()) + firstOfMonth(startOrSelectedDate ?? new Date()) ); const [focusedDate, setFocusedDate] = useState( - () => startDate ?? endDate ?? new Date() + () => startOrSelectedDate ?? endDate ?? new Date() ); const wasOpenRef = useRef(false); @@ -72,23 +69,23 @@ export const DatePickerCalendar: React.FC = ({ const justOpened = isCalendarOpen && !wasOpenRef.current; wasOpenRef.current = isCalendarOpen; if (!justOpened) return; - const anchor = startDate ?? endDate; + const anchor = startOrSelectedDate ?? endDate; if (anchor) { setVisibleDate(firstOfMonth(anchor)); - setFocusedDate(startDate ?? endDate ?? new Date()); + setFocusedDate(startOrSelectedDate ?? endDate ?? new Date()); } - }, [isCalendarOpen, startDate, endDate]); + }, [isCalendarOpen, startOrSelectedDate, endDate]); const onDateSelect = (date: Date) => { - setActiveRangePart(null); if (!isRange) { - handleDateSelectSingle(date, selectedDate, setSelection); + handleDateSelectSingle(date, startOrSelectedDate, setSelection); } else { + context.setActiveRangePart(null); handleDateSelectRange( date, - activeRangePart, - startDate, - endDate, + context.activeRangePart, + startOrSelectedDate, + context.endDate, setSelection ); } @@ -106,7 +103,8 @@ export const DatePickerCalendar: React.FC = ({ setFocusedDate(today); }; - const focusTarget = focusedDate ?? startDate ?? endDate ?? new Date(); + const focusTarget = + focusedDate ?? startOrSelectedDate ?? endDate ?? new Date(); const addMonths = (date: Date, n: number) => new Date(date.getFullYear(), date.getMonth() + n, 1); @@ -129,7 +127,7 @@ export const DatePickerCalendar: React.FC = ({ focusedDate={focusTarget} labelledById={headingId} locale={locale} - selectedDate={startDate} + selectedDate={startOrSelectedDate} visibleDate={visibleDate} weekStartsOn={weekStartsOn} onDateSelect={onDateSelect} @@ -144,7 +142,7 @@ export const DatePickerCalendar: React.FC = ({ focusedDate={focusTarget} labelledById={headingId} locale={locale} - selectedDate={startDate} + selectedDate={startOrSelectedDate} visibleDate={secondMonthDate} weekStartsOn={weekStartsOn} onDateSelect={onDateSelect} diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx index 15b326cbcad..7a4a14f5040 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput.tsx @@ -57,10 +57,8 @@ export const DatePickerInput = forwardRef< const { mode, - startDate, - endDate, + startOrSelectedDate, setSelection, - setActiveRangePart, openCalendar, locale, isCalendarOpen, @@ -73,7 +71,8 @@ export const DatePickerInput = forwardRef< const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; // Range with two inputs: each input binds to one part. Single or range combined: one value. - const boundDate = isRange && rangePart === 'end' ? endDate : startDate; + const boundDate = + isRange && rangePart === 'end' ? context.endDate : startOrSelectedDate; const formattedValue = boundDate != null ? formatDateForInput(boundDate, locale) : ''; @@ -92,16 +91,16 @@ export const DatePickerInput = forwardRef< const trimmed = raw.trim(); if (!trimmed) { if (isRange && rangePart) { - if (rangePart === 'start') setSelection(null, endDate); - else setSelection(startDate, null); + if (rangePart === 'start') setSelection(null, context.endDate); + else setSelection(startOrSelectedDate, null); } else setSelection(null); return undefined; } const parsed = parseDateFromInput(trimmed, locale); if (!parsed) return undefined; if (isRange && rangePart) { - if (rangePart === 'start') setSelection(parsed, endDate); - else setSelection(startDate, parsed); + if (rangePart === 'start') setSelection(parsed, context.endDate); + else setSelection(startOrSelectedDate, parsed); } else setSelection(parsed); return formatDateForInput(parsed, locale); }; @@ -157,7 +156,7 @@ export const DatePickerInput = forwardRef< onClick={handleOpenCalendar} onFocus={() => { isInputFocusedRef.current = true; - if (isRange && rangePart) setActiveRangePart(rangePart); + if (isRange && rangePart) context.setActiveRangePart(rangePart); }} onKeyDown={handleKeyDown} /> diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 5739212da69..9362ef33467 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -55,24 +55,38 @@ export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; export type ActiveRangePart = 'start' | 'end' | null; /** Shared state provided by DatePicker via context. */ -export interface DatePickerContextValue { - mode: 'single' | 'range'; - /** Selected date (single) or start date (range). Same value as startDate. */ - selectedDate: Date | null; - /** Alias for selectedDate (same value). */ - startDate: Date | null; - /** Range only: end date. */ - endDate: Date | null; - /** Set selection. Single: (date). Range: (start, end). */ - setSelection: (startDate: Date | null, endDate?: Date | null) => void; - /** Range only: which input is active (start/end focused); null = selection mode. */ - activeRangePart: ActiveRangePart; - /** Range only: set which input is active (e.g. when input receives focus). */ - setActiveRangePart: (part: ActiveRangePart) => void; +export interface DatePickerBaseContextValue { isCalendarOpen: boolean; openCalendar: () => void; closeCalendar: () => void; locale: string; disabledDates: Date[]; calendarDialogId: string; + /** Start date (range) or selected date (single). */ + startOrSelectedDate: Date | null; + /** Set selection. Single: (date). Range: (start, end). */ + setSelection: ( + startOrSelectedDate: Date | null, + endDate?: Date | null + ) => void; +} + +export interface DatePickerSingleContextValue + extends DatePickerBaseContextValue { + mode: 'single'; } + +export interface DatePickerRangeContextValue + extends DatePickerBaseContextValue { + mode: 'range'; + /** Range only: end date. */ + endDate: Date | null; + /** Range only: which input is active (start/end focused); null = selection mode. */ + activeRangePart: ActiveRangePart; + /** Range only: set which input is active (e.g. when input receives focus). */ + setActiveRangePart: (part: ActiveRangePart) => void; +} + +export type DatePickerContextValue = + | DatePickerSingleContextValue + | DatePickerRangeContextValue; From b6575568709579bb400c7ac9ceaecb98003df5ea Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 17 Mar 2026 14:47:29 -0400 Subject: [PATCH 026/110] update placeholder text to be locale based --- .../src/DatePicker/Calendar/utils/format.ts | 48 +++++++++++++++++-- packages/gamut/src/DatePicker/DatePicker.tsx | 8 +--- .../gamut/src/DatePicker/DatePickerInput.tsx | 3 +- packages/gamut/src/DatePicker/types.ts | 2 +- .../DatePicker/DatePicker.stories.tsx | 3 -- 5 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index d857e8392ff..27b0c105139 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -6,7 +6,7 @@ * Format month and year for the calendar header (e.g. "February 2026"). */ export const formatMonthYear = (date: Date, locale?: string) => { - return new Intl.DateTimeFormat(locale ?? 'en-US', { + return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', }).format(date); @@ -17,7 +17,7 @@ export const formatMonthYear = (date: Date, locale?: string) => { * Order depends on weekStartsOn: 0 = Sunday first, 1 = Monday first. */ export const getWeekdayLabels = (locale?: string, weekStartsOn: 0 | 1 = 0) => { - const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { + const formatter = new Intl.DateTimeFormat(locale, { weekday: 'short', }); // Jan 7, 2024 is a Sunday; add 0..6 days to get Sun..Sat @@ -41,7 +41,7 @@ export const getWeekdayFullNames = ( locale?: string, weekStartsOn: 0 | 1 = 0 ) => { - const formatter = new Intl.DateTimeFormat(locale ?? 'en-US', { + const formatter = new Intl.DateTimeFormat(locale, { weekday: 'long', }); const sunday = new Date(2024, 0, 7); @@ -56,11 +56,51 @@ export const getWeekdayFullNames = ( return names; }; +/** + * Get localized "next month" and "previous month" labels for calendar nav. + * Uses Intl.RelativeTimeFormat with numeric: "auto" (e.g. "next month", "last month"). + */ +export const getRelativeMonthLabels = (locale?: string) => { + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + return { + nextMonth: rtf.format(1, 'month'), + lastMonth: rtf.format(-1, 'month'), + }; +}; + +/** + * Get the locale's short date format pattern (e.g. "MM/DD/YYYY" for en-US, + * "DD/MM/YYYY" for en-GB). Uses Intl.DateTimeFormat formatToParts to infer + * order and separators. Useful for parsing or building locale-aware inputs. + */ +export const getDateFormatPattern = (locale?: string) => { + const parts = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(2025, 0, 15)); + + return parts + .map((p) => { + switch (p.type) { + case 'day': + return 'DD'; + case 'month': + return 'MM'; + case 'year': + return 'YYYY'; + default: + return p.value; + } + }) + .join(''); +}; + /** * Format a date for display in the date picker input (e.g. "2/15/2026"). */ export const formatDateForInput = (date: Date, locale?: string) => { - return new Intl.DateTimeFormat(locale ?? 'en-US', { + return new Intl.DateTimeFormat(locale, { month: 'numeric', day: 'numeric', year: 'numeric', diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index cb15ee8c10b..7892e1ffd33 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -21,13 +21,7 @@ function isRangeProps(props: DatePickerProps): props is DatePickerRangeProps { * With no children, renders default layout (input + calendar popover). */ export const DatePicker: React.FC = (props) => { - const { - locale = 'en-US', - disabledDates = [], - placeholder, - mode, - children, - } = props; + const { locale, disabledDates = [], placeholder, mode, children } = props; const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [activeRangePart, setActiveRangePart] = useState< 'start' | 'end' | null diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx index 7a4a14f5040..fd4deb4af0e 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput.tsx @@ -11,6 +11,7 @@ import { import { Input } from '../Form/inputs/Input'; import { formatDateForInput, + getDateFormatPattern, parseDateFromInput, } from './Calendar/utils/format'; import { useDatePicker } from './DatePickerContext'; @@ -146,7 +147,7 @@ export const DatePickerInput = forwardRef< icon={CalendarIcon} // add mini calendar icon and update id={inputId} label={label ?? defaultLabel} // this isnt actually adding a label - placeholder={placeholder ?? 'MM/DD/YYYY'} + placeholder={placeholder ?? getDateFormatPattern(locale)} ref={ref} role="combobox" type="text" diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 9362ef33467..f4d0bf835ec 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -59,7 +59,7 @@ export interface DatePickerBaseContextValue { isCalendarOpen: boolean; openCalendar: () => void; closeCalendar: () => void; - locale: string; + locale?: string; disabledDates: Date[]; calendarDialogId: string; /** Start date (range) or selected date (single). */ diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx index 7527c9fda9a..5e7cfb5c862 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx @@ -26,7 +26,6 @@ export const Default: Story = { @@ -43,7 +42,6 @@ export const WithInitialDate: Story = { return ( @@ -84,7 +82,6 @@ export const ComposedWithContext: Story = { From 19244333f4434839655c7c41748f2e77d0877ceb Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 17 Mar 2026 14:47:42 -0400 Subject: [PATCH 027/110] update next/last month text to be locale based --- .../DatePicker/Calendar/CalendarHeader.tsx | 24 ++++++++++--------- .../gamut/src/DatePicker/Calendar/types.ts | 2 +- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx index d08bff4287c..4db7ed5da65 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx @@ -8,25 +8,27 @@ import { FlexBox } from '../../Box'; import { IconButton } from '../../Button'; import { Text } from '../../Typography'; import { CalendarHeaderProps } from './types'; -import { formatMonthYear } from './utils/format'; +import { formatMonthYear, getRelativeMonthLabels } from './utils/format'; export const CalendarHeader: React.FC = ({ currentMonthYear, onCurrentMonthYearChange, secondMonthYear, - onPreviousMonthClick, + onLastMonthClick, onNextMonthClick, locale, headingId, }) => { - const handlePreviousMonth = () => { - const previousMonth = new Date( + const { nextMonth, lastMonth } = getRelativeMonthLabels(locale); + + const handleLastMonth = () => { + const lastMonth = new Date( currentMonthYear.getFullYear(), currentMonthYear.getMonth() - 1, 1 ); - onCurrentMonthYearChange?.(previousMonth); - onPreviousMonthClick?.(); + onCurrentMonthYearChange?.(lastMonth); + onLastMonthClick?.(); }; const handleNextMonth = () => { @@ -42,11 +44,11 @@ export const CalendarHeader: React.FC = ({ return ( = ({ )} diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index 0889b4d4497..a3b98231ad4 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -10,7 +10,7 @@ export interface CalendarHeaderProps { /** Currently displayed second month and year (used for heading and prev/next range) */ secondMonthYear?: Date; /** Called after navigating to previous month; use for click tracking. */ - onPreviousMonthClick?: () => void; + onLastMonthClick?: () => void; /** Called after navigating to next month; use for click tracking. */ onNextMonthClick?: () => void; /** Locale for month/year formatting (e.g. 'en-US') */ From a83a99a4344d23189c1509b1fad23a9921e7e04a Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 17 Mar 2026 15:03:10 -0400 Subject: [PATCH 028/110] capitalize --- .../src/DatePicker/Calendar/CalendarFooter.tsx | 6 +++++- .../gamut/src/DatePicker/Calendar/types.ts | 1 + .../src/DatePicker/Calendar/utils/format.ts | 18 ++++++++++++++++-- .../src/DatePicker/DatePickerCalendar.tsx | 1 + 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index 2b35c13e646..5cc36194d1a 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { FlexBox } from '../../Box'; import { TextButton } from '../../Button'; import { CalendarFooterProps } from './types'; +import { getRelativeTodayLabel } from './utils/format'; // function formatQuickActionLabel(action: QuickAction): string { // const { num, timePeriod } = action; @@ -30,6 +31,7 @@ export const CalendarFooter: React.FC = ({ onTodayClick, onSelectedDateChange, onCurrentMonthYearChange, + locale, }) => { const handleClearDate = () => { onSelectedDateChange(null); @@ -55,7 +57,9 @@ export const CalendarFooter: React.FC = ({ > Clear - Today + + {getRelativeTodayLabel(locale)} + ); diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index a3b98231ad4..c20509d06fc 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -53,6 +53,7 @@ export interface QuickAction { } export interface CalendarFooterProps { + locale?: string; onClearDate?: () => void; onTodayClick?: () => void; /** Called when the user navigates to a different month. Pass the new date (e.g. setVisibleDate) so the calendar updates. */ diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index 27b0c105139..693f4fd15df 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -2,6 +2,12 @@ * Date formatting for the calendar using Intl.DateTimeFormat. */ +/** + * Capitalize the first character of a string; rest unchanged (e.g. "next month" → "Next month"). + */ +export const capitalizeFirst = (str: string) => + str.length === 0 ? str : str[0].toUpperCase() + str.slice(1); + /** * Format month and year for the calendar header (e.g. "February 2026"). */ @@ -63,11 +69,19 @@ export const getWeekdayFullNames = ( export const getRelativeMonthLabels = (locale?: string) => { const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); return { - nextMonth: rtf.format(1, 'month'), - lastMonth: rtf.format(-1, 'month'), + nextMonth: capitalizeFirst(rtf.format(1, 'month')), + lastMonth: capitalizeFirst(rtf.format(-1, 'month')), }; }; +/** + * Get localized "today" label (e.g. "today"). + */ +export const getRelativeTodayLabel = (locale?: string) => { + const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); + return capitalizeFirst(rtf.format(0, 'day')); +}; + /** * Get the locale's short date format pattern (e.g. "MM/DD/YYYY" for en-US, * "DD/MM/YYYY" for en-GB). Uses Intl.DateTimeFormat formatToParts to infer diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index ba3bf693b2c..f1043aec31b 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -154,6 +154,7 @@ export const DatePickerCalendar: React.FC = ({ From bfd82068003799c7f0d6b7b916f1dc3ff1405e13 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 17 Mar 2026 16:07:12 -0400 Subject: [PATCH 029/110] translations --- .../DatePicker/Calendar/CalendarFooter.tsx | 3 ++- .../gamut/src/DatePicker/Calendar/types.ts | 1 + packages/gamut/src/DatePicker/DatePicker.tsx | 24 +++++++++++++++---- .../src/DatePicker/DatePickerCalendar.tsx | 2 ++ packages/gamut/src/DatePicker/translations.ts | 7 ++++++ packages/gamut/src/DatePicker/types.ts | 10 ++++++++ .../DatePicker/DatePicker.stories.tsx | 5 ++-- 7 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 packages/gamut/src/DatePicker/translations.ts diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index 5cc36194d1a..e7c4ebf608f 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -32,6 +32,7 @@ export const CalendarFooter: React.FC = ({ onSelectedDateChange, onCurrentMonthYearChange, locale, + clearText, }) => { const handleClearDate = () => { onSelectedDateChange(null); @@ -55,7 +56,7 @@ export const CalendarFooter: React.FC = ({ justifyContent="space-between" p={12} > - Clear + {clearText} {getRelativeTodayLabel(locale)} diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index c20509d06fc..40254c2d3c0 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -54,6 +54,7 @@ export interface QuickAction { export interface CalendarFooterProps { locale?: string; + clearText: string; onClearDate?: () => void; onTodayClick?: () => void; /** Called when the user navigates to a different month. Pass the new date (e.g. setVisibleDate) so the calendar updates. */ diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 7892e1ffd33..68b4d572882 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -5,10 +5,11 @@ import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; import { DatePickerProvider } from './DatePickerContext'; import { DatePickerInput } from './DatePickerInput'; -import type { - DatePickerContextValue, - DatePickerProps, - DatePickerRangeProps, +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './translations'; +import { + type DatePickerContextValue, + type DatePickerProps, + type DatePickerRangeProps, } from './types'; function isRangeProps(props: DatePickerProps): props is DatePickerRangeProps { @@ -21,7 +22,14 @@ function isRangeProps(props: DatePickerProps): props is DatePickerRangeProps { * With no children, renders default layout (input + calendar popover). */ export const DatePicker: React.FC = (props) => { - const { locale, disabledDates = [], placeholder, mode, children } = props; + const { + locale, + disabledDates = [], + placeholder, + mode, + children, + translations: translationsProp, + } = props; const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [activeRangePart, setActiveRangePart] = useState< 'start' | 'end' | null @@ -55,6 +63,10 @@ export const DatePicker: React.FC = (props) => { ); const contextValue = useMemo(() => { + const translations = { + ...DEFAULT_DATE_PICKER_TRANSLATIONS, + ...translationsProp, + }; const base = { startOrSelectedDate, setSelection, @@ -64,6 +76,7 @@ export const DatePicker: React.FC = (props) => { locale, disabledDates, calendarDialogId, + translations, }; return mode === 'range' ? { @@ -87,6 +100,7 @@ export const DatePicker: React.FC = (props) => { locale, disabledDates, calendarDialogId, + translationsProp, ]); // what is this doing diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index f1043aec31b..4c6c14b38d8 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -48,6 +48,7 @@ export const DatePickerCalendar: React.FC = ({ locale, closeCalendar, isCalendarOpen, + translations, } = context; const isRange = mode === 'range'; @@ -154,6 +155,7 @@ export const DatePickerCalendar: React.FC = ({ = + { + clear: 'Clear', + }; diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index f4d0bf835ec..e240c94bf22 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -18,6 +18,8 @@ export interface DatePickerBaseProps { children?: React.ReactNode; /** Placeholder for the input. */ placeholder?: string; + /** Override UI strings (e.g. clear button). Merged with defaults. */ + translations?: DatePickerTranslations; } /** Props for the DatePicker (single-date mode). */ @@ -54,6 +56,12 @@ export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; /** Which range input is active (focused); null = calendar drives both (selection mode). */ export type ActiveRangePart = 'start' | 'end' | null; +/** Optional translations for DatePicker UI strings. Pass to override defaults. */ +export interface DatePickerTranslations { + /** Label for the clear date button (default: "Clear"). */ + clear?: string; +} + /** Shared state provided by DatePicker via context. */ export interface DatePickerBaseContextValue { isCalendarOpen: boolean; @@ -62,6 +70,8 @@ export interface DatePickerBaseContextValue { locale?: string; disabledDates: Date[]; calendarDialogId: string; + /** UI string overrides (e.g. clear button). */ + translations: Required; /** Start date (range) or selected date (single). */ startOrSelectedDate: Date | null; /** Set selection. Single: (date). Range: (start, end). */ diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx index 5e7cfb5c862..6933ea354f2 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx @@ -22,12 +22,13 @@ export const Default: Story = { render: function DatePickerStory() { const [selectedDate, setSelectedDate] = useState(null); return ( - + ); @@ -55,7 +56,7 @@ export const Range: Story = { const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); return ( - + Date: Tue, 17 Mar 2026 16:07:19 -0400 Subject: [PATCH 030/110] add formgroup --- .../gamut/src/DatePicker/DatePickerInput.tsx | 55 +++++++++++-------- 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx index fd4deb4af0e..4fb6daed2e8 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput.tsx @@ -8,6 +8,7 @@ import { useState, } from 'react'; +import { FormGroup } from '../Form/elements/FormGroup'; import { Input } from '../Form/inputs/Input'; import { formatDateForInput, @@ -138,28 +139,36 @@ export const DatePickerInput = forwardRef< : 'Date'; return ( - { - isInputFocusedRef.current = true; - if (isRange && rangePart) context.setActiveRangePart(rangePart); - }} - onKeyDown={handleKeyDown} - /> + + { + isInputFocusedRef.current = true; + if (isRange && rangePart) context.setActiveRangePart(rangePart); + }} + onKeyDown={handleKeyDown} + /> + ); }); From c9a757630dbd1b0842ff2381fd8adb0660518b4a Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 17 Mar 2026 16:59:33 -0400 Subject: [PATCH 031/110] fix calendar alignment and add shadow --- .../src/DatePicker/Calendar/Calendar.tsx | 29 +++++++++++----- packages/gamut/src/DatePicker/DatePicker.tsx | 34 +++++++++++++++---- .../gamut/src/DatePicker/DatePickerInput.tsx | 4 +-- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/Calendar.tsx b/packages/gamut/src/DatePicker/Calendar/Calendar.tsx index d4a4b2c4672..5aaaa67616e 100644 --- a/packages/gamut/src/DatePicker/Calendar/Calendar.tsx +++ b/packages/gamut/src/DatePicker/Calendar/Calendar.tsx @@ -1,15 +1,26 @@ -import { css } from '@codecademy/gamut-styles'; -import styled from '@emotion/styled'; +import { CheckerDense } from '@codecademy/gamut-patterns'; +import * as React from 'react'; + +import { Box } from '../../Box'; /** * Outer wrapper for the calendar (header + body + footer). * Used by DatePickerCalendar to group the calendar content. + * Renders a CheckerDense pattern shadow at offset left 8, top 8. */ -export const Calendar = styled.div( - css({ - backgroundColor: 'background', - borderRadius: 'lg', - boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)', - width: 'max-content', - }) +export const Calendar: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( + + + + {children} + + ); diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 68b4d572882..8b9022c9307 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -1,4 +1,12 @@ -import { useCallback, useId, useMemo, useRef, useState } from 'react'; +import { MiniArrowRightIcon } from '@codecademy/gamut-icons'; +import { + useCallback, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { FlexBox } from '../Box'; import { PopoverContainer } from '../PopoverContainer'; @@ -37,6 +45,18 @@ export const DatePicker: React.FC = (props) => { const inputRef = useRef(null); const dialogId = useId(); const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; + const popoverOffset = 4; + + // Align popover left edge with input left edge. PopoverContainer's "bottom-right" + // sets popover left = target left + (target width + offset + x), so we pass + // x = -(target width + offset) to get popover left = target left. + const [popoverX, setPopoverX] = useState(0); + useLayoutEffect(() => { + if (isCalendarOpen && inputRef.current) { + const width = inputRef.current.offsetWidth; + setPopoverX(-(width + popoverOffset)); + } + }, [isCalendarOpen, popoverOffset]); const openCalendar = useCallback(() => setIsCalendarOpen(true), []); const closeCalendar = useCallback(() => { @@ -124,6 +144,8 @@ export const DatePicker: React.FC = (props) => { rangePart="start" ref={inputRef} /> + {' '} + {/* hide when they stack */} = (props) => { )} + ); })} From dfdabe8d5e8d20a138e4983be97c4ada0f23c5aa Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 23 Mar 2026 10:55:04 -0400 Subject: [PATCH 042/110] fix alignments --- .../DatePicker/Calendar/CalendarHeader.tsx | 57 ++++++++++++------- .../src/DatePicker/Calendar/utils/format.ts | 43 -------------- packages/gamut/src/DatePicker/DatePicker.tsx | 9 +-- 3 files changed, 41 insertions(+), 68 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx index 4db7ed5da65..ecf33831fb2 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx @@ -42,7 +42,7 @@ export const CalendarHeader: React.FC = ({ }; return ( - + = ({ tip={lastMonth} onClick={handleLastMonth} /> - - {formatMonthYear(currentMonthYear, locale)} - - {secondMonthYear && ( - - {formatMonthYear(secondMonthYear, locale)} - - )} + + + {formatMonthYear(currentMonthYear, locale)} + + + {secondMonthYear && ( + + + {formatMonthYear(secondMonthYear, locale)} + + + )} + { if (parts.length >= 3) return parsed; return null; }; - -const RANGE_SEPARATOR = ' – '; - -/** - * Format a date range for the input (e.g. "2/15/2026 – 2/20/2026"). - */ -export const formatDateRangeForInput = ( - startDate: Date | null, - endDate: Date | null, - locale?: string -) => { - if (!startDate && !endDate) return ''; - if (!startDate) return formatDateForInput(endDate!, locale); - if (!endDate) return formatDateForInput(startDate, locale); - return `${formatDateForInput( - startDate, - locale - )}${RANGE_SEPARATOR}${formatDateForInput(endDate, locale)}`; -}; - -/** - * Parse a range string (e.g. "2/15/2026 – 2/20/2026") into { startDate, endDate }. - * Returns null if invalid. Single date is allowed and yields startDate = endDate. - */ -export const parseDateRangeFromInput = (value: string, locale?: string) => { - const trimmed = value.trim(); - if (!trimmed) return null; - const parts = trimmed.split(RANGE_SEPARATOR).map((part) => part.trim()); - if (parts.length === 1) { - const date = parseDateFromInput(parts[0], locale); - if (!date) return null; - return { startDate: date, endDate: new Date(date) }; - } - if (parts.length === 2) { - const start = parseDateFromInput(parts[0], locale); - const end = parseDateFromInput(parts[1], locale); - if (!start || !end) return null; - return start.getTime() <= end.getTime() - ? { startDate: start, endDate: end } - : { startDate: end, endDate: start }; - } - return null; -}; diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 4b1a5d21f97..a0ba5c6470f 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -1,7 +1,7 @@ import { MiniArrowRightIcon } from '@codecademy/gamut-icons'; import { useCallback, useId, useMemo, useRef, useState } from 'react'; -import { FlexBox } from '../Box'; +import { Box, FlexBox } from '../Box'; import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; import { DatePickerProvider } from './DatePickerContext'; @@ -109,7 +109,7 @@ export const DatePicker: React.FC = (props) => { children ) : ( <> - + {mode === 'range' ? ( <> = (props) => { rangePart="start" ref={inputRef} /> - - {/* hide when they stack */} + + + Date: Mon, 23 Mar 2026 13:46:45 -0400 Subject: [PATCH 043/110] rename props --- .../DatePicker/Calendar/CalendarHeader.tsx | 26 +++++++++---------- .../gamut/src/DatePicker/Calendar/types.ts | 16 ++++++------ .../src/DatePicker/DatePickerCalendar.tsx | 24 ++++++++--------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx index ecf33831fb2..9b0eb69151a 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx @@ -11,9 +11,9 @@ import { CalendarHeaderProps } from './types'; import { formatMonthYear, getRelativeMonthLabels } from './utils/format'; export const CalendarHeader: React.FC = ({ - currentMonthYear, - onCurrentMonthYearChange, - secondMonthYear, + displayDate, + onDisplayDateChange, + secondDisplayDate, onLastMonthClick, onNextMonthClick, locale, @@ -23,21 +23,21 @@ export const CalendarHeader: React.FC = ({ const handleLastMonth = () => { const lastMonth = new Date( - currentMonthYear.getFullYear(), - currentMonthYear.getMonth() - 1, + displayDate.getFullYear(), + displayDate.getMonth() - 1, 1 ); - onCurrentMonthYearChange?.(lastMonth); + onDisplayDateChange?.(lastMonth); onLastMonthClick?.(); }; const handleNextMonth = () => { const nextMonth = new Date( - currentMonthYear.getFullYear(), - currentMonthYear.getMonth() + 1, + displayDate.getFullYear(), + displayDate.getMonth() + 1, 1 ); - onCurrentMonthYearChange?.(nextMonth); + onDisplayDateChange?.(nextMonth); onNextMonthClick?.(); }; @@ -52,7 +52,7 @@ export const CalendarHeader: React.FC = ({ /> @@ -64,10 +64,10 @@ export const CalendarHeader: React.FC = ({ id={headingId} textAlign="center" > - {formatMonthYear(currentMonthYear, locale)} + {formatMonthYear(displayDate, locale)} - {secondMonthYear && ( + {secondDisplayDate && ( = ({ fontWeight="title" textAlign="center" > - {formatMonthYear(secondMonthYear, locale)} + {formatMonthYear(secondDisplayDate, locale)} )} diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index 193440c3eaf..a19af8fff76 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -4,11 +4,11 @@ export interface CalendarHeaderProps { /** Currently displayed month and year (used for heading and prev/next range) */ - currentMonthYear: Date; - /** Called when the user navigates to a different month. Pass the new date (e.g. setVisibleDate) so the calendar updates. */ - onCurrentMonthYearChange: (newDate: Date) => void; + displayDate: Date; + /** Called when the user navigates to a different month. Pass the new date (e.g. setDisplayDate) so the calendar updates. */ + onDisplayDateChange: (newDate: Date) => void; /** Currently displayed second month and year (used for heading and prev/next range) */ - secondMonthYear?: Date; + secondDisplayDate?: Date; /** Called after navigating to previous month; use for click tracking. */ onLastMonthClick?: () => void; /** Called after navigating to next month; use for click tracking. */ @@ -21,7 +21,9 @@ export interface CalendarHeaderProps { export interface CalendarBodyProps { /** The month to display (typically first day of that month) */ - visibleDate: Date; + displayDate: Date; + /** Called when grid keyboard nav changes month (e.g. Page Up/Down). Pass setDisplayDate so the calendar updates. */ + onDisplayDateChange: (newDate: Date) => void; /** Selected start date (single or range start) */ selectedDate: Date | null; /** Selected end date (range only; undefined for single-date mode) */ @@ -40,8 +42,6 @@ export interface CalendarBodyProps { focusedDate: Date | null; /** Callback when focused date changes (e.g. arrow keys) */ onFocusedDateChange: (date: Date | null) => void; - /** Called when grid keyboard nav changes month (e.g. Page Up/Down). Pass setVisibleDate so the calendar updates. */ - onVisibleDateChange: (newDate: Date) => void; /** Called when the escape key is pressed */ onEscapeKeyPress?: () => void; /** When true (e.g. two-month view), arrow keys move focus to adjacent month without changing visible date. */ @@ -59,7 +59,7 @@ export interface CalendarFooterProps { disabled?: boolean; showClearButton?: boolean; locale?: string; - clearText: string; + clearText?: string; onClearDate?: () => void; onTodayClick?: () => void; /** Max 3 quick actions (e.g. "7 days", "1 month") */ diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 9e0e2a4fa8c..d07f8fbcd68 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -58,7 +58,7 @@ export const DatePickerCalendar: React.FC = ({ const firstOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); - const [visibleDate, setVisibleDate] = useState(() => + const [displayDate, setDisplayDate] = useState(() => firstOfMonth(startOrSelectedDate ?? new Date()) ); const [focusedDate, setFocusedDate] = useState( @@ -74,7 +74,7 @@ export const DatePickerCalendar: React.FC = ({ if (!justOpened) return; const anchor = startOrSelectedDate ?? endDate; if (anchor) { - setVisibleDate(firstOfMonth(anchor)); + setDisplayDate(firstOfMonth(anchor)); setFocusedDate(startOrSelectedDate ?? endDate ?? new Date()); } }, [isCalendarOpen, startOrSelectedDate, endDate]); @@ -97,13 +97,13 @@ export const DatePickerCalendar: React.FC = ({ const handleClearDate = () => { setSelection(null); - setFocusedDate(visibleDate); + setFocusedDate(displayDate); }; const handleTodayClick = () => { const today = new Date(); setSelection(today); - setVisibleDate(firstOfMonth(today)); + setDisplayDate(firstOfMonth(today)); setFocusedDate(today); }; @@ -112,7 +112,7 @@ export const DatePickerCalendar: React.FC = ({ const addMonths = (date: Date, n: number) => new Date(date.getFullYear(), date.getMonth() + n, 1); - const secondMonthDate = addMonths(visibleDate, 1); + const secondMonthDate = addMonths(displayDate, 1); const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.xs})`); @@ -120,43 +120,43 @@ export const DatePickerCalendar: React.FC = ({ From a6c8388794dc4697bfaa9144bbdc483254d764f8 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 23 Mar 2026 13:46:59 -0400 Subject: [PATCH 044/110] move around helpers --- .../src/DatePicker/Calendar/CalendarBody.tsx | 15 ++++----- .../src/DatePicker/Calendar/utils/dateGrid.ts | 11 +++++++ .../DatePicker/Calendar/utils/keyHandler.ts | 31 ++++++------------- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 8c351ee1548..4e181e5fe59 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -6,13 +6,14 @@ import * as React from 'react'; import { TextButton } from '../../Button'; import { CalendarBodyProps } from './types'; import { + getDatesWithRow, getMonthGrid, isDateDisabled, isDateInRange, isSameDay, } from './utils/dateGrid'; import { getWeekdayNames } from './utils/format'; -import { getDatesWithRow, keyHandler } from './utils/keyHandler'; +import { keyHandler } from './utils/keyHandler'; const TableHeader = styled.th( css({ @@ -87,7 +88,7 @@ const DateButton = styled(TextButton)( ); export const CalendarBody: React.FC = ({ - visibleDate, + displayDate, selectedDate, endDate = null, disabledDates = [], @@ -97,13 +98,13 @@ export const CalendarBody: React.FC = ({ labelledById, focusedDate, onFocusedDateChange, - onVisibleDateChange, + onDisplayDateChange, onEscapeKeyPress, hasAdjacentMonthRight, hasAdjacentMonthLeft, }) => { - const year = visibleDate.getFullYear(); - const month = visibleDate.getMonth(); + const year = displayDate.getFullYear(); + const month = displayDate.getMonth(); const weeks = getMonthGrid(year, month, weekStartsOn); const weekdayLabels = getWeekdayNames('short', locale, weekStartsOn); const weekdayFullNames = getWeekdayNames('long', locale, weekStartsOn); @@ -143,7 +144,7 @@ export const CalendarBody: React.FC = ({ disabledDates, onDateSelect, onEscapeKeyPress, - onVisibleDateChange, + onDisplayDateChange, hasAdjacentMonthRight, hasAdjacentMonthLeft ), @@ -155,7 +156,7 @@ export const CalendarBody: React.FC = ({ disabledDates, onDateSelect, onEscapeKeyPress, - onVisibleDateChange, + onDisplayDateChange, hasAdjacentMonthLeft, hasAdjacentMonthRight, ] diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts index ca7376262c3..9aee77a8d5c 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts @@ -102,3 +102,14 @@ export const isDateInRange = ( export const isDateDisabled = (date: Date, disabledDates: Date[] = []) => { return disabledDates.some((d) => isSameDay(date, d)); }; + +/** Flat list of dates in grid order (row-major, non-null only) with row index for Home/End */ +export const getDatesWithRow = (weeks: (Date | null)[][]) => { + const result: { date: Date; rowIndex: number }[] = []; + weeks.forEach((week, rowIndex) => { + week.forEach((date) => { + if (date !== null) result.push({ date, rowIndex }); + }); + }); + return result; +}; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts b/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts index e765f6d5cbe..9b84b59ad78 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts @@ -3,22 +3,11 @@ import { isDateDisabled } from './dateGrid'; /** * Clamp a day to the last day of the given month (e.g. Jan 31 -> Feb 28). */ -export const clampToMonth = (year: number, month: number, day: number) => { +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)); }; -/** Flat list of dates in grid order (row-major, non-null only) with row index for Home/End */ -export const getDatesWithRow = (weeks: (Date | null)[][]) => { - const result: { date: Date; rowIndex: number }[] = []; - weeks.forEach((week, rowIndex) => { - week.forEach((date) => { - if (date !== null) result.push({ date, rowIndex }); - }); - }); - return result; -}; - export const keyHandler = ( e: React.KeyboardEvent, date: Date, @@ -29,7 +18,7 @@ export const keyHandler = ( disabledDates: Date[], onDateSelect: (date: Date) => void, onEscapeKeyPress?: () => void, - onVisibleDateChange?: (newDate: Date) => void, + onDisplayDateChange?: (newDate: Date) => void, /** When true, adjacent month to the right is visible; don't change visible date when moving focus there. */ hasAdjacentMonthRight?: boolean, /** When true, adjacent month to the left is visible; don't change visible date when moving focus there. */ @@ -46,7 +35,7 @@ export const keyHandler = ( const hasLeft = !!hasAdjacentMonthLeft; let newDate: Date | null = null; - let newVisibleDate: Date | null = null; + let newDisplayDate: Date | null = null; switch (e.key) { case 'ArrowLeft': @@ -57,7 +46,7 @@ export const keyHandler = ( const lastDayPrevMonth = new Date(year, month, 0); newDate = lastDayPrevMonth; if (!hasLeft) { - newVisibleDate = new Date(year, month - 1, 1); + newDisplayDate = new Date(year, month - 1, 1); } } break; @@ -68,7 +57,7 @@ export const keyHandler = ( } else { newDate = new Date(year, month + 1, 1); if (!hasRight) { - newVisibleDate = new Date(year, month + 1, 1); + newDisplayDate = new Date(year, month + 1, 1); } } break; @@ -78,7 +67,7 @@ export const keyHandler = ( newDate.setDate(newDate.getDate() - 7); if (newDate.getMonth() !== month || newDate.getFullYear() !== year) { if (!hasLeft) { - newVisibleDate = new Date( + newDisplayDate = new Date( newDate.getFullYear(), newDate.getMonth(), 1 @@ -92,7 +81,7 @@ export const keyHandler = ( newDate.setDate(newDate.getDate() + 7); if (newDate.getMonth() !== month || newDate.getFullYear() !== year) { if (!hasRight) { - newVisibleDate = new Date( + newDisplayDate = new Date( newDate.getFullYear(), newDate.getMonth(), 1 @@ -120,7 +109,7 @@ export const keyHandler = ( } else { newDate = clampToMonth(year, month + 1, day); } - newVisibleDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); + newDisplayDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); break; case 'PageUp': e.preventDefault(); @@ -129,7 +118,7 @@ export const keyHandler = ( } else { newDate = clampToMonth(year, month - 1, day); } - newVisibleDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); + newDisplayDate = new Date(newDate.getFullYear(), newDate.getMonth(), 1); break; case 'Enter': case ' ': @@ -146,6 +135,6 @@ export const keyHandler = ( if (newDate !== null) { onFocusedDateChange(newDate); - if (newVisibleDate !== null) onVisibleDateChange?.(newVisibleDate); + if (newDisplayDate !== null) onDisplayDateChange?.(newDisplayDate); } }; From 3bfab02d5ec664af500c867df6c65c600e588b94 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 23 Mar 2026 13:47:06 -0400 Subject: [PATCH 045/110] fix calendar story --- .../DatePicker/Calendar/CalendarFooter.tsx | 3 +- .../Molecules/DatePicker/Calendar.stories.tsx | 45 +++++++++++-------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index eac7f53442c..f03253b0eca 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { FlexBox } from '../../Box'; import { TextButton } from '../../Button'; +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../translations'; import { CalendarFooterProps } from './types'; import { getRelativeTodayLabel } from './utils/format'; @@ -30,7 +31,7 @@ export const CalendarFooter: React.FC = ({ onClearDate, onTodayClick, locale, - clearText, + clearText = DEFAULT_DATE_PICKER_TRANSLATIONS.clearText, disabled, showClearButton, }) => { diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx index ec93ffd864c..811e30ca463 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx @@ -1,4 +1,5 @@ import { + Box, Calendar, CalendarBody, CalendarFooter, @@ -19,7 +20,7 @@ type Story = StoryObj; export const Default: Story = { render: function CalendarStory() { const headingId = useId(); - const [visibleDate, setVisibleDate] = useState(() => new Date()); + const [displayDate, setDisplayDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(null); const [focusedDate, setFocusedDate] = useState( () => new Date() @@ -27,25 +28,31 @@ export const Default: Story = { return ( - - + + + + setSelectedDate(null)} + onTodayClick={() => { + const today = new Date(); + setSelectedDate(today); + setDisplayDate(today); + setFocusedDate(today); + }} /> ); From afaea714a1cdf7e7831b65cb4ba06a407cac6b94 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 23 Mar 2026 14:03:36 -0400 Subject: [PATCH 046/110] support small input size --- packages/gamut/src/DatePicker/DatePicker.tsx | 6 ++++- packages/gamut/src/DatePicker/types.ts | 5 ++++ .../DatePicker/DatePicker.stories.tsx | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index a0ba5c6470f..52afaef861b 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -30,6 +30,7 @@ export const DatePicker: React.FC = (props) => { mode, children, translations: translationsProp, + inputSize, } = props; const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [activeRangePart, setActiveRangePart] = useState< @@ -109,7 +110,7 @@ export const DatePicker: React.FC = (props) => { children ) : ( <> - + {mode === 'range' ? ( <> = (props) => { placeholder={placeholder} rangePart="start" ref={inputRef} + size={inputSize} /> @@ -125,6 +127,7 @@ export const DatePicker: React.FC = (props) => { label={props.endLabel} placeholder={placeholder} rangePart="end" + size={inputSize} // does this need a ref? /> @@ -133,6 +136,7 @@ export const DatePicker: React.FC = (props) => { label={props.label} placeholder={placeholder} ref={inputRef} + size={inputSize} /> )} diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 37a338643bd..83d7a1fb754 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -2,6 +2,10 @@ * Public and internal types for the DatePicker (single-date and range). */ +import { ComponentProps } from 'react'; + +import { Input } from '../Form/inputs/Input'; + /** Result of custom validation; null means valid. */ export interface DatePickerValidationResult { errorMessage: string; @@ -20,6 +24,7 @@ export interface DatePickerBaseProps { placeholder?: string; /** Override UI strings (e.g. clear button). Merged with defaults. */ translations?: DatePickerTranslations; + inputSize?: ComponentProps['size']; } /** Props for the DatePicker (single-date mode). */ diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx index 26c23306636..8f17cc84aa3 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx @@ -42,6 +42,7 @@ export const WithInitialDate: Story = { ); return ( (null); + const [endDate, setEndDate] = useState(null); + return ( + + + + ); + }, +}; + /** * Composed usage: DatePicker with children provides shared state via context. * The child uses useDatePicker() to get open/close and inputRef, then composes From e4e15be2d50d408a10ada9d4d6a4779e637ac03b Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 25 Mar 2026 13:16:32 -0400 Subject: [PATCH 047/110] initial focus a11y updates --- .../src/DatePicker/Calendar/CalendarBody.tsx | 47 ++++++++++++++++--- .../gamut/src/DatePicker/Calendar/types.ts | 32 ++++--------- packages/gamut/src/DatePicker/DatePicker.tsx | 33 ++++++++++++- .../src/DatePicker/DatePickerCalendar.tsx | 16 ++++++- .../gamut/src/DatePicker/DatePickerInput.tsx | 22 ++++----- packages/gamut/src/DatePicker/index.tsx | 1 + packages/gamut/src/DatePicker/types.ts | 22 ++++++++- 7 files changed, 128 insertions(+), 45 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 4e181e5fe59..d60c615da3a 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -102,6 +102,7 @@ export const CalendarBody: React.FC = ({ onEscapeKeyPress, hasAdjacentMonthRight, hasAdjacentMonthLeft, + focusGridSync, }) => { const year = displayDate.getFullYear(); const month = displayDate.getMonth(); @@ -109,6 +110,7 @@ export const CalendarBody: React.FC = ({ const weekdayLabels = getWeekdayNames('short', locale, weekStartsOn); const weekdayFullNames = getWeekdayNames('long', locale, weekStartsOn); const buttonRefs = useRef>(new Map()); + const tableRef = useRef(null); const datesWithRow = useMemo(() => getDatesWithRow(weeks), [weeks]); const focusTarget = focusedDate ?? selectedDate; @@ -118,19 +120,47 @@ export const CalendarBody: React.FC = ({ [] ); - const focusButton = useCallback((date: Date | null) => { - if (date === null) return; + const focusButton = useCallback((date: Date | null): boolean => { + if (date === null) return false; const key = new Date( date.getFullYear(), date.getMonth(), date.getDate() ).getTime(); - buttonRefs.current.get(key)?.focus(); + const el = buttonRefs.current.get(key); + if (!el) return false; + el.focus(); + return true; }, []); useEffect(() => { - if (focusTarget !== null) focusButton(focusTarget); - }, [focusTarget, focusButton]); + // Keep the roving tabindex / focused day aligned with `focusTarget` when it makes sense for a11y. + if (focusTarget === null) return; + + // Standalone calendar (e.g. Storybook): always move DOM focus to the active day. + if (!focusGridSync) { + focusButton(focusTarget); + return; + } + + const inGrid = tableRef.current?.contains(document.activeElement); + const requested = focusGridSync.gridFocusRequested; + + // Focus is already in this grid (keyboard nav): update which day is focused as `focusTarget` changes. + if (inGrid) { + focusButton(focusTarget); + return; + } + + // DatePicker opened via keyboard / ArrowDown: parent asked to move focus into the grid once. + if (requested) { + const success = focusButton(focusTarget); + if (success) { + focusGridSync.onGridFocusRequestHandled(); + } + } + // If !inGrid && !requested (e.g. calendar opened with the mouse): leave focus on the input — do not call focusButton. + }, [focusTarget, focusButton, focusGridSync]); const handleKeyDown = useCallback( (e: React.KeyboardEvent, date: Date) => @@ -173,7 +203,12 @@ export const CalendarBody: React.FC = ({ }, []); return ( -
= ({ role="gridcell" > setButtonRef(date, el as HTMLElement | null)} variant="secondary" width="36px" disabled={disabled} @@ -92,7 +230,9 @@ export const CalendarBody: React.FC = ({ isSelected={selected} isInRange={inRange} onClick={() => onDateSelect(date)} - tabIndex={selected ? 0 : -1} // isnt entirely right + tabIndex={isFocused ? 0 : -1} + onKeyDown={(e: React.KeyboardEvent) => handleKeyDown(e, date)} + onFocus={() => onFocusedDateChange?.(date)} > {date.getDate()} diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index ee1593add4a..a5e39b8726f 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -32,12 +32,14 @@ export interface CalendarBodyProps { locale?: string; /** 0 = Sunday, 1 = Monday (default from locale if not set) */ weekStartsOn?: 0 | 1; + /** Id of the month/year heading (aria-labelledby on grid) */ + labelledById?: string; /** For keyboard nav: which cell has focus (roving tabindex) */ focusedDate?: Date | null; /** Callback when focused date changes (e.g. arrow keys) */ onFocusedDateChange?: (date: Date | null) => void; - /** Id of the month/year heading (aria-labelledby on grid) */ - labelledById?: string; + /** Called when grid keyboard nav changes month (e.g. Page Up/Down). Pass setVisibleDate so the calendar updates. */ + onVisibleDateChange?: (newDate: Date) => void; } export interface QuickAction { diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts index 745dfbc407a..683452ddef0 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts @@ -101,3 +101,11 @@ export function isDateDisabled( ): boolean { return disabledDates.some((d) => isSameDay(date, d)); } + +/** + * Clamp a day to the last day of the given month (e.g. Jan 31 -> Feb 28). + */ +export function clampToMonth(year: number, month: number, day: number): Date { + const last = new Date(year, month + 1, 0).getDate(); + return new Date(year, month, Math.min(day, last)); +} diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx index 8643423dd9a..b6a49712f18 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx @@ -21,6 +21,7 @@ export const Default: Story = { const headingId = useId(); const [visibleDate, setVisibleDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(null); + const [focusedDate, setFocusedDate] = useState(() => new Date()); return ( @@ -34,6 +35,9 @@ export const Default: Story = { visibleDate={visibleDate} selectedDate={selectedDate} onDateSelect={setSelectedDate} + focusedDate={focusedDate} + onFocusedDateChange={setFocusedDate} + onVisibleDateChange={setVisibleDate} labelledById={headingId} locale="en-US" /> From b8a856db5e1e86cbd5e492db98f8b049519e6acb Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 4 Mar 2026 13:52:16 -0500 Subject: [PATCH 003/110] add input --- .../gamut/src/DatePicker/DatePickerInput.tsx | 30 +++++++++++++++++++ packages/gamut/src/DatePicker/index.tsx | 6 ++-- 2 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 packages/gamut/src/DatePicker/DatePickerInput.tsx diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx new file mode 100644 index 00000000000..269d46050f1 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput.tsx @@ -0,0 +1,30 @@ +import { CalendarIcon } from '@codecademy/gamut-icons'; +import { ComponentProps, forwardRef } from 'react'; + +import { Input } from '../Form/inputs/Input'; + +/** + * Props for DatePickerInput. Extends all Input props except `type` and `icon`, + * which are fixed: the input is always type="text" (for formatted date display) + * and shows CalendarIcon. + */ +export type DatePickerInputProps = Omit< + ComponentProps, + 'type' | 'icon' +>; + +/** + * A controlled, presentational date input. Wraps the form Input with type="text" + * and CalendarIcon. The parent (or useDatePicker) is responsible for formatting + * the date value for display and parsing manual entry. + * + * Use for single-date (pass value/onChange for one date) or as the start/end + * field in range mode (two instances with distinct labels, e.g. "Start date" / "End date"). + */ +export const DatePickerInput = forwardRef( + (props, ref) => { + return ; + } +); + +DatePickerInput.displayName = 'DatePickerInput'; diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index a9653fd9d21..03ee44cb6e6 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -1,7 +1,9 @@ /** - * DatePicker – Calendar components for use by DatePickerCalendar. - * Full DatePicker provider and DatePickerInput will be added in a follow-up. + * DatePicker – Calendar, DatePickerInput, and related components. + * DatePicker provider and DatePickerCalendar will be added in a follow-up. */ +export { DatePickerInput } from './DatePickerInput'; +export type { DatePickerInputProps } from './DatePickerInput'; export { Calendar, CalendarHeader, From c096fe9f5641098dd3b368bf177863886cd2be57 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 4 Mar 2026 15:14:12 -0500 Subject: [PATCH 004/110] datepicker --- .../src/DatePicker/Calendar/utils/format.ts | 27 +++ packages/gamut/src/DatePicker/DatePicker.tsx | 84 +++++++ .../src/DatePicker/DatePickerCalendar.tsx | 68 ++++++ packages/gamut/src/DatePicker/index.tsx | 13 +- .../gamut/src/DatePicker/useDatePicker.ts | 212 ++++++++++++++++++ .../DatePicker/DatePicker.stories.tsx | 44 ++++ 6 files changed, 446 insertions(+), 2 deletions(-) create mode 100644 packages/gamut/src/DatePicker/DatePicker.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerCalendar.tsx create mode 100644 packages/gamut/src/DatePicker/useDatePicker.ts create mode 100644 packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index 78430f01a8e..fd9f7fbd4f6 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -58,3 +58,30 @@ export function getWeekdayFullNames( } return names; } + +/** + * Format a date for display in the date picker input (e.g. "2/15/2026"). + */ +export function formatDateForInput(date: Date, locale?: string): string { + return new Intl.DateTimeFormat(locale ?? 'en-US', { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }).format(date); +} + +/** + * Parse a string from the date input into a Date, or null if invalid. + * Uses Intl.DateTimeFormat to parse in a locale-aware way where possible; + * falls back to Date parsing for simple numeric formats. + */ +export function parseDateFromInput( + value: string, + locale?: string +): Date | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parsed = new Date(trimmed); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +} diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx new file mode 100644 index 00000000000..956f7398181 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -0,0 +1,84 @@ +import { useRef } from 'react'; + +import { Box } from '../Box'; +import { PopoverContainer } from '../PopoverContainer'; +import { DatePickerCalendar } from './DatePickerCalendar'; +import { DatePickerInput } from './DatePickerInput'; +import type { DatePickerProps } from './types'; +import { useDatePicker } from './useDatePicker'; + +/** + * Single-date DatePicker. With no children, renders the default layout: + * one DatePickerInput and a calendar in a popover (open via Down Arrow or click). + * With children, only provides state via useDatePicker; compose DatePickerInput + * and DatePickerCalendar yourself. + */ +export function DatePicker({ + selectedDate, + onDateSelect, + locale, + disabledDates, + placeholder, + label, + id, + children, +}: DatePickerProps) { + const { + inputProps: rawInputProps, + calendarProps, + isCalendarOpen, + openCalendar, + closeCalendar, + calendarDialogId, + } = useDatePicker({ + selectedDate, + onDateSelect, + locale, + disabledDates, + }); + + const containerRef = useRef(null); + const { inputRef, ...restInputProps } = rawInputProps; + + const inputProps = { + ...restInputProps, + placeholder: placeholder ?? rawInputProps.placeholder, + label: label ?? rawInputProps.label, + id: id ?? rawInputProps.id, + }; + + if (children !== undefined) { + return <>{children}; + } + + return ( + <> + { + if (e.key === 'ArrowDown' || e.key === 'Down') { + e.preventDefault(); + openCalendar(); + } + }} + width="fit-content" + > + + + + + + + ); +} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx new file mode 100644 index 00000000000..56bb9fcd19b --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -0,0 +1,68 @@ +import { useId } from 'react'; + +import { + Calendar, + CalendarBody, + CalendarFooter, + CalendarHeader, +} from './Calendar'; +import type { UseDatePickerReturn } from './types'; + +/** Props for the presentational DatePickerCalendar (single-date). */ +export type DatePickerCalendarProps = UseDatePickerReturn['calendarProps'] & { + /** Optional id for the dialog (for aria-controls from input). */ + dialogId?: string; +}; + +/** + * Presentational calendar that composes Calendar, CalendarHeader, CalendarBody, + * and CalendarFooter. Used inside a popover; does not manage open/close state. + */ +export function DatePickerCalendar({ + visibleDate, + onVisibleDateChange, + selectedDate, + onDateSelect, + disabledDates, + focusedDate, + onFocusedDateChange, + onClearDate, + onTodayClick, + locale = 'en-US', + weekStartsOn = 0, + dialogId: dialogIdProp, +}: DatePickerCalendarProps) { + const generatedId = useId(); + const headingId = dialogIdProp ?? `datepicker-calendar-${generatedId.replace(/:/g, '')}`; + + return ( + + + + + date === null ? onClearDate() : onDateSelect(date) + } + onCurrentMonthYearChange={onVisibleDateChange} + onClearDate={onClearDate} + onTodayClick={onTodayClick} + /> + + ); +} diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index 03ee44cb6e6..f877376f5ce 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -1,9 +1,18 @@ /** - * DatePicker – Calendar, DatePickerInput, and related components. - * DatePicker provider and DatePickerCalendar will be added in a follow-up. + * DatePicker – Single-date picker with input + calendar popover. */ +export { DatePicker } from './DatePicker'; +export { DatePickerCalendar } from './DatePickerCalendar'; +export type { DatePickerCalendarProps } from './DatePickerCalendar'; export { DatePickerInput } from './DatePickerInput'; export type { DatePickerInputProps } from './DatePickerInput'; +export { useDatePicker } from './useDatePicker'; +export type { + DatePickerProps, + UseDatePickerArgs, + UseDatePickerReturn, + UseDatePickerInputProps, +} from './types'; export { Calendar, CalendarHeader, diff --git a/packages/gamut/src/DatePicker/useDatePicker.ts b/packages/gamut/src/DatePicker/useDatePicker.ts new file mode 100644 index 00000000000..76a69284969 --- /dev/null +++ b/packages/gamut/src/DatePicker/useDatePicker.ts @@ -0,0 +1,212 @@ +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { + formatDateForInput, + parseDateFromInput, +} from './Calendar/utils/format'; +import type { UseDatePickerArgs, UseDatePickerReturn } from './types'; + +/** + * Hook for single-date DatePicker state and props. Use with DatePicker, + * DatePickerInput, and DatePickerCalendar (or the default DatePicker layout). + */ +export function useDatePicker( + args: UseDatePickerArgs = {} +): UseDatePickerReturn { + const { + selectedDate: controlledDate, + onDateSelect: onDateSelectProp, + locale = 'en-US', + disabledDates = [], + } = args; + + const [internalDate, setInternalDate] = useState(null); + const selectedDate = + controlledDate !== undefined ? controlledDate : internalDate; + const setSelectedDate = useCallback( + (date: Date | null) => { + if (onDateSelectProp) onDateSelectProp(date); + if (controlledDate === undefined) setInternalDate(date); + }, + [controlledDate, onDateSelectProp] + ); + + const [visibleDate, setVisibleDate] = useState(() => { + const d = selectedDate ?? new Date(); + return new Date(d.getFullYear(), d.getMonth(), 1); + }); + const [focusedDate, setFocusedDate] = useState( + () => selectedDate ?? new Date() + ); + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [inputValue, setInputValue] = useState(() => + selectedDate ? formatDateForInput(selectedDate, locale) : '' + ); + const inputRef = useRef(null); + const isInputFocusedRef = useRef(false); + const dialogId = useId(); + + const formattedValue = useMemo( + () => (selectedDate ? formatDateForInput(selectedDate, locale) : ''), + [selectedDate, locale] + ); + + // Sync input display when selectedDate changes from outside the input (e.g. calendar selection). + // Don't overwrite while the user is typing (input focused). + useEffect(() => { + if (!isInputFocusedRef.current) { + setInputValue(formattedValue); + } + }, [formattedValue]); + + const openCalendar = useCallback(() => setIsCalendarOpen(true), []); + const closeCalendar = useCallback(() => { + setIsCalendarOpen(false); + inputRef.current?.focus(); + }, []); + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const raw = e.target.value; + setInputValue(raw); + if (!raw.trim()) { + setSelectedDate(null); + return; + } + const parsed = parseDateFromInput(raw, locale); + if (parsed) setSelectedDate(parsed); + }, + [locale, setSelectedDate] + ); + + const handleInputBlur = useCallback(() => { + isInputFocusedRef.current = false; + const trimmed = inputValue.trim(); + if (!trimmed) { + setSelectedDate(null); + return; + } + const parsed = parseDateFromInput(trimmed, locale); + if (parsed) { + setSelectedDate(parsed); + setInputValue(formatDateForInput(parsed, locale)); + } else { + setInputValue(formattedValue); + } + }, [inputValue, locale, formattedValue, setSelectedDate]); + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown' || e.key === 'Down') { + e.preventDefault(); + openCalendar(); + } + }, + [openCalendar] + ); + + const handleDateSelect = useCallback( + (date: Date) => { + setInputValue(formatDateForInput(date, locale)); + setSelectedDate(date); + }, + [locale, setSelectedDate] + ); + + const handleClearDate = useCallback(() => { + setSelectedDate(null); + setFocusedDate(visibleDate); + }, [visibleDate]); + + const handleTodayClick = useCallback(() => { + const today = new Date(); + setSelectedDate(today); + setVisibleDate(new Date(today.getFullYear(), today.getMonth(), 1)); + setFocusedDate(today); + }, []); + + const datePickerProps = useMemo( + () => ({ + selectedDate, + onDateSelect: setSelectedDate, + locale, + disabledDates, + }), + [selectedDate, setSelectedDate, locale, disabledDates] + ); + + const dialogIdSafe = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; + + const inputProps = useMemo( + (): UseDatePickerReturn['inputProps'] => ({ + value: inputValue, + onChange: handleInputChange, + onFocus: () => { + isInputFocusedRef.current = true; + }, + onBlur: handleInputBlur, + onKeyDown: handleInputKeyDown, + onClick: openCalendar, + label: 'Date', + id: dialogId, + placeholder: 'MM/DD/YYYY', + inputRef, + 'aria-expanded': isCalendarOpen, + 'aria-controls': dialogIdSafe, + 'aria-haspopup': 'dialog', + }), + [ + inputValue, + handleInputChange, + handleInputBlur, + handleInputKeyDown, + openCalendar, + isCalendarOpen, + dialogId, + dialogIdSafe, + ] + ); + + const calendarProps = useMemo( + () => ({ + visibleDate, + onVisibleDateChange: setVisibleDate, + selectedDate, + onDateSelect: handleDateSelect, + disabledDates, + focusedDate, + onFocusedDateChange: setFocusedDate, + onClearDate: handleClearDate, + onTodayClick: handleTodayClick, + locale, + weekStartsOn: 0 as 0 | 1, + }), + [ + visibleDate, + selectedDate, + handleDateSelect, + disabledDates, + focusedDate, + handleClearDate, + handleTodayClick, + locale, + ] + ); + + return { + datePickerProps, + inputProps, + calendarProps, + isCalendarOpen, + openCalendar, + closeCalendar, + calendarDialogId: dialogIdSafe, + }; +} diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx new file mode 100644 index 00000000000..0655ed80238 --- /dev/null +++ b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx @@ -0,0 +1,44 @@ +import { Box, DatePicker } from '@codecademy/gamut'; +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; + +const meta: Meta = { + component: DatePicker, + title: 'Molecules/DatePicker/DatePicker', +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: function DatePickerStory() { + const [selectedDate, setSelectedDate] = useState(null); + return ( + + + + ); + }, +}; + +export const WithInitialDate: Story = { + render: function DatePickerStory() { + const [selectedDate, setSelectedDate] = useState( + () => new Date(2026, 1, 15) + ); + return ( + + ); + }, +}; From 9346ac96463c22ab2969ca37a4b23b6e17219db0 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 5 Mar 2026 11:15:10 -0500 Subject: [PATCH 005/110] clean up --- packages/gamut/src/DatePicker/DatePicker.tsx | 18 ++---------------- packages/gamut/src/DatePicker/useDatePicker.ts | 4 +++- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 956f7398181..50b8f489c99 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -1,5 +1,3 @@ -import { useRef } from 'react'; - import { Box } from '../Box'; import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; @@ -27,7 +25,6 @@ export function DatePicker({ inputProps: rawInputProps, calendarProps, isCalendarOpen, - openCalendar, closeCalendar, calendarDialogId, } = useDatePicker({ @@ -37,7 +34,6 @@ export function DatePicker({ disabledDates, }); - const containerRef = useRef(null); const { inputRef, ...restInputProps } = rawInputProps; const inputProps = { @@ -53,17 +49,7 @@ export function DatePicker({ return ( <> - { - if (e.key === 'ArrowDown' || e.key === 'Down') { - e.preventDefault(); - openCalendar(); - } - }} - width="fit-content" - > +
= ({ isInRange={inRange} onClick={() => onDateSelect(date)} tabIndex={isFocused ? 0 : -1} - onKeyDown={(e: React.KeyboardEvent) => handleKeyDown(e, date)} + onKeyDown={(e: React.KeyboardEvent) => + handleKeyDown(e, date) + } onFocus={() => onFocusedDateChange?.(date)} > {date.getDate()} diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index a5e39b8726f..528e72c78df 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -40,6 +40,8 @@ export interface CalendarBodyProps { onFocusedDateChange?: (date: Date | null) => void; /** Called when grid keyboard nav changes month (e.g. Page Up/Down). Pass setVisibleDate so the calendar updates. */ onVisibleDateChange?: (newDate: Date) => void; + /** Called when the escape key is pressed */ + onEscapeKeyPress?: () => void; } export interface QuickAction { diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index 2696a8937ae..7c745ac1785 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -91,3 +91,46 @@ export function parseDateFromInput( if (parts.length >= 3) return parsed; return null; } + +const RANGE_SEPARATOR = ' – '; + +/** + * Format a date range for the input (e.g. "2/15/2026 – 2/20/2026"). + */ +export function formatDateRangeForInput( + startDate: Date | null, + endDate: Date | null, + locale?: string +): string { + if (!startDate && !endDate) return ''; + if (!startDate) return formatDateForInput(endDate!, locale); + if (!endDate) return formatDateForInput(startDate, locale); + return `${formatDateForInput(startDate, locale)}${RANGE_SEPARATOR}${formatDateForInput(endDate, locale)}`; +} + +/** + * Parse a range string (e.g. "2/15/2026 – 2/20/2026") into { startDate, endDate }. + * Returns null if invalid. Single date is allowed and yields startDate = endDate. + */ +export function parseDateRangeFromInput( + value: string, + locale?: string +): { startDate: Date; endDate: Date } | null { + const trimmed = value.trim(); + if (!trimmed) return null; + const parts = trimmed.split(RANGE_SEPARATOR).map((s) => s.trim()); + if (parts.length === 1) { + const d = parseDateFromInput(parts[0], locale); + if (!d) return null; + return { startDate: d, endDate: new Date(d) }; + } + if (parts.length === 2) { + const start = parseDateFromInput(parts[0], locale); + const end = parseDateFromInput(parts[1], locale); + if (!start || !end) return null; + return start.getTime() <= end.getTime() + ? { startDate: start, endDate: end } + : { startDate: end, endDate: start }; + } + return null; +} diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 453fc60e8f7..d33f012c3f2 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -1,36 +1,33 @@ -import { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useId, useMemo, useRef, useState } from 'react'; -import { Box } from '../Box'; +import { FlexBox } from '../Box'; import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; import { DatePickerProvider } from './DatePickerContext'; import { DatePickerInput } from './DatePickerInput'; -import type { DatePickerContextValue, DatePickerProps } from './types'; +import type { + DatePickerContextValue, + DatePickerRangeProps, + DatePickerProps, +} from './types'; + +function isRangeProps(props: DatePickerProps): props is DatePickerRangeProps { + return props.mode === 'range'; +} /** - * Single-date DatePicker. Holds shared state (selectedDate, isCalendarOpen, inputRef) - * and provides it via context. DatePickerInput and DatePickerCalendar own their - * specific state and update this shared state when needed. - * With no children, renders the default layout (input + calendar popover). - * With children, renders only children so you can compose the layout yourself. + * DatePicker: single-date or range. Holds shared state and provides it via context. + * Single: selectedDate, setSelectedDate. Range: startDate, endDate, setStartDate, setEndDate. + * With no children, renders default layout (input + calendar popover). */ -export function DatePicker({ - selectedDate, - setSelectedDate, - locale = 'en-US', - disabledDates = [], - placeholder, - label, - id, - children, -}: DatePickerProps) { +export const DatePicker: React.FC = (props) => { + const { + locale = 'en-US', + disabledDates = [], + placeholder, + mode, + children, + } = props; const [isCalendarOpen, setIsCalendarOpen] = useState(false); const inputRef = useRef(null); const dialogId = useId(); @@ -42,20 +39,41 @@ export function DatePicker({ inputRef.current?.focus(); }, []); + // do we want to refer to this as startDate or selectedDate internally? its the selected date in single mode and the start date in range mode + const startDate = isRangeProps(props) ? props.startDate : props.selectedDate; + const endDate = isRangeProps(props) ? props.endDate : null; // null vs undefined? + + const setSelection = useCallback( + (start: Date | null, end?: Date | null) => { + if (isRangeProps(props)) { + props.setStartDate(start); + props.setEndDate(end ?? null); + } else { + props.setSelectedDate(start); + } + }, + [props] + ); + const contextValue = useMemo( () => ({ - selectedDate, - setSelectedDate, + mode: mode ?? 'single', + selectedDate: startDate, + startDate, + endDate, + setSelection, isCalendarOpen, openCalendar, closeCalendar, locale, disabledDates, - calendarDialogId, // do we need this in context? or just pass it as props? does that defeat the purpose of the context? + calendarDialogId, }), [ - selectedDate, - setSelectedDate, + mode, + startDate, + endDate, + setSelection, isCalendarOpen, openCalendar, closeCalendar, @@ -65,25 +83,42 @@ export function DatePicker({ ] ); - useEffect(() => { - if (!isCalendarOpen) return; - const id = setTimeout(() => inputRef.current?.focus(), 0); - return () => clearTimeout(id); - }, [isCalendarOpen]); + // what is this doing + // useEffect(() => { + // if (!isCalendarOpen) return; + // const id = setTimeout(() => inputRef.current?.focus(), 0); + // return () => clearTimeout(id); + // }, [isCalendarOpen]); const content = children !== undefined ? ( children ) : ( <> - - - + + {mode === 'range' ? ( + <> + + + + ) : ( + + )} + {content} ); -} +}; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 75f3fd4931d..9402125febf 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -18,18 +18,19 @@ export type DatePickerCalendarProps = { /** * Calendar that composes Calendar, CalendarHeader, CalendarBody, CalendarFooter. * When inside DatePicker: owns local visibleDate and focusedDate; updates shared - * selectedDate via context on select/clear/today. When outside DatePicker: receives - * all props from parent (standalone mode). + * state via context. Supports single-date and range modes. */ -export function DatePickerCalendar(props: DatePickerCalendarProps) { +export const DatePickerCalendar: React.FC = ({ + dialogId, + weekStartsOn = 0, +}) => { const context = useDatePicker(); const generatedId = useId(); const fallbackDialogId = `datepicker-calendar-${generatedId.replace( /:/g, '' )}`; - const headingId = - props.dialogId ?? context?.calendarDialogId ?? fallbackDialogId; + const headingId = dialogId ?? context?.calendarDialogId ?? fallbackDialogId; if (context == null) { throw new Error( @@ -37,41 +38,70 @@ export function DatePickerCalendar(props: DatePickerCalendarProps) { ); } - const { selectedDate, setSelectedDate, disabledDates, locale } = context; + const { + mode, + selectedDate, + setSelection, + endDate, + disabledDates, + locale, + closeCalendar, + } = context; - const firstOfMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1); + const isRange = mode === 'range'; + const firstOfMonth = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), 1); const [visibleDate, setVisibleDate] = useState(() => firstOfMonth(selectedDate ?? new Date()) ); const [focusedDate, setFocusedDate] = useState( - () => selectedDate ?? new Date() + () => selectedDate ?? endDate ?? new Date() ); useEffect(() => { - if (selectedDate) { - setVisibleDate(firstOfMonth(selectedDate)); - setFocusedDate(selectedDate); + const anchor = selectedDate ?? endDate; + if (anchor) { + setVisibleDate(firstOfMonth(anchor)); + setFocusedDate(selectedDate ?? endDate ?? new Date()); } - }, [selectedDate]); + }, [selectedDate, endDate]); const handleDateSelect = (date: Date) => { - setSelectedDate(date); + if (!isRange) { + setSelection(date); + return; + } + // Range mode: first click = start, second = end; if both set, next click starts over + const hasStart = selectedDate != null; + const hasEnd = endDate != null; + if (hasStart && hasEnd) { + setSelection(date, null); + } else if (hasStart && !hasEnd) { + const start = selectedDate!; + if (date.getTime() < start.getTime()) { + setSelection(date, start); + } else { + setSelection(start, date); + } + } else { + setSelection(date, null); + } }; const handleClearDate = () => { - setSelectedDate(null); + setSelection(null); setFocusedDate(visibleDate); }; const handleTodayClick = () => { const today = new Date(); - setSelectedDate(today); + setSelection(today); setVisibleDate(firstOfMonth(today)); setFocusedDate(today); }; - const weekStartsOn = (props.weekStartsOn ?? 0) as 0 | 1; + const focusTarget = focusedDate ?? selectedDate ?? endDate ?? new Date(); return ( @@ -84,18 +114,20 @@ export function DatePickerCalendar(props: DatePickerCalendarProps) { - date === null ? handleClearDate() : handleDateSelect(date) + date === null ? handleClearDate() : handleTodayClick() } onCurrentMonthYearChange={setVisibleDate} onClearDate={handleClearDate} @@ -103,4 +135,4 @@ export function DatePickerCalendar(props: DatePickerCalendarProps) { /> ); -} +}; diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx index f42a1a413ff..1c88cc952ac 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput.tsx @@ -1,5 +1,12 @@ import { CalendarIcon } from '@codecademy/gamut-icons'; -import { ComponentProps, forwardRef, useEffect, useRef, useState } from 'react'; +import { + ComponentProps, + forwardRef, + useEffect, + useId, + useRef, + useState, +} from 'react'; import { Input } from '../Form/inputs/Input'; import { @@ -10,12 +17,15 @@ import { useDatePicker } from './DatePickerContext'; /** * Props for DatePickerInput. When used inside DatePicker, only overrides (e.g. placeholder, label). - * When used outside DatePicker, pass value, onChange, and other input props. + * In range mode, use rangePart to bind to start or end date. When outside DatePicker, pass value, onChange, etc. */ export type DatePickerInputProps = Omit< ComponentProps, 'type' | 'icon' ->; +> & { + /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */ + rangePart?: 'start' | 'end'; +}; /** * Date input. When inside DatePicker: owns local input value state and syncs to @@ -25,71 +35,82 @@ export type DatePickerInputProps = Omit< export const DatePickerInput = forwardRef< HTMLInputElement, DatePickerInputProps ->((props, ref) => { +>(({ placeholder, label, rangePart, ...rest }, ref) => { const context = useDatePicker(); + // do we want to do this or just throw an error? if (context == null) { return ( ); } const { - selectedDate, - setSelectedDate, + mode, + startDate, + endDate, + setSelection, openCalendar, locale, isCalendarOpen, calendarDialogId, } = context; - const [inputValue, setInputValue] = useState(() => - selectedDate ? formatDateForInput(selectedDate, locale) : '' - ); - const isInputFocusedRef = useRef(false); + const isRange = mode === 'range'; + + const inputID = useId(); + const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; + + // Range with two inputs: each input binds to one part. Single or range combined: one value. + const boundDate = isRange && rangePart === 'end' ? endDate : startDate; + const formattedValue = + boundDate != null ? formatDateForInput(boundDate, locale) : ''; - const formattedValue = selectedDate - ? formatDateForInput(selectedDate, locale) - : ''; + const [inputValue, setInputValue] = useState(() => formattedValue); + const isInputFocusedRef = useRef(false); - // Sync input from shared selectedDate (e.g. after calendar select). Skip when - // input is focused so we don't overwrite while the user is typing. + // Sync input from shared state. Skip when focused so we don't overwrite while typing. useEffect(() => { if (!isInputFocusedRef.current) { setInputValue(formattedValue); } }, [formattedValue]); + /** Apply raw input string to selection state. Returns formatted string if parsed so caller can sync input (e.g. on blur). */ + const applyValueToSelection = (raw: string) => { + const trimmed = raw.trim(); + if (!trimmed) { + if (isRange && rangePart) { + if (rangePart === 'start') setSelection(null, endDate); + else setSelection(startDate, null); + } else setSelection(null); + return undefined; + } + const parsed = parseDateFromInput(trimmed, locale); + if (!parsed) return undefined; + if (isRange && rangePart) { + if (rangePart === 'start') setSelection(parsed, endDate); + else setSelection(startDate, parsed); + } else setSelection(parsed); + return formatDateForInput(parsed, locale); + }; + const handleChange = (e: React.ChangeEvent) => { const raw = e.target.value; setInputValue(raw); - if (!raw.trim()) { - setSelectedDate(null); - return; - } - const parsed = parseDateFromInput(raw, locale); - if (parsed) setSelectedDate(parsed); + applyValueToSelection(raw); }; const handleBlur = () => { isInputFocusedRef.current = false; - const trimmed = inputValue.trim(); - if (!trimmed) { - setSelectedDate(null); - return; - } - const parsed = parseDateFromInput(trimmed, locale); - if (parsed) { - setSelectedDate(parsed); - setInputValue(formatDateForInput(parsed, locale)); - } else { - setInputValue(formattedValue); - } + const formatted = applyValueToSelection(inputValue.trim()); + if (formatted) setInputValue(formatted); + else if (inputValue.trim()) setInputValue(formattedValue); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -99,9 +120,21 @@ export const DatePickerInput = forwardRef< } }; + const handleOpenCalendar = () => { + openCalendar(); + }; + + const defaultLabel = + isRange && rangePart === 'end' + ? 'End date' + : isRange + ? 'Start date' + : 'Date'; + return ( ); }); diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index 0556d6fb376..93a62e869bb 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -1,6 +1,14 @@ /** - * DatePicker – Single-date picker with input + calendar popover. + * DatePicker – Single-date and range picker with input + calendar popover. */ +export type { + DatePickerContextValue, + DatePickerProps, + DatePickerRangeProps, + DatePickerSingleProps, + UseDatePickerReturn, +} from './types'; + export { DatePicker } from './DatePicker'; export { DatePickerContext, @@ -11,13 +19,7 @@ export { DatePickerCalendar } from './DatePickerCalendar'; export type { DatePickerCalendarProps } from './DatePickerCalendar'; export { DatePickerInput } from './DatePickerInput'; export type { DatePickerInputProps } from './DatePickerInput'; -export type { - DatePickerContextValue, - DatePickerProps, - UseDatePickerArgs, - UseDatePickerReturn, - UseDatePickerInputProps, -} from './types'; + export { Calendar, CalendarHeader, diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 39fb926f065..3354894fe00 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -1,5 +1,5 @@ /** - * Public and internal types for the DatePicker (single-date). + * Public and internal types for the DatePicker (single-date and range). */ /** Result of custom validation; null means valid. */ @@ -8,34 +8,60 @@ export interface DatePickerValidationResult { errorType: string; } -/** Props for the DatePicker provider / standalone component. */ -export interface DatePickerProps { - /** Controlled selected date (single-date mode). */ - selectedDate: Date | null; - /** Called when the user selects a date. */ - setSelectedDate: (date: Date | null) => void; +/** Shared props for all DatePicker modes. */ +export interface DatePickerBaseProps { /** Locale for formatting (e.g. 'en-US'). */ locale?: string; /** Dates that are disabled (unselectable) in the calendar. */ disabledDates?: Date[]; - /** Custom validation; if returns non-null, error is shown and date may be unselectable. */ - validation?: (value: { - date: Date | null; - }) => DatePickerValidationResult | null; + /** When provided, only the provider is rendered and children compose Input + Calendar. */ + children?: React.ReactNode; /** Placeholder for the input. */ placeholder?: string; +} + +/** Props for the DatePicker (single-date mode). */ +export interface DatePickerSingleProps extends DatePickerBaseProps { + mode?: 'single'; + /** Controlled selected date. */ + selectedDate: Date | null; + /** Called when the user selects a date. */ + setSelectedDate: (date: Date | null) => void; /** Label for the input. */ label?: string; - /** Id for the input. */ - id?: string; - /** When provided, only the provider is rendered and children compose Input + Calendar. */ - children?: React.ReactNode; } +/** Props for the DatePicker (range mode). */ +export interface DatePickerRangeProps extends DatePickerBaseProps { + mode: 'range'; + /** Controlled start date. */ + startDate: Date | null; + /** Controlled end date. */ + endDate: Date | null; + /** Called when the user changes the start date. */ + setStartDate: (date: Date | null) => void; + /** Called when the user changes the end date. */ + setEndDate: (date: Date | null) => void; + /** Label for the start date input. */ + startLabel?: string; + /** Label for the end date input. */ + endLabel?: string; +} + +/** Props for the DatePicker provider / standalone component. */ +export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; + /** Shared state provided by DatePicker via context. */ export interface DatePickerContextValue { + mode: 'single' | 'range'; + /** Selected date (single) or start date (range). Same value as startDate. */ selectedDate: Date | null; - setSelectedDate: (date: Date | null) => void; + /** Alias for selectedDate (same value). */ + startDate: Date | null; + /** Range only: end date. */ + endDate: Date | null; + /** Range only: set full range. */ + setSelection: (startDate: Date | null, endDate?: Date | null) => void; isCalendarOpen: boolean; openCalendar: () => void; closeCalendar: () => void; diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx index 944bc0ecd63..14e028f97c0 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx @@ -28,6 +28,7 @@ export const Default: Story = { setSelectedDate={setSelectedDate} label="Date" placeholder="MM/DD/YYYY" + locale="de-DE" /> ); @@ -50,6 +51,27 @@ export const WithInitialDate: Story = { }, }; +/** Range mode: two inputs for start and end date. First calendar click sets start, second sets end. */ +export const Range: Story = { + render: function DatePickerStory() { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + + + ); + }, +}; + /** * Composed usage: DatePicker with children provides shared state via context. * The child uses useDatePicker() to get open/close and inputRef, then composes @@ -74,13 +96,8 @@ export const ComposedWithContext: Story = { }; function ComposedDatePickerLayout() { - const { - isCalendarOpen, - openCalendar, - closeCalendar, - calendarDialogId, - inputRef, - } = useDatePicker(); + const { isCalendarOpen, openCalendar, closeCalendar, calendarDialogId } = + useDatePicker(); return ( <> @@ -90,7 +107,7 @@ function ComposedDatePickerLayout() { Date: Thu, 12 Mar 2026 12:51:56 -0400 Subject: [PATCH 013/110] update range logic --- .../src/DatePicker/DatePickerCalendar.tsx | 63 +++++++++++++------ .../gamut/src/DatePicker/DatePickerInput.tsx | 2 + 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 9402125febf..d327a157f61 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -40,7 +40,7 @@ export const DatePickerCalendar: React.FC = ({ const { mode, - selectedDate, + startDate, setSelection, endDate, disabledDates, @@ -53,38 +53,61 @@ export const DatePickerCalendar: React.FC = ({ new Date(date.getFullYear(), date.getMonth(), 1); const [visibleDate, setVisibleDate] = useState(() => - firstOfMonth(selectedDate ?? new Date()) + firstOfMonth(startDate ?? new Date()) ); const [focusedDate, setFocusedDate] = useState( - () => selectedDate ?? endDate ?? new Date() + () => startDate ?? endDate ?? new Date() ); useEffect(() => { - const anchor = selectedDate ?? endDate; + const anchor = startDate ?? endDate; if (anchor) { setVisibleDate(firstOfMonth(anchor)); - setFocusedDate(selectedDate ?? endDate ?? new Date()); + setFocusedDate(startDate ?? endDate ?? new Date()); } - }, [selectedDate, endDate]); + }, [startDate, endDate]); + + // Clicking Start Date: Clears it. If End exists, End becomes new Start + // Clicking End Date: Clears End Date (Start remains) + // If Start == End (single day range): Clicking it clears everything const handleDateSelect = (date: Date) => { + console.log('handle date select'); + // single date select if (!isRange) { + // If clicked date is the same as Start Date: Clear Start Date + if (startDate && date.getTime() === startDate.getTime()) { + setSelection(null); + return; + } + // If clicked date is not the same as Start Date: Set Start Date to clicked date setSelection(date); return; } - // Range mode: first click = start, second = end; if both set, next click starts over - const hasStart = selectedDate != null; - const hasEnd = endDate != null; - if (hasStart && hasEnd) { - setSelection(date, null); - } else if (hasStart && !hasEnd) { - const start = selectedDate!; - if (date.getTime() < start.getTime()) { - setSelection(date, start); - } else { - setSelection(start, date); + // Range mode + if (startDate && endDate) { + // If clicked date > Start: Updates End Date to new date (Start remains) + if (date.getTime() > startDate.getTime()) { + setSelection(startDate, date); + } + // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left + else { + setSelection(date, endDate); } - } else { + } + // Start is Set, End is Empty + else if (startDate && !endDate) { + // If clicked date < Start: Restarts selection with clicked date as new Start + if (date.getTime() < startDate.getTime()) { + setSelection(date, null); + } + // If clicked date > Start: Sets it as End Date + else { + setSelection(startDate, date); + } + } + // otherwise set start to selected date + else { setSelection(date, null); } }; @@ -101,7 +124,7 @@ export const DatePickerCalendar: React.FC = ({ setFocusedDate(today); }; - const focusTarget = focusedDate ?? selectedDate ?? endDate ?? new Date(); + const focusTarget = focusedDate ?? startDate ?? endDate ?? new Date(); return ( @@ -113,7 +136,7 @@ export const DatePickerCalendar: React.FC = ({ /> ) => { + console.log('handle change'); const raw = e.target.value; setInputValue(raw); applyValueToSelection(raw); }; const handleBlur = () => { + console.log('handle blur'); isInputFocusedRef.current = false; const formatted = applyValueToSelection(inputValue.trim()); if (formatted) setInputValue(formatted); From d194237fd442c092a8973934bd4e015d5e1ed7bc Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 12 Mar 2026 14:12:12 -0400 Subject: [PATCH 014/110] range logic when specific input is focused --- packages/gamut/src/DatePicker/DatePicker.tsx | 7 +++++ .../src/DatePicker/DatePickerCalendar.tsx | 29 +++++++++++++++---- .../gamut/src/DatePicker/DatePickerInput.tsx | 3 ++ packages/gamut/src/DatePicker/types.ts | 9 +++++- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index d33f012c3f2..59c6fd45a35 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -29,6 +29,9 @@ export const DatePicker: React.FC = (props) => { children, } = props; const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [activeRangePart, setActiveRangePart] = useState< + 'start' | 'end' | null + >(null); const inputRef = useRef(null); const dialogId = useId(); const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; @@ -36,6 +39,7 @@ export const DatePicker: React.FC = (props) => { const openCalendar = useCallback(() => setIsCalendarOpen(true), []); const closeCalendar = useCallback(() => { setIsCalendarOpen(false); + setActiveRangePart(null); inputRef.current?.focus(); }, []); @@ -62,6 +66,8 @@ export const DatePicker: React.FC = (props) => { startDate, endDate, setSelection, + activeRangePart: mode === 'range' ? activeRangePart : null, + setActiveRangePart: mode === 'range' ? setActiveRangePart : () => {}, isCalendarOpen, openCalendar, closeCalendar, @@ -74,6 +80,7 @@ export const DatePicker: React.FC = (props) => { startDate, endDate, setSelection, + activeRangePart, isCalendarOpen, openCalendar, closeCalendar, diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index d327a157f61..b800fb4ccaa 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -43,6 +43,8 @@ export const DatePickerCalendar: React.FC = ({ startDate, setSelection, endDate, + activeRangePart, + setActiveRangePart, disabledDates, locale, closeCalendar, @@ -73,6 +75,7 @@ export const DatePickerCalendar: React.FC = ({ const handleDateSelect = (date: Date) => { console.log('handle date select'); + setActiveRangePart(null); // single date select if (!isRange) { // If clicked date is the same as Start Date: Clear Start Date @@ -84,7 +87,24 @@ export const DatePickerCalendar: React.FC = ({ setSelection(date); return; } - // Range mode + + // Range mode: field targeting (start or end input was focused) + if (activeRangePart === 'start') { + const newEnd = + endDate != null && date.getTime() <= endDate.getTime() ? endDate : null; + setSelection(date, newEnd); + return; + } + if (activeRangePart === 'end') { + const newStart = + startDate != null && date.getTime() >= startDate.getTime() + ? startDate + : null; + setSelection(newStart, date); + return; + } + + // Range selection mode (no field focused: calendar drives both) if (startDate && endDate) { // If clicked date > Start: Updates End Date to new date (Start remains) if (date.getTime() > startDate.getTime()) { @@ -94,6 +114,7 @@ export const DatePickerCalendar: React.FC = ({ else { setSelection(date, endDate); } + return; } // Start is Set, End is Empty else if (startDate && !endDate) { @@ -105,11 +126,9 @@ export const DatePickerCalendar: React.FC = ({ else { setSelection(startDate, date); } + return; } - // otherwise set start to selected date - else { - setSelection(date, null); - } + setSelection(date, null); }; const handleClearDate = () => { diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx index b9785d97a7f..29019615f89 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput.tsx @@ -55,6 +55,7 @@ export const DatePickerInput = forwardRef< startDate, endDate, setSelection, + setActiveRangePart, openCalendar, locale, isCalendarOpen, @@ -143,7 +144,9 @@ export const DatePickerInput = forwardRef< value={inputValue} onChange={handleChange} onFocus={() => { + console.log('input focus'); isInputFocusedRef.current = true; + if (isRange && rangePart) setActiveRangePart(rangePart); }} onBlur={handleBlur} onKeyDown={handleKeyDown} diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 3354894fe00..e59c971b41e 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -51,6 +51,9 @@ export interface DatePickerRangeProps extends DatePickerBaseProps { /** Props for the DatePicker provider / standalone component. */ export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; +/** Which range input is active (focused); null = calendar drives both (selection mode). */ +export type ActiveRangePart = 'start' | 'end' | null; + /** Shared state provided by DatePicker via context. */ export interface DatePickerContextValue { mode: 'single' | 'range'; @@ -60,8 +63,12 @@ export interface DatePickerContextValue { startDate: Date | null; /** Range only: end date. */ endDate: Date | null; - /** Range only: set full range. */ + /** Set selection. Single: (date). Range: (start, end). */ setSelection: (startDate: Date | null, endDate?: Date | null) => void; + /** Range only: which input is active (start/end focused); null = selection mode. */ + activeRangePart: ActiveRangePart; + /** Range only: set which input is active (e.g. when input receives focus). */ + setActiveRangePart: (part: ActiveRangePart) => void; isCalendarOpen: boolean; openCalendar: () => void; closeCalendar: () => void; From 3bbeb01946fd678d06ef50141f0bbded14dcb331 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 12 Mar 2026 15:11:07 -0400 Subject: [PATCH 015/110] deselect logic --- .../src/DatePicker/DatePickerCalendar.tsx | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index b800fb4ccaa..a3eb8f88c9b 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -90,12 +90,20 @@ export const DatePickerCalendar: React.FC = ({ // Range mode: field targeting (start or end input was focused) if (activeRangePart === 'start') { + if (date.getTime() === startDate?.getTime()) { + setSelection(null, endDate); + return; + } const newEnd = endDate != null && date.getTime() <= endDate.getTime() ? endDate : null; setSelection(date, newEnd); return; } if (activeRangePart === 'end') { + if (date.getTime() === endDate?.getTime()) { + setSelection(startDate, null); + return; + } const newStart = startDate != null && date.getTime() >= startDate.getTime() ? startDate @@ -106,8 +114,26 @@ export const DatePickerCalendar: React.FC = ({ // Range selection mode (no field focused: calendar drives both) if (startDate && endDate) { + // if start date is end date and is clicked, clears everything + if ( + startDate.getTime() === endDate.getTime() && + date.getTime() === startDate.getTime() + ) { + setSelection(null, null); + return; + } + // if clicked on start date, end date becomes start date + else if (date.getTime() === startDate.getTime()) { + setSelection(endDate, null); + return; + } + // if clicked on end date, clears end date and start remains + else if (date.getTime() === endDate.getTime()) { + setSelection(startDate, null); + return; + } // If clicked date > Start: Updates End Date to new date (Start remains) - if (date.getTime() > startDate.getTime()) { + else if (date.getTime() > startDate.getTime()) { setSelection(startDate, date); } // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left From a6cbe800c4d113bb113eb3e307724d2cb4ebf8ed Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 12 Mar 2026 15:17:13 -0400 Subject: [PATCH 016/110] fix lint --- packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx | 2 +- packages/gamut/src/DatePicker/Calendar/index.tsx | 7 ++++++- packages/gamut/src/DatePicker/Calendar/utils/format.ts | 5 ++++- .../src/lib/Molecules/DatePicker/Calendar.stories.tsx | 4 +++- 4 files changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index b59c1405755..707cb73e62b 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { FlexBox } from '../../Box'; import { TextButton } from '../../Button'; -import { CalendarFooterProps, QuickAction } from './types'; +import { CalendarFooterProps } from './types'; // function formatQuickActionLabel(action: QuickAction): string { // const { num, timePeriod } = action; diff --git a/packages/gamut/src/DatePicker/Calendar/index.tsx b/packages/gamut/src/DatePicker/Calendar/index.tsx index f01bca09a2d..62279a61f3b 100644 --- a/packages/gamut/src/DatePicker/Calendar/index.tsx +++ b/packages/gamut/src/DatePicker/Calendar/index.tsx @@ -2,5 +2,10 @@ export { Calendar } from './Calendar'; export { CalendarHeader } from './CalendarHeader'; export { CalendarBody } from './CalendarBody'; export { CalendarFooter } from './CalendarFooter'; -export type { CalendarHeaderProps, CalendarBodyProps, CalendarFooterProps, QuickAction } from './types'; +export type { + CalendarHeaderProps, + CalendarBodyProps, + CalendarFooterProps, + QuickAction, +} from './types'; export * from './utils'; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index 7c745ac1785..c9cc34d266e 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -105,7 +105,10 @@ export function formatDateRangeForInput( if (!startDate && !endDate) return ''; if (!startDate) return formatDateForInput(endDate!, locale); if (!endDate) return formatDateForInput(startDate, locale); - return `${formatDateForInput(startDate, locale)}${RANGE_SEPARATOR}${formatDateForInput(endDate, locale)}`; + return `${formatDateForInput( + startDate, + locale + )}${RANGE_SEPARATOR}${formatDateForInput(endDate, locale)}`; } /** diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx index b6a49712f18..377fd42a349 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx @@ -21,7 +21,9 @@ export const Default: Story = { const headingId = useId(); const [visibleDate, setVisibleDate] = useState(() => new Date()); const [selectedDate, setSelectedDate] = useState(null); - const [focusedDate, setFocusedDate] = useState(() => new Date()); + const [focusedDate, setFocusedDate] = useState( + () => new Date() + ); return ( From a56426fcd51afdabb5aa955bf6d568aabff9e0ef Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 12 Mar 2026 15:20:51 -0400 Subject: [PATCH 017/110] fix story --- .../src/lib/Molecules/DatePicker/DatePicker.stories.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx index 14e028f97c0..6984172d379 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx @@ -7,7 +7,7 @@ import { useDatePicker, } from '@codecademy/gamut'; import type { Meta, StoryObj } from '@storybook/react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; const meta: Meta = { component: DatePicker, @@ -98,16 +98,17 @@ export const ComposedWithContext: Story = { function ComposedDatePickerLayout() { const { isCalendarOpen, openCalendar, closeCalendar, calendarDialogId } = useDatePicker(); + const inputRef = useRef(null); return ( <> - + Date: Thu, 12 Mar 2026 15:35:11 -0400 Subject: [PATCH 018/110] fix lint again --- .../src/DatePicker/Calendar/CalendarBody.tsx | 23 +++++----- .../DatePicker/Calendar/CalendarFooter.tsx | 1 - .../src/DatePicker/Calendar/utils/format.ts | 2 +- packages/gamut/src/DatePicker/DatePicker.tsx | 24 +++++----- .../src/DatePicker/DatePickerCalendar.tsx | 35 +++++++-------- .../gamut/src/DatePicker/DatePickerInput.tsx | 44 ++++++++++--------- .../Molecules/DatePicker/Calendar.stories.tsx | 14 +++--- .../DatePicker/DatePicker.stories.tsx | 34 +++++++------- 8 files changed, 90 insertions(+), 87 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 6f7969e4777..79f69f4c772 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -3,6 +3,7 @@ import styled from '@emotion/styled'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import * as React from 'react'; +import { TextButton } from '../../Button'; import { CalendarBodyProps } from './types'; import { clampToMonth, @@ -12,7 +13,6 @@ import { isSameDay, } from './utils/dateGrid'; import { getWeekdayFullNames, getWeekdayLabels } from './utils/format'; -import { TextButton } from '../../Button'; const DateButton = styled(TextButton)( states({ @@ -200,12 +200,13 @@ export const CalendarBody: React.FC = ({ } }, [ + onFocusedDateChange, datesWithRow, - disabledDates, month, year, + disabledDates, onDateSelect, - onFocusedDateChange, + onEscapeKeyPress, onVisibleDateChange, ] ); @@ -233,10 +234,12 @@ export const CalendarBody: React.FC = ({
); } @@ -257,19 +260,19 @@ export const CalendarBody: React.FC = ({ role="gridcell" > setButtonRef(date, el as HTMLElement | null)} + tabIndex={isFocused ? 0 : -1} variant="secondary" width="36px" - disabled={disabled} - isToday={today} - isSelected={selected} - isInRange={inRange} onClick={() => onDateSelect(date)} - tabIndex={isFocused ? 0 : -1} + onFocus={() => onFocusedDateChange?.(date)} onKeyDown={(e: React.KeyboardEvent) => handleKeyDown(e, date) } - onFocus={() => onFocusedDateChange?.(date)} > {date.getDate()} diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index 707cb73e62b..58318278d4d 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -30,7 +30,6 @@ export const CalendarFooter: React.FC = ({ onTodayClick, onSelectedDateChange, onCurrentMonthYearChange, - quickActions = [], }) => { const handleClearDate = () => { onSelectedDateChange(null); diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index c9cc34d266e..d1eb0543224 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -76,7 +76,7 @@ export function formatDateForInput(date: Date, locale?: string): string { * Partial input like "1" or "2/15" returns null even though Date("1") would parse. */ -//this logic needs some work +// this logic needs some work export function parseDateFromInput( value: string, locale?: string diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 59c6fd45a35..411bda59c2d 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -7,8 +7,8 @@ import { DatePickerProvider } from './DatePickerContext'; import { DatePickerInput } from './DatePickerInput'; import type { DatePickerContextValue, - DatePickerRangeProps, DatePickerProps, + DatePickerRangeProps, } from './types'; function isRangeProps(props: DatePickerProps): props is DatePickerRangeProps { @@ -102,40 +102,40 @@ export const DatePicker: React.FC = (props) => { children ) : ( <> - + {mode === 'range' ? ( <> ) : ( )}
+ {label} -
+ ); } const selected = isSameDay(date, selectedDate) || isSameDay(date, endDate); + const range = !!selectedDate && !!endDate; const inRange = - !!selectedDate && - !!endDate && - isDateInRange(date, selectedDate, endDate); + range && isDateInRange(date, selectedDate, endDate); const disabled = isDateDisabled(date, disabledDates); const today = isToday(date); // this is making the selected date a differnet color bc it is focused, look into further @@ -194,7 +208,7 @@ export const CalendarBody: React.FC = ({ focusTarget !== null && isSameDay(date, focusTarget); return ( - = ({ setButtonRef(date, el as HTMLElement | null)} @@ -215,7 +231,7 @@ export const CalendarBody: React.FC = ({ > {date.getDate()} -
+
{weekdayLabels.map((label, i) => ( diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index a19af8fff76..2d30da7c752 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -48,6 +48,16 @@ export interface CalendarBodyProps { hasAdjacentMonthRight?: boolean; /** When true (e.g. two-month view), arrow keys move focus to adjacent month without changing visible date. */ hasAdjacentMonthLeft?: boolean; + /** + * When set (DatePicker), only programmatically focuses a day when the grid already has focus + * or `gridFocusRequested` is true (keyboard open / ArrowDown from input). + * Omit for standalone calendar stories — keeps legacy behavior (always sync focus to focusedDate). + */ + focusGridSync?: { + gridFocusRequested: boolean; + signal: boolean; + onGridFocusRequestHandled: () => void; + }; } export interface QuickAction { @@ -65,25 +75,3 @@ export interface CalendarFooterProps { /** Max 3 quick actions (e.g. "7 days", "1 month") */ quickActions?: QuickAction[]; } - -// interface CalendarFooterBaseProps { -// disabled?: boolean; -// locale?: string; -// onTodayClick?: () => void; -// /** Max 3 quick actions (e.g. "7 days", "1 month") */ -// quickActions?: QuickAction[]; -// } - -// interface CalendarFooterWithClearProps extends CalendarFooterBaseProps { -// showClearButton: true; -// clearText: string; -// onClearDate: () => void; -// } - -// interface CalendarFooterNoClearProps extends CalendarFooterBaseProps { -// showClearButton?: false; -// } - -// export type CalendarFooterProps = -// | CalendarFooterWithClearProps -// | CalendarFooterNoClearProps; diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 52afaef861b..1df5bff3d30 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -11,6 +11,7 @@ import { type DatePickerContextValue, type DatePickerProps, type DatePickerRangeProps, + type OpenCalendarOptions, } from './types'; function isRangeProps(props: DatePickerProps): props is DatePickerRangeProps { @@ -33,6 +34,8 @@ export const DatePicker: React.FC = (props) => { inputSize, } = props; const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [focusGridSignal, setFocusGridSignal] = useState(false); + const [gridFocusRequested, setGridFocusRequested] = useState(false); const [activeRangePart, setActiveRangePart] = useState< 'start' | 'end' | null >(null); @@ -40,10 +43,30 @@ export const DatePicker: React.FC = (props) => { const dialogId = useId(); const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; - const openCalendar = useCallback(() => setIsCalendarOpen(true), []); + const clearGridFocusRequest = useCallback(() => { + setGridFocusRequested(false); + }, []); + + const openCalendar = useCallback((options?: OpenCalendarOptions) => { + const moveFocus = options?.moveFocusIntoCalendar ?? false; + setIsCalendarOpen(true); + if (moveFocus) { + setGridFocusRequested(true); + setFocusGridSignal((signal) => !signal); + } else { + setGridFocusRequested(false); + } + }, []); + + const focusCalendarGrid = useCallback(() => { + setGridFocusRequested(true); + setFocusGridSignal((signal) => !signal); + }, []); + const closeCalendar = useCallback(() => { setIsCalendarOpen(false); setActiveRangePart(null); + setGridFocusRequested(false); inputRef.current?.focus(); }, []); @@ -74,6 +97,10 @@ export const DatePicker: React.FC = (props) => { setSelection, isCalendarOpen, openCalendar, + focusCalendarGrid, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, closeCalendar, locale, disabledDates, @@ -98,6 +125,10 @@ export const DatePicker: React.FC = (props) => { setActiveRangePart, isCalendarOpen, openCalendar, + focusCalendarGrid, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, closeCalendar, locale, disabledDates, diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index d07f8fbcd68..afc4dd7aa3f 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -1,5 +1,5 @@ import { breakpoints } from '@codecademy/gamut-styles'; -import { useEffect, useId, useRef, useState } from 'react'; +import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { useMedia } from 'react-use'; import { Box, FlexBox } from '../Box'; @@ -51,8 +51,20 @@ export const DatePickerCalendar: React.FC = ({ closeCalendar, isCalendarOpen, translations, + focusGridSignal, + gridFocusRequested, + clearGridFocusRequest, } = context; + const focusGridSync = useMemo( + () => ({ + gridFocusRequested, + signal: focusGridSignal, + onGridFocusRequestHandled: clearGridFocusRequest, + }), + [gridFocusRequested, focusGridSignal, clearGridFocusRequest] + ); + const isRange = mode === 'range'; const endDate = isRange ? context.endDate : undefined; const firstOfMonth = (date: Date) => @@ -131,6 +143,7 @@ export const DatePickerCalendar: React.FC = ({ disabledDates={disabledDates} displayDate={displayDate} endDate={endDate} + focusGridSync={focusGridSync} focusedDate={focusTarget} hasAdjacentMonthRight={isTwoMonthsVisible} labelledById={headingId} @@ -147,6 +160,7 @@ export const DatePickerCalendar: React.FC = ({ disabledDates={disabledDates} displayDate={secondMonthDate} endDate={endDate} + focusGridSync={focusGridSync} focusedDate={focusTarget} hasAdjacentMonthLeft={isTwoMonthsVisible} labelledById={headingId} diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx index 177c8d1c1b3..1df85b5e2fe 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput.tsx @@ -39,18 +39,7 @@ export const DatePickerInput = forwardRef< DatePickerInputProps >(({ placeholder, label, rangePart, ...rest }, ref) => { const context = useDatePicker(); - // do we want to do this or just throw an error? - // if (context == null) { - // return ( - // - // ); - // } + if (context == null) { throw new Error( 'DatePickerInput must be used inside a DatePicker (it reads shared state from context).' @@ -62,6 +51,7 @@ export const DatePickerInput = forwardRef< startOrSelectedDate, setSelection, openCalendar, + focusCalendarGrid, locale, isCalendarOpen, calendarDialogId, @@ -124,12 +114,16 @@ export const DatePickerInput = forwardRef< const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown' || e.key === 'Down') { e.preventDefault(); - openCalendar(); + if (isCalendarOpen) { + focusCalendarGrid(); + } else { + openCalendar({ moveFocusIntoCalendar: true }); + } } }; const handleOpenCalendar = () => { - openCalendar(); + openCalendar({ moveFocusIntoCalendar: false }); }; const defaultLabel = !isRange diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index da22954a486..ed7c25878e1 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -6,6 +6,7 @@ export type { DatePickerProps, DatePickerRangeProps, DatePickerSingleProps, + OpenCalendarOptions, } from './types'; export { DatePicker } from './DatePicker'; diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 83d7a1fb754..eb080651928 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -75,10 +75,30 @@ export interface DatePickerTranslations { calendarDialogAriaLabel?: string; } +/** Options for opening the calendar popover. */ +export type OpenCalendarOptions = { + /** + * When true, move DOM focus into the date grid after open (keyboard / explicit request). + * When false (default), keep focus on the input so pointer users can type (WCAG 3.2.1). + */ + moveFocusIntoCalendar?: boolean; +}; + /** Shared state provided by DatePicker via context. */ export interface DatePickerBaseContextValue { isCalendarOpen: boolean; - openCalendar: () => void; + openCalendar: (options?: OpenCalendarOptions) => void; + /** Move focus from the input into the grid when the calendar is already open (e.g. ArrowDown). */ + focusCalendarGrid: () => void; + /** + * Flips on each grid focus request so `CalendarBody` effects re-run when `focusTarget` is unchanged. + * Not a semantic true/false — only the change matters; pair with `gridFocusRequested`. + */ + focusGridSignal: boolean; + /** When true, `CalendarBody` runs a one-shot move of DOM focus into the grid if it is not already there. */ + gridFocusRequested: boolean; + /** Clears `gridFocusRequested` after focus has moved into the grid (or call when closing). */ + clearGridFocusRequest: () => void; closeCalendar: () => void; locale?: string; disabledDates: Date[]; From 422e2200b12ec10c78809c418437eeda505f8bd5 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 25 Mar 2026 13:54:23 -0400 Subject: [PATCH 048/110] PR feedback --- .../src/DatePicker/Calendar/CalendarBody.tsx | 80 +------------------ .../{Calendar.tsx => CalendarWrapper.tsx} | 5 +- .../gamut/src/DatePicker/Calendar/index.tsx | 2 +- .../gamut/src/DatePicker/Calendar/types.ts | 38 ++++----- .../DatePicker/Calendar/utils/elements.tsx | 76 ++++++++++++++++++ packages/gamut/src/DatePicker/DatePicker.tsx | 14 ++-- .../src/DatePicker/DatePickerCalendar.tsx | 6 +- packages/gamut/src/DatePicker/translations.ts | 14 +++- packages/gamut/src/DatePicker/types.ts | 54 +++---------- packages/gamut/src/DatePicker/utils.ts | 12 ++- .../Molecules/DatePicker/Calendar.stories.tsx | 12 +-- 11 files changed, 144 insertions(+), 169 deletions(-) rename packages/gamut/src/DatePicker/Calendar/{Calendar.tsx => CalendarWrapper.tsx} (82%) create mode 100644 packages/gamut/src/DatePicker/Calendar/utils/elements.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index d60c615da3a..da9b6569ef7 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -1,9 +1,6 @@ -import { css, states } from '@codecademy/gamut-styles'; -import styled from '@emotion/styled'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import * as React from 'react'; -import { TextButton } from '../../Button'; import { CalendarBodyProps } from './types'; import { getDatesWithRow, @@ -12,81 +9,10 @@ import { isDateInRange, isSameDay, } from './utils/dateGrid'; +import { DateButton, DateCell, TableHeader } from './utils/elements'; import { getWeekdayNames } from './utils/format'; import { keyHandler } from './utils/keyHandler'; -const TableHeader = styled.th( - css({ - fontSize: 14, - fontWeight: 'base', - color: 'text-disabled', - textAlign: 'center', - }) -); - -const DateCell = styled.td( - css({ - padding: 0, - }) -); - -const DateButton = styled(TextButton)( - states({ - isToday: { - position: 'relative', - '&::after': { - content: '""', - position: 'absolute', - bottom: 4, - 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', - }, - }, - disabled: { - color: 'text-disabled', - textDecoration: 'line-through', - '&:hover': { - textDecoration: 'line-through', - }, - }, - }), - css({ - fontWeight: 'base', - width: '32px', - }) -); - export const CalendarBody: React.FC = ({ displayDate, selectedDate, @@ -224,9 +150,9 @@ export const CalendarBody: React.FC = ({ {week.map((date, colIndex) => { if (date === null) { return ( - // fix this error - // eslint-disable-next-line react/no-array-index-key, jsx-a11y/control-has-associated-label diff --git a/packages/gamut/src/DatePicker/Calendar/Calendar.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarWrapper.tsx similarity index 82% rename from packages/gamut/src/DatePicker/Calendar/Calendar.tsx rename to packages/gamut/src/DatePicker/Calendar/CalendarWrapper.tsx index 5aaaa67616e..e1f39944a3a 100644 --- a/packages/gamut/src/DatePicker/Calendar/Calendar.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarWrapper.tsx @@ -2,15 +2,14 @@ import { CheckerDense } from '@codecademy/gamut-patterns'; import * as React from 'react'; import { Box } from '../../Box'; +import { WithChildrenProp } from '../../utils'; /** * Outer wrapper for the calendar (header + body + footer). * Used by DatePickerCalendar to group the calendar content. * Renders a CheckerDense pattern shadow at offset left 8, top 8. */ -export const Calendar: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => ( +export const CalendarWrapper: React.FC = ({ children }) => ( void; - /** Currently displayed second month and year (used for heading and prev/next range) */ + /** Locale for month/year formatting and translations (e.g. 'en-US') */ + locale?: string; + /** Dates that should be disabled (unselectable) */ + disabledDates?: Date[]; +} +export interface CalendarHeaderProps + extends Omit { + /** Used for the currently displayed second month and year when in two-month view */ secondDisplayDate?: Date; - /** Called after navigating to previous month; use for click tracking. */ + /** Called after navigating to previous month. */ onLastMonthClick?: () => void; - /** Called after navigating to next month; use for click tracking. */ + /** Called after navigating to next month */ onNextMonthClick?: () => void; - /** Locale for month/year formatting (e.g. 'en-US') */ - locale?: string; /** id for the heading (for grid aria-labelledby) */ headingId: string; } -export interface CalendarBodyProps { - /** The month to display (typically first day of that month) */ - displayDate: Date; - /** Called when grid keyboard nav changes month (e.g. Page Up/Down). Pass setDisplayDate so the calendar updates. */ - onDisplayDateChange: (newDate: Date) => void; +export interface CalendarBodyProps extends CalendarBaseProps { /** Selected start date (single or range start) */ selectedDate: Date | null; /** Selected end date (range only; undefined for single-date mode) */ endDate?: Date | null; - /** Dates that should be disabled (unselectable) */ - disabledDates?: Date[]; /** Called when a date cell is selected */ onDateSelect: (date: Date) => void; - /** Locale for weekday names and week start */ - locale?: string; /** 0 = Sunday, 1 = Monday (default from locale if not set) */ weekStartsOn?: 0 | 1; /** Id of the month/year heading (aria-labelledby on grid) */ @@ -65,10 +58,9 @@ export interface QuickAction { timePeriod: 'day' | 'week' | 'month' | 'year'; onClick: () => void; } -export interface CalendarFooterProps { +export interface CalendarFooterProps extends Pick { disabled?: boolean; showClearButton?: boolean; - locale?: string; clearText?: string; onClearDate?: () => void; onTodayClick?: () => void; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/Calendar/utils/elements.tsx new file mode 100644 index 00000000000..4eef7f6f0a3 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/utils/elements.tsx @@ -0,0 +1,76 @@ +import { css, states } from '@codecademy/gamut-styles'; +import styled from '@emotion/styled'; + +import { TextButton } from '../../../Button'; + +export const TableHeader = styled.th( + css({ + fontSize: 14, + fontWeight: 'base', + color: 'text-disabled', + textAlign: 'center', + }) +); + +export const DateCell = styled.td( + css({ + padding: 0, + }) +); + +export const DateButton = styled(TextButton)( + states({ + isToday: { + position: 'relative', + '&::after': { + content: '""', + position: 'absolute', + bottom: 4, + 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', + }, + }, + disabled: { + color: 'text-disabled', + textDecoration: 'line-through', + '&:hover': { + textDecoration: 'line-through', + }, + }, + }), + css({ + fontWeight: 'base', + width: '32px', + }) +); diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 1df5bff3d30..b61f3ab2cd9 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -7,16 +7,12 @@ import { DatePickerCalendar } from './DatePickerCalendar'; import { DatePickerProvider } from './DatePickerContext'; import { DatePickerInput } from './DatePickerInput'; import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './translations'; -import { - type DatePickerContextValue, - type DatePickerProps, - type DatePickerRangeProps, - type OpenCalendarOptions, +import type { + DatePickerContextValue, + DatePickerProps, + OpenCalendarOptions, } from './types'; - -function isRangeProps(props: DatePickerProps): props is DatePickerRangeProps { - return props.mode === 'range'; -} +import { isRangeProps } from './utils'; /** * DatePicker: single-date or range. Holds shared state and provides it via context. diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index afc4dd7aa3f..e3a5939451b 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -4,10 +4,10 @@ import { useMedia } from 'react-use'; import { Box, FlexBox } from '../Box'; import { - Calendar, CalendarBody, CalendarFooter, CalendarHeader, + CalendarWrapper, } from './Calendar'; import { useDatePicker } from './DatePickerContext'; import { handleDateSelectRange, handleDateSelectSingle } from './utils'; @@ -129,7 +129,7 @@ export const DatePickerCalendar: React.FC = ({ const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.xs})`); return ( - + = ({ onClearDate={handleClearDate} onTodayClick={handleTodayClick} /> - + ); }; diff --git a/packages/gamut/src/DatePicker/translations.ts b/packages/gamut/src/DatePicker/translations.ts index b0b1a654693..e6100add835 100644 --- a/packages/gamut/src/DatePicker/translations.ts +++ b/packages/gamut/src/DatePicker/translations.ts @@ -1,4 +1,16 @@ -import { DatePickerTranslations } from './types'; +/** Optional translations for DatePicker UI strings. Pass to override defaults. */ +export interface DatePickerTranslations { + /** Label for the clear date button (default: "Clear"). */ + clearText?: string; + /** Default label for the date input in single mode (default: "Date"). */ + dateLabel?: string; + /** Default label for the start date input in range mode (default: "Start date"). */ + startDateLabel?: string; + /** Default 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; +} /** Default UI strings; pass translations prop to override. */ export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required = diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index eb080651928..1e9579074b1 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -1,23 +1,11 @@ -/** - * Public and internal types for the DatePicker (single-date and range). - */ - import { ComponentProps } from 'react'; import { Input } from '../Form/inputs/Input'; +import { CalendarBaseProps } from './Calendar/types'; +import { DatePickerTranslations } from './translations'; -/** Result of custom validation; null means valid. */ -export interface DatePickerValidationResult { - errorMessage: string; - errorType: string; -} - -/** Shared props for all DatePicker modes. */ -export interface DatePickerBaseProps { - /** Locale for formatting (e.g. 'en-US'). */ - locale?: string; - /** Dates that are disabled (unselectable) in the calendar. */ - disabledDates?: Date[]; +export interface DatePickerBaseProps + extends Pick { /** When provided, only the provider is rendered and children compose Input + Calendar. */ children?: React.ReactNode; /** Placeholder for the input. */ @@ -27,7 +15,6 @@ export interface DatePickerBaseProps { inputSize?: ComponentProps['size']; } -/** Props for the DatePicker (single-date mode). */ export interface DatePickerSingleProps extends DatePickerBaseProps { mode?: 'single'; /** Controlled selected date. */ @@ -38,7 +25,6 @@ export interface DatePickerSingleProps extends DatePickerBaseProps { label?: string; } -/** Props for the DatePicker (range mode). */ export interface DatePickerRangeProps extends DatePickerBaseProps { mode: 'range'; /** Controlled start date. */ @@ -55,27 +41,8 @@ export interface DatePickerRangeProps extends DatePickerBaseProps { endLabel?: string; } -/** Props for the DatePicker provider / standalone component. */ export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; -/** Which range input is active (focused); null = calendar drives both (selection mode). */ -export type ActiveRangePart = 'start' | 'end' | null; - -/** Optional translations for DatePicker UI strings. Pass to override defaults. */ -export interface DatePickerTranslations { - /** Label for the clear date button (default: "Clear"). */ - clearText?: string; - /** Default label for the date input in single mode (default: "Date"). */ - dateLabel?: string; - /** Default label for the start date input in range mode (default: "Start date"). */ - startDateLabel?: string; - /** Default 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; -} - -/** Options for opening the calendar popover. */ export type OpenCalendarOptions = { /** * When true, move DOM focus into the date grid after open (keyboard / explicit request). @@ -84,8 +51,8 @@ export type OpenCalendarOptions = { moveFocusIntoCalendar?: boolean; }; -/** Shared state provided by DatePicker via context. */ -export interface DatePickerBaseContextValue { +export interface DatePickerBaseContextValue + extends Pick { isCalendarOpen: boolean; openCalendar: (options?: OpenCalendarOptions) => void; /** Move focus from the input into the grid when the calendar is already open (e.g. ArrowDown). */ @@ -100,8 +67,6 @@ export interface DatePickerBaseContextValue { /** Clears `gridFocusRequested` after focus has moved into the grid (or call when closing). */ clearGridFocusRequest: () => void; closeCalendar: () => void; - locale?: string; - disabledDates: Date[]; calendarDialogId: string; /** UI string overrides (e.g. clear button). */ translations: Required; @@ -119,14 +84,15 @@ export interface DatePickerSingleContextValue mode: 'single'; } +export type ActiveRangePart = 'start' | 'end' | null; + export interface DatePickerRangeContextValue extends DatePickerBaseContextValue { mode: 'range'; - /** Range only: end date. */ endDate: Date | null; - /** Range only: which input is active (start/end focused); null = selection mode. */ + /** Which input is active (start/end focused); null = selection mode. */ activeRangePart: ActiveRangePart; - /** Range only: set which input is active (e.g. when input receives focus). */ + /** Set which input is active (e.g. when input receives focus). */ setActiveRangePart: (part: ActiveRangePart) => void; } diff --git a/packages/gamut/src/DatePicker/utils.ts b/packages/gamut/src/DatePicker/utils.ts index 2d4557fe12c..fade459d714 100644 --- a/packages/gamut/src/DatePicker/utils.ts +++ b/packages/gamut/src/DatePicker/utils.ts @@ -1,5 +1,13 @@ import { isDateInRange, isSameDay } from './Calendar/utils/dateGrid'; -import { ActiveRangePart } from './types'; +import { + ActiveRangePart, + DatePickerProps, + DatePickerRangeProps, +} from './types'; + +export const isRangeProps = ( + props: DatePickerProps +): props is DatePickerRangeProps => props.mode === 'range'; /** True if any disabled date falls within [start, end] (inclusive, by calendar day). */ export const rangeContainsDisabled = ( @@ -50,7 +58,7 @@ export const handleDateSelectRange = ( startDate: Date | null, endDate: Date | null, setSelection: (startDate: Date | null, endDate?: Date | null) => void, - disabledDates: Date[] + disabledDates: Date[] = [] ) => { // Range mode: field targeting (start or end input was focused) if (activeRangePart === 'start') { diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx index 811e30ca463..6cbcc3c9d6d 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx @@ -1,21 +1,21 @@ import { Box, - Calendar, CalendarBody, CalendarFooter, CalendarHeader, + CalendarWrapper, } from '@codecademy/gamut'; import type { Meta, StoryObj } from '@storybook/react'; import { useId, useState } from 'react'; -const meta: Meta = { - component: Calendar, +const meta: Meta = { + component: CalendarWrapper, title: 'Molecules/DatePicker/Calendar', }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { render: function CalendarStory() { @@ -27,7 +27,7 @@ export const Default: Story = { ); return ( - + - + ); }, }; From 80726920c0025d51d909e7691bc9fbb124422a18 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 26 Mar 2026 10:51:30 -0400 Subject: [PATCH 049/110] clean up utils --- .../src/DatePicker/Calendar/CalendarBody.tsx | 6 +- .../src/DatePicker/Calendar/utils/dateGrid.ts | 5 +- .../DatePicker/Calendar/utils/keyHandler.ts | 52 +++++--- .../src/DatePicker/DatePickerCalendar.tsx | 18 ++- packages/gamut/src/DatePicker/index.tsx | 2 +- packages/gamut/src/DatePicker/utils.ts | 123 +++++++++++++----- 6 files changed, 144 insertions(+), 62 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index da9b6569ef7..7dd9e639144 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -90,7 +90,7 @@ export const CalendarBody: React.FC = ({ const handleKeyDown = useCallback( (e: React.KeyboardEvent, date: Date) => - keyHandler( + keyHandler({ e, date, onFocusedDateChange, @@ -102,8 +102,8 @@ export const CalendarBody: React.FC = ({ onEscapeKeyPress, onDisplayDateChange, hasAdjacentMonthRight, - hasAdjacentMonthLeft - ), + hasAdjacentMonthLeft, + }), [ onFocusedDateChange, datesWithRow, diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts index 9aee77a8d5c..a227b2c4227 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts @@ -103,9 +103,12 @@ export const isDateDisabled = (date: Date, disabledDates: Date[] = []) => { return disabledDates.some((d) => isSameDay(date, d)); }; +/** One visible day in the month grid with its row (for Home/End and keyboard nav). */ +export type DateWithRow = { date: Date; rowIndex: number }; + /** Flat list of dates in grid order (row-major, non-null only) with row index for Home/End */ export const getDatesWithRow = (weeks: (Date | null)[][]) => { - const result: { date: Date; rowIndex: number }[] = []; + const result: DateWithRow[] = []; weeks.forEach((week, rowIndex) => { week.forEach((date) => { if (date !== null) result.push({ date, rowIndex }); diff --git a/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts b/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts index 9b84b59ad78..a16a5c88a2c 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts @@ -1,4 +1,24 @@ -import { isDateDisabled } from './dateGrid'; +import type { CalendarBodyProps } from '../types'; +import { type DateWithRow,isDateDisabled } from './dateGrid'; + +/** Calendar grid props and callbacks used by `keyHandler`, aligned with `CalendarBodyProps`. */ +export type KeyHandlerParams = Pick< + CalendarBodyProps, + | 'onFocusedDateChange' + | 'onDateSelect' + | 'onDisplayDateChange' + | 'onEscapeKeyPress' + | 'hasAdjacentMonthRight' + | 'hasAdjacentMonthLeft' + | 'disabledDates' +> & { + e: React.KeyboardEvent; + /** The date for the day cell that received the key event */ + 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). @@ -8,22 +28,20 @@ const clampToMonth = (year: number, month: number, day: number) => { return new Date(year, month, Math.min(day, last)); }; -export const keyHandler = ( - e: React.KeyboardEvent, - date: Date, - onFocusedDateChange: (date: Date | null) => void, - datesWithRow: { date: Date; rowIndex: number }[], - month: number, - year: number, - disabledDates: Date[], - onDateSelect: (date: Date) => void, - onEscapeKeyPress?: () => void, - onDisplayDateChange?: (newDate: Date) => void, - /** When true, adjacent month to the right is visible; don't change visible date when moving focus there. */ - hasAdjacentMonthRight?: boolean, - /** When true, adjacent month to the left is visible; don't change visible date when moving focus there. */ - hasAdjacentMonthLeft?: boolean -) => { +export const keyHandler = ({ + e, + date, + onFocusedDateChange, + datesWithRow, + month, + year, + disabledDates = [], + onDateSelect, + onEscapeKeyPress, + onDisplayDateChange, + hasAdjacentMonthRight, + hasAdjacentMonthLeft, +}: KeyHandlerParams) => { const idx = datesWithRow.findIndex( ({ date: dateWithRow }) => dateWithRow.getTime() === date.getTime() ); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index e3a5939451b..2c44e49cba1 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -93,17 +93,21 @@ export const DatePickerCalendar: React.FC = ({ const onDateSelect = (date: Date) => { if (!isRange) { - handleDateSelectSingle(date, startOrSelectedDate, setSelection); + handleDateSelectSingle({ + date, + selectedDate: startOrSelectedDate, + setSelection, + }); } else { context.setActiveRangePart(null); - handleDateSelectRange( + handleDateSelectRange({ date, - context.activeRangePart, - startOrSelectedDate, - context.endDate, + activeRangePart: context.activeRangePart, + startDate: startOrSelectedDate, + endDate: context.endDate, setSelection, - disabledDates - ); + disabledDates, + }); } }; diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index ed7c25878e1..d086910a572 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -21,7 +21,7 @@ export { DatePickerInput } from './DatePickerInput'; export type { DatePickerInputProps } from './DatePickerInput'; export { - Calendar, + CalendarWrapper, CalendarHeader, CalendarBody, CalendarFooter, diff --git a/packages/gamut/src/DatePicker/utils.ts b/packages/gamut/src/DatePicker/utils.ts index fade459d714..63106358b73 100644 --- a/packages/gamut/src/DatePicker/utils.ts +++ b/packages/gamut/src/DatePicker/utils.ts @@ -1,7 +1,8 @@ import { isDateInRange, isSameDay } from './Calendar/utils/dateGrid'; -import { - ActiveRangePart, +import type { + DatePickerBaseContextValue, DatePickerProps, + DatePickerRangeContextValue, DatePickerRangeProps, } from './types'; @@ -9,12 +10,18 @@ export const isRangeProps = ( props: DatePickerProps ): props is DatePickerRangeProps => props.mode === 'range'; +export type RangeContainsDisabledParams = { + start: Date; + end: Date; + disabledDates: Date[]; +}; + /** True if any disabled date falls within [start, end] (inclusive, by calendar day). */ -export const rangeContainsDisabled = ( - start: Date, - end: Date, - disabledDates: Date[] -) => { +export const rangeContainsDisabled = ({ + start, + end, + disabledDates, +}: RangeContainsDisabledParams) => { return disabledDates.some( (date) => isSameDay(date, start) || @@ -23,11 +30,16 @@ export const rangeContainsDisabled = ( ); }; -export const handleDateSelectSingle = ( - date: Date, - selectedDate: Date | null, - setSelection: (date: Date | null) => void -) => { +export type HandleDateSelectSingleParams = { + date: Date; + selectedDate: DatePickerBaseContextValue['startOrSelectedDate']; +} & Pick; + +export const handleDateSelectSingle = ({ + date, + selectedDate, + setSelection, +}: HandleDateSelectSingleParams) => { // If clicked date is the same as Start Date: Clear Start Date if (selectedDate && date.getTime() === selectedDate.getTime()) { setSelection(null); @@ -37,29 +49,44 @@ export const handleDateSelectSingle = ( setSelection(date); }; -const applyRangeOrNewStart = ( - start: Date, - end: Date, - clickedDate: Date, - disabledDates: Date[], - setSelection: (startDate: Date | null, endDate?: Date | null) => void -) => { +type ApplyRangeOrNewStartParams = { + start: Date; + end: Date; + clickedDate: Date; + disabledDates: Date[]; +} & Pick; + +const applyRangeOrNewStart = ({ + start, + end, + clickedDate, + disabledDates, + setSelection, +}: ApplyRangeOrNewStartParams) => { // if range contains disabled dates, set start date to clicked date and end date to null - if (rangeContainsDisabled(start, end, disabledDates)) { + if (rangeContainsDisabled({ start, end, disabledDates })) { setSelection(clickedDate, null); } else { setSelection(start, end); } }; -export const handleDateSelectRange = ( - date: Date, - activeRangePart: ActiveRangePart, - startDate: Date | null, - endDate: Date | null, - setSelection: (startDate: Date | null, endDate?: Date | null) => void, - disabledDates: Date[] = [] -) => { +export type HandleDateSelectRangeParams = { + date: Date; + startDate: DatePickerRangeContextValue['startOrSelectedDate']; +} & Pick< + DatePickerRangeContextValue, + 'activeRangePart' | 'endDate' | 'setSelection' | 'disabledDates' +>; + +export const handleDateSelectRange = ({ + date, + activeRangePart, + startDate, + endDate, + setSelection, + disabledDates = [], +}: HandleDateSelectRangeParams) => { // Range mode: field targeting (start or end input was focused) if (activeRangePart === 'start') { if (date.getTime() === startDate?.getTime()) { @@ -69,7 +96,13 @@ export const handleDateSelectRange = ( const newEnd = endDate != null && date.getTime() <= endDate.getTime() ? endDate : null; if (newEnd != null) { - applyRangeOrNewStart(date, newEnd, date, disabledDates, setSelection); + applyRangeOrNewStart({ + start: date, + end: newEnd, + clickedDate: date, + disabledDates, + setSelection, + }); } else { setSelection(date, newEnd); } @@ -85,7 +118,13 @@ export const handleDateSelectRange = ( ? startDate : null; if (newStart != null) { - applyRangeOrNewStart(newStart, date, date, disabledDates, setSelection); + applyRangeOrNewStart({ + start: newStart, + end: date, + clickedDate: date, + disabledDates, + setSelection, + }); } else { setSelection(newStart, date); } @@ -114,11 +153,23 @@ export const handleDateSelectRange = ( } // If clicked date > Start: Updates End Date to new date (Start remains) if (date.getTime() > startDate.getTime()) { - applyRangeOrNewStart(startDate, date, date, disabledDates, setSelection); + applyRangeOrNewStart({ + start: startDate, + end: date, + clickedDate: date, + disabledDates, + setSelection, + }); return; } // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left - applyRangeOrNewStart(date, endDate, date, disabledDates, setSelection); + applyRangeOrNewStart({ + start: date, + end: endDate, + clickedDate: date, + disabledDates, + setSelection, + }); return; } // Start is Set, End is Empty @@ -128,7 +179,13 @@ export const handleDateSelectRange = ( setSelection(date, null); } else { // If clicked date > Start: Sets it as End Date (if range valid) - applyRangeOrNewStart(startDate, date, date, disabledDates, setSelection); + applyRangeOrNewStart({ + start: startDate, + end: date, + clickedDate: date, + disabledDates, + setSelection, + }); } return; } From ca9a66bae932238aceb8882eb0192ec7dabf7fd9 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 26 Mar 2026 11:00:53 -0400 Subject: [PATCH 050/110] add full date aria label to date cells --- packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx | 3 ++- packages/gamut/src/DatePicker/Calendar/utils/format.ts | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 7dd9e639144..0b0d8f7785b 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -10,7 +10,7 @@ import { isSameDay, } from './utils/dateGrid'; import { DateButton, DateCell, TableHeader } from './utils/elements'; -import { getWeekdayNames } from './utils/format'; +import { formatDateForAriaLabel, getWeekdayNames } from './utils/format'; import { keyHandler } from './utils/keyHandler'; export const CalendarBody: React.FC = ({ @@ -171,6 +171,7 @@ export const CalendarBody: React.FC = ({ return ( { if (parts.length >= 3) return parsed; return null; }; + +export const formatDateForAriaLabel = (date: Date, locale?: string) => { + return new Intl.DateTimeFormat(locale, { + month: 'long', + day: 'numeric', + year: 'numeric', + }).format(date); +}; From 13d1a3dbcd1d26fcafb28b4f6436580994dbda7d Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 26 Mar 2026 15:15:54 -0400 Subject: [PATCH 051/110] move around files and clean up exports --- .../gamut/src/DatePicker/Calendar/index.tsx | 15 ++----- .../src/DatePicker/Calendar/utils/index.ts | 3 -- packages/gamut/src/DatePicker/index.tsx | 39 ++----------------- .../{utils.ts => utils/dateSelect.ts} | 4 +- .../DatePicker/{ => utils}/translations.ts | 0 packages/gamut/src/index.tsx | 1 + 6 files changed, 11 insertions(+), 51 deletions(-) delete mode 100644 packages/gamut/src/DatePicker/Calendar/utils/index.ts rename packages/gamut/src/DatePicker/{utils.ts => utils/dateSelect.ts} (98%) rename packages/gamut/src/DatePicker/{ => utils}/translations.ts (100%) diff --git a/packages/gamut/src/DatePicker/Calendar/index.tsx b/packages/gamut/src/DatePicker/Calendar/index.tsx index 3091c35c550..79dd3a3f1af 100644 --- a/packages/gamut/src/DatePicker/Calendar/index.tsx +++ b/packages/gamut/src/DatePicker/Calendar/index.tsx @@ -1,11 +1,4 @@ -export { CalendarWrapper } from './CalendarWrapper'; -export { CalendarHeader } from './CalendarHeader'; -export { CalendarBody } from './CalendarBody'; -export { CalendarFooter } from './CalendarFooter'; -export type { - CalendarHeaderProps, - CalendarBodyProps, - CalendarFooterProps, - QuickAction, -} from './types'; -export * from './utils'; +export * from './CalendarWrapper'; +export * from './CalendarHeader'; +export * from './CalendarBody'; +export * from './CalendarFooter'; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/index.ts b/packages/gamut/src/DatePicker/Calendar/utils/index.ts deleted file mode 100644 index 6598cd31155..00000000000 --- a/packages/gamut/src/DatePicker/Calendar/utils/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './dateGrid'; -export * from './format'; -export * from './validation'; diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index d086910a572..b92e9652725 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -1,35 +1,4 @@ -/** - * DatePicker – Single-date and range picker with input + calendar popover. - */ -export type { - DatePickerContextValue, - DatePickerProps, - DatePickerRangeProps, - DatePickerSingleProps, - OpenCalendarOptions, -} from './types'; - -export { DatePicker } from './DatePicker'; -export { - DatePickerContext, - DatePickerProvider, - useDatePicker, -} from './DatePickerContext'; -export { DatePickerCalendar } from './DatePickerCalendar'; -export type { DatePickerCalendarProps } from './DatePickerCalendar'; -export { DatePickerInput } from './DatePickerInput'; -export type { DatePickerInputProps } from './DatePickerInput'; - -export { - CalendarWrapper, - CalendarHeader, - CalendarBody, - CalendarFooter, -} from './Calendar'; -export type { - CalendarHeaderProps, - CalendarBodyProps, - CalendarFooterProps, - QuickAction, -} from './Calendar/types'; -export * from './Calendar/utils'; +export * from './DatePicker'; +export * from './DatePickerContext'; +export * from './DatePickerCalendar'; +export * from './DatePickerInput'; diff --git a/packages/gamut/src/DatePicker/utils.ts b/packages/gamut/src/DatePicker/utils/dateSelect.ts similarity index 98% rename from packages/gamut/src/DatePicker/utils.ts rename to packages/gamut/src/DatePicker/utils/dateSelect.ts index 63106358b73..40ade95db41 100644 --- a/packages/gamut/src/DatePicker/utils.ts +++ b/packages/gamut/src/DatePicker/utils/dateSelect.ts @@ -1,10 +1,10 @@ -import { isDateInRange, isSameDay } from './Calendar/utils/dateGrid'; +import { isDateInRange, isSameDay } from '../Calendar/utils/dateGrid'; import type { DatePickerBaseContextValue, DatePickerProps, DatePickerRangeContextValue, DatePickerRangeProps, -} from './types'; +} from '../types'; export const isRangeProps = ( props: DatePickerProps diff --git a/packages/gamut/src/DatePicker/translations.ts b/packages/gamut/src/DatePicker/utils/translations.ts similarity index 100% rename from packages/gamut/src/DatePicker/translations.ts rename to packages/gamut/src/DatePicker/utils/translations.ts diff --git a/packages/gamut/src/index.tsx b/packages/gamut/src/index.tsx index 3742d5afa77..8f1bb36439f 100644 --- a/packages/gamut/src/index.tsx +++ b/packages/gamut/src/index.tsx @@ -16,6 +16,7 @@ export * from './Coachmark'; export * from './ConnectedForm'; export * from './ContentContainer'; export * from './DatePicker'; +export * from './DatePicker/Calendar'; export * from './DelayedRenderWrapper'; export * from './Disclosure'; export * from './DataList'; From 6d9082ac28b8c6c16349310428f83750d0815bd6 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 26 Mar 2026 15:15:59 -0400 Subject: [PATCH 052/110] update locale --- .../src/DatePicker/Calendar/CalendarBody.tsx | 12 +++-- .../DatePicker/Calendar/CalendarFooter.tsx | 6 ++- .../DatePicker/Calendar/CalendarHeader.tsx | 8 +-- .../gamut/src/DatePicker/Calendar/types.ts | 7 ++- .../src/DatePicker/Calendar/utils/format.ts | 45 +++++++++-------- packages/gamut/src/DatePicker/DatePicker.tsx | 11 +++-- .../src/DatePicker/DatePickerCalendar.tsx | 5 +- packages/gamut/src/DatePicker/types.ts | 9 +++- packages/gamut/src/DatePicker/utils/locale.ts | 49 +++++++++++++++++++ 9 files changed, 114 insertions(+), 38 deletions(-) create mode 100644 packages/gamut/src/DatePicker/utils/locale.ts diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 0b0d8f7785b..d851a4b5160 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import * as React from 'react'; +import { useResolvedLocale } from '../utils/locale'; import { CalendarBodyProps } from './types'; import { getDatesWithRow, @@ -30,11 +31,16 @@ export const CalendarBody: React.FC = ({ hasAdjacentMonthLeft, focusGridSync, }) => { + const resolvedLocale = useResolvedLocale(locale); const year = displayDate.getFullYear(); const month = displayDate.getMonth(); const weeks = getMonthGrid(year, month, weekStartsOn); - const weekdayLabels = getWeekdayNames('short', locale, weekStartsOn); - const weekdayFullNames = getWeekdayNames('long', locale, weekStartsOn); + const weekdayLabels = getWeekdayNames('short', resolvedLocale, weekStartsOn); + const weekdayFullNames = getWeekdayNames( + 'long', + resolvedLocale, + weekStartsOn + ); const buttonRefs = useRef>(new Map()); const tableRef = useRef(null); @@ -171,7 +177,7 @@ export const CalendarBody: React.FC = ({ return ( = ({ disabled, showClearButton, }) => { + const resolvedLocale = useResolvedLocale(locale); // const actions = quickActions.slice(0, 3); return ( @@ -51,7 +53,7 @@ export const CalendarFooter: React.FC = ({ )} onTodayClick?.()}> - {getRelativeTodayLabel(locale)} + {getRelativeTodayLabel(resolvedLocale)} diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx index 9b0eb69151a..102b8c221cb 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import { FlexBox } from '../../Box'; import { IconButton } from '../../Button'; import { Text } from '../../Typography'; +import { useResolvedLocale } from '../utils/locale'; import { CalendarHeaderProps } from './types'; import { formatMonthYear, getRelativeMonthLabels } from './utils/format'; @@ -19,7 +20,8 @@ export const CalendarHeader: React.FC = ({ locale, headingId, }) => { - const { nextMonth, lastMonth } = getRelativeMonthLabels(locale); + const resolvedLocale = useResolvedLocale(locale); + const { nextMonth, lastMonth } = getRelativeMonthLabels(resolvedLocale); const handleLastMonth = () => { const lastMonth = new Date( @@ -64,7 +66,7 @@ export const CalendarHeader: React.FC = ({ id={headingId} textAlign="center" > - {formatMonthYear(displayDate, locale)} + {formatMonthYear(displayDate, resolvedLocale)} {secondDisplayDate && ( @@ -81,7 +83,7 @@ export const CalendarHeader: React.FC = ({ fontWeight="title" textAlign="center" > - {formatMonthYear(secondDisplayDate, locale)} + {formatMonthYear(secondDisplayDate, resolvedLocale)} )} diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index 84c9e184025..753d6593ad9 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -3,8 +3,11 @@ export interface CalendarBaseProps { displayDate: Date; /** Called when the displayed month changes. Pass the new date (e.g. setDisplayDate) so the calendar updates. */ onDisplayDateChange: (newDate: Date) => void; - /** Locale for month/year formatting and translations (e.g. 'en-US') */ - locale?: string; + /** + * 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; /** Dates that should be disabled (unselectable) */ disabledDates?: Date[]; } diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index 7b5fcacc3be..71a8af69913 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -1,20 +1,19 @@ -/** - * Date formatting for the calendar using Intl.DateTimeFormat. - */ - +import { stringifyLocale } from '../../utils/locale'; import { isValidDate } from './validation'; /** * Capitalize the first character of a string using the locale; rest unchanged (e.g. "next month" → "Next month"). */ -export const capitalizeFirst = (str: string, locale?: string) => - str.length === 0 ? str : str[0].toLocaleUpperCase(locale) + str.slice(1); +export const capitalizeFirst = (str: string, locale: Intl.Locale) => + str.length === 0 + ? str + : str[0].toLocaleUpperCase(stringifyLocale(locale)) + str.slice(1); /** * Format month and year for the calendar header (e.g. "February 2026"). */ -export const formatMonthYear = (date: Date, locale?: string) => { - return new Intl.DateTimeFormat(locale, { +export const formatMonthYear = (date: Date, locale: Intl.Locale) => { + return new Intl.DateTimeFormat(stringifyLocale(locale), { month: 'long', year: 'numeric', }).format(date); @@ -27,10 +26,10 @@ export const formatMonthYear = (date: Date, locale?: string) => { */ export const getWeekdayNames = ( format: 'short' | 'long', - locale?: string, + locale: Intl.Locale, weekStartsOn: 0 | 1 = 0 ) => { - const formatter = new Intl.DateTimeFormat(locale, { + const formatter = new Intl.DateTimeFormat(stringifyLocale(locale), { weekday: format, }); // Jan 7, 2024 is a Sunday; add 0..6 days to get Sun..Sat @@ -50,8 +49,10 @@ export const getWeekdayNames = ( * Get localized "next month" and "previous month" labels for calendar nav. * Uses Intl.RelativeTimeFormat with numeric: "auto" (e.g. "next month", "last month"). */ -export const getRelativeMonthLabels = (locale?: string) => { - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); +export const getRelativeMonthLabels = (locale: Intl.Locale) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); return { nextMonth: capitalizeFirst(rtf.format(1, 'month'), locale), lastMonth: capitalizeFirst(rtf.format(-1, 'month'), locale), @@ -61,8 +62,10 @@ export const getRelativeMonthLabels = (locale?: string) => { /** * Get localized "today" label (e.g. "today"). */ -export const getRelativeTodayLabel = (locale?: string) => { - const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' }); +export const getRelativeTodayLabel = (locale: Intl.Locale) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); return capitalizeFirst(rtf.format(0, 'day'), locale); }; @@ -71,8 +74,8 @@ export const getRelativeTodayLabel = (locale?: string) => { * "DD/MM/YYYY" for en-GB). Uses Intl.DateTimeFormat formatToParts to infer * order and separators. Useful for parsing or building locale-aware inputs. */ -export const getDateFormatPattern = (locale?: string) => { - const parts = new Intl.DateTimeFormat(locale, { +export const getDateFormatPattern = (locale: Intl.Locale) => { + const parts = new Intl.DateTimeFormat(stringifyLocale(locale), { year: 'numeric', month: '2-digit', day: '2-digit', @@ -97,8 +100,8 @@ export const getDateFormatPattern = (locale?: string) => { /** * Format a date for display in the date picker input (e.g. "2/15/2026"). */ -export const formatDateForInput = (date: Date, locale?: string) => { - return new Intl.DateTimeFormat(locale, { +export const formatDateForInput = (date: Date, locale: Intl.Locale) => { + return new Intl.DateTimeFormat(stringifyLocale(locale), { month: 'numeric', day: 'numeric', year: 'numeric', @@ -112,7 +115,7 @@ export const formatDateForInput = (date: Date, locale?: string) => { */ // this logic needs some work -export const parseDateFromInput = (value: string, locale?: string) => { +export const parseDateFromInput = (value: string, locale: Intl.Locale) => { const trimmed = value.trim(); if (!trimmed) return null; const parsed = new Date(trimmed); @@ -124,8 +127,8 @@ export const parseDateFromInput = (value: string, locale?: string) => { return null; }; -export const formatDateForAriaLabel = (date: Date, locale?: string) => { - return new Intl.DateTimeFormat(locale, { +export const formatDateForAriaLabel = (date: Date, locale: Intl.Locale) => { + return new Intl.DateTimeFormat(stringifyLocale(locale), { month: 'long', day: 'numeric', year: 'numeric', diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index b61f3ab2cd9..ee191e1d343 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -6,13 +6,14 @@ import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; import { DatePickerProvider } from './DatePickerContext'; import { DatePickerInput } from './DatePickerInput'; -import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './translations'; import type { DatePickerContextValue, DatePickerProps, OpenCalendarOptions, } from './types'; -import { isRangeProps } from './utils'; +import { isRangeProps } from './utils/dateSelect'; +import { useResolvedLocale } from './utils/locale'; +import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './utils/translations'; /** * DatePicker: single-date or range. Holds shared state and provides it via context. @@ -43,6 +44,8 @@ export const DatePicker: React.FC = (props) => { setGridFocusRequested(false); }, []); + const resolvedLocale = useResolvedLocale(locale); + const openCalendar = useCallback((options?: OpenCalendarOptions) => { const moveFocus = options?.moveFocusIntoCalendar ?? false; setIsCalendarOpen(true); @@ -98,7 +101,7 @@ export const DatePicker: React.FC = (props) => { gridFocusRequested, clearGridFocusRequest, closeCalendar, - locale, + locale: resolvedLocale, disabledDates, calendarDialogId, translations, @@ -126,7 +129,7 @@ export const DatePicker: React.FC = (props) => { gridFocusRequested, clearGridFocusRequest, closeCalendar, - locale, + resolvedLocale, disabledDates, calendarDialogId, translationsProp, diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 2c44e49cba1..41a44e93ec8 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -10,7 +10,10 @@ import { CalendarWrapper, } from './Calendar'; import { useDatePicker } from './DatePickerContext'; -import { handleDateSelectRange, handleDateSelectSingle } from './utils'; +import { + handleDateSelectRange, + handleDateSelectSingle, +} from './utils/dateSelect'; export type DatePickerCalendarProps = { /** id for the dialog (for aria-controls from input). */ diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 1e9579074b1..78b4f8f2049 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -2,7 +2,7 @@ import { ComponentProps } from 'react'; import { Input } from '../Form/inputs/Input'; import { CalendarBaseProps } from './Calendar/types'; -import { DatePickerTranslations } from './translations'; +import { DatePickerTranslations } from './utils/translations'; export interface DatePickerBaseProps extends Pick { @@ -52,7 +52,12 @@ export type OpenCalendarOptions = { }; export interface DatePickerBaseContextValue - extends Pick { + extends Pick { + /** + * Resolved `Intl.Locale` from the `locale` prop (or runtime default). Same instance passed to + * formatters and available for `getWeekInfo()` etc. + */ + locale: Intl.Locale; isCalendarOpen: boolean; openCalendar: (options?: OpenCalendarOptions) => void; /** Move focus from the input into the grid when the calendar is already open (e.g. ArrowDown). */ diff --git a/packages/gamut/src/DatePicker/utils/locale.ts b/packages/gamut/src/DatePicker/utils/locale.ts new file mode 100644 index 00000000000..f58a68f4dd0 --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/locale.ts @@ -0,0 +1,49 @@ +import { useMemo } 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 (e.g. `getWeekInfo()` in supporting environments). + * + * - `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)); +}; + +/** + * Memoized {@link resolveLocale} for calendar subcomponents. Pass the same `locale` prop you accept + * from `CalendarBaseProps` (optional `Intl.LocalesArgument`). + */ +export const useResolvedLocale = (locale?: Intl.LocalesArgument) => + useMemo(() => resolveLocale(locale), [locale]); + +/** + * Convert an Intl.Locale to a string. This is necessary the Intl.DateTimeFormat constructor only accepts a string in some versions of TS. + * @param locale - The Intl.Locale to convert to a string. + * @returns The stringified locale. + */ +export const stringifyLocale = (locale: Intl.Locale) => locale.toString(); From 15ceaf4fa878d0113566a247a613b801918ad7e7 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 27 Mar 2026 10:49:02 -0400 Subject: [PATCH 053/110] week starts on from locale + polyfill --- packages/gamut/package.json | 1 + .../src/DatePicker/Calendar/CalendarBody.tsx | 15 ++--- .../gamut/src/DatePicker/Calendar/types.ts | 9 ++- .../src/DatePicker/Calendar/utils/dateGrid.ts | 23 ++++--- .../src/DatePicker/Calendar/utils/format.ts | 22 +++---- .../src/DatePicker/DatePickerCalendar.tsx | 10 +-- packages/gamut/src/DatePicker/index.tsx | 1 + packages/gamut/src/DatePicker/utils/locale.ts | 64 ++++++++++++++++++- yarn.lock | 63 ++++++++++++++++++ 9 files changed, 172 insertions(+), 36 deletions(-) diff --git a/packages/gamut/package.json b/packages/gamut/package.json index 541303efdc3..82cacfc6305 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -9,6 +9,7 @@ "@codecademy/gamut-patterns": "0.10.26", "@codecademy/gamut-styles": "17.12.1", "@codecademy/variance": "0.26.0", + "@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/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index d851a4b5160..5800ad6fe03 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import * as React from 'react'; -import { useResolvedLocale } from '../utils/locale'; +import { useIsoFirstWeekday, useResolvedLocale } from '../utils/locale'; import { CalendarBodyProps } from './types'; import { getDatesWithRow, @@ -21,7 +21,7 @@ export const CalendarBody: React.FC = ({ disabledDates = [], onDateSelect, locale, - weekStartsOn = 0, + weekStartsOn, labelledById, focusedDate, onFocusedDateChange, @@ -32,15 +32,12 @@ export const CalendarBody: React.FC = ({ focusGridSync, }) => { const resolvedLocale = useResolvedLocale(locale); + const firstWeekday = useIsoFirstWeekday(resolvedLocale, weekStartsOn); const year = displayDate.getFullYear(); const month = displayDate.getMonth(); - const weeks = getMonthGrid(year, month, weekStartsOn); - const weekdayLabels = getWeekdayNames('short', resolvedLocale, weekStartsOn); - const weekdayFullNames = getWeekdayNames( - 'long', - resolvedLocale, - weekStartsOn - ); + const weeks = getMonthGrid(year, month, firstWeekday); + const weekdayLabels = getWeekdayNames('short', resolvedLocale, firstWeekday); + const weekdayFullNames = getWeekdayNames('long', resolvedLocale, firstWeekday); const buttonRefs = useRef>(new Map()); const tableRef = useRef(null); diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index 753d6593ad9..931ef7b6562 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -1,3 +1,5 @@ +import type { IsoWeekday } from '../utils/locale'; + export interface CalendarBaseProps { /** Used for the currently displayed month and year */ displayDate: Date; @@ -30,8 +32,11 @@ export interface CalendarBodyProps extends CalendarBaseProps { endDate?: Date | null; /** Called when a date cell is selected */ onDateSelect: (date: Date) => void; - /** 0 = Sunday, 1 = Monday (default from locale if not set) */ - weekStartsOn?: 0 | 1; + /** + * Force first column to this ISO weekday (1 = Monday … 7 = Sunday). Same scale as + * `Intl.Locale#getWeekInfo().firstDay`. Omit to use locale (polyfill where needed). + */ + weekStartsOn?: IsoWeekday; /** Id of the month/year heading (aria-labelledby on grid) */ labelledById: string; /** For keyboard nav: which cell has focus (roving tabindex) */ diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts index a227b2c4227..17a204e246a 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts @@ -3,6 +3,8 @@ * Each row has 7 cells; leading/trailing cells may be null (padding from adjacent months). */ +import type { IsoWeekday } from '../../utils/locale'; + const DAYS_PER_WEEK = 7; /** @@ -17,13 +19,16 @@ const normalizeDate = (date: Date) => { }; /** - * Get the weekday for a date (0 = Sunday, 6 = Saturday). - * Optionally use weekStartsOn to compute "offset" for display (e.g. Monday = 0). + * 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 getDayOfWeek = (date: Date, weekStartsOn: 0 | 1 = 0) => { - const sundayBased = date.getDay(); - if (weekStartsOn === 0) return sundayBased; - return (sundayBased + 6) % 7; // Monday = 0 +export const getWeekdayOffsetInGrid = ( + date: Date, + firstWeekday: IsoWeekday +) => { + const js = date.getDay(); + const iso = js === 0 ? 7 : js; + return (iso - firstWeekday + 14) % 7; }; /** @@ -32,16 +37,16 @@ export const getDayOfWeek = (date: Date, weekStartsOn: 0 | 1 = 0) => { * * @param year - Full year (e.g. 2026) * @param month - Month 0-11 (0 = January) - * @param weekStartsOn - 0 = Sunday, 1 = Monday + * @param firstWeekday - First day of the week for the calendar row (ISO 1–7, from `getWeekInfo().firstDay`) */ export const getMonthGrid = ( year: number, month: number, - weekStartsOn: 0 | 1 = 0 + firstWeekday: IsoWeekday ) => { const first = new Date(year, month, 1); const last = new Date(year, month + 1, 0); - const firstDayOfWeek = getDayOfWeek(first, weekStartsOn); + const firstDayOfWeek = getWeekdayOffsetInGrid(first, firstWeekday); const daysInMonth = last.getDate(); const weeks: (Date | null)[][] = []; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index 71a8af69913..80dff7b55b3 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -1,3 +1,4 @@ +import type { IsoWeekday } from '../../utils/locale'; import { stringifyLocale } from '../../utils/locale'; import { isValidDate } from './validation'; @@ -21,28 +22,27 @@ export const formatMonthYear = (date: Date, locale: Intl.Locale) => { /** * Get weekday names for column headers or abbr attributes. - * Order depends on weekStartsOn: 0 = Sunday first, 1 = Monday first. + * 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: 'short' | 'long', locale: Intl.Locale, - weekStartsOn: 0 | 1 = 0 + firstWeekday: IsoWeekday ) => { const formatter = new Intl.DateTimeFormat(stringifyLocale(locale), { weekday: format, }); - // Jan 7, 2024 is a Sunday; add 0..6 days to get Sun..Sat - const sunday = new Date(2024, 0, 7); - const names = Array.from({ length: 7 }, (_, i) => { - const date = new Date(sunday); - date.setDate(sunday.getDate() + i); + 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); }); - if (weekStartsOn === 1) { - return [...names.slice(1), names[0]]; - } - return names; + return Array.from({ length: 7 }, (_, j) => { + const iso = ((firstWeekday - 1 + j) % 7) + 1; + return namesMonToSun[iso - 1]; + }); }; /** diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 41a44e93ec8..41ef0ee4ba0 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -9,17 +9,19 @@ import { CalendarHeader, CalendarWrapper, } from './Calendar'; +import type { CalendarBodyProps } from './Calendar/types'; import { useDatePicker } from './DatePickerContext'; import { handleDateSelectRange, handleDateSelectSingle, } from './utils/dateSelect'; -export type DatePickerCalendarProps = { +export type DatePickerCalendarProps = Pick< + CalendarBodyProps, + 'weekStartsOn' +> & { /** id for the dialog (for aria-controls from input). */ dialogId: string; - /** Whether to start the calendar on Sunday (0) or Monday (1). Default is Sunday. */ - weekStartsOn?: 0 | 1; }; /** @@ -29,7 +31,7 @@ export type DatePickerCalendarProps = { */ export const DatePickerCalendar: React.FC = ({ dialogId, - weekStartsOn = 0, + weekStartsOn, }) => { const context = useDatePicker(); const generatedId = useId(); diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index b92e9652725..d9e3b5b31c7 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -2,3 +2,4 @@ export * from './DatePicker'; export * from './DatePickerContext'; export * from './DatePickerCalendar'; export * from './DatePickerInput'; +export type { IsoWeekday } from './utils/locale'; diff --git a/packages/gamut/src/DatePicker/utils/locale.ts b/packages/gamut/src/DatePicker/utils/locale.ts index f58a68f4dd0..5116b13e14b 100644 --- a/packages/gamut/src/DatePicker/utils/locale.ts +++ b/packages/gamut/src/DatePicker/utils/locale.ts @@ -1,4 +1,8 @@ -import { useMemo } from 'react'; +// 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. @@ -47,3 +51,61 @@ export const useResolvedLocale = (locale?: Intl.LocalesArgument) => * @returns The stringified 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. + * - `weekStartsOnOverride` — ISO weekday **1–7** (Monday … Sunday), same as `getWeekInfo().firstDay` + * - omitted → `locale.getWeekInfo().firstDay` when available, else **7** (Sunday) + */ +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 { + /* ignore */ + } + return 7; +}; + +/** + * Hook: 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/yarn.lock b/yarn.lock index cf921564b0d..953292121d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1714,6 +1714,7 @@ __metadata: "@codecademy/gamut-patterns": "npm:0.10.26" "@codecademy/gamut-styles": "npm:17.12.1" "@codecademy/variance": "npm:0.26.0" + "@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" @@ -2512,6 +2513,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" @@ -2524,6 +2532,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" @@ -2533,6 +2552,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" @@ -2554,6 +2580,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" @@ -2563,6 +2607,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 + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" From 2effd5fc874023a26459287be90391f1d4d928a8 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 27 Mar 2026 14:47:24 -0400 Subject: [PATCH 054/110] remove button from DateCell --- .../src/DatePicker/Calendar/CalendarBody.tsx | 49 +++--- .../DatePicker/Calendar/CalendarHeader.tsx | 2 +- .../DatePicker/Calendar/utils/elements.tsx | 155 ++++++++++++------ .../src/DatePicker/DatePickerCalendar.tsx | 2 +- 4 files changed, 127 insertions(+), 81 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 5800ad6fe03..bd0e808759d 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -10,7 +10,7 @@ import { isDateInRange, isSameDay, } from './utils/dateGrid'; -import { DateButton, DateCell, TableHeader } from './utils/elements'; +import { CalendarTable, DateCell, TableHeader } from './utils/elements'; import { formatDateForAriaLabel, getWeekdayNames } from './utils/format'; import { keyHandler } from './utils/keyHandler'; @@ -37,7 +37,11 @@ export const CalendarBody: React.FC = ({ const month = displayDate.getMonth(); const weeks = getMonthGrid(year, month, firstWeekday); const weekdayLabels = getWeekdayNames('short', resolvedLocale, firstWeekday); - const weekdayFullNames = getWeekdayNames('long', resolvedLocale, firstWeekday); + const weekdayFullNames = getWeekdayNames( + 'long', + resolvedLocale, + firstWeekday + ); const buttonRefs = useRef>(new Map()); const tableRef = useRef(null); @@ -132,12 +136,7 @@ export const CalendarBody: React.FC = ({ }, []); return ( -
+ {weekdayLabels.map((label, i) => ( @@ -153,7 +152,7 @@ export const CalendarBody: React.FC = ({ {week.map((date, colIndex) => { if (date === null) { return ( - = ({ setButtonRef(date, el as HTMLElement | null)} role="gridcell" + tabIndex={isFocused ? 0 : -1} + onClick={() => onDateSelect(date)} + onFocus={() => onFocusedDateChange?.(date)} + onKeyDown={(e: React.KeyboardEvent) => handleKeyDown(e, date)} > - setButtonRef(date, el as HTMLElement | null)} - tabIndex={isFocused ? 0 : -1} - variant="secondary" - onClick={() => onDateSelect(date)} - onFocus={() => onFocusedDateChange?.(date)} - onKeyDown={(e: React.KeyboardEvent) => - handleKeyDown(e, date) - } - > - {date.getDate()} - + {date.getDate()} ); })} ))} -
+ ); }; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx index 102b8c221cb..6549dacb800 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx @@ -44,7 +44,7 @@ export const CalendarHeader: React.FC = ({ }; return ( - + ; + +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.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 41ef0ee4ba0..1eb4099dc1a 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -139,7 +139,7 @@ export const DatePickerCalendar: React.FC = ({ return ( - + Date: Fri, 27 Mar 2026 16:16:05 -0400 Subject: [PATCH 055/110] refactor CalendarHeader --- .../DatePicker/Calendar/CalendarHeader.tsx | 114 ++++++------------ .../Calendar/CalendarNavLastMonth.tsx | 38 ++++++ .../Calendar/CalendarNavNextMonth.tsx | 38 ++++++ .../gamut/src/DatePicker/Calendar/index.tsx | 2 + .../gamut/src/DatePicker/Calendar/types.ts | 12 +- .../src/DatePicker/DatePickerCalendar.tsx | 65 +++++----- 6 files changed, 157 insertions(+), 112 deletions(-) create mode 100644 packages/gamut/src/DatePicker/Calendar/CalendarNavLastMonth.tsx create mode 100644 packages/gamut/src/DatePicker/Calendar/CalendarNavNextMonth.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx index 6549dacb800..76b2c7f1b7d 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx @@ -1,100 +1,54 @@ -import { - MiniChevronLeftIcon, - MiniChevronRightIcon, -} from '@codecademy/gamut-icons'; import * as React from 'react'; import { FlexBox } from '../../Box'; -import { IconButton } from '../../Button'; import { Text } from '../../Typography'; import { useResolvedLocale } from '../utils/locale'; +import { CalendarNavLastMonth } from './CalendarNavLastMonth'; +import { CalendarNavNextMonth } from './CalendarNavNextMonth'; import { CalendarHeaderProps } from './types'; -import { formatMonthYear, getRelativeMonthLabels } from './utils/format'; +import { formatMonthYear } from './utils/format'; export const CalendarHeader: React.FC = ({ displayDate, + locale, + headingId, onDisplayDateChange, - secondDisplayDate, + hideLastNav, + hideNextNav, onLastMonthClick, onNextMonthClick, - locale, - headingId, }) => { const resolvedLocale = useResolvedLocale(locale); - const { nextMonth, lastMonth } = getRelativeMonthLabels(resolvedLocale); - - const handleLastMonth = () => { - const lastMonth = new Date( - displayDate.getFullYear(), - displayDate.getMonth() - 1, - 1 - ); - onDisplayDateChange?.(lastMonth); - onLastMonthClick?.(); - }; - - const handleNextMonth = () => { - const nextMonth = new Date( - displayDate.getFullYear(), - displayDate.getMonth() + 1, - 1 - ); - onDisplayDateChange?.(nextMonth); - onNextMonthClick?.(); - }; return ( - - - - - - {formatMonthYear(displayDate, resolvedLocale)} - - - {secondDisplayDate && ( - - - {formatMonthYear(secondDisplayDate, resolvedLocale)} - - - )} + + {!hideLastNav && ( + + )} + + + {formatMonthYear(displayDate, resolvedLocale)} + - + {!hideNextNav && ( + + )} ); }; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarNavLastMonth.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarNavLastMonth.tsx new file mode 100644 index 00000000000..39d8dfe6c4b --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/CalendarNavLastMonth.tsx @@ -0,0 +1,38 @@ +import { MiniChevronLeftIcon } from '@codecademy/gamut-icons'; +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, + locale, +}) => { + const resolvedLocale = useResolvedLocale(locale); + const { lastMonth } = getRelativeMonthLabels(resolvedLocale); + + const handleLastMonth = () => { + const lastMonth = new Date( + displayDate.getFullYear(), + displayDate.getMonth() - 1, + 1 + ); + onDisplayDateChange?.(lastMonth); + onLastMonthClick?.(); + }; + + return ( + + ); +}; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarNavNextMonth.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarNavNextMonth.tsx new file mode 100644 index 00000000000..ac2335c1c58 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/CalendarNavNextMonth.tsx @@ -0,0 +1,38 @@ +import { MiniChevronRightIcon } from '@codecademy/gamut-icons'; +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, + locale, +}) => { + const resolvedLocale = useResolvedLocale(locale); + const { nextMonth } = getRelativeMonthLabels(resolvedLocale); + + const handleNextMonth = () => { + const nextMonth = new Date( + displayDate.getFullYear(), + displayDate.getMonth() + 1, + 1 + ); + onDisplayDateChange?.(nextMonth); + onNextMonthClick?.(); + }; + + return ( + + ); +}; diff --git a/packages/gamut/src/DatePicker/Calendar/index.tsx b/packages/gamut/src/DatePicker/Calendar/index.tsx index 79dd3a3f1af..b40af89a23d 100644 --- a/packages/gamut/src/DatePicker/Calendar/index.tsx +++ b/packages/gamut/src/DatePicker/Calendar/index.tsx @@ -2,3 +2,5 @@ export * from './CalendarWrapper'; export * from './CalendarHeader'; export * from './CalendarBody'; export * from './CalendarFooter'; +export * from './CalendarNavLastMonth'; +export * from './CalendarNavNextMonth'; diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index 931ef7b6562..5e8a54a411e 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -13,14 +13,20 @@ export interface CalendarBaseProps { /** Dates that should be disabled (unselectable) */ disabledDates?: Date[]; } -export interface CalendarHeaderProps + +export interface CalendarNavProps extends Omit { - /** Used for the currently displayed second month and year when in two-month view */ - secondDisplayDate?: Date; /** Called after navigating to previous month. */ onLastMonthClick?: () => void; /** Called after navigating to next month */ onNextMonthClick?: () => void; +} + +export interface CalendarHeaderProps extends CalendarNavProps { + hideLastNav?: boolean; + hideNextNav?: boolean; + /** Used for the currently displayed second month and year when in two-month view */ + secondDisplayDate?: Date; /** id for the heading (for grid aria-labelledby) */ headingId: string; } diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 1eb4099dc1a..28976f5067c 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -139,15 +139,15 @@ export const DatePickerCalendar: React.FC = ({ return ( - - - + + + = ({ onEscapeKeyPress={closeCalendar} onFocusedDateChange={setFocusedDate} /> - - - - - + + + + + + Date: Mon, 30 Mar 2026 10:31:16 -0400 Subject: [PATCH 056/110] fix errors --- .../__snapshots__/gamut.test.ts.snap | 19 +----- packages/gamut/jest.config.ts | 2 +- packages/gamut/package.json | 1 + yarn.lock | 63 +++++++++++++++++++ 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 46b12be1dfc..4a442d6e4e5 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -14,13 +14,14 @@ exports[`Gamut Exported Keys 1`] = ` "BodyPortal", "Box", "Breadcrumbs", - "Calendar", "CalendarBody", "CalendarFooter", "CalendarHeader", + "CalendarNavLastMonth", + "CalendarNavNextMonth", + "CalendarWrapper", "Card", "Checkbox", - "clampToMonth", "Coachmark", "Column", "ConnectedCheckbox", @@ -57,9 +58,6 @@ exports[`Gamut Exported Keys 1`] = ` "FocusTrap", "focusVisibleStyle", "Form", - "formatDateForInput", - "formatDateRangeForInput", - "formatMonthYear", "FormError", "FormGroup", "FormGroupDescription", @@ -67,11 +65,7 @@ exports[`Gamut Exported Keys 1`] = ` "FormPropsContext", "FormRequiredText", "generateResponsiveClassnames", - "getDayOfWeek", "getFocusableElements", - "getMonthGrid", - "getWeekdayFullNames", - "getWeekdayLabels", "GridBox", "GridForm", "GridFormContent", @@ -81,11 +75,6 @@ exports[`Gamut Exported Keys 1`] = ` "InfoTip", "Input", "isClickableCrumb", - "isDateDisabled", - "isDateInRange", - "isPastDate", - "isSameDay", - "isValidDate", "LayoutGrid", "List", "ListCol", @@ -98,8 +87,6 @@ exports[`Gamut Exported Keys 1`] = ` "omitProps", "Overlay", "Pagination", - "parseDateFromInput", - "parseDateRangeFromInput", "Popover", "PopoverContainer", "PreviewTip", 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 440d6032e9e..34fece3624c 100644 --- a/packages/gamut/package.json +++ b/packages/gamut/package.json @@ -9,6 +9,7 @@ "@codecademy/gamut-patterns": "0.10.28", "@codecademy/gamut-styles": "17.13.1", "@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/yarn.lock b/yarn.lock index 25f946ad83f..ecf63f4a4a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1841,6 +1841,7 @@ __metadata: "@codecademy/gamut-patterns": "npm:0.10.28" "@codecademy/gamut-styles": "npm:17.13.1" "@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" @@ -2447,6 +2448,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" @@ -2459,6 +2467,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" @@ -2468,6 +2487,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" @@ -2489,6 +2515,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" @@ -2498,6 +2542,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 + "@humanwhocodes/config-array@npm:^0.13.0": version: 0.13.0 resolution: "@humanwhocodes/config-array@npm:0.13.0" From 4df63bb8334cef9f1e1104f3b0c0aa07d1cb4508 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 30 Mar 2026 10:55:41 -0400 Subject: [PATCH 057/110] fix ci errors --- .../src/DatePicker/Calendar/CalendarBody.tsx | 1 + .../DatePicker/Calendar/utils/keyHandler.ts | 2 +- yarn.lock | 308 +----------------- 3 files changed, 18 insertions(+), 293 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index bd0e808759d..34dbb6a477d 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -152,6 +152,7 @@ export const CalendarBody: React.FC = ({ {week.map((date, colIndex) => { if (date === null) { return ( + // eslint-disable-next-line jsx-a11y/control-has-associated-label =5.1.0, safe-buffer@npm:^5.1.0, safe-buffer@npm:~5.2.0": +"safe-buffer@npm:5.2.1, safe-buffer@npm:>=5.1.0, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -20329,18 +20157,6 @@ __metadata: languageName: node linkType: hard -"schema-utils@npm:^4.3.2": - version: 4.3.2 - resolution: "schema-utils@npm:4.3.2" - dependencies: - "@types/json-schema": "npm:^7.0.9" - ajv: "npm:^8.9.0" - ajv-formats: "npm:^2.1.1" - ajv-keywords: "npm:^5.1.0" - checksum: 10c0/981632f9bf59f35b15a9bcdac671dd183f4946fe4b055ae71a301e66a9797b95e5dd450de581eb6cca56fb6583ce8f24d67b2d9f8e1b2936612209697f6c277e - languageName: node - linkType: hard - "screenfull@npm:^5.0.0": version: 5.2.0 resolution: "screenfull@npm:5.2.0" @@ -20440,15 +20256,6 @@ __metadata: languageName: node linkType: hard -"serialize-javascript@npm:^6.0.2": - version: 6.0.2 - resolution: "serialize-javascript@npm:6.0.2" - dependencies: - randombytes: "npm:^2.1.0" - checksum: 10c0/2dd09ef4b65a1289ba24a788b1423a035581bef60817bea1f01eda8e3bda623f86357665fe7ac1b50f6d4f583f97db9615b3f07b2a2e8cbcb75033965f771dd2 - languageName: node - linkType: hard - "serialize-javascript@npm:^7.0.3": version: 7.0.4 resolution: "serialize-javascript@npm:7.0.4" @@ -21405,20 +21212,13 @@ __metadata: languageName: node linkType: hard -"tapable@npm:2.3.0, tapable@npm:^2.3.0": +"tapable@npm:2.3.0, tapable@npm:^2.0.0, tapable@npm:^2.2.1, tapable@npm:^2.3.0": version: 2.3.0 resolution: "tapable@npm:2.3.0" checksum: 10c0/cb9d67cc2c6a74dedc812ef3085d9d681edd2c1fa18e4aef57a3c0605fdbe44e6b8ea00bd9ef21bc74dd45314e39d31227aa031ebf2f5e38164df514136f2681 languageName: node linkType: hard -"tapable@npm:^2.0.0, tapable@npm:^2.1.1, tapable@npm:^2.2.0, tapable@npm:^2.2.1": - version: 2.2.1 - resolution: "tapable@npm:2.2.1" - checksum: 10c0/bc40e6efe1e554d075469cedaba69a30eeb373552aaf41caeaaa45bf56ffacc2674261b106245bd566b35d8f3329b52d838e851ee0a852120acae26e622925c9 - languageName: node - linkType: hard - "tar-stream@npm:~2.2.0": version: 2.2.0 resolution: "tar-stream@npm:2.2.0" @@ -21480,28 +21280,6 @@ __metadata: languageName: node linkType: hard -"terser-webpack-plugin@npm:^5.3.11": - version: 5.3.14 - resolution: "terser-webpack-plugin@npm:5.3.14" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.25" - jest-worker: "npm:^27.4.5" - schema-utils: "npm:^4.3.0" - serialize-javascript: "npm:^6.0.2" - terser: "npm:^5.31.1" - peerDependencies: - webpack: ^5.1.0 - peerDependenciesMeta: - "@swc/core": - optional: true - esbuild: - optional: true - uglify-js: - optional: true - checksum: 10c0/9b060947241af43bd6fd728456f60e646186aef492163672a35ad49be6fbc7f63b54a7356c3f6ff40a8f83f00a977edc26f044b8e106cc611c053c8c0eaf8569 - languageName: node - linkType: hard - "terser@npm:^5.10.0, terser@npm:^5.31.1": version: 5.39.0 resolution: "terser@npm:5.39.0" @@ -22549,16 +22327,6 @@ __metadata: languageName: node linkType: hard -"watchpack@npm:^2.4.1": - version: 2.4.2 - resolution: "watchpack@npm:2.4.2" - dependencies: - glob-to-regexp: "npm:^0.4.1" - graceful-fs: "npm:^4.1.2" - checksum: 10c0/ec60a5f0e9efaeca0102fd9126346b3b2d523e01c34030d3fddf5813a7125765121ebdc2552981136dcd2c852deb1af0b39340f2fcc235f292db5399d0283577 - languageName: node - linkType: hard - "watchpack@npm:^2.5.1": version: 2.5.1 resolution: "watchpack@npm:2.5.1" @@ -22708,13 +22476,6 @@ __metadata: languageName: node linkType: hard -"webpack-sources@npm:^3.2.3": - version: 3.2.3 - resolution: "webpack-sources@npm:3.2.3" - checksum: 10c0/2ef63d77c4fad39de4a6db17323d75eb92897b32674e97d76f0a1e87c003882fc038571266ad0ef581ac734cbe20952912aaa26155f1905e96ce251adbb1eb4e - languageName: node - linkType: hard - "webpack-subresource-integrity@npm:^5.1.0": version: 5.1.0 resolution: "webpack-subresource-integrity@npm:5.1.0" @@ -22737,44 +22498,7 @@ __metadata: languageName: node linkType: hard -"webpack@npm:5": - version: 5.99.9 - resolution: "webpack@npm:5.99.9" - dependencies: - "@types/eslint-scope": "npm:^3.7.7" - "@types/estree": "npm:^1.0.6" - "@types/json-schema": "npm:^7.0.15" - "@webassemblyjs/ast": "npm:^1.14.1" - "@webassemblyjs/wasm-edit": "npm:^1.14.1" - "@webassemblyjs/wasm-parser": "npm:^1.14.1" - acorn: "npm:^8.14.0" - browserslist: "npm:^4.24.0" - chrome-trace-event: "npm:^1.0.2" - enhanced-resolve: "npm:^5.17.1" - es-module-lexer: "npm:^1.2.1" - eslint-scope: "npm:5.1.1" - events: "npm:^3.2.0" - glob-to-regexp: "npm:^0.4.1" - graceful-fs: "npm:^4.2.11" - json-parse-even-better-errors: "npm:^2.3.1" - loader-runner: "npm:^4.2.0" - mime-types: "npm:^2.1.27" - neo-async: "npm:^2.6.2" - schema-utils: "npm:^4.3.2" - tapable: "npm:^2.1.1" - terser-webpack-plugin: "npm:^5.3.11" - watchpack: "npm:^2.4.1" - webpack-sources: "npm:^3.2.3" - peerDependenciesMeta: - webpack-cli: - optional: true - bin: - webpack: bin/webpack.js - checksum: 10c0/34ec3f19b50bccaf27929e5e5b901b25047f2d414acba7d0967dc98eb4f404d107fb1a4b63095edbca2b006ff5815f1720b131e10b20664b074dfc86b7ffa717 - languageName: node - linkType: hard - -"webpack@npm:^5.101.3": +"webpack@npm:5, webpack@npm:^5.101.3": version: 5.105.4 resolution: "webpack@npm:5.105.4" dependencies: From 23458a7023b97784aed19a23db3ae49a7d5b015c Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Apr 2026 10:02:54 -0400 Subject: [PATCH 058/110] input segments --- .../src/DatePicker/Calendar/utils/format.ts | 59 ---- packages/gamut/src/DatePicker/DatePicker.tsx | 17 +- .../gamut/src/DatePicker/DatePickerInput.tsx | 169 ------------ .../Segment/DatePickerInputSegment.tsx | 190 +++++++++++++ .../DatePickerInput/Segment/elements.tsx | 41 +++ .../DatePickerInput/Segment/index.ts | 4 + .../DatePickerInput/Segment/segmentUtils.ts | 183 +++++++++++++ .../DatePicker/DatePickerInput/elements.tsx | 35 +++ .../src/DatePicker/DatePickerInput/index.tsx | 259 ++++++++++++++++++ .../src/DatePicker/DatePickerInput/utils.ts | 50 ++++ 10 files changed, 773 insertions(+), 234 deletions(-) delete mode 100644 packages/gamut/src/DatePicker/DatePickerInput.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/Segment/index.ts create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/elements.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/index.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/utils.ts diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/Calendar/utils/format.ts index 80dff7b55b3..0f87fcf4ae4 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/format.ts @@ -1,6 +1,5 @@ import type { IsoWeekday } from '../../utils/locale'; import { stringifyLocale } from '../../utils/locale'; -import { isValidDate } from './validation'; /** * Capitalize the first character of a string using the locale; rest unchanged (e.g. "next month" → "Next month"). @@ -69,64 +68,6 @@ export const getRelativeTodayLabel = (locale: Intl.Locale) => { return capitalizeFirst(rtf.format(0, 'day'), locale); }; -/** - * Get the locale's short date format pattern (e.g. "MM/DD/YYYY" for en-US, - * "DD/MM/YYYY" for en-GB). Uses Intl.DateTimeFormat formatToParts to infer - * order and separators. Useful for parsing or building locale-aware inputs. - */ -export const getDateFormatPattern = (locale: Intl.Locale) => { - const parts = new Intl.DateTimeFormat(stringifyLocale(locale), { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).formatToParts(new Date(2025, 0, 15)); - - return parts - .map((part) => { - switch (part.type) { - case 'day': - return 'DD'; - case 'month': - return 'MM'; - case 'year': - return 'YYYY'; - default: - return part.value; - } - }) - .join(''); -}; - -/** - * Format a date for display in the date picker input (e.g. "2/15/2026"). - */ -export const formatDateForInput = (date: Date, locale: Intl.Locale) => { - return new Intl.DateTimeFormat(stringifyLocale(locale), { - month: 'numeric', - day: 'numeric', - year: 'numeric', - }).format(date); -}; - -/** - * Parse a string from the date input into a Date, or null if invalid. - * Only returns a date when the input is a complete valid date (e.g. "2/15/2026"). - * Partial input like "1" or "2/15" returns null even though Date("1") would parse. - */ - -// this logic needs some work -export const parseDateFromInput = (value: string, locale: Intl.Locale) => { - const trimmed = value.trim(); - if (!trimmed) return null; - const parsed = new Date(trimmed); - if (!isValidDate(parsed)) return null; - const formatted = formatDateForInput(parsed, locale); - if (formatted === trimmed) return parsed; - const parts = trimmed.split(/[/-]/); - if (parts.length >= 3) return parsed; - return null; -}; - export const formatDateForAriaLabel = (date: Date, locale: Intl.Locale) => { return new Intl.DateTimeFormat(stringifyLocale(locale), { month: 'long', diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index ee191e1d343..3e0c6a60d24 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -36,7 +36,7 @@ export const DatePicker: React.FC = (props) => { const [activeRangePart, setActiveRangePart] = useState< 'start' | 'end' | null >(null); - const inputRef = useRef(null); + const inputRef = useRef(null); const dialogId = useId(); const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; @@ -66,7 +66,10 @@ export const DatePicker: React.FC = (props) => { setIsCalendarOpen(false); setActiveRangePart(null); setGridFocusRequested(false); - inputRef.current?.focus(); + const shell = inputRef.current; + const toFocus = + shell?.querySelector('[role="spinbutton"]') ?? shell; + toFocus?.focus(); }, []); const startOrSelectedDate = isRangeProps(props) @@ -140,14 +143,17 @@ export const DatePicker: React.FC = (props) => { children ) : ( <> - + {mode === 'range' ? ( <> @@ -158,14 +164,13 @@ export const DatePicker: React.FC = (props) => { placeholder={placeholder} rangePart="end" size={inputSize} - // does this need a ref? /> ) : ( )} diff --git a/packages/gamut/src/DatePicker/DatePickerInput.tsx b/packages/gamut/src/DatePicker/DatePickerInput.tsx deleted file mode 100644 index 1df85b5e2fe..00000000000 --- a/packages/gamut/src/DatePicker/DatePickerInput.tsx +++ /dev/null @@ -1,169 +0,0 @@ -import { MiniCalendarIcon } from '@codecademy/gamut-icons'; -import { - ComponentProps, - forwardRef, - useEffect, - useId, - useRef, - useState, -} from 'react'; - -import { FormGroup } from '../Form/elements/FormGroup'; -import { Input } from '../Form/inputs/Input'; -import { - formatDateForInput, - getDateFormatPattern, - parseDateFromInput, -} from './Calendar/utils/format'; -import { useDatePicker } from './DatePickerContext'; - -/** - * Props for DatePickerInput. When used inside DatePicker, only overrides (e.g. placeholder, label). - * In range mode, use rangePart to bind to start or end date. When outside DatePicker, pass value, onChange, etc. - */ -export type DatePickerInputProps = Omit< - ComponentProps, - 'type' | 'icon' -> & { - /** In range mode: which part of the range this input edits. Omit for single-date or combined display. */ - rangePart?: 'start' | 'end'; -}; - -/** - * Date input. When inside DatePicker: owns local input value state and syncs to - * shared selectedDate via context on blur/parse; opens calendar on click/arrow down. - * When outside DatePicker: fully controlled by props. - */ -export const DatePickerInput = forwardRef< - HTMLInputElement, - DatePickerInputProps ->(({ placeholder, label, rangePart, ...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, - startOrSelectedDate, - setSelection, - openCalendar, - focusCalendarGrid, - locale, - isCalendarOpen, - calendarDialogId, - translations, - } = context; - - const isRange = mode === 'range'; - - const inputID = useId(); - const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; - - // Range with two inputs: each input binds to one part. Single or range combined: one value. - const boundDate = - isRange && rangePart === 'end' ? context.endDate : startOrSelectedDate; - const formattedValue = - boundDate != null ? formatDateForInput(boundDate, locale) : ''; - - const [inputValue, setInputValue] = useState(() => formattedValue); - const isInputFocusedRef = useRef(false); - - // Sync input from shared state. Skip when focused so we don't overwrite while typing. - useEffect(() => { - if (!isInputFocusedRef.current) { - setInputValue(formattedValue); - } - }, [formattedValue]); - - /** Apply raw input string to selection state. Returns formatted string if parsed so caller can sync input (e.g. on blur). */ - const applyValueToSelection = (raw: string) => { - const trimmed = raw.trim(); - if (!trimmed) { - if (isRange && rangePart) { - if (rangePart === 'start') setSelection(null, context.endDate); - else setSelection(startOrSelectedDate, null); - } else setSelection(null); - return undefined; - } - const parsed = parseDateFromInput(trimmed, locale); - if (!parsed) return undefined; - if (isRange && rangePart) { - if (rangePart === 'start') setSelection(parsed, context.endDate); - else setSelection(startOrSelectedDate, parsed); - } else setSelection(parsed); - return formatDateForInput(parsed, locale); - }; - - const handleChange = (e: React.ChangeEvent) => { - const raw = e.target.value; - setInputValue(raw); - applyValueToSelection(raw); - }; - - const handleBlur = () => { - isInputFocusedRef.current = false; - const formatted = applyValueToSelection(inputValue.trim()); - if (formatted) setInputValue(formatted); - else if (inputValue.trim()) setInputValue(formattedValue); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'ArrowDown' || e.key === 'Down') { - e.preventDefault(); - if (isCalendarOpen) { - focusCalendarGrid(); - } else { - openCalendar({ moveFocusIntoCalendar: true }); - } - } - }; - - const handleOpenCalendar = () => { - openCalendar({ moveFocusIntoCalendar: false }); - }; - - const defaultLabel = !isRange - ? translations.dateLabel - : rangePart === 'end' - ? translations.endDateLabel - : translations.startDateLabel; - - return ( - - } - id={inputId} - placeholder={placeholder ?? getDateFormatPattern(locale)} - ref={ref} - role="combobox" - type="text" - value={inputValue} - onBlur={handleBlur} - onChange={handleChange} - onClick={handleOpenCalendar} - onFocus={() => { - isInputFocusedRef.current = true; - if (isRange && rangePart) context.setActiveRangePart(rangePart); - }} - onKeyDown={handleKeyDown} - /> - - ); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx new file mode 100644 index 00000000000..057a83df8c7 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx @@ -0,0 +1,190 @@ +import { + type Dispatch, + type SetStateAction, + useCallback, + useId, + useRef, +} from 'react'; + +import type { DatePartKind } from '../utils'; +import { Segment } from './elements'; +import { + appendSegmentDigit, + getSegmentSpinBounds, + parseSegmentNumericString, + segmentMaxLength, + segmentPlaceholder, + SegmentValues, + spinSegment, +} from './segmentUtils'; + +export type DatePickerInputSegmentProps = { + field: DatePartKind; + segments: SegmentValues; + disabled: boolean; + error: boolean; + handleOnClick: () => void; + handleOnFocus: () => void; + focusOrOpenCalendarGrid: () => void; + setSegments: Dispatch>; + prevField: DatePartKind | null; + nextField: DatePartKind | null; + applySegments: (next: SegmentValues) => void; +}; + +/** + * Editable date unit (`role="spinbutton"`). Focus with Tab; type digits or use Arrow up/down. + */ +export const DatePickerInputSegment: React.FC = ({ + field, + segments, + disabled, + error, + handleOnClick, + handleOnFocus, + focusOrOpenCalendarGrid, + setSegments, + prevField, + nextField, + applySegments, +}) => { + const { min, max } = getSegmentSpinBounds(field, segments); + const n = parseSegmentNumericString(segments[field]); + const ariaValue = segments[field].length > 0 && n != null ? n : undefined; + const display = + segments[field].length > 0 ? segments[field] : segmentPlaceholder(field); + const inputID = useId(); + const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; + const segmentRefs = useRef< + Partial> + >({}); + + const focusField = useCallback((field: DatePartKind) => { + segmentRefs.current[field]?.focus(); + }, []); + + const handleSegmentKeyDown = useCallback( + (field: DatePartKind) => (e: React.KeyboardEvent) => { + if (disabled) return; + + if (e.altKey && (e.key === 'ArrowDown' || e.key === 'Down')) { + e.preventDefault(); + focusOrOpenCalendarGrid(); + return; + } + + if (e.key === 'ArrowLeft') { + if (prevField) { + e.preventDefault(); + focusField(prevField); + } + return; + } + + if (e.key === 'ArrowRight') { + if (nextField) { + e.preventDefault(); + focusField(nextField); + } + return; + } + + if (e.key === 'ArrowUp') { + e.preventDefault(); + setSegments((prev) => { + const next = { + ...prev, + [field]: spinSegment(field, prev, 1), + }; + applySegments(next); + return next; + }); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSegments((prev) => { + const next = { + ...prev, + [field]: spinSegment(field, prev, -1), + }; + 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), + }; + applySegments(next); + return next; + } + if (prevField) { + queueMicrotask(() => focusField(prevField)); + } + return prev; + }); + return; + } + + if (e.key.length === 1 && /^\d$/.test(e.key)) { + e.preventDefault(); + setSegments((prev) => { + const next = { + ...prev, + [field]: appendSegmentDigit(field, prev[field], e.key), + }; + applySegments(next); + const maxLen = segmentMaxLength(field); + if (next[field].length >= maxLen && nextField) { + queueMicrotask(() => focusField(nextField)); + } + return next; + }); + } + }, + [ + disabled, + focusOrOpenCalendarGrid, + prevField, + focusField, + nextField, + setSegments, + applySegments, + ] + ); + + return ( + { + segmentRefs.current[field] = el; + }} + role="spinbutton" + tabIndex={disabled ? -1 : 0} + onClick={handleOnClick} + onFocus={handleOnFocus} + onKeyDown={handleSegmentKeyDown(field)} + > + {display} + + ); +}; 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..704083c62e2 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx @@ -0,0 +1,41 @@ +import { css, states } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +import { DatePartKind } from '../utils'; + +const segmentStyles = states({ + default: { + color: 'text-secondary', + }, +}); + +type SegmentStyleProps = StyleProps & { + field: DatePartKind; +}; + +export const Segment = styled.span( + ({ field }) => + css({ + display: 'inline-block', + textAlign: 'center', + minWidth: field === 'year' ? '4ch' : '2ch', + padding: 0, + margin: 0, + color: 'text', + cursor: 'text', + '&:focus': { + outline: '1px solid blue', + }, + }), + segmentStyles +); + +/** Locale separator (`/`, `.`, etc.) between segments. */ +export const SegmentLiteral = styled.span( + css({ + color: 'text-secondary', + userSelect: 'none', + px: 4, + }) +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.ts new file mode 100644 index 00000000000..7858fc78da4 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.ts @@ -0,0 +1,4 @@ +export { Segment, SegmentLiteral } from './elements'; +export { DatePickerInputSegment } from './DatePickerInputSegment'; +export type { DatePickerInputSegmentProps } from './DatePickerInputSegment'; +export * from './segmentUtils'; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts new file mode 100644 index 00000000000..5548c91f5e9 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts @@ -0,0 +1,183 @@ +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()), + }; +}; + +/** + * Build a calendar date from segment strings. Requires a 4-digit year and non-empty month/day. + */ +export const parseSegmentsToDate = (segments: SegmentValues): Date | null => { + const { month, day, year } = segments; + if (year.length !== 4) return null; + if (month.length === 0 || day.length === 0) return null; + const m = parseInt(month, 10); + const d = parseInt(day, 10); + const y = parseInt(year, 10); + if (!Number.isFinite(m) || !Number.isFinite(d) || !Number.isFinite(y)) + return null; + if (m < 1 || m > 12) return null; + const parsed = new Date(y, m - 1, d); + if ( + parsed.getFullYear() !== y || + parsed.getMonth() !== m - 1 || + parsed.getDate() !== d + ) { + return null; + } + return parsed; +}; + +/** Clamp and pad segments after blur (digits only). */ +export const normalizeSegmentValues = (s: SegmentValues): SegmentValues => { + const year = s.year.replace(/\D/g, '').slice(0, 4); + let month = s.month.replace(/\D/g, '').slice(0, 2); + let day = s.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 segmentPlaceholder = (field: DatePartKind) => + field === 'year' ? 'YYYY' : field === 'month' ? 'MM' : 'DD'; + +/** Digit capacity per field (typing / spinbutton editing). */ +export const segmentMaxLength = (field: DatePartKind): number => + field === 'year' ? 4 : 2; + +/** + * Min/max for spinbutton `aria-*` and ArrowUp/ArrowDown stepping (month/day/year). + * Day max uses month/year when available so February etc. behave correctly. + */ +export const getSegmentSpinBounds = ( + field: DatePartKind, + segments: SegmentValues +): { min: number; max: number } => { + switch (field) { + case 'month': + return { min: 1, max: 12 }; + case 'day': { + const y = + segments.year.length === 4 ? parseInt(segments.year, 10) : 2024; + const m = + segments.month.length >= 1 + ? Math.min(12, Math.max(1, parseInt(segments.month, 10) || 1)) + : 1; + const maxD = new Date(y, m, 0).getDate(); + return { min: 1, max: Number.isFinite(maxD) ? maxD : 31 }; + } + case 'year': + return { min: 1, max: 9999 }; + default: + return { min: 1, max: 9999 }; + } +}; + +/** Numeric value of a segment string (digits only), or null if empty. */ +export const parseSegmentNumericString = (s: string): number | null => { + const digits = s.replace(/\D/g, ''); + if (digits.length === 0) return null; + const n = parseInt(digits, 10); + return Number.isFinite(n) ? n : null; +}; + +const parseSegmentDigits = (_field: DatePartKind, s: string): number | null => + parseSegmentNumericString(s); + +const padSegmentNumber = (field: DatePartKind, n: number): string => { + if (field === 'year') { + const clamped = Math.min(9999, Math.max(1, n)); + return String(clamped).padStart(4, '0'); + } + const clamped = Math.min(99, Math.max(0, n)); + return String(clamped).padStart(2, '0').slice(-2); +}; + +/** Append one digit to a segment string (max length enforced). */ +export const appendSegmentDigit = ( + field: DatePartKind, + prev: string, + digit: string +): string => { + if (!/^\d$/.test(digit)) return prev; + const maxLen = segmentMaxLength(field); + return (prev.replace(/\D/g, '') + digit).slice(0, maxLen); +}; + +/** + * Step a segment up/down (ArrowUp / ArrowDown). Empty year steps from the current calendar year. + */ +export const spinSegment = ( + field: DatePartKind, + segments: SegmentValues, + delta: 1 | -1 +): string => { + const { min, max } = getSegmentSpinBounds(field, segments); + let cur = parseSegmentDigits(field, segments[field]); + + if (cur == null) { + cur = + field === 'year' + ? delta > 0 + ? new Date().getFullYear() + : max + : delta > 0 + ? min + : max; + } else { + cur += delta; + } + + cur = Math.min(max, Math.max(min, cur)); + return padSegmentNumber(field, cur); +}; + +/** Build the visible date string from segment state in locale layout order (includes literal separators). */ +export const buildCombinedFromSegments = ( + segments: SegmentValues, + layout: DateFormatLayoutItem[] +): string => + layout + .map((item) => + item.kind === 'literal' ? item.text : segments[item.field] + ) + .join(''); + +/** Map a digit-only string into segment fields following locale field order (2 / 2 / 4). */ +export const digitsToSegments = ( + digits: string, + fieldOrder: DatePartKind[] +): SegmentValues => { + let rest = digits; + const out: SegmentValues = { month: '', day: '', year: '' }; + for (const field of fieldOrder) { + const maxLen = field === 'year' ? 4 : 2; + out[field] = rest.slice(0, maxLen); + rest = rest.slice(maxLen); + } + return out; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx new file mode 100644 index 00000000000..592b829b9de --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx @@ -0,0 +1,35 @@ +import { css, theme } from '@codecademy/gamut-styles'; +import { StyleProps } from '@codecademy/variance'; +import styled from '@emotion/styled'; + +import { FlexBox } from '../../Box'; +import { + conditionalStyles, + formFieldFocusStyles, + formFieldStyles, + inputSizeStyles, +} from '../../Form/styles'; + +interface SegmentedShellProps + extends StyleProps, + StyleProps {} + +/** + * Shell uses the same style stack 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, + conditionalStyles, + inputSizeStyles, + ({ variant }) => + css({ + '&:focus-within': + variant === 'error' + ? { + borderColor: 'feedback-error', + boxShadow: `inset 0 0 0 1px ${theme.colors['feedback-error']}`, + } + : formFieldFocusStyles, + }) +); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx new file mode 100644 index 00000000000..2f14a698f6c --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -0,0 +1,259 @@ +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 { useDatePicker } from '../DatePickerContext'; +import { SegmentedShell } from './elements'; +import { + type SegmentValues, + DatePickerInputSegment, + getDateSegmentsFromDate, + normalizeSegmentValues, + parseSegmentsToDate, + SegmentLiteral, +} from './Segment'; +import { + formatDateISO8601DateOnly, + getDateFieldOrder, + getDateFormatLayout, +} from './utils'; + +/** + * Props for DatePickerInput. When used inside DatePicker, only overrides (e.g. placeholder, label). + * In range mode, use rangePart to bind to start or end date. When outside DatePicker, pass value, onChange, etc. + */ +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, + startOrSelectedDate, + setSelection, + openCalendar, + focusCalendarGrid, + locale, + isCalendarOpen, + translations, + } = context; + + const isRange = mode === 'range'; + const endDate = isRange ? context.endDate : null; + + 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 firstField = fieldOrder[0]; + const firstFieldId = `${inputId}-${firstField}`; + + const defaultLabel = !isRange + ? translations.dateLabel + : rangePart === 'end' + ? translations.endDateLabel + : translations.startDateLabel; + + const boundDate = + isRange && rangePart === 'end' ? endDate : startOrSelectedDate; + const segmentsFromBound = useCallback( + () => 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 setShellRef = 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 && rangePart) { + if (rangePart === 'start') setSelection(parsed, endDate); + else setSelection(startOrSelectedDate, parsed); + } else setSelection(parsed); + }, + [isRange, rangePart, setSelection, endDate, startOrSelectedDate] + ); + + + const clearSelection = useCallback(() => { + if (isRange && rangePart) { + if (rangePart === 'start') setSelection(null, endDate); + else setSelection(startOrSelectedDate, null); + } else setSelection(null); + }, [isRange, rangePart, setSelection, endDate, startOrSelectedDate]); + + const applySegments = useCallback( + (next: SegmentValues) => { + const parsed = parseSegmentsToDate(next); + if (parsed) commitParsedDate(parsed); + else if (!next.month && !next.day && !next.year) clearSelection(); + }, + [clearSelection, commitParsedDate] + ); + + const handleContainerBlur = (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) { + commitParsedDate(parsed); + return getDateSegmentsFromDate(parsed); + } + if (!normalized.month && !normalized.day && !normalized.year) { + clearSelection(); + return getDateSegmentsFromDate(null); + } + return segmentsFromBound(); + }); + }; + + const handleContainerFocus = () => { + isInputFocusedRef.current = true; + }; + + const handleSegmentFocus = () => { + handleContainerFocus(); + if (isRange && rangePart) context.setActiveRangePart(rangePart); + }; + + const handleOpenCalendar = () => { + if (!disabled) openCalendar({ moveFocusIntoCalendar: false }); + }; + + const focusOrOpenCalendarGrid = () => { + if (isCalendarOpen) focusCalendarGrid(); + else openCalendar({ moveFocusIntoCalendar: true }); + }; + + 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..89e50198442 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/utils.ts @@ -0,0 +1,50 @@ +import { stringifyLocale } from '../utils/locale'; + +/** Single date field in locale order (from `Intl.DateTimeFormat#formatToParts`). */ +export type DatePartKind = 'month' | 'day' | 'year'; + +export type DateFormatLayoutItem = + | { kind: 'field'; field: DatePartKind } + | { kind: 'literal'; text: string }; + +/** + * Month/day/year order and literal separators for the locale (e.g. MM/DD/YYYY vs DD/MM/YYYY). + */ +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; +}; + +/** Focus / tab order for the three fields (locale order). */ +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[]); +}; + +/** ISO 8601 date-only string for hidden form fields. */ +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')}`; +}; From bab532a2514fecf6234199e95459e90fb4b96b88 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Apr 2026 13:04:39 -0400 Subject: [PATCH 059/110] fix segments typing --- .../Segment/DatePickerInputSegment.tsx | 1 + .../DatePickerInput/Segment/segmentUtils.ts | 85 +++++++++++++------ .../src/DatePicker/DatePickerInput/index.tsx | 2 +- 3 files changed, 63 insertions(+), 25 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx index 057a83df8c7..28bde1295f4 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx @@ -134,6 +134,7 @@ export const DatePickerInputSegment: React.FC = ({ return; } + // if the key is a single digit and is a number, append the digit to the segment if (e.key.length === 1 && /^\d$/.test(e.key)) { e.preventDefault(); setSegments((prev) => { diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts index 5548c91f5e9..192f7ca07af 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts @@ -18,7 +18,7 @@ export const getDateSegmentsFromDate = (date: Date | null): SegmentValues => { /** * Build a calendar date from segment strings. Requires a 4-digit year and non-empty month/day. */ -export const parseSegmentsToDate = (segments: SegmentValues): Date | null => { +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; @@ -39,11 +39,40 @@ export const parseSegmentsToDate = (segments: SegmentValues): Date | null => { return parsed; }; -/** Clamp and pad segments after blur (digits only). */ -export const normalizeSegmentValues = (s: SegmentValues): SegmentValues => { - const year = s.year.replace(/\D/g, '').slice(0, 4); - let month = s.month.replace(/\D/g, '').slice(0, 2); - let day = s.day.replace(/\D/g, '').slice(0, 2); +/** Digits-only slices used when checking for a fully typed date (2 / 2 / 4). */ +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), +}); + +/** User finished all three fields (2-digit month, 2-digit day, 4-digit year). */ +export const isStrictlyCompleteDateEntry = (strictSegments: SegmentValues) => { + const { month, day, year } = strictSegments; + return year.length === 4 && month.length === 2 && day.length === 2; +}; + +/** + * Normalize segment strings after blur (digits only). + * When the user has fully typed 2 / 2 / 4 digits, validates the calendar date without + * clamping invalid days/months — if invalid, returns empty segments (caller clears selection). + * Otherwise pads/clamps partial input as before. + */ +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))); @@ -81,8 +110,7 @@ export const getSegmentSpinBounds = ( case 'month': return { min: 1, max: 12 }; case 'day': { - const y = - segments.year.length === 4 ? parseInt(segments.year, 10) : 2024; + const y = segments.year.length === 4 ? parseInt(segments.year, 10) : 2024; const m = segments.month.length >= 1 ? Math.min(12, Math.max(1, parseInt(segments.month, 10) || 1)) @@ -98,22 +126,25 @@ export const getSegmentSpinBounds = ( }; /** Numeric value of a segment string (digits only), or null if empty. */ -export const parseSegmentNumericString = (s: string): number | null => { - const digits = s.replace(/\D/g, ''); +export const parseSegmentNumericString = (str: string) => { + const digits = str.replace(/\D/g, ''); if (digits.length === 0) return null; - const n = parseInt(digits, 10); - return Number.isFinite(n) ? n : null; + const numericValue = parseInt(digits, 10); + return Number.isFinite(numericValue) ? numericValue : null; }; -const parseSegmentDigits = (_field: DatePartKind, s: string): number | null => - parseSegmentNumericString(s); +const parseSegmentDigits = (_field: DatePartKind, str: string) => + parseSegmentNumericString(str); -const padSegmentNumber = (field: DatePartKind, n: number): string => { +const padSegmentNumber = ( + field: DatePartKind, + numericValue: number +): string => { if (field === 'year') { - const clamped = Math.min(9999, Math.max(1, n)); + const clamped = Math.min(9999, Math.max(1, numericValue)); return String(clamped).padStart(4, '0'); } - const clamped = Math.min(99, Math.max(0, n)); + const clamped = Math.min(99, Math.max(0, numericValue)); return String(clamped).padStart(2, '0').slice(-2); }; @@ -122,10 +153,18 @@ export const appendSegmentDigit = ( field: DatePartKind, prev: string, digit: string -): string => { +) => { + // if the digit is not a single digit, return the previous value if (!/^\d$/.test(digit)) return prev; const maxLen = segmentMaxLength(field); - return (prev.replace(/\D/g, '') + digit).slice(0, maxLen); + const digitsOnly = prev.replace(/\D/g, ''); + // When the segment is already full, another digit would only be appended then + // truncated back to the same string — so typing could not change the value. + // Treat the new digit as the start of a replacement (same as clearing then typing). + if (digitsOnly.length >= maxLen) { + return digit.slice(0, maxLen); + } + return (digitsOnly + digit).slice(0, maxLen); }; /** @@ -135,7 +174,7 @@ export const spinSegment = ( field: DatePartKind, segments: SegmentValues, delta: 1 | -1 -): string => { +) => { const { min, max } = getSegmentSpinBounds(field, segments); let cur = parseSegmentDigits(field, segments[field]); @@ -160,11 +199,9 @@ export const spinSegment = ( export const buildCombinedFromSegments = ( segments: SegmentValues, layout: DateFormatLayoutItem[] -): string => +) => layout - .map((item) => - item.kind === 'literal' ? item.text : segments[item.field] - ) + .map((item) => (item.kind === 'literal' ? item.text : segments[item.field])) .join(''); /** Map a digit-only string into segment fields following locale field order (2 / 2 / 4). */ diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index 2f14a698f6c..d6d72776ca7 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -150,7 +150,7 @@ export const DatePickerInput = forwardRef( const parsed = parseSegmentsToDate(normalized); if (parsed) { commitParsedDate(parsed); - return getDateSegmentsFromDate(parsed); + return normalized; } if (!normalized.month && !normalized.day && !normalized.year) { clearSelection(); From dc273f3998678e9ee385a23a681054ded82ac7a5 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Apr 2026 13:13:08 -0400 Subject: [PATCH 060/110] dedupe and format --- .../src/DatePicker/DatePickerInput/index.tsx | 1 - yarn.lock | 711 +----------------- 2 files changed, 13 insertions(+), 699 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index d6d72776ca7..e3777360926 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -125,7 +125,6 @@ export const DatePickerInput = forwardRef( [isRange, rangePart, setSelection, endDate, startOrSelectedDate] ); - const clearSelection = useCallback(() => { if (isRange && rangePart) { if (rangePart === 'start') setSelection(null, endDate); diff --git a/yarn.lock b/yarn.lock index ac95a0ccb7a..a145a948d5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -114,30 +114,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.9, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.27.4": - version: 7.28.4 - resolution: "@babel/core@npm:7.28.4" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@babel/generator": "npm:^7.28.3" - "@babel/helper-compilation-targets": "npm:^7.27.2" - "@babel/helper-module-transforms": "npm:^7.28.3" - "@babel/helpers": "npm:^7.28.4" - "@babel/parser": "npm:^7.28.4" - "@babel/template": "npm:^7.27.2" - "@babel/traverse": "npm:^7.28.4" - "@babel/types": "npm:^7.28.4" - "@jridgewell/remapping": "npm:^2.3.5" - convert-source-map: "npm:^2.0.0" - debug: "npm:^4.1.0" - gensync: "npm:^1.0.0-beta.2" - json5: "npm:^2.2.3" - semver: "npm:^6.3.1" - checksum: 10c0/ef5a6c3c6bf40d3589b5593f8118cfe2602ce737412629fb6e26d595be2fcbaae0807b43027a5c42ec4fba5b895ff65891f2503b5918c8a3ea3542ab44d4c278 - languageName: node - linkType: hard - -"@babel/core@npm:^7.22.5, @babel/core@npm:^7.26.0, @babel/core@npm:^7.28.0, @babel/core@npm:^7.7.5": +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.18.9, @babel/core@npm:^7.21.3, @babel/core@npm:^7.22.5, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.26.0, @babel/core@npm:^7.27.4, @babel/core@npm:^7.28.0, @babel/core@npm:^7.7.5": version: 7.29.0 resolution: "@babel/core@npm:7.29.0" dependencies: @@ -160,7 +137,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.12.5, @babel/generator@npm:^7.22.5, @babel/generator@npm:^7.24.7, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.28.3, @babel/generator@npm:^7.29.0, @babel/generator@npm:^7.7.2": +"@babel/generator@npm:^7.12.5, @babel/generator@npm:^7.22.5, @babel/generator@npm:^7.24.7, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.0, @babel/generator@npm:^7.7.2": version: 7.29.1 resolution: "@babel/generator@npm:7.29.1" dependencies: @@ -192,7 +169,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-compilation-targets@npm:^7.22.6, @babel/helper-compilation-targets@npm:^7.24.7, @babel/helper-compilation-targets@npm:^7.25.9, @babel/helper-compilation-targets@npm:^7.27.2, @babel/helper-compilation-targets@npm:^7.28.6": +"@babel/helper-compilation-targets@npm:^7.22.6, @babel/helper-compilation-targets@npm:^7.24.7, @babel/helper-compilation-targets@npm:^7.25.9, @babel/helper-compilation-targets@npm:^7.28.6": version: 7.28.6 resolution: "@babel/helper-compilation-targets@npm:7.28.6" dependencies: @@ -277,7 +254,7 @@ __metadata: languageName: node linkType: hard -"@babel/helper-module-transforms@npm:^7.12.1, @babel/helper-module-transforms@npm:^7.24.7, @babel/helper-module-transforms@npm:^7.25.9, @babel/helper-module-transforms@npm:^7.28.3, @babel/helper-module-transforms@npm:^7.28.6": +"@babel/helper-module-transforms@npm:^7.12.1, @babel/helper-module-transforms@npm:^7.24.7, @babel/helper-module-transforms@npm:^7.25.9, @babel/helper-module-transforms@npm:^7.28.6": version: 7.28.6 resolution: "@babel/helper-module-transforms@npm:7.28.6" dependencies: @@ -391,7 +368,7 @@ __metadata: languageName: node linkType: hard -"@babel/helpers@npm:^7.12.5, @babel/helpers@npm:^7.24.7, @babel/helpers@npm:^7.28.4, @babel/helpers@npm:^7.28.6": +"@babel/helpers@npm:^7.12.5, @babel/helpers@npm:^7.24.7, @babel/helpers@npm:^7.28.6": version: 7.29.2 resolution: "@babel/helpers@npm:7.29.2" dependencies: @@ -401,7 +378,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.12.7, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.28.4, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.12.7, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.24.7, @babel/parser@npm:^7.28.6, @babel/parser@npm:^7.29.0": version: 7.29.2 resolution: "@babel/parser@npm:7.29.2" dependencies: @@ -1548,7 +1525,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.12.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.27.2, @babel/template@npm:^7.28.6, @babel/template@npm:^7.3.3": +"@babel/template@npm:^7.12.7, @babel/template@npm:^7.22.5, @babel/template@npm:^7.24.7, @babel/template@npm:^7.25.9, @babel/template@npm:^7.28.6, @babel/template@npm:^7.3.3": version: 7.28.6 resolution: "@babel/template@npm:7.28.6" dependencies: @@ -1559,7 +1536,7 @@ __metadata: languageName: node linkType: hard -"@babel/traverse@npm:^7.12.9, @babel/traverse@npm:^7.16.0, @babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.24.7, @babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.4, @babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": +"@babel/traverse@npm:^7.12.9, @babel/traverse@npm:^7.16.0, @babel/traverse@npm:^7.18.9, @babel/traverse@npm:^7.24.7, @babel/traverse@npm:^7.25.9, @babel/traverse@npm:^7.28.0, @babel/traverse@npm:^7.28.6, @babel/traverse@npm:^7.29.0": version: 7.29.0 resolution: "@babel/traverse@npm:7.29.0" dependencies: @@ -1574,7 +1551,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.6, @babel/types@npm:^7.12.7, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.22.5, @babel/types@npm:^7.24.7, @babel/types@npm:^7.25.9, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.4, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.12.6, @babel/types@npm:^7.12.7, @babel/types@npm:^7.18.9, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.22.5, @babel/types@npm:^7.24.7, @babel/types@npm:^7.25.9, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.28.6, @babel/types@npm:^7.29.0, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.29.0 resolution: "@babel/types@npm:7.29.0" dependencies: @@ -2622,20 +2599,6 @@ __metadata: languageName: node linkType: hard -"@jest/console@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/console@npm:30.0.5" - dependencies: - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - jest-message-util: "npm:30.0.5" - jest-util: "npm:30.0.5" - slash: "npm:^3.0.0" - checksum: 10c0/1400e9ee281dd070f543f8f8696b9aca4ba1f81d5cbfb3cae030664012ff5961c76ac2c8ccee172e416e15f88af3b10840548adbee4de0ec63100d44416b17ef - languageName: node - linkType: hard - "@jest/console@npm:30.3.0": version: 30.3.0 resolution: "@jest/console@npm:30.3.0" @@ -2754,13 +2717,6 @@ __metadata: languageName: node linkType: hard -"@jest/diff-sequences@npm:30.0.1": - version: 30.0.1 - resolution: "@jest/diff-sequences@npm:30.0.1" - checksum: 10c0/3a840404e6021725ef7f86b11f7b2d13dd02846481264db0e447ee33b7ee992134e402cdc8b8b0ac969d37c6c0183044e382dedee72001cdf50cfb3c8088de74 - languageName: node - linkType: hard - "@jest/diff-sequences@npm:30.3.0": version: 30.3.0 resolution: "@jest/diff-sequences@npm:30.3.0" @@ -2768,18 +2724,6 @@ __metadata: languageName: node linkType: hard -"@jest/environment@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/environment@npm:30.0.5" - dependencies: - "@jest/fake-timers": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - jest-mock: "npm:30.0.5" - checksum: 10c0/e403b6f98fa3e39dd6462fa192e3bd55e9ac9c2322ca4471b9342495913a90ecaa5fc53238d4ad8a0dca7d53aa4b9de122721234e36f3a0445031c25757a3178 - languageName: node - linkType: hard - "@jest/environment@npm:30.3.0": version: 30.3.0 resolution: "@jest/environment@npm:30.3.0" @@ -2804,15 +2748,6 @@ __metadata: languageName: node linkType: hard -"@jest/expect-utils@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/expect-utils@npm:30.0.5" - dependencies: - "@jest/get-type": "npm:30.0.1" - checksum: 10c0/d0ee162a1d1816724580bea53e7b422b891af073bdae439e78d04d5db09e6557e334f4c3d2892b9de750a59e79605f55d3ca8dbec9fb2ba33d8b803ed98463ad - languageName: node - linkType: hard - "@jest/expect-utils@npm:30.3.0": version: 30.3.0 resolution: "@jest/expect-utils@npm:30.3.0" @@ -2831,16 +2766,6 @@ __metadata: languageName: node linkType: hard -"@jest/expect@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/expect@npm:30.0.5" - dependencies: - expect: "npm:30.0.5" - jest-snapshot: "npm:30.0.5" - checksum: 10c0/6ff40adf2f2cfa53f7a23bc2b85ae99d3264420e81202d45d1dc198009f4441ee575d910e79e589f69c2dd47e0ef9a3b66018f44760da02d98f474361f7c4d1c - languageName: node - linkType: hard - "@jest/expect@npm:30.3.0": version: 30.3.0 resolution: "@jest/expect@npm:30.3.0" @@ -2861,20 +2786,6 @@ __metadata: languageName: node linkType: hard -"@jest/fake-timers@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/fake-timers@npm:30.0.5" - dependencies: - "@jest/types": "npm:30.0.5" - "@sinonjs/fake-timers": "npm:^13.0.0" - "@types/node": "npm:*" - jest-message-util: "npm:30.0.5" - jest-mock: "npm:30.0.5" - jest-util: "npm:30.0.5" - checksum: 10c0/4c403e624d758780016c2012b23112ff421efd601def289b201c4a5e03c46f995c7c3509d7b0b56dbe17cd5cbc66920734bd976ebe12125d6fd864d71888a50d - languageName: node - linkType: hard - "@jest/fake-timers@npm:30.3.0": version: 30.3.0 resolution: "@jest/fake-timers@npm:30.3.0" @@ -2903,13 +2814,6 @@ __metadata: languageName: node linkType: hard -"@jest/get-type@npm:30.0.1": - version: 30.0.1 - resolution: "@jest/get-type@npm:30.0.1" - checksum: 10c0/92437ae42d0df57e8acc2d067288151439db4752cde4f5e680c73c8a6e34568bbd8c1c81a2f2f9a637a619c2aac8bc87553fb80e31475b59e2ed789a71e5e540 - languageName: node - linkType: hard - "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -2917,18 +2821,6 @@ __metadata: languageName: node linkType: hard -"@jest/globals@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/globals@npm:30.0.5" - dependencies: - "@jest/environment": "npm:30.0.5" - "@jest/expect": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - jest-mock: "npm:30.0.5" - checksum: 10c0/abe8e4b11f30c2885e42afa9e01d4364db8c6de4c3221f411b00a9081d3cc67226f84775efbbd17735dedb391222253f945ee260714d78b2a7304b7afa61b6d8 - languageName: node - linkType: hard - "@jest/globals@npm:30.3.0": version: 30.3.0 resolution: "@jest/globals@npm:30.3.0" @@ -2963,7 +2855,7 @@ __metadata: languageName: node linkType: hard -"@jest/reporters@npm:30.3.0": +"@jest/reporters@npm:30.3.0, @jest/reporters@npm:^30.0.2": version: 30.3.0 resolution: "@jest/reporters@npm:30.3.0" dependencies: @@ -3036,42 +2928,6 @@ __metadata: languageName: node linkType: hard -"@jest/reporters@npm:^30.0.2": - version: 30.0.5 - resolution: "@jest/reporters@npm:30.0.5" - dependencies: - "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:30.0.5" - "@jest/test-result": "npm:30.0.5" - "@jest/transform": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - "@jridgewell/trace-mapping": "npm:^0.3.25" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - collect-v8-coverage: "npm:^1.0.2" - exit-x: "npm:^0.2.2" - glob: "npm:^10.3.10" - graceful-fs: "npm:^4.2.11" - istanbul-lib-coverage: "npm:^3.0.0" - istanbul-lib-instrument: "npm:^6.0.0" - istanbul-lib-report: "npm:^3.0.0" - istanbul-lib-source-maps: "npm:^5.0.0" - istanbul-reports: "npm:^3.1.3" - jest-message-util: "npm:30.0.5" - jest-util: "npm:30.0.5" - jest-worker: "npm:30.0.5" - slash: "npm:^3.0.0" - string-length: "npm:^4.0.2" - v8-to-istanbul: "npm:^9.0.1" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - checksum: 10c0/9f8a214ff69427b644e26981fa92af49b77819d512ac17d0b4190d1dc110b0bebeb7791faa7548b8097f010b094c3b5e3244e18f3837a3fe8385ff60c7114539 - languageName: node - linkType: hard - "@jest/schemas@npm:30.0.5": version: 30.0.5 resolution: "@jest/schemas@npm:30.0.5" @@ -3090,18 +2946,6 @@ __metadata: languageName: node linkType: hard -"@jest/snapshot-utils@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/snapshot-utils@npm:30.0.5" - dependencies: - "@jest/types": "npm:30.0.5" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - natural-compare: "npm:^1.4.0" - checksum: 10c0/db270c2d6e216d132c5e0b05d8ff5bbe4fbd4e65b2de4cf94eacb44152e8f17fbbba8bdd2cb83b5fc2b1094db6424c7e1507b7eaade518dbc815cfacbdf6598b - languageName: node - linkType: hard - "@jest/snapshot-utils@npm:30.3.0": version: 30.3.0 resolution: "@jest/snapshot-utils@npm:30.3.0" @@ -3136,18 +2980,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-result@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/test-result@npm:30.0.5" - dependencies: - "@jest/console": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - collect-v8-coverage: "npm:^1.0.2" - checksum: 10c0/2a43134ee28616a178b5a6379c837f2fb054a5e4a6ab411b9d15b85224e5d459d88902cdbf83edf5821c2c77fe13e67d078eff64c6871f3b08ebff0548a9a2e4 - languageName: node - linkType: hard - "@jest/test-result@npm:30.3.0, @jest/test-result@npm:^30.0.2": version: 30.3.0 resolution: "@jest/test-result@npm:30.3.0" @@ -3172,18 +3004,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-sequencer@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/test-sequencer@npm:30.0.5" - dependencies: - "@jest/test-result": "npm:30.0.5" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.0.5" - slash: "npm:^3.0.0" - checksum: 10c0/3caaea0558474764cd616f38acdc22ff4ce6ef806d931134ed366429fdea7110352b89d702e9cc1d71fa142d79e86f2f4e6eb0441a76a1896682e124ed8f42b4 - languageName: node - linkType: hard - "@jest/test-sequencer@npm:30.3.0": version: 30.3.0 resolution: "@jest/test-sequencer@npm:30.3.0" @@ -3208,29 +3028,6 @@ __metadata: languageName: node linkType: hard -"@jest/transform@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/transform@npm:30.0.5" - dependencies: - "@babel/core": "npm:^7.27.4" - "@jest/types": "npm:30.0.5" - "@jridgewell/trace-mapping": "npm:^0.3.25" - babel-plugin-istanbul: "npm:^7.0.0" - chalk: "npm:^4.1.2" - convert-source-map: "npm:^2.0.0" - fast-json-stable-stringify: "npm:^2.1.0" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.0.5" - jest-regex-util: "npm:30.0.1" - jest-util: "npm:30.0.5" - micromatch: "npm:^4.0.8" - pirates: "npm:^4.0.7" - slash: "npm:^3.0.0" - write-file-atomic: "npm:^5.0.1" - checksum: 10c0/771f57b1bede66049de80dcbf984c74b7d3c072e905f2516ff3f86dc01abd2f79d821b9a6ae21f27cb26d484cd539c13b1a51f71c15e1aed0c62314203c5a186 - languageName: node - linkType: hard - "@jest/transform@npm:30.3.0": version: 30.3.0 resolution: "@jest/transform@npm:30.3.0" @@ -3276,21 +3073,6 @@ __metadata: languageName: node linkType: hard -"@jest/types@npm:30.0.5": - version: 30.0.5 - resolution: "@jest/types@npm:30.0.5" - dependencies: - "@jest/pattern": "npm:30.0.1" - "@jest/schemas": "npm:30.0.5" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - "@types/istanbul-reports": "npm:^3.0.4" - "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.33" - chalk: "npm:^4.1.2" - checksum: 10c0/fd097a390e36edacbd2c92a8378ec0cd67abec5e234bab7a80aec6eb8625568052b0c32acf472388d04c4cf384b8fa2871d0d12a56b4b06eaea93f2c6df0ec6c - languageName: node - linkType: hard - "@jest/types@npm:30.3.0, @jest/types@npm:^30.0.1": version: 30.3.0 resolution: "@jest/types@npm:30.3.0" @@ -6756,15 +6538,6 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^13.0.0": - version: 13.0.5 - resolution: "@sinonjs/fake-timers@npm:13.0.5" - dependencies: - "@sinonjs/commons": "npm:^3.0.1" - checksum: 10c0/a707476efd523d2138ef6bba916c83c4a377a8372ef04fad87499458af9f01afc58f4f245c5fd062793d6d70587309330c6f96947b5bd5697961c18004dc3e26 - languageName: node - linkType: hard - "@sinonjs/fake-timers@npm:^15.0.0": version: 15.1.1 resolution: "@sinonjs/fake-timers@npm:15.1.1" @@ -9516,23 +9289,6 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:30.0.5": - version: 30.0.5 - resolution: "babel-jest@npm:30.0.5" - dependencies: - "@jest/transform": "npm:30.0.5" - "@types/babel__core": "npm:^7.20.5" - babel-plugin-istanbul: "npm:^7.0.0" - babel-preset-jest: "npm:30.0.1" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - slash: "npm:^3.0.0" - peerDependencies: - "@babel/core": ^7.11.0 - checksum: 10c0/48fcdbf97519216f8897c4d83c0d2a64dffd90e4876b386e4ea4530021aaedbd7253de65a71d554cb57fdeb7bd8509bed43a6c016eb150e49e1fbe1236248f0f - languageName: node - linkType: hard - "babel-jest@npm:30.3.0": version: 30.3.0 resolution: "babel-jest@npm:30.3.0" @@ -9624,19 +9380,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-istanbul@npm:^7.0.0": - version: 7.0.0 - resolution: "babel-plugin-istanbul@npm:7.0.0" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.0.0" - "@istanbuljs/load-nyc-config": "npm:^1.0.0" - "@istanbuljs/schema": "npm:^0.1.3" - istanbul-lib-instrument: "npm:^6.0.2" - test-exclude: "npm:^6.0.0" - checksum: 10c0/79c37bd59ea9bcb16218e874993621e24048776fac7ee72eabe78f0909200851bdb93b32f6eba5b463206f15a1ee7ad40a725af8447952321ae1fdf14e740fe9 - languageName: node - linkType: hard - "babel-plugin-istanbul@npm:^7.0.1": version: 7.0.1 resolution: "babel-plugin-istanbul@npm:7.0.1" @@ -9650,17 +9393,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:30.0.1": - version: 30.0.1 - resolution: "babel-plugin-jest-hoist@npm:30.0.1" - dependencies: - "@babel/template": "npm:^7.27.2" - "@babel/types": "npm:^7.27.3" - "@types/babel__core": "npm:^7.20.5" - checksum: 10c0/49087f45c8ac359d68c622f4bd471300376b0ca2b6bd6ecaa1bd254ea87eda8fa3ce6144848e3bbabad337d276474a47e2ac3f6272f82e1f2337924ff49a02bd - languageName: node - linkType: hard - "babel-plugin-jest-hoist@npm:30.3.0": version: 30.3.0 resolution: "babel-plugin-jest-hoist@npm:30.3.0" @@ -9749,7 +9481,7 @@ __metadata: languageName: node linkType: hard -"babel-preset-current-node-syntax@npm:^1.0.0, babel-preset-current-node-syntax@npm:^1.1.0, babel-preset-current-node-syntax@npm:^1.2.0": +"babel-preset-current-node-syntax@npm:^1.0.0, babel-preset-current-node-syntax@npm:^1.2.0": version: 1.2.0 resolution: "babel-preset-current-node-syntax@npm:1.2.0" dependencies: @@ -9774,18 +9506,6 @@ __metadata: languageName: node linkType: hard -"babel-preset-jest@npm:30.0.1": - version: 30.0.1 - resolution: "babel-preset-jest@npm:30.0.1" - dependencies: - babel-plugin-jest-hoist: "npm:30.0.1" - babel-preset-current-node-syntax: "npm:^1.1.0" - peerDependencies: - "@babel/core": ^7.11.0 - checksum: 10c0/33da0094965929b1742b02e55272b544f189cd487d55bbba60e68d96d62d48f466264fe51f65950454829d4f2271541f2433e1c1c5e6a7ff5b9e91f1303471b7 - languageName: node - linkType: hard - "babel-preset-jest@npm:30.3.0": version: 30.3.0 resolution: "babel-preset-jest@npm:30.3.0" @@ -12955,20 +12675,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:30.0.5": - version: 30.0.5 - resolution: "expect@npm:30.0.5" - dependencies: - "@jest/expect-utils": "npm:30.0.5" - "@jest/get-type": "npm:30.0.1" - jest-matcher-utils: "npm:30.0.5" - jest-message-util: "npm:30.0.5" - jest-mock: "npm:30.0.5" - jest-util: "npm:30.0.5" - checksum: 10c0/e08e4ced2856a0898b3a4e8d09aab7f8e2212cde701e41a560c3ab7e9053517947ff1a762fc425dbe0c48ed54e131aa7190de67a402f98b4e5ada23eb21c0a9f - languageName: node - linkType: hard - "expect@npm:30.3.0": version: 30.3.0 resolution: "expect@npm:30.3.0" @@ -13913,22 +13619,6 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.3.10": - version: 10.4.5 - resolution: "glob@npm:10.4.5" - dependencies: - foreground-child: "npm:^3.1.0" - jackspeak: "npm:^3.1.2" - minimatch: "npm:^9.0.4" - minipass: "npm:^7.1.2" - package-json-from-dist: "npm:^1.0.0" - path-scurry: "npm:^1.11.1" - bin: - glob: dist/esm/bin.mjs - checksum: 10c0/19a9759ea77b8e3ca0a43c2f07ecddc2ad46216b786bb8f993c445aee80d345925a21e5280c7b7c6c59e860a0154b84e4b2b60321fea92cd3c56b4a7489f160e - languageName: node - linkType: hard - "glob@npm:^10.5.0": version: 10.5.0 resolution: "glob@npm:10.5.0" @@ -15399,7 +15089,7 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.0.2": +"istanbul-reports@npm:^3.0.2, istanbul-reports@npm:^3.1.3": version: 3.2.0 resolution: "istanbul-reports@npm:3.2.0" dependencies: @@ -15409,16 +15099,6 @@ __metadata: languageName: node linkType: hard -"istanbul-reports@npm:^3.1.3": - version: 3.1.7 - resolution: "istanbul-reports@npm:3.1.7" - dependencies: - html-escaper: "npm:^2.0.0" - istanbul-lib-report: "npm:^3.0.0" - checksum: 10c0/a379fadf9cf8dc5dfe25568115721d4a7eb82fbd50b005a6672aff9c6989b20cc9312d7865814e0859cd8df58cbf664482e1d3604be0afde1f7fc3ccc1394a51 - languageName: node - linkType: hard - "iterator.prototype@npm:^1.1.3": version: 1.1.3 resolution: "iterator.prototype@npm:1.1.3" @@ -15481,34 +15161,6 @@ __metadata: languageName: node linkType: hard -"jest-circus@npm:30.0.5": - version: 30.0.5 - resolution: "jest-circus@npm:30.0.5" - dependencies: - "@jest/environment": "npm:30.0.5" - "@jest/expect": "npm:30.0.5" - "@jest/test-result": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - co: "npm:^4.6.0" - dedent: "npm:^1.6.0" - is-generator-fn: "npm:^2.1.0" - jest-each: "npm:30.0.5" - jest-matcher-utils: "npm:30.0.5" - jest-message-util: "npm:30.0.5" - jest-runtime: "npm:30.0.5" - jest-snapshot: "npm:30.0.5" - jest-util: "npm:30.0.5" - p-limit: "npm:^3.1.0" - pretty-format: "npm:30.0.5" - pure-rand: "npm:^7.0.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10c0/028204897eee7bef2d04eea0216b48f94e3da77ff1d12b0e3a5e265e8e73bcd31192cec70282aa1ece91150c00fcb5662c2c68e86b3892cffbfbe7058fa7f4e5 - languageName: node - linkType: hard - "jest-circus@npm:30.3.0, jest-circus@npm:^30.0.4": version: 30.3.0 resolution: "jest-circus@npm:30.3.0" @@ -15616,7 +15268,7 @@ __metadata: languageName: node linkType: hard -"jest-config@npm:30.3.0": +"jest-config@npm:30.3.0, jest-config@npm:^30.0.2": version: 30.3.0 resolution: "jest-config@npm:30.3.0" dependencies: @@ -15696,61 +15348,6 @@ __metadata: languageName: node linkType: hard -"jest-config@npm:^30.0.2": - version: 30.0.5 - resolution: "jest-config@npm:30.0.5" - dependencies: - "@babel/core": "npm:^7.27.4" - "@jest/get-type": "npm:30.0.1" - "@jest/pattern": "npm:30.0.1" - "@jest/test-sequencer": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - babel-jest: "npm:30.0.5" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - deepmerge: "npm:^4.3.1" - glob: "npm:^10.3.10" - graceful-fs: "npm:^4.2.11" - jest-circus: "npm:30.0.5" - jest-docblock: "npm:30.0.1" - jest-environment-node: "npm:30.0.5" - jest-regex-util: "npm:30.0.1" - jest-resolve: "npm:30.0.5" - jest-runner: "npm:30.0.5" - jest-util: "npm:30.0.5" - jest-validate: "npm:30.0.5" - micromatch: "npm:^4.0.8" - parse-json: "npm:^5.2.0" - pretty-format: "npm:30.0.5" - slash: "npm:^3.0.0" - strip-json-comments: "npm:^3.1.1" - peerDependencies: - "@types/node": "*" - esbuild-register: ">=3.4.0" - ts-node: ">=9.0.0" - peerDependenciesMeta: - "@types/node": - optional: true - esbuild-register: - optional: true - ts-node: - optional: true - checksum: 10c0/da68048801e6f6622bf6e9a361dcfb3859017bbd58fabcf53bade41157bdf31cc35a1bd3dab1e3cca86e69da23e2c27c7aa5e308efc04564a454e23de6f22062 - languageName: node - linkType: hard - -"jest-diff@npm:30.0.5": - version: 30.0.5 - resolution: "jest-diff@npm:30.0.5" - dependencies: - "@jest/diff-sequences": "npm:30.0.1" - "@jest/get-type": "npm:30.0.1" - chalk: "npm:^4.1.2" - pretty-format: "npm:30.0.5" - checksum: 10c0/b218ced37b7676f578ea866762f04caa74901bdcf3f593872aa9a4991a586302651a1d16bb0386772adacc7580a452ec621359af75d733c0b50ea947fe1881d3 - languageName: node - linkType: hard - "jest-diff@npm:30.3.0, jest-diff@npm:^30.0.2": version: 30.3.0 resolution: "jest-diff@npm:30.3.0" @@ -15775,15 +15372,6 @@ __metadata: languageName: node linkType: hard -"jest-docblock@npm:30.0.1": - version: 30.0.1 - resolution: "jest-docblock@npm:30.0.1" - dependencies: - detect-newline: "npm:^3.1.0" - checksum: 10c0/f9bad2651db8afa029867ea7a40f422c9d73c67657360297371846a314a40c8786424be00483261df9137499f52c2af28cd458fbd15a7bf7fac8775b4bcd6ee1 - languageName: node - linkType: hard - "jest-docblock@npm:30.2.0": version: 30.2.0 resolution: "jest-docblock@npm:30.2.0" @@ -15802,19 +15390,6 @@ __metadata: languageName: node linkType: hard -"jest-each@npm:30.0.5": - version: 30.0.5 - resolution: "jest-each@npm:30.0.5" - dependencies: - "@jest/get-type": "npm:30.0.1" - "@jest/types": "npm:30.0.5" - chalk: "npm:^4.1.2" - jest-util: "npm:30.0.5" - pretty-format: "npm:30.0.5" - checksum: 10c0/fe7509bfd8b0c8553bbdaffda5d3b674a4da870c5ce9fe69c1ca8111d9e0f21a8f265799eba0f927581d16f4810e5eb5bebfd7e51f5f137cbef08cc44d8fd9cd - languageName: node - linkType: hard - "jest-each@npm:30.3.0": version: 30.3.0 resolution: "jest-each@npm:30.3.0" @@ -15871,21 +15446,6 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:30.0.5": - version: 30.0.5 - resolution: "jest-environment-node@npm:30.0.5" - dependencies: - "@jest/environment": "npm:30.0.5" - "@jest/fake-timers": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - jest-mock: "npm:30.0.5" - jest-util: "npm:30.0.5" - jest-validate: "npm:30.0.5" - checksum: 10c0/1b608597f0755814e7c24b9ed2a45abc2340cfd8f8d3691caf929f332facd9c62ac5092e7f01056708a0ca41ae0458b6d442fd1ae9f6d21b7b416b252e1ae210 - languageName: node - linkType: hard - "jest-environment-node@npm:30.3.0, jest-environment-node@npm:^30.0.4": version: 30.3.0 resolution: "jest-environment-node@npm:30.3.0" @@ -15922,28 +15482,6 @@ __metadata: languageName: node linkType: hard -"jest-haste-map@npm:30.0.5": - version: 30.0.5 - resolution: "jest-haste-map@npm:30.0.5" - dependencies: - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - anymatch: "npm:^3.1.3" - fb-watchman: "npm:^2.0.2" - fsevents: "npm:^2.3.3" - graceful-fs: "npm:^4.2.11" - jest-regex-util: "npm:30.0.1" - jest-util: "npm:30.0.5" - jest-worker: "npm:30.0.5" - micromatch: "npm:^4.0.8" - walker: "npm:^1.0.8" - dependenciesMeta: - fsevents: - optional: true - checksum: 10c0/eab5d85d820f149bcf4bf4e0c49316f48973c85d39b4c3a2e08f57504f069afe9b0f1665e556330a98c6fc6bd5a6932767b466c1c96124fa0161aef017ab17b3 - languageName: node - linkType: hard - "jest-haste-map@npm:30.3.0": version: 30.3.0 resolution: "jest-haste-map@npm:30.3.0" @@ -16001,16 +15539,6 @@ __metadata: languageName: node linkType: hard -"jest-leak-detector@npm:30.0.5": - version: 30.0.5 - resolution: "jest-leak-detector@npm:30.0.5" - dependencies: - "@jest/get-type": "npm:30.0.1" - pretty-format: "npm:30.0.5" - checksum: 10c0/04207ab6f44dec22d3d656b5f3b4f334440f4c01ccd21c55474f26706530244d34b8dc9922c9449e00e8649e5da1b8de4aca58c9895c9de19951d5ecdc0ff113 - languageName: node - linkType: hard - "jest-leak-detector@npm:30.3.0": version: 30.3.0 resolution: "jest-leak-detector@npm:30.3.0" @@ -16031,18 +15559,6 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:30.0.5": - version: 30.0.5 - resolution: "jest-matcher-utils@npm:30.0.5" - dependencies: - "@jest/get-type": "npm:30.0.1" - chalk: "npm:^4.1.2" - jest-diff: "npm:30.0.5" - pretty-format: "npm:30.0.5" - checksum: 10c0/231d891b29bfc218f2f5739c10873b6671426e31ad1c5538eed1531e62608fd3f60d32f41821332a6cf41f1614fd37361434c754fdd49c849b35ef2e5156c02e - languageName: node - linkType: hard - "jest-matcher-utils@npm:30.3.0": version: 30.3.0 resolution: "jest-matcher-utils@npm:30.3.0" @@ -16067,23 +15583,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:30.0.5": - version: 30.0.5 - resolution: "jest-message-util@npm:30.0.5" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@jest/types": "npm:30.0.5" - "@types/stack-utils": "npm:^2.0.3" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - micromatch: "npm:^4.0.8" - pretty-format: "npm:30.0.5" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10c0/38b710c127db6c79c36d690377d9f9f1e3c2e4b2d2e60f3b82a5b4da70efb1f4783c6cf0cf1f6be6e3b7fb2d2aed889583d2430f65afc09e7e6d68aa5fa981dc - languageName: node - linkType: hard - "jest-message-util@npm:30.3.0": version: 30.3.0 resolution: "jest-message-util@npm:30.3.0" @@ -16118,17 +15617,6 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:30.0.5": - version: 30.0.5 - resolution: "jest-mock@npm:30.0.5" - dependencies: - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - jest-util: "npm:30.0.5" - checksum: 10c0/207fd79297f514a8e26ede9b4b5035e70212b8850a2f460b51d3cc58e8e7c9585bd2dbc5df2475a3321c4cd114b90e0b24190f00d6eeb88c8f088a8ed00416d5 - languageName: node - linkType: hard - "jest-mock@npm:30.3.0": version: 30.3.0 resolution: "jest-mock@npm:30.3.0" @@ -16215,22 +15703,6 @@ __metadata: languageName: node linkType: hard -"jest-resolve@npm:30.0.5": - version: 30.0.5 - resolution: "jest-resolve@npm:30.0.5" - dependencies: - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.0.5" - jest-pnp-resolver: "npm:^1.2.3" - jest-util: "npm:30.0.5" - jest-validate: "npm:30.0.5" - slash: "npm:^3.0.0" - unrs-resolver: "npm:^1.7.11" - checksum: 10c0/6edea75db950131513cd642743d4c5dd36c209c94652e469eebc86fdf85eb579a7614c30262668fcd429e1c841f1d17a26831259db69c17dffd0718c37f69196 - languageName: node - linkType: hard - "jest-resolve@npm:30.3.0, jest-resolve@npm:^30.0.2": version: 30.3.0 resolution: "jest-resolve@npm:30.3.0" @@ -16264,36 +15736,6 @@ __metadata: languageName: node linkType: hard -"jest-runner@npm:30.0.5": - version: 30.0.5 - resolution: "jest-runner@npm:30.0.5" - dependencies: - "@jest/console": "npm:30.0.5" - "@jest/environment": "npm:30.0.5" - "@jest/test-result": "npm:30.0.5" - "@jest/transform": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - emittery: "npm:^0.13.1" - exit-x: "npm:^0.2.2" - graceful-fs: "npm:^4.2.11" - jest-docblock: "npm:30.0.1" - jest-environment-node: "npm:30.0.5" - jest-haste-map: "npm:30.0.5" - jest-leak-detector: "npm:30.0.5" - jest-message-util: "npm:30.0.5" - jest-resolve: "npm:30.0.5" - jest-runtime: "npm:30.0.5" - jest-util: "npm:30.0.5" - jest-watcher: "npm:30.0.5" - jest-worker: "npm:30.0.5" - p-limit: "npm:^3.1.0" - source-map-support: "npm:0.5.13" - checksum: 10c0/5da84e4f393cc4b0c2b86a7058c154e524bc91947867f892d252300d06c595058690a61ffdbfa74381498f4ebb9cc7d8d967a62f53cb5f5383ec59fb5ed21d91 - languageName: node - linkType: hard - "jest-runner@npm:30.3.0, jest-runner@npm:^30.0.4": version: 30.3.0 resolution: "jest-runner@npm:30.3.0" @@ -16353,36 +15795,6 @@ __metadata: languageName: node linkType: hard -"jest-runtime@npm:30.0.5": - version: 30.0.5 - resolution: "jest-runtime@npm:30.0.5" - dependencies: - "@jest/environment": "npm:30.0.5" - "@jest/fake-timers": "npm:30.0.5" - "@jest/globals": "npm:30.0.5" - "@jest/source-map": "npm:30.0.1" - "@jest/test-result": "npm:30.0.5" - "@jest/transform": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - cjs-module-lexer: "npm:^2.1.0" - collect-v8-coverage: "npm:^1.0.2" - glob: "npm:^10.3.10" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.0.5" - jest-message-util: "npm:30.0.5" - jest-mock: "npm:30.0.5" - jest-regex-util: "npm:30.0.1" - jest-resolve: "npm:30.0.5" - jest-snapshot: "npm:30.0.5" - jest-util: "npm:30.0.5" - slash: "npm:^3.0.0" - strip-bom: "npm:^4.0.0" - checksum: 10c0/c1afa36da0582172e9a73d69fcc23fd433efc8a7d0328ba5fee45858dc85cb01410b47ba53540bb3758277eb84bb5a42e872bc58d2e5a3cad533f4b33e3abe61 - languageName: node - linkType: hard - "jest-runtime@npm:30.3.0": version: 30.3.0 resolution: "jest-runtime@npm:30.3.0" @@ -16452,35 +15864,6 @@ __metadata: languageName: node linkType: hard -"jest-snapshot@npm:30.0.5": - version: 30.0.5 - resolution: "jest-snapshot@npm:30.0.5" - dependencies: - "@babel/core": "npm:^7.27.4" - "@babel/generator": "npm:^7.27.5" - "@babel/plugin-syntax-jsx": "npm:^7.27.1" - "@babel/plugin-syntax-typescript": "npm:^7.27.1" - "@babel/types": "npm:^7.27.3" - "@jest/expect-utils": "npm:30.0.5" - "@jest/get-type": "npm:30.0.1" - "@jest/snapshot-utils": "npm:30.0.5" - "@jest/transform": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - babel-preset-current-node-syntax: "npm:^1.1.0" - chalk: "npm:^4.1.2" - expect: "npm:30.0.5" - graceful-fs: "npm:^4.2.11" - jest-diff: "npm:30.0.5" - jest-matcher-utils: "npm:30.0.5" - jest-message-util: "npm:30.0.5" - jest-util: "npm:30.0.5" - pretty-format: "npm:30.0.5" - semver: "npm:^7.7.2" - synckit: "npm:^0.11.8" - checksum: 10c0/2bda246367373003abfbd66de261bfd355618926c28261d7ffcdfac0c4c7a7f575c9f598745b0b59eb2cfa8907889dcc07db3ad65d940061275d490c1eb3e1fe - languageName: node - linkType: hard - "jest-snapshot@npm:30.3.0": version: 30.3.0 resolution: "jest-snapshot@npm:30.3.0" @@ -16538,20 +15921,6 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:30.0.5": - version: 30.0.5 - resolution: "jest-util@npm:30.0.5" - dependencies: - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - graceful-fs: "npm:^4.2.11" - picomatch: "npm:^4.0.2" - checksum: 10c0/d3808b5f7720044d0464664c795e2b795ed82edf3b5871db74b8b603c3a0a38107668730348d26f92920ca3b8245a99cbbc2c93e77d0abb1f5e27524079a4ba8 - languageName: node - linkType: hard - "jest-util@npm:30.3.0, jest-util@npm:^30.0.2": version: 30.3.0 resolution: "jest-util@npm:30.3.0" @@ -16580,20 +15949,6 @@ __metadata: languageName: node linkType: hard -"jest-validate@npm:30.0.5": - version: 30.0.5 - resolution: "jest-validate@npm:30.0.5" - dependencies: - "@jest/get-type": "npm:30.0.1" - "@jest/types": "npm:30.0.5" - camelcase: "npm:^6.3.0" - chalk: "npm:^4.1.2" - leven: "npm:^3.1.0" - pretty-format: "npm:30.0.5" - checksum: 10c0/739a5df57befd763ba40693c9c1d7e93234af44ca21226a42272fbf87dea076a23848072b46871ce02cc0f2614f8ad41542e98965b405320276102b4de35b063 - languageName: node - linkType: hard - "jest-validate@npm:30.3.0": version: 30.3.0 resolution: "jest-validate@npm:30.3.0" @@ -16639,22 +15994,6 @@ __metadata: languageName: node linkType: hard -"jest-watcher@npm:30.0.5": - version: 30.0.5 - resolution: "jest-watcher@npm:30.0.5" - dependencies: - "@jest/test-result": "npm:30.0.5" - "@jest/types": "npm:30.0.5" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.3.2" - chalk: "npm:^4.1.2" - emittery: "npm:^0.13.1" - jest-util: "npm:30.0.5" - string-length: "npm:^4.0.2" - checksum: 10c0/5c26617c53e6314e2143806cbc8c1cdca7100cc8de3241c7debf7b5feb0df17bdc9a92ee4a4efa953a261d8806ffd7f6c89e72d567236e62492dd554eaa91f97 - languageName: node - linkType: hard - "jest-watcher@npm:30.3.0, jest-watcher@npm:^30.0.0": version: 30.3.0 resolution: "jest-watcher@npm:30.3.0" @@ -16687,19 +16026,6 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:30.0.5": - version: 30.0.5 - resolution: "jest-worker@npm:30.0.5" - dependencies: - "@types/node": "npm:*" - "@ungap/structured-clone": "npm:^1.3.0" - jest-util: "npm:30.0.5" - merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.1.1" - checksum: 10c0/50a724b39b8691168a456544f32ef8e937c827cd6d326fa0bc27df786c80af1e1f16d9f2d9cc800af4baac85a0f9e9ed78fbd4a06f13eb32e72ec66d11b85f38 - languageName: node - linkType: hard - "jest-worker@npm:30.3.0, jest-worker@npm:^30.0.5": version: 30.3.0 resolution: "jest-worker@npm:30.3.0" @@ -19718,17 +19044,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:30.0.5": - version: 30.0.5 - resolution: "pretty-format@npm:30.0.5" - dependencies: - "@jest/schemas": "npm:30.0.5" - ansi-styles: "npm:^5.2.0" - react-is: "npm:^18.3.1" - checksum: 10c0/9f6cf1af5c3169093866c80adbfdad32f69c692b62f24ba3ca8cdec8519336123323f896396f9fa40346a41b197c5f6be15aec4d8620819f12496afaaca93f81 - languageName: node - linkType: hard - "pretty-format@npm:30.3.0": version: 30.3.0 resolution: "pretty-format@npm:30.3.0" From 43007af5819f141e5b1a46a5908756617064836f Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Apr 2026 13:18:48 -0400 Subject: [PATCH 061/110] version plan --- .nx/version-plans/version-plan-1775495905156.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .nx/version-plans/version-plan-1775495905156.md 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 From aa6748ddc39d2b0685d7b516a93d36ef9ee99693 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Apr 2026 13:26:52 -0400 Subject: [PATCH 062/110] fix sb import --- packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx index b107fda00dc..3c8788e8487 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx +++ b/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx @@ -1,4 +1,4 @@ -import { Canvas, Controls, Meta } from '@storybook/blocks'; +import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; import { ComponentHeader } from '~styleguide/blocks'; From cd040bb193857f43b5259b45d1e752c04f8545ec Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Apr 2026 15:34:19 -0400 Subject: [PATCH 063/110] tests round 1 --- .../__tests__/CalendarFooter.test.tsx | 55 ++++ .../Calendar/utils/__tests__/dateGrid.test.ts | 97 +++++++ .../Calendar/utils/__tests__/format.test.ts | 120 +++++++++ .../utils/__tests__/keyHandler.test.tsx | 253 ++++++++++++++++++ .../DatePicker/Calendar/utils/validation.ts | 23 -- 5 files changed, 525 insertions(+), 23 deletions(-) create mode 100644 packages/gamut/src/DatePicker/Calendar/__tests__/CalendarFooter.test.tsx create mode 100644 packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts create mode 100644 packages/gamut/src/DatePicker/Calendar/utils/__tests__/format.test.ts create mode 100644 packages/gamut/src/DatePicker/Calendar/utils/__tests__/keyHandler.test.tsx delete mode 100644 packages/gamut/src/DatePicker/Calendar/utils/validation.ts diff --git a/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarFooter.test.tsx b/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarFooter.test.tsx new file mode 100644 index 00000000000..fec4fc17921 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarFooter.test.tsx @@ -0,0 +1,55 @@ +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 mockOnTodayClick = jest.fn(); + +const renderView = setupRtl(CalendarFooter, { + locale: 'en-US', + onClearDate: mockOnClearDate, + onTodayClick: mockOnTodayClick, +}); + +describe('CalendarFooter', () => { + it('calls onTodayClick when the today button is clicked', async () => { + const { view } = renderView(); + + await userEvent.click(view.getByRole('button', { name: /today/i })); + + expect(mockOnTodayClick).toHaveBeenCalledTimes(1); + }); + + it('renders clear button when showClearButton is true and calls onClearDate when clicked', async () => { + const { view } = renderView({ + showClearButton: true, + }); + + await userEvent.click( + view.getByRole('button', { + name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearText, + }) + ); + + expect(mockOnClearDate).toHaveBeenCalledTimes(1); + }); + + it('does not render clear button when showClearButton is false', () => { + const { view } = renderView({ showClearButton: false }); + + expect( + view.queryByRole('button', { name: /clear/i }) + ).not.toBeInTheDocument(); + }); + + it('renders passed clearText when provided', () => { + const { view } = renderView({ + showClearButton: true, + clearText: 'Clear dates', + }); + + view.getByRole('button', { name: 'Clear dates' }); + }); +}); diff --git a/packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts b/packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts new file mode 100644 index 00000000000..7a3638f4b86 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts @@ -0,0 +1,97 @@ +import { + getDatesWithRow, + getMonthGrid, + getWeekdayOffsetInGrid, + isDateDisabled, + isDateInRange, + isSameDay, +} 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(first, 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(first, 7)).toBeGreaterThan(0); + }); +}); + +describe('getMonthGrid', () => { + it('includes exactly the number of days in the month', () => { + const weeks = getMonthGrid(2024, 2, 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(2024, 2, 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 start = new Date(2024, 2, 10); + const end = new Date(2024, 2, 20); + + it('returns true strictly between start and end', () => { + expect(isDateInRange(new Date(2024, 2, 15), start, end)).toBe(true); + }); + + it('returns false on start, end, or outside', () => { + expect(isDateInRange(start, start, end)).toBe(false); + expect(isDateInRange(end, start, end)).toBe(false); + expect(isDateInRange(new Date(2024, 2, 5), start, end)).toBe(false); + }); + + it('returns false when start is null', () => { + expect(isDateInRange(new Date(2024, 2, 15), null, end)).toBe(false); + }); +}); + +describe('isDateDisabled', () => { + it('returns true when any disabled date matches the day', () => { + const target = new Date(2024, 4, 10); + const disabled = [new Date(2024, 4, 10, 15, 30)]; + expect(isDateDisabled(target, disabled)).toBe(true); + }); + + it('returns false when the list is empty or no match', () => { + expect(isDateDisabled(new Date(2024, 4, 10), [])).toBe(false); + expect( + isDateDisabled(new Date(2024, 4, 10), [new Date(2024, 4, 11)]) + ).toBe(false); + }); +}); + +describe('getDatesWithRow', () => { + it('lists only non-null dates with row indices', () => { + const weeks = getMonthGrid(2024, 0, 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/Calendar/utils/__tests__/format.test.ts b/packages/gamut/src/DatePicker/Calendar/utils/__tests__/format.test.ts new file mode 100644 index 00000000000..3a452f150af --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/utils/__tests__/format.test.ts @@ -0,0 +1,120 @@ +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('hello', enUS)).toBe('Hello'); + }); + + it('returns empty string unchanged', () => { + expect(capitalizeFirst('', enUS)).toBe(''); + }); +}); + +describe('formatMonthYear', () => { + it('formats month in long format and year in numeric format', () => { + const text = formatMonthYear(new Date(2026, 0, 15), enUS); + expect(text).toMatch(/2026/); + expect(text.toLowerCase()).toContain('january'); + }); + + it('formats month and year based on the given locale', () => { + const text = formatMonthYear(new Date(2026, 0, 15), frFR); + expect(text).toMatch(/2026/); + expect(text.toLowerCase()).toContain('janvier'); + }); +}); + +describe('getWeekdayNames', () => { + it('returns short weekday names when format is short', () => { + const short = getWeekdayNames('short', enUS, 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('long', enUS, 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('short', frFR, 1); + expect(short).toEqual([ + 'lun.', + 'mar.', + 'mer.', + 'jeu.', + 'ven.', + 'sam.', + 'dim.', + ]); + const long = getWeekdayNames('long', frFR, 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(new Date(2026, 1, 14), 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(new Date(2026, 1, 14), frFR); + expect(label).toMatch(/2026/); + expect(label.toLowerCase()).toContain('février'); + expect(label).toMatch(/14/); + }); +}); diff --git a/packages/gamut/src/DatePicker/Calendar/utils/__tests__/keyHandler.test.tsx b/packages/gamut/src/DatePicker/Calendar/utils/__tests__/keyHandler.test.tsx new file mode 100644 index 00000000000..f4138eaa327 --- /dev/null +++ b/packages/gamut/src/DatePicker/Calendar/utils/__tests__/keyHandler.test.tsx @@ -0,0 +1,253 @@ +import type { KeyboardEvent } from 'react'; + +import { getDatesWithRow, getMonthGrid } 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, + disabledDates: [], + 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'), + disabledDates: [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(); + }); +}); diff --git a/packages/gamut/src/DatePicker/Calendar/utils/validation.ts b/packages/gamut/src/DatePicker/Calendar/utils/validation.ts deleted file mode 100644 index 034e8a7c0ac..00000000000 --- a/packages/gamut/src/DatePicker/Calendar/utils/validation.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Validation helpers for DatePicker (single-date). - * Used to mark invalid dates as unselectable and for manual entry validation. - */ - -/** - * Check if a date is in the past (before today at start of day). - * Useful for disabling past dates in the calendar. - */ -export const isPastDate = (date: Date) => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const normalizedDate = new Date(date); - normalizedDate.setHours(0, 0, 0, 0); - return normalizedDate.getTime() < today.getTime(); -}; - -/** - * Check if a date is valid (finite and not NaN). - */ -export const isValidDate = (date: Date) => { - return date instanceof Date && !Number.isNaN(date.getTime()); -}; From e086026fb1cbdd5edc986fe04043b4bed218c580 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 6 Apr 2026 16:09:38 -0400 Subject: [PATCH 064/110] CalendarBody tests --- .../src/DatePicker/Calendar/CalendarBody.tsx | 4 +- .../Calendar/__tests__/CalendarBody.test.tsx | 139 ++++++++++++++++++ 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 packages/gamut/src/DatePicker/Calendar/__tests__/CalendarBody.test.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 34dbb6a477d..46e0c74f85f 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -174,8 +174,10 @@ export const CalendarBody: React.FC = ({ return ( { + 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('calls keyHandler when a key is pressed on a focused day cell', async () => { + const keyHandlerSpy = jest.spyOn(keyHandlerModule, 'keyHandler'); + + const { view } = renderView(); + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{ArrowLeft}'); + + expect(keyHandlerSpy).toHaveBeenCalledTimes(1); + keyHandlerSpy.mockRestore(); + }); + + 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(today, 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({ disabledDates: [new Date(2024, 2, 1)] }); + + const disabled = view.getByRole('gridcell', { name: /March 1, 2024/i }); + expect(disabled).toHaveAttribute('aria-disabled', 'true'); + }); + + 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('calls onGridFocusRequestHandled when grid focus is requested', () => { + renderView({ + focusGridSync: { + gridFocusRequested: true, + signal: false, + onGridFocusRequestHandled: mockOnGridFocusRequestHandled, + }, + }); + + expect(mockOnGridFocusRequestHandled).toHaveBeenCalled(); + }); +}); From 3b676f293868c971ecf0d3b88cb0895cfe6ccb9e Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 7 Apr 2026 10:03:06 -0400 Subject: [PATCH 065/110] style updates --- .../DatePickerInput/Segment/DatePickerInputSegment.tsx | 3 --- .../gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx | 4 +++- packages/gamut/src/DatePicker/DatePickerInput/index.tsx | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx index 28bde1295f4..b7b4fa9c65e 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx @@ -23,7 +23,6 @@ export type DatePickerInputSegmentProps = { segments: SegmentValues; disabled: boolean; error: boolean; - handleOnClick: () => void; handleOnFocus: () => void; focusOrOpenCalendarGrid: () => void; setSegments: Dispatch>; @@ -40,7 +39,6 @@ export const DatePickerInputSegment: React.FC = ({ segments, disabled, error, - handleOnClick, handleOnFocus, focusOrOpenCalendarGrid, setSegments, @@ -181,7 +179,6 @@ export const DatePickerInputSegment: React.FC = ({ }} role="spinbutton" tabIndex={disabled ? -1 : 0} - onClick={handleOnClick} onFocus={handleOnFocus} onKeyDown={handleSegmentKeyDown(field)} > diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx index 704083c62e2..3c2365bc2a0 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx @@ -25,7 +25,9 @@ export const Segment = styled.span( color: 'text', cursor: 'text', '&:focus': { - outline: '1px solid blue', + bg: 'primary', + color: 'background', + borderRadius: 'md', }, }), segmentStyles diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index e3777360926..0027435ab74 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -194,6 +194,7 @@ export const DatePickerInput = forwardRef( variant={error ? 'error' : undefined} width="113px" onBlur={handleContainerBlur} + onClick={handleOpenCalendar} onFocus={handleContainerFocus} {...rest} > @@ -222,7 +223,6 @@ export const DatePickerInput = forwardRef( error={Boolean(error)} field={item.field} focusOrOpenCalendarGrid={focusOrOpenCalendarGrid} - handleOnClick={handleOpenCalendar} handleOnFocus={handleSegmentFocus} key={item.field} nextField={nextField} @@ -247,7 +247,6 @@ export const DatePickerInput = forwardRef( pl={16} pr={8} role="presentation" - onClick={handleOpenCalendar} > From 878339f82bd6ca9e613a795f9ea37519383fccfa Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 7 Apr 2026 14:56:50 -0400 Subject: [PATCH 066/110] utils tests --- .../utils/__tests__/dateSelect.test.ts | 607 ++++++++++++++++++ .../DatePicker/utils/__tests__/locale.test.ts | 72 +++ .../gamut/src/DatePicker/utils/dateSelect.ts | 2 +- 3 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts create mode 100644 packages/gamut/src/DatePicker/utils/__tests__/locale.test.ts diff --git a/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts new file mode 100644 index 00000000000..0b7005a8d8c --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts @@ -0,0 +1,607 @@ +import { + applyRangeOrNewStart, + handleDateSelectRange, + handleDateSelectSingle, + rangeContainsDisabled, +} from '../dateSelect'; + +const createDate = (y: number, month: number, day: number) => + new Date(y, month, day); + +const mockSetSelection = jest.fn(); +describe('rangeContainsDisabled', () => { + const start = createDate(2024, 0, 10); + const end = createDate(2024, 0, 20); + + it('returns true when a disabled date is the start date', () => { + expect( + rangeContainsDisabled({ + start, + end, + disabledDates: [createDate(2024, 0, 10)], + }) + ).toBe(true); + }); + + it('returns true when a disabled date is the end date', () => { + expect( + rangeContainsDisabled({ + start, + end, + disabledDates: [createDate(2024, 0, 20)], + }) + ).toBe(true); + }); + + it('returns true when a disabled date is strictly between start and end', () => { + expect( + rangeContainsDisabled({ + start, + end, + disabledDates: [createDate(2024, 0, 15)], + }) + ).toBe(true); + }); + + it('returns false when no disabled date touches the inclusive range', () => { + expect( + rangeContainsDisabled({ + start, + end, + disabledDates: [createDate(2024, 0, 5), createDate(2024, 0, 25)], + }) + ).toBe(false); + }); +}); + +describe('handleDateSelectSingle', () => { + it('clears selection when the same date is clicked again', () => { + const selected = createDate(2024, 5, 15); + handleDateSelectSingle({ + date: selected, + selectedDate: selected, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(null); + }); + + it('sets selection when no date was previously selected', () => { + const newSelected = createDate(2024, 5, 10); + handleDateSelectSingle({ + date: newSelected, + selectedDate: null, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).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), + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(newSelected); + }); +}); + +describe('applyRangeOrNewStart', () => { + it('sets selection to the start and end date when the range does not contain a disabled date', () => { + const start = createDate(2024, 5, 10); + const end = createDate(2024, 5, 20); + const clicked = createDate(2024, 5, 10); + applyRangeOrNewStart({ + start, + end, + clickedDate: clicked, + disabledDates: [createDate(2024, 5, 30)], + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, end); + }); + + it('sets selection to the clicked date as start and null as end when the range contains a disabled date', () => { + const start = createDate(2024, 5, 10); + const end = createDate(2024, 5, 20); + const clicked = createDate(2024, 5, 10); + applyRangeOrNewStart({ + start, + end, + clickedDate: clicked, + disabledDates: [createDate(2024, 5, 12)], + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); +}); + +describe('handleDateSelectRange', () => { + describe('activeRangePart === start', () => { + describe('start date is set', () => { + it('clears start when the start date is clicked again', () => { + const start = createDate(2024, 2, 10); + const end = createDate(2024, 2, 20); + handleDateSelectRange({ + date: start, + activeRangePart: 'start', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(null, end); + }); + + it('sets start date when no end date is set', () => { + const start = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: start, + endDate: null, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when new start is after end', () => { + const start = createDate(2024, 2, 2); + const end = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 20); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and keeps end date when new start is before end', () => { + const start = createDate(2024, 2, 10); + const end = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + }); + + it('sets start date and keeps end date when new start is the same as end', () => { + const start = createDate(2024, 2, 10); + const end = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 25); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const start = createDate(2024, 2, 2); + const end = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + disabledDates: [createDate(2024, 2, 12)], + }); + expect(mockSetSelection).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, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when new start is after end', () => { + const end = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 20); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and keeps end date when new start is before end', () => { + const end = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + }); + + it('sets start date and keeps end date when new start is the same as end', () => { + const end = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 25); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const end = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'start', + startDate: null, + endDate: end, + setSelection: mockSetSelection, + disabledDates: [createDate(2024, 2, 12)], + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + }); + + describe('activeRangePart === end', () => { + describe('end date is set', () => { + it('clears end date when the end date is clicked again', () => { + const start = createDate(2024, 2, 5); + const end = createDate(2024, 2, 18); + handleDateSelectRange({ + date: end, + activeRangePart: 'end', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, null); + }); + + it('sets end date when no start date is set', () => { + const end = createDate(2024, 2, 18); + const clicked = createDate(2024, 2, 19); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: null, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and clears start date when new end is before start', () => { + const start = createDate(2024, 2, 20); + const end = createDate(2024, 2, 12); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and keeps start date when new end is after start', () => { + const start = createDate(2024, 2, 10); + const end = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + }); + + it('sets end date and keeps start date when new end is the same as start', () => { + const start = createDate(2024, 2, 10); + const end = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const start = createDate(2024, 2, 10); + const end = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: start, + endDate: end, + setSelection: mockSetSelection, + disabledDates: [createDate(2024, 2, 12)], + }); + expect(mockSetSelection).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, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and clears start date when new end is before start', () => { + const start = createDate(2024, 2, 20); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: start, + endDate: null, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(null, clicked); + }); + + it('sets end date and keeps start date when new end is after start', () => { + const start = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: start, + endDate: null, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + }); + + it('sets end date and keeps start date when new end is the same as start', () => { + const start = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 10); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: start, + endDate: null, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + }); + + it('sets start date to the clicked date when the range would contain a disabled date', () => { + const start = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 15); + handleDateSelectRange({ + date: clicked, + activeRangePart: 'end', + startDate: start, + endDate: null, + setSelection: mockSetSelection, + disabledDates: [createDate(2024, 2, 12)], + }); + expect(mockSetSelection).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, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(null, null); + }); + + it('end date becomes start date when start date is clicked', () => { + const start = createDate(2024, 2, 5); + const end = createDate(2024, 2, 28); + handleDateSelectRange({ + date: start, + activeRangePart: null, + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(end, null); + }); + + it('clears end date when end date is clicked', () => { + const start = createDate(2024, 2, 5); + const end = createDate(2024, 2, 28); + handleDateSelectRange({ + date: end, + activeRangePart: null, + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, null); + }); + + it('updates end date when a date after start date is clicked', () => { + const start = createDate(2024, 2, 5); + const end = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, 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 start = createDate(2024, 2, 5); + const end = createDate(2024, 2, 10); + const clicked = createDate(2024, 2, 18); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: start, + endDate: end, + setSelection: mockSetSelection, + disabledDates: [createDate(2024, 2, 12)], + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('updates start date when a date before start date is clicked', () => { + const start = createDate(2024, 2, 15); + const end = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: start, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + }); + + it('updates start date to the clicked date and clears end date when the range extending to left would contain a disabled date', () => { + const start = createDate(2024, 2, 15); + const end = createDate(2024, 2, 25); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: start, + endDate: end, + setSelection: mockSetSelection, + disabledDates: [createDate(2024, 2, 12)], + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + }); + + describe('start date set, end date empty, selection mode', () => { + it('updates start date when clicked date is before start date', () => { + const start = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: start, + endDate: null, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets end date when clicked date is on or after start date', () => { + const start = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 22); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: start, + endDate: null, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + }); + + it('updates start date to the clicked date and does not set end date when the range would contain a disabled date', () => { + const start = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: start, + endDate: null, + setSelection: mockSetSelection, + disabledDates: [createDate(2024, 2, 12)], + }); + expect(mockSetSelection).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 end = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('sets start date and clears end date when clicked date is after end date', () => { + const end = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 22); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate: end, + setSelection: mockSetSelection, + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + + it('updates start date to the clicked date and clears end date when the range would contain a disabled date', () => { + const end = createDate(2024, 2, 15); + const clicked = createDate(2024, 2, 8); + handleDateSelectRange({ + date: clicked, + activeRangePart: null, + startDate: null, + endDate: end, + setSelection: mockSetSelection, + disabledDates: [createDate(2024, 2, 12)], + }); + expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + }); + }); +}); 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/dateSelect.ts b/packages/gamut/src/DatePicker/utils/dateSelect.ts index 40ade95db41..fd53c418888 100644 --- a/packages/gamut/src/DatePicker/utils/dateSelect.ts +++ b/packages/gamut/src/DatePicker/utils/dateSelect.ts @@ -56,7 +56,7 @@ type ApplyRangeOrNewStartParams = { disabledDates: Date[]; } & Pick; -const applyRangeOrNewStart = ({ +export const applyRangeOrNewStart = ({ start, end, clickedDate, From c87e08ddc9c9c7ca22bb6b0e31a065146924542d Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 8 Apr 2026 11:54:30 -0400 Subject: [PATCH 067/110] segmentUtils tests --- .../Segment/__tests__/segmentUtils.test.ts | 373 ++++++++++++++++++ .../DatePickerInput/Segment/segmentUtils.ts | 32 +- 2 files changed, 388 insertions(+), 17 deletions(-) create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts new file mode 100644 index 00000000000..8fbd00d7889 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts @@ -0,0 +1,373 @@ +import type { DateFormatLayoutItem } from '../../utils'; +import { + appendSegmentDigit, + buildCombinedFromSegments, + digitsToSegments, + getDateSegmentsFromDate, + getSegmentSpinBounds, + getStrictSegmentDigits, + isStrictlyCompleteDateEntry, + normalizeSegmentValues, + padSegmentNumber, + parseSegmentNumericString, + parseSegmentsToDate, + spinSegment, +} from '../segmentUtils'; + +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('month', { month: '', day: '', year: '' }) + ).toEqual({ min: 1, max: 12 }); + }); + + it('bounds year to 1-9999', () => { + expect( + getSegmentSpinBounds('year', { month: '', day: '', year: '' }) + ).toEqual({ min: 1, max: 9999 }); + }); + + it('bounds day using parsed month and four-digit year', () => { + expect( + getSegmentSpinBounds('day', { month: '02', year: '2024', day: '' }) + ).toEqual({ min: 1, max: 29 }); + expect( + getSegmentSpinBounds('day', { month: '02', year: '2023', day: '' }) + ).toEqual({ min: 1, max: 28 }); + }); + + it('uses default year 2024 when year segment is incomplete', () => { + expect( + getSegmentSpinBounds('day', { 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('year', 123)).toBe('0123'); + }); + it('pads month to two digits', () => { + expect(padSegmentNumber('month', 3)).toBe('03'); + }); + + it('pads day to two digits', () => { + expect(padSegmentNumber('day', 1)).toBe('01'); + }); +}); + +describe('appendSegmentDigit', () => { + it('ignores non-digit characters', () => { + expect(appendSegmentDigit('month', '01', 'x')).toBe('01'); + }); + + it('appends until max length', () => { + expect(appendSegmentDigit('month', '', '1')).toBe('1'); + expect(appendSegmentDigit('month', '1', '2')).toBe('12'); + }); + + it('replaces when segment is already full', () => { + expect(appendSegmentDigit('month', '12', '5')).toBe('5'); + expect(appendSegmentDigit('year', '2024', '9')).toBe('9'); + }); + + it('strips non-digits from previous value before appending', () => { + expect(appendSegmentDigit('day', '1a', '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('year', empty, 1)).toBe('2024'); + }); + + it('uses max year when stepping down', () => { + expect(spinSegment('year', empty, -1)).toBe('9999'); + }); + + it('steps month up from empty to min', () => { + expect(spinSegment('month', { month: '', day: '', year: '' }, 1)).toBe( + '01' + ); + }); + + it('steps month down from empty to max', () => { + expect(spinSegment('month', { month: '', day: '', year: '' }, -1)).toBe( + '12' + ); + }); + + it('steps day up from empty to min', () => { + expect(spinSegment('day', { month: '', day: '', year: '' }, 1)).toBe('01'); + }); + + it('steps day down from empty to max', () => { + expect(spinSegment('day', { month: '', day: '', year: '' }, -1)).toBe('31'); + }); + + it('increments within bounds', () => { + expect(spinSegment('month', { month: '06', day: '', year: '' }, 1)).toBe( + '07' + ); + expect(spinSegment('month', { month: '12', day: '', year: '' }, 1)).toBe( + '12' + ); + }); + + it('decrements within bounds', () => { + expect(spinSegment('month', { month: '06', day: '', year: '' }, -1)).toBe( + '05' + ); + expect(spinSegment('month', { month: '01', day: '', year: '' }, -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( + { month: '03', day: '15', year: '2024' }, + 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( + { month: '03', day: '15', year: '2024' }, + ukLayout + ) + ).toBe('15/03/2024'); + }); +}); + +describe('digitsToSegments', () => { + it('splits digit string by field order (MDY)', () => { + expect(digitsToSegments('03152024', ['month', 'day', 'year'])).toEqual({ + month: '03', + day: '15', + year: '2024', + }); + }); + + it('splits digit string by field order (DMY)', () => { + expect(digitsToSegments('15032024', ['day', 'month', 'year'])).toEqual({ + month: '03', + day: '15', + year: '2024', + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts index 192f7ca07af..facfa912298 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts @@ -22,17 +22,21 @@ 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 m = parseInt(month, 10); - const d = parseInt(day, 10); - const y = parseInt(year, 10); - if (!Number.isFinite(m) || !Number.isFinite(d) || !Number.isFinite(y)) + 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 (m < 1 || m > 12) return null; - const parsed = new Date(y, m - 1, d); + if (monthNumber < 1 || monthNumber > 12) return null; + const parsed = new Date(yearNumber, monthNumber - 1, dayNumber); if ( - parsed.getFullYear() !== y || - parsed.getMonth() !== m - 1 || - parsed.getDate() !== d + parsed.getFullYear() !== yearNumber || + parsed.getMonth() !== monthNumber - 1 || + parsed.getDate() !== dayNumber ) { return null; } @@ -133,13 +137,7 @@ export const parseSegmentNumericString = (str: string) => { return Number.isFinite(numericValue) ? numericValue : null; }; -const parseSegmentDigits = (_field: DatePartKind, str: string) => - parseSegmentNumericString(str); - -const padSegmentNumber = ( - field: DatePartKind, - numericValue: number -): string => { +export const padSegmentNumber = (field: DatePartKind, numericValue: number) => { if (field === 'year') { const clamped = Math.min(9999, Math.max(1, numericValue)); return String(clamped).padStart(4, '0'); @@ -176,7 +174,7 @@ export const spinSegment = ( delta: 1 | -1 ) => { const { min, max } = getSegmentSpinBounds(field, segments); - let cur = parseSegmentDigits(field, segments[field]); + let cur = parseSegmentNumericString(segments[field]); if (cur == null) { cur = From e8fe147f90c836fa0597ce73b984a16ad8a1a2b9 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 8 Apr 2026 15:29:49 -0400 Subject: [PATCH 068/110] segment tests --- .../Segment/DatePickerInputSegment.tsx | 3 +- .../__tests__/DatePickerInputSegment.test.tsx | 169 ++++++++++++++++++ 2 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx index b7b4fa9c65e..0866748f807 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx @@ -163,13 +163,12 @@ export const DatePickerInputSegment: React.FC = ({ return ( 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('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 user.keyboard('{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 user.keyboard('{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 user.keyboard('{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 user.keyboard('{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 user.keyboard('{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 user.keyboard('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 user.keyboard('{Backspace}'); + + expect(month).toHaveAttribute('aria-valuetext', '0'); + }); +}); From 0394f4be167b3c69f534a9d3c7c535859f9919ac Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 9 Apr 2026 13:21:27 -0400 Subject: [PATCH 069/110] fix segment refs --- .../Segment/DatePickerInputSegment.tsx | 39 ++++++++----------- .../__tests__/DatePickerInputSegment.test.tsx | 7 ++++ .../DatePickerInput/Segment/index.ts | 5 ++- .../src/DatePicker/DatePickerInput/index.tsx | 21 +++++++++- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx index 0866748f807..bddd354124d 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx @@ -1,10 +1,4 @@ -import { - type Dispatch, - type SetStateAction, - useCallback, - useId, - useRef, -} from 'react'; +import { type Dispatch, type SetStateAction, useCallback, useId } from 'react'; import type { DatePartKind } from '../utils'; import { Segment } from './elements'; @@ -18,6 +12,11 @@ import { spinSegment, } from './segmentUtils'; +export type AssignSegmentRef = ( + field: DatePartKind, + el: HTMLSpanElement | null +) => void; + export type DatePickerInputSegmentProps = { field: DatePartKind; segments: SegmentValues; @@ -25,6 +24,9 @@ export type DatePickerInputSegmentProps = { error: boolean; handleOnFocus: () => void; focusOrOpenCalendarGrid: () => void; + /** Focus a sibling segment; must use refs registered via `assignSegmentRef` (owned by parent). */ + focusSegmentField: (field: DatePartKind) => void; + assignSegmentRef: AssignSegmentRef; setSegments: Dispatch>; prevField: DatePartKind | null; nextField: DatePartKind | null; @@ -41,6 +43,8 @@ export const DatePickerInputSegment: React.FC = ({ error, handleOnFocus, focusOrOpenCalendarGrid, + focusSegmentField, + assignSegmentRef, setSegments, prevField, nextField, @@ -53,13 +57,6 @@ export const DatePickerInputSegment: React.FC = ({ segments[field].length > 0 ? segments[field] : segmentPlaceholder(field); const inputID = useId(); const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; - const segmentRefs = useRef< - Partial> - >({}); - - const focusField = useCallback((field: DatePartKind) => { - segmentRefs.current[field]?.focus(); - }, []); const handleSegmentKeyDown = useCallback( (field: DatePartKind) => (e: React.KeyboardEvent) => { @@ -74,7 +71,7 @@ export const DatePickerInputSegment: React.FC = ({ if (e.key === 'ArrowLeft') { if (prevField) { e.preventDefault(); - focusField(prevField); + focusSegmentField(prevField); } return; } @@ -82,7 +79,7 @@ export const DatePickerInputSegment: React.FC = ({ if (e.key === 'ArrowRight') { if (nextField) { e.preventDefault(); - focusField(nextField); + focusSegmentField(nextField); } return; } @@ -125,7 +122,7 @@ export const DatePickerInputSegment: React.FC = ({ return next; } if (prevField) { - queueMicrotask(() => focusField(prevField)); + queueMicrotask(() => focusSegmentField(prevField)); } return prev; }); @@ -143,7 +140,7 @@ export const DatePickerInputSegment: React.FC = ({ applySegments(next); const maxLen = segmentMaxLength(field); if (next[field].length >= maxLen && nextField) { - queueMicrotask(() => focusField(nextField)); + queueMicrotask(() => focusSegmentField(nextField)); } return next; }); @@ -153,7 +150,7 @@ export const DatePickerInputSegment: React.FC = ({ disabled, focusOrOpenCalendarGrid, prevField, - focusField, + focusSegmentField, nextField, setSegments, applySegments, @@ -173,9 +170,7 @@ export const DatePickerInputSegment: React.FC = ({ default={segments[field].length === 0} field={field} id={`${inputId}-${field}`} - ref={(el) => { - segmentRefs.current[field] = el; - }} + ref={(el) => assignSegmentRef(field, el)} role="spinbutton" tabIndex={disabled ? -1 : 0} onFocus={handleOnFocus} diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx index 5ee4b0ff322..ac067248bc1 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx @@ -7,10 +7,15 @@ import userEvent from '@testing-library/user-event'; import { DatePickerInputSegment } from '../DatePickerInputSegment'; import type { DatePartKind } from '../../utils'; +import type { AssignSegmentRef } from '../DatePickerInputSegment'; import type { SegmentValues } from '../segmentUtils'; const noop = () => undefined; +const noopAssignSegmentRef: AssignSegmentRef = () => undefined; + +const noopFocusSegmentField = () => undefined; + type HarnessProps = { field?: DatePartKind; segment?: SegmentValues; @@ -37,10 +42,12 @@ const SegmentHarness: FC = ({ return ( ( const isInputFocusedRef = useRef(false); const containerRef = useRef(null); + const segmentElRefs = useRef< + Partial> + >({}); + + const assignSegmentRef = useCallback( + (field: DatePartKind, el: HTMLSpanElement | null) => { + segmentElRefs.current[field] = el; + }, + [] + ); + + const focusSegmentField = useCallback((field: DatePartKind) => { + segmentElRefs.current[field]?.focus(); + }, []); const setShellRef = useCallback( (el: HTMLDivElement | null) => { @@ -219,10 +234,12 @@ export const DatePickerInput = forwardRef( return ( Date: Thu, 9 Apr 2026 14:01:23 -0400 Subject: [PATCH 070/110] close on date select --- .../Calendar/utils/__tests__/dateGrid.test.ts | 10 ++- .../src/DatePicker/DatePickerCalendar.tsx | 25 ++++--- .../utils/__tests__/dateSelect.test.ts | 67 +++++++++++++++---- .../gamut/src/DatePicker/utils/dateSelect.ts | 57 ++++++++-------- 4 files changed, 100 insertions(+), 59 deletions(-) diff --git a/packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts b/packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts index 7a3638f4b86..043d9629504 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts @@ -42,9 +42,7 @@ describe('isSameDay', () => { }); 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(2024, 5, 15), new Date(2024, 5, 16))).toBe(false); expect(isSameDay(new Date(), null)).toBe(false); }); }); @@ -77,9 +75,9 @@ describe('isDateDisabled', () => { it('returns false when the list is empty or no match', () => { expect(isDateDisabled(new Date(2024, 4, 10), [])).toBe(false); - expect( - isDateDisabled(new Date(2024, 4, 10), [new Date(2024, 4, 11)]) - ).toBe(false); + expect(isDateDisabled(new Date(2024, 4, 10), [new Date(2024, 4, 11)])).toBe( + false + ); }); }); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 28976f5067c..3ac4a6ea73b 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -103,17 +103,21 @@ export const DatePickerCalendar: React.FC = ({ selectedDate: startOrSelectedDate, setSelection, }); - } else { - context.setActiveRangePart(null); - handleDateSelectRange({ - date, - activeRangePart: context.activeRangePart, - startDate: startOrSelectedDate, - endDate: context.endDate, - setSelection, - disabledDates, - }); + // Defer close so React can commit the new date and the input can sync segments + // before closeCalendar focuses the spinbutton (which blocks segment sync while "focused"). + queueMicrotask(closeCalendar); + return; } + context.setActiveRangePart(null); + const shouldClose = handleDateSelectRange({ + date, + activeRangePart: context.activeRangePart, + startDate: startOrSelectedDate, + endDate: context.endDate, + setSelection, + disabledDates, + }); + if (shouldClose) closeCalendar(); }; const handleClearDate = () => { @@ -126,6 +130,7 @@ export const DatePickerCalendar: React.FC = ({ setSelection(today); setDisplayDate(firstOfMonth(today)); setFocusedDate(today); + if (!isRange) queueMicrotask(closeCalendar); }; const focusTarget = diff --git a/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts index 0b7005a8d8c..f1c76a5991b 100644 --- a/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts +++ b/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts @@ -91,13 +91,15 @@ describe('applyRangeOrNewStart', () => { const start = createDate(2024, 5, 10); const end = createDate(2024, 5, 20); const clicked = createDate(2024, 5, 10); - applyRangeOrNewStart({ - start, - end, - clickedDate: clicked, - disabledDates: [createDate(2024, 5, 30)], - setSelection: mockSetSelection, - }); + expect( + applyRangeOrNewStart({ + start, + end, + clickedDate: clicked, + disabledDates: [createDate(2024, 5, 30)], + setSelection: mockSetSelection, + }) + ).toBe(true); expect(mockSetSelection).toHaveBeenCalledWith(start, end); }); @@ -105,18 +107,55 @@ describe('applyRangeOrNewStart', () => { const start = createDate(2024, 5, 10); const end = createDate(2024, 5, 20); const clicked = createDate(2024, 5, 10); - applyRangeOrNewStart({ - start, - end, - clickedDate: clicked, - disabledDates: [createDate(2024, 5, 12)], - setSelection: mockSetSelection, - }); + expect( + applyRangeOrNewStart({ + start, + end, + clickedDate: clicked, + disabledDates: [createDate(2024, 5, 12)], + setSelection: mockSetSelection, + }) + ).toBe(false); expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); }); }); describe('handleDateSelectRange', () => { + describe('close calendar return value', () => { + it('returns false when only a start date is chosen (calendar mode)', () => { + const setSelection = jest.fn(); + expect( + handleDateSelectRange({ + date: createDate(2024, 5, 10), + activeRangePart: null, + startDate: null, + endDate: null, + setSelection, + disabledDates: [], + }) + ).toBe(false); + expect(setSelection).toHaveBeenCalledWith(createDate(2024, 5, 10), null); + }); + + it('returns true when end date is chosen after start (calendar mode)', () => { + const setSelection = jest.fn(); + expect( + handleDateSelectRange({ + date: createDate(2024, 5, 20), + activeRangePart: null, + startDate: createDate(2024, 5, 10), + endDate: null, + setSelection, + disabledDates: [], + }) + ).toBe(true); + expect(setSelection).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', () => { diff --git a/packages/gamut/src/DatePicker/utils/dateSelect.ts b/packages/gamut/src/DatePicker/utils/dateSelect.ts index fd53c418888..ab3d9dcf6bc 100644 --- a/packages/gamut/src/DatePicker/utils/dateSelect.ts +++ b/packages/gamut/src/DatePicker/utils/dateSelect.ts @@ -56,6 +56,7 @@ type ApplyRangeOrNewStartParams = { disabledDates: Date[]; } & Pick; +/** @returns whether a full start+end range was committed (calendar may close). */ export const applyRangeOrNewStart = ({ start, end, @@ -66,9 +67,10 @@ export const applyRangeOrNewStart = ({ // if range contains disabled dates, set start date to clicked date and end date to null if (rangeContainsDisabled({ start, end, disabledDates })) { setSelection(clickedDate, null); - } else { - setSelection(start, end); + return false; } + setSelection(start, end); + return true; }; export type HandleDateSelectRangeParams = { @@ -79,6 +81,7 @@ export type HandleDateSelectRangeParams = { 'activeRangePart' | 'endDate' | 'setSelection' | 'disabledDates' >; +/** @returns whether the calendar should close (full range selected and committed). */ export const handleDateSelectRange = ({ date, activeRangePart, @@ -91,44 +94,42 @@ export const handleDateSelectRange = ({ if (activeRangePart === 'start') { if (date.getTime() === startDate?.getTime()) { setSelection(null, endDate); - return; + return false; } const newEnd = endDate != null && date.getTime() <= endDate.getTime() ? endDate : null; if (newEnd != null) { - applyRangeOrNewStart({ + return applyRangeOrNewStart({ start: date, end: newEnd, clickedDate: date, disabledDates, setSelection, }); - } else { - setSelection(date, newEnd); } - return; + setSelection(date, newEnd); + return false; } if (activeRangePart === 'end') { if (date.getTime() === endDate?.getTime()) { setSelection(startDate, null); - return; + return false; } const newStart = startDate != null && date.getTime() >= startDate.getTime() ? startDate : null; if (newStart != null) { - applyRangeOrNewStart({ + return applyRangeOrNewStart({ start: newStart, end: date, clickedDate: date, disabledDates, setSelection, }); - } else { - setSelection(newStart, date); } - return; + setSelection(newStart, date); + return false; } // Range selection mode (no field focused: calendar drives both) @@ -139,55 +140,53 @@ export const handleDateSelectRange = ({ date.getTime() === startDate.getTime() ) { setSelection(null, null); - return; + return false; } // if clicked on start date, end date becomes start date if (date.getTime() === startDate.getTime()) { setSelection(endDate, null); - return; + return false; } // if clicked on end date, clears end date and start remains if (date.getTime() === endDate.getTime()) { setSelection(startDate, null); - return; + return false; } // If clicked date > Start: Updates End Date to new date (Start remains) if (date.getTime() > startDate.getTime()) { - applyRangeOrNewStart({ + return applyRangeOrNewStart({ start: startDate, end: date, clickedDate: date, disabledDates, setSelection, }); - return; } // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left - applyRangeOrNewStart({ + return applyRangeOrNewStart({ start: date, end: endDate, clickedDate: date, disabledDates, setSelection, }); - return; } // 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()) { setSelection(date, null); - } else { - // If clicked date > Start: Sets it as End Date (if range valid) - applyRangeOrNewStart({ - start: startDate, - end: date, - clickedDate: date, - disabledDates, - setSelection, - }); + return false; } - return; + // If clicked date > Start: Sets it as End Date (if range valid) + return applyRangeOrNewStart({ + start: startDate, + end: date, + clickedDate: date, + disabledDates, + setSelection, + }); } setSelection(date, null); + return false; }; From ed8e59c63d9d9761d417d4d861b590eac30c63a2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 9 Apr 2026 17:13:37 -0400 Subject: [PATCH 071/110] fix focus & active range part --- .../src/DatePicker/DatePickerCalendar.tsx | 2 +- .../src/DatePicker/DatePickerInput/index.tsx | 25 +++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index 3ac4a6ea73b..ba1c04a8833 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -117,7 +117,7 @@ export const DatePickerCalendar: React.FC = ({ setSelection, disabledDates, }); - if (shouldClose) closeCalendar(); + if (shouldClose) queueMicrotask(closeCalendar); }; const handleClearDate = () => { diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index 6b01479088e..0a5456bd27a 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -98,6 +98,7 @@ export const DatePickerInput = forwardRef( ? formatDateISO8601DateOnly(parsedForHidden) : ''; + /** True only while a segment spinbutton is focused — avoids clobbering partial typing. Icon/shell-only focus must not set this or calendar picks won't sync to segments. */ const isInputFocusedRef = useRef(false); const containerRef = useRef(null); const segmentElRefs = useRef< @@ -174,17 +175,25 @@ export const DatePickerInput = forwardRef( }); }; - const handleContainerFocus = () => { - isInputFocusedRef.current = true; + const setActiveRangePartForField = () => { + if (isRange && rangePart) context.setActiveRangePart(rangePart); }; const handleSegmentFocus = () => { - handleContainerFocus(); - if (isRange && rangePart) context.setActiveRangePart(rangePart); + isInputFocusedRef.current = true; + setActiveRangePartForField(); + }; + + /** Focus entered the shell (segment, icon, etc.). Range targeting only — does not mark segment editing. */ + const handleShellFocus = () => { + setActiveRangePartForField(); }; - const handleOpenCalendar = () => { - if (!disabled) openCalendar({ moveFocusIntoCalendar: false }); + /** Pointer activation on the shell (bubbles from segments/icon). Ensures range targeting even if focus order differs from click. */ + const handleShellClick = () => { + if (disabled) return; + setActiveRangePartForField(); + openCalendar({ moveFocusIntoCalendar: false }); }; const focusOrOpenCalendarGrid = () => { @@ -209,8 +218,8 @@ export const DatePickerInput = forwardRef( variant={error ? 'error' : undefined} width="113px" onBlur={handleContainerBlur} - onClick={handleOpenCalendar} - onFocus={handleContainerFocus} + onClick={handleShellClick} + onFocus={handleShellFocus} {...rest} > From 6431e61d0c9b0c93af301c6735ab6b8ff283a55c Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 10 Apr 2026 14:45:42 -0400 Subject: [PATCH 072/110] move DatePicker to organism --- .../src/lib/{Molecules => Organisms}/DatePicker/Calendar.mdx | 0 .../{Molecules => Organisms}/DatePicker/Calendar.stories.tsx | 2 +- .../{Molecules => Organisms}/DatePicker/DatePicker.stories.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename packages/styleguide/src/lib/{Molecules => Organisms}/DatePicker/Calendar.mdx (100%) rename packages/styleguide/src/lib/{Molecules => Organisms}/DatePicker/Calendar.stories.tsx (97%) rename packages/styleguide/src/lib/{Molecules => Organisms}/DatePicker/DatePicker.stories.tsx (98%) diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx b/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.mdx similarity index 100% rename from packages/styleguide/src/lib/Molecules/DatePicker/Calendar.mdx rename to packages/styleguide/src/lib/Organisms/DatePicker/Calendar.mdx diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.stories.tsx similarity index 97% rename from packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx rename to packages/styleguide/src/lib/Organisms/DatePicker/Calendar.stories.tsx index 6cbcc3c9d6d..c0598aec53f 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/Calendar.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.stories.tsx @@ -10,7 +10,7 @@ import { useId, useState } from 'react'; const meta: Meta = { component: CalendarWrapper, - title: 'Molecules/DatePicker/Calendar', + title: 'Organisms/DatePicker/Calendar', }; export default meta; diff --git a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx similarity index 98% rename from packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx rename to packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx index 8f17cc84aa3..d764064aa48 100644 --- a/packages/styleguide/src/lib/Molecules/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -11,7 +11,7 @@ import { useRef, useState } from 'react'; const meta: Meta = { component: DatePicker, - title: 'Molecules/DatePicker/DatePicker', + title: 'Organisms/DatePicker', }; export default meta; From 464b82ab9dc5636d62e4412e9f6fc3eae70c455d Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Fri, 10 Apr 2026 14:46:09 -0400 Subject: [PATCH 073/110] WIP mdx file --- .../lib/Organisms/DatePicker/DatePicker.mdx | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx 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..f4575439cf9 --- /dev/null +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx @@ -0,0 +1,126 @@ +import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; + +import { ComponentHeader, LinkTo } from '~styleguide/blocks'; + +import * as DatePickerStories from './DatePicker.stories'; + +export const parameters = { + title: 'DatePicker', + subtitle: `Single-date or range selection with a segmented date field, calendar popover, and shared React context for custom layouts.`, + status: 'current', + source: { + repo: 'gamut', + githubLink: + 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/DatePicker/DatePicker.tsx', + }, +}; + + + + + +## Overview + +`DatePicker` is a **controlled** component: you own the selected value in React state and pass setters back in. It wraps children in `DatePickerProvider` so subcomponents can read calendar open/close, locale, disabled dates, and selection via **`useDatePicker()`**. + +With **no `children`**, it renders the default layout: segmented inputs (and a range arrow in range mode), a calendar icon, and a **`PopoverContainer`**-anchored calendar dialog. Pass **`children`** to compose your own shell (for example a different popover target or layout) while still using **`DatePickerInput`**, **`DatePickerCalendar`**, and the same context. + +The lower-level **Calendar** building blocks (month grid, header, footer) are documented under DatePicker/Calendar. + +## Modes + +### Single date (`mode` omitted or `'single'`) + +- **`selectedDate`** / **`setSelectedDate`**: the current value (`Date | null`). +- Optional **`label`** for the field. + +### Range (`mode="range"`) + +- **`startDate`**, **`endDate`**, **`setStartDate`**, **`setEndDate`**. +- Optional **`startLabel`** and **`endLabel`** for the two inputs. + +## Common props + +| Area | Props | +| --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| **Locale** | **`locale`**: `string` or `Intl.Locale` — drives date format order in the input (e.g. MM/DD/YYYY vs DD/MM/YYYY) and calendar copy. | +| **Constraints** | **`disabledDates`**: individual days that cannot be selected; range selection avoids ranges that include disabled days. | +| **Copy** | **`translations`**: optional overrides (e.g. clear button, dialog `aria-label`, start/end labels) merged with defaults. | +| **Size** | **`inputSize`**: forwarded to the segmented inputs (`small` vs default). | + +## Subcomponents and context + +- **`DatePickerInput`**: segmented month/day/year spinbuttons; must be used inside a `DatePicker` (or `DatePickerProvider` with a matching value). In range mode, pass **`rangePart="start"`** or **`"end"`** on each input. +- **`DatePickerCalendar`**: full calendar UI wired to context; pass a stable **`dialogId`** that matches the dialog wrapper (see default `DatePicker` implementation). +- **`useDatePicker()`**: **`openCalendar`**, **`closeCalendar`**, **`isCalendarOpen`**, **`calendarDialogId`**, **`setSelection`**, **`locale`**, **`focusCalendarGrid`**, etc. + +For floating UI, the default implementation uses PopoverContainer with **`allowPageInteraction`** and focus settings so users can type in the field without focus jumping into the grid on every open. + +## Code examples + +### Single date (minimal) + +```tsx +const [selectedDate, setSelectedDate] = useState(null); + +return ( + +); +``` + +### Range + +```tsx +const [startDate, setStartDate] = useState(null); +const [endDate, setEndDate] = useState(null); + +return ( + +); +``` + +### Custom layout (`children`) + +Render **`DatePicker`** with **`children`** only (no default input/calendar). Inside the tree, call **`useDatePicker()`** and place **`DatePickerInput`** and **`DatePickerCalendar`** wherever you need, and wire your own **`PopoverContainer`** (or other overlay) to **`openCalendar`**, **`closeCalendar`**, and **`calendarDialogId`**. See the **Composed with context** story below. + +## Accessibility notes + +- The calendar is exposed as a **`role="dialog"`** region with an id used for labeling. +- Segments use **`role="spinbutton"`** with arrow keys to change values, **Arrow Left/Right** to move between segments, and **Alt+Arrow Down** to move focus into the calendar grid when appropriate. + +## Playground + + + + + +## Examples + +### With initial date + + + +### Range + + + +### Range (small inputs) + + + +### Composed with context + +Custom composition using **`children`**, **`useDatePicker`**, **`DatePickerInput`**, **`DatePickerCalendar`**, and **`PopoverContainer`**. + + From 533625b82deacb91d0777f3f74c1f5f16fa3906b Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 13 Apr 2026 15:09:00 -0400 Subject: [PATCH 074/110] quick actions --- .../__snapshots__/gamut.test.ts.snap | 2 +- .../DatePicker/Calendar/CalendarFooter.tsx | 43 ++----- .../gamut/src/DatePicker/Calendar/types.ts | 6 +- packages/gamut/src/DatePicker/DatePicker.tsx | 26 +++-- .../src/DatePicker/DatePickerCalendar.tsx | 67 +++++++++-- packages/gamut/src/DatePicker/index.tsx | 1 + packages/gamut/src/DatePicker/types.ts | 9 +- .../utils/__tests__/quickActions.test.ts | 55 +++++++++ .../src/DatePicker/utils/quickActions.ts | 110 ++++++++++++++++++ .../src/DatePicker/utils/translations.ts | 9 ++ .../DatePicker/DatePicker.stories.tsx | 33 +++++- 11 files changed, 307 insertions(+), 54 deletions(-) create mode 100644 packages/gamut/src/DatePicker/utils/__tests__/quickActions.test.ts create mode 100644 packages/gamut/src/DatePicker/utils/quickActions.ts diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index 4a442d6e4e5..f9500f06cd6 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing exports[`Gamut Exported Keys 1`] = ` [ diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index e671208f8d7..38d359e7ac0 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -2,42 +2,17 @@ import * as React from 'react'; import { FlexBox } from '../../Box'; import { TextButton } from '../../Button'; -import { useResolvedLocale } from '../utils/locale'; import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../utils/translations'; import { CalendarFooterProps } from './types'; -import { getRelativeTodayLabel } from './utils/format'; - -// function formatQuickActionLabel(action: QuickAction): string { -// const { num, timePeriod } = action; -// const period = -// timePeriod === 'day' -// ? num === 1 -// ? 'day' -// : 'days' -// : timePeriod === 'week' -// ? num === 1 -// ? 'week' -// : 'weeks' -// : timePeriod === 'month' -// ? num === 1 -// ? 'month' -// : 'months' -// : num === 1 -// ? 'year' -// : 'years'; -// return `${num} ${period}`; -// } export const CalendarFooter: React.FC = ({ onClearDate, - onTodayClick, - locale, clearText = DEFAULT_DATE_PICKER_TRANSLATIONS.clearText, disabled, showClearButton, + quickActions = [], }) => { - const resolvedLocale = useResolvedLocale(locale); - // const actions = quickActions.slice(0, 3); + const actions = quickActions.slice(0, 3); return ( = ({ {clearText} )} - - onTodayClick?.()}> - {getRelativeTodayLabel(resolvedLocale)} - + + {actions.map((action, index) => ( + + {action.displayText} + + ))} ); diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index 5e8a54a411e..59f1ec9d8c2 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -70,14 +70,14 @@ export interface CalendarBodyProps extends CalendarBaseProps { export interface QuickAction { num: number; timePeriod: 'day' | 'week' | 'month' | 'year'; - onClick: () => void; + displayText: string; + onClick?: () => void; } -export interface CalendarFooterProps extends Pick { +export interface CalendarFooterProps { disabled?: boolean; showClearButton?: boolean; clearText?: string; onClearDate?: () => void; - onTodayClick?: () => void; /** Max 3 quick actions (e.g. "7 days", "1 month") */ quickActions?: QuickAction[]; } diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 3e0c6a60d24..21687ec0905 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -13,6 +13,10 @@ import type { } from './types'; import { isRangeProps } from './utils/dateSelect'; import { useResolvedLocale } from './utils/locale'; +import { + getDefaultRangeQuickActions, + getDefaultSingleQuickActions, +} from './utils/quickActions'; import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './utils/translations'; /** @@ -29,6 +33,7 @@ export const DatePicker: React.FC = (props) => { children, translations: translationsProp, inputSize, + quickActions, } = props; const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [focusGridSignal, setFocusGridSignal] = useState(false); @@ -94,6 +99,10 @@ export const DatePicker: React.FC = (props) => { ...DEFAULT_DATE_PICKER_TRANSLATIONS, ...translationsProp, }; + const resolvedQuickActions = + quickActions ?? mode === 'range' + ? getDefaultRangeQuickActions(translations) + : getDefaultSingleQuickActions(resolvedLocale); const base = { startOrSelectedDate, setSelection, @@ -108,6 +117,7 @@ export const DatePicker: React.FC = (props) => { disabledDates, calendarDialogId, translations, + quickActions: quickActions === null ? [] : resolvedQuickActions, }; return mode === 'range' ? { @@ -117,14 +127,14 @@ export const DatePicker: React.FC = (props) => { activeRangePart, setActiveRangePart, } - : { ...base, mode: 'single' }; + : { + ...base, + mode: 'single', + }; }, [ - mode, + translationsProp, startOrSelectedDate, - endDate, setSelection, - activeRangePart, - setActiveRangePart, isCalendarOpen, openCalendar, focusCalendarGrid, @@ -135,7 +145,10 @@ export const DatePicker: React.FC = (props) => { resolvedLocale, disabledDates, calendarDialogId, - translationsProp, + mode, + endDate, + activeRangePart, + quickActions, ]); const content = @@ -170,7 +183,6 @@ export const DatePicker: React.FC = (props) => { )} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx index ba1c04a8833..4d9c3df554a 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar.tsx @@ -9,12 +9,15 @@ import { CalendarHeader, CalendarWrapper, } from './Calendar'; -import type { CalendarBodyProps } from './Calendar/types'; +import type { CalendarBodyProps, QuickAction } from './Calendar/types'; import { useDatePicker } from './DatePickerContext'; import { + applyRangeOrNewStart, handleDateSelectRange, handleDateSelectSingle, + rangeContainsDisabled, } from './utils/dateSelect'; +import { computeQuickAction } from './utils/quickActions'; export type DatePickerCalendarProps = Pick< CalendarBodyProps, @@ -59,6 +62,7 @@ export const DatePickerCalendar: React.FC = ({ focusGridSignal, gridFocusRequested, clearGridFocusRequest, + quickActions: quickActionsFromContext, } = context; const focusGridSync = useMemo( @@ -72,6 +76,8 @@ export const DatePickerCalendar: React.FC = ({ const isRange = mode === 'range'; const endDate = isRange ? context.endDate : undefined; + const setActiveRangePart = + context.mode === 'range' ? context.setActiveRangePart : undefined; const firstOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1); @@ -125,13 +131,55 @@ export const DatePickerCalendar: React.FC = ({ setFocusedDate(displayDate); }; - const handleTodayClick = () => { - const today = new Date(); - setSelection(today); - setDisplayDate(firstOfMonth(today)); - setFocusedDate(today); - if (!isRange) queueMicrotask(closeCalendar); - }; + const footerQuickActions: QuickAction[] = useMemo(() => { + const safeDisabled = disabledDates ?? []; + return quickActionsFromContext.slice(0, 3).map((action) => ({ + ...action, + onClick: () => { + action.onClick?.(); + setActiveRangePart?.(null); + const { start, end } = computeQuickAction( + action.num, + action.timePeriod, + isRange + ); + if (isRange) { + if ( + rangeContainsDisabled({ + start, + end, + disabledDates: safeDisabled, + }) + ) { + applyRangeOrNewStart({ + start, + end, + clickedDate: end, + disabledDates: safeDisabled, + setSelection, + }); + } else { + setSelection(start, end); + } + setDisplayDate(firstOfMonth(end)); + setFocusedDate(end); + queueMicrotask(closeCalendar); + } else { + setSelection(start); + setDisplayDate(firstOfMonth(start)); + setFocusedDate(start); + queueMicrotask(closeCalendar); + } + }, + })); + }, [ + closeCalendar, + disabledDates, + isRange, + quickActionsFromContext, + setActiveRangePart, + setSelection, + ]); const focusTarget = focusedDate ?? startOrSelectedDate ?? endDate ?? new Date(); @@ -199,10 +247,9 @@ export const DatePickerCalendar: React.FC = ({ ); diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index d9e3b5b31c7..56fe593be76 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -3,3 +3,4 @@ export * from './DatePickerContext'; export * from './DatePickerCalendar'; export * from './DatePickerInput'; export type { IsoWeekday } from './utils/locale'; +export type { QuickAction } from './Calendar/types'; diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 78b4f8f2049..760f3febcbe 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -1,7 +1,7 @@ import { ComponentProps } from 'react'; import { Input } from '../Form/inputs/Input'; -import { CalendarBaseProps } from './Calendar/types'; +import { CalendarBaseProps, QuickAction } from './Calendar/types'; import { DatePickerTranslations } from './utils/translations'; export interface DatePickerBaseProps @@ -13,6 +13,11 @@ export interface DatePickerBaseProps /** Override UI strings (e.g. clear button). Merged with defaults. */ translations?: DatePickerTranslations; inputSize?: ComponentProps['size']; + /** + * Calendar footer quick actions (max 3). Omit `onClick` on an action to use default + * range from `num` + `timePeriod` (range mode) or today as single date (single mode). + */ + quickActions?: QuickAction[] | null; } export interface DatePickerSingleProps extends DatePickerBaseProps { @@ -75,6 +80,8 @@ export interface DatePickerBaseContextValue calendarDialogId: string; /** UI string overrides (e.g. clear button). */ translations: Required; + /** Calendar footer quick actions (max 3 shown). */ + quickActions: QuickAction[]; /** Start date (range) or selected date (single). */ startOrSelectedDate: Date | null; /** Set selection. Single: (date). Range: (start, end). */ diff --git a/packages/gamut/src/DatePicker/utils/__tests__/quickActions.test.ts b/packages/gamut/src/DatePicker/utils/__tests__/quickActions.test.ts new file mode 100644 index 00000000000..9dbecf15f57 --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/__tests__/quickActions.test.ts @@ -0,0 +1,55 @@ +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 [start, end] with end = anchor day when range is entirely in the past', () => { + const { start, end } = computeQuickAction(-30, 'day', true, fixed); + expect(end).toEqual(new Date(2026, 4, 15)); + expect(start).toEqual(new Date(2026, 3, 15)); + }); + + it('applies month as num * 30 rolling days', () => { + const { start, end } = computeQuickAction(-1, 'month', true, fixed); + expect(end).toEqual(new Date(2026, 4, 15)); + expect(start).toEqual(new Date(2026, 3, 15)); + }); + + it('applies multiple months as additional 30-day steps', () => { + const { start, end } = computeQuickAction(-2, 'month', true, fixed); + expect(end).toEqual(new Date(2026, 4, 15)); + const expected = new Date(2026, 4, 15); + expected.setDate(expected.getDate() - 60); + expect(start).toEqual(expected); + }); + + it('applies year as num * 365 rolling days', () => { + const { start, end } = computeQuickAction(-1, 'year', true, fixed); + expect(end).toEqual(new Date(2026, 4, 15)); + const expected = new Date(2026, 4, 15); + expected.setDate(expected.getDate() - 365); + expect(start).toEqual(expected); + }); + + it('when start is after anchor day, orders as [anchor, start] so the range is forward', () => { + const { start, end } = computeQuickAction(5, 'day', true, fixed); + expect(start).toEqual(new Date(2026, 4, 15)); + expect(end).toEqual(new Date(2026, 4, 20)); + }); + }); + + describe('single mode (isRange: false)', () => { + it('does not swap when start is after anchor; end is still the anchor day', () => { + const { start, end } = computeQuickAction(1, 'day', false, fixed); + expect(end).toEqual(new Date(2026, 4, 15)); + expect(start).toEqual(new Date(2026, 4, 16)); + }); + + it('returns past start with end = anchor for yesterday', () => { + const { start, end } = computeQuickAction(-1, 'day', false, fixed); + expect(end).toEqual(new Date(2026, 4, 15)); + expect(start).toEqual(new Date(2026, 4, 14)); + }); + }); +}); diff --git a/packages/gamut/src/DatePicker/utils/quickActions.ts b/packages/gamut/src/DatePicker/utils/quickActions.ts new file mode 100644 index 00000000000..1f06192031c --- /dev/null +++ b/packages/gamut/src/DatePicker/utils/quickActions.ts @@ -0,0 +1,110 @@ +import { QuickAction } from '../Calendar/types'; +import { capitalizeFirst } from '../Calendar/utils/format'; +import { stringifyLocale } from './locale'; +import { DatePickerTranslations } from './translations'; + +const getRelativeDisplayText = ( + num: number, + timePeriod: QuickAction['timePeriod'], + locale: Intl.Locale +) => { + const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { + numeric: 'auto', + }); + return capitalizeFirst(rtf.format(num, timePeriod), locale); +}; + +export const getDefaultSingleQuickActions = ( + locale: Intl.Locale +): QuickAction[] => [ + { + num: -1, + timePeriod: 'day', + displayText: getRelativeDisplayText(-1, 'day', locale), + }, + { + num: 0, + timePeriod: 'day', + displayText: getRelativeDisplayText(0, 'day', locale), + }, + { + num: 1, + timePeriod: 'day', + displayText: getRelativeDisplayText(1, 'day', locale), + }, +]; + +export const getDefaultRangeQuickActions = ( + translations: Required +): QuickAction[] => [ + { + num: -7, + timePeriod: 'day', + displayText: translations.last7DaysDisplayText, + }, + { + num: -30, + timePeriod: 'day', + displayText: translations.last30DaysDisplayText, + }, + { + num: -90, + timePeriod: 'day', + displayText: translations.last90DaysDisplayText, + }, +]; + +/** + * Computes [start, end] for footer quick actions when `onClick` is omitted. + * `date` (local calendar day of `now`) is the anchor — returned as `end` unless the range + * is reordered (see below). + * + * **Rolling** offsets (no month/year boundaries): + * - **day**: `num` days from anchor. + * - **week**: `num × 7` days. + * - **month**: `num × 30` days. + * - **year**: `num × 365` days. + * + * **Range mode (`isRange: true`):** if `start` is after the anchor day, returns + * `{ start: anchor, end: computed }` so the interval runs forward. Otherwise + * `{ start: computed, end: anchor }` (past through “today”). + * + * **Single mode:** same return shape; the calendar uses `start` as the selected day. + */ +export const computeQuickAction = ( + num: number, + timePeriod: QuickAction['timePeriod'], + isRange: boolean, + now = new Date() +) => { + const date = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let start: Date; + + switch (timePeriod) { + case 'day': { + start = new Date(date); + start.setDate(start.getDate() + num); + break; + } + case 'week': { + start = new Date(date); + start.setDate(start.getDate() + num * 7); + break; + } + case 'month': { + start = new Date(date); + start.setDate(start.getDate() + num * 30); + break; + } + case 'year': { + start = new Date(date); + start.setDate(start.getDate() + num * 365); + break; + } + } + + if (isRange && start.getTime() > date.getTime()) { + return { start: date, end: start }; + } + return { start, end: date }; +}; diff --git a/packages/gamut/src/DatePicker/utils/translations.ts b/packages/gamut/src/DatePicker/utils/translations.ts index e6100add835..2fdd6785486 100644 --- a/packages/gamut/src/DatePicker/utils/translations.ts +++ b/packages/gamut/src/DatePicker/utils/translations.ts @@ -10,6 +10,12 @@ export interface DatePickerTranslations { endDateLabel?: string; /** aria-label for the calendar dialog (default: "Choose date"). */ calendarDialogAriaLabel?: string; + /** Default label for the last 7 days quick action (default: "Last 7 days"). */ + last7DaysDisplayText?: string; + /** Default label for the last 30 days quick action (default: "Last 30 days"). */ + last30DaysDisplayText?: string; + /** Default label for the last 90 days quick action (default: "Last 90 days"). */ + last90DaysDisplayText?: string; } /** Default UI strings; pass translations prop to override. */ @@ -20,4 +26,7 @@ export const DEFAULT_DATE_PICKER_TRANSLATIONS: Required 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/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx index d764064aa48..9d9c1ed3ac9 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -74,6 +74,32 @@ export const Range: Story = { }, }; +/** Range mode with footer quick actions (calendar month or rolling days through today). */ +export const RangeWithQuickActions: Story = { + render: function DatePickerStory() { + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + return ( + + + + ); + }, +}; + export const RangeSmall: Story = { render: function DatePickerStory() { const [startDate, setStartDate] = useState(null); @@ -126,7 +152,12 @@ function ComposedDatePickerLayout() { return ( <> - + { + openCalendar(); + }} + > Date: Mon, 13 Apr 2026 15:12:05 -0400 Subject: [PATCH 075/110] evan pr feedback --- .../DatePickerInput/Segment/DatePickerInputSegment.tsx | 1 + .../gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx index bddd354124d..d353014cf2b 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx @@ -64,6 +64,7 @@ export const DatePickerInputSegment: React.FC = ({ if (e.altKey && (e.key === 'ArrowDown' || e.key === 'Down')) { e.preventDefault(); + e.stopPropagation(); focusOrOpenCalendarGrid(); return; } diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx index 3c2365bc2a0..97b98dfd3a2 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx @@ -29,6 +29,9 @@ export const Segment = styled.span( color: 'background', borderRadius: 'md', }, + '&:focus-visible': { + outline: 'none', + }, }), segmentStyles ); From e4b90929b81749212111e17b7c4a196a1d682506 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Mon, 13 Apr 2026 15:16:57 -0400 Subject: [PATCH 076/110] dont render empty border in footer --- packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index 38d359e7ac0..ef054709192 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -12,6 +12,9 @@ export const CalendarFooter: React.FC = ({ showClearButton, quickActions = [], }) => { + // if there are no quick actions and the clear button is not shown, don't render anything + if (quickActions.length === 0 && !showClearButton) return null; + const actions = quickActions.slice(0, 3); return ( From 1cc29cd0b8136bc2df0f5d6c0823281275c821c1 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 14 Apr 2026 17:19:37 -0400 Subject: [PATCH 077/110] first passthrough of PR feedback --- .../src/DatePicker/Calendar/CalendarBody.tsx | 4 +- .../DatePicker/Calendar/CalendarFooter.tsx | 13 +- .../gamut/src/DatePicker/Calendar/index.tsx | 1 + .../gamut/src/DatePicker/Calendar/types.ts | 15 +- .../src/DatePicker/Calendar/utils/dateGrid.ts | 10 +- .../DatePicker/Calendar/utils/elements.tsx | 2 +- packages/gamut/src/DatePicker/DatePicker.tsx | 73 ++---- .../src/DatePicker/DatePickerCalendar.tsx | 135 ++++++----- .../src/DatePicker/DatePickerContext.tsx | 63 ++++- .../Segment/DatePickerInputSegment.tsx | 43 ++-- .../DatePickerInput/Segment/segmentUtils.ts | 2 +- .../src/DatePicker/DatePickerInput/index.tsx | 118 +++++----- packages/gamut/src/DatePicker/index.tsx | 3 +- packages/gamut/src/DatePicker/types.ts | 215 +++++++++++------- .../utils/__tests__/dateSelect.test.ts | 174 +++++++------- .../gamut/src/DatePicker/utils/dateSelect.ts | 57 +++-- packages/gamut/src/index.tsx | 1 - .../src/lib/Organisms/DatePicker/Calendar.mdx | 26 --- .../Organisms/DatePicker/Calendar.stories.tsx | 60 ----- .../DatePicker/DatePicker.stories.tsx | 19 +- 20 files changed, 533 insertions(+), 501 deletions(-) delete mode 100644 packages/styleguide/src/lib/Organisms/DatePicker/Calendar.mdx delete mode 100644 packages/styleguide/src/lib/Organisms/DatePicker/Calendar.stories.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx index 46e0c74f85f..9dc414c4bb3 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx @@ -95,7 +95,7 @@ export const CalendarBody: React.FC = ({ // If !inGrid && !requested (e.g. calendar opened with the mouse): leave focus on the input — do not call focusButton. }, [focusTarget, focusButton, focusGridSync]); - const handleKeyDown = useCallback( + const onKeyDown = useCallback( (e: React.KeyboardEvent, date: Date) => keyHandler({ e, @@ -190,7 +190,7 @@ export const CalendarBody: React.FC = ({ tabIndex={isFocused ? 0 : -1} onClick={() => onDateSelect(date)} onFocus={() => onFocusedDateChange?.(date)} - onKeyDown={(e: React.KeyboardEvent) => handleKeyDown(e, date)} + onKeyDown={(e: React.KeyboardEvent) => onKeyDown(e, date)} > {date.getDate()} diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx index ef054709192..f1018ccbaae 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx @@ -6,14 +6,11 @@ import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../utils/translations'; import { CalendarFooterProps } from './types'; export const CalendarFooter: React.FC = ({ - onClearDate, - clearText = DEFAULT_DATE_PICKER_TRANSLATIONS.clearText, - disabled, - showClearButton, + clear, quickActions = [], }) => { // if there are no quick actions and the clear button is not shown, don't render anything - if (quickActions.length === 0 && !showClearButton) return null; + if (quickActions.length === 0 && !clear) return null; const actions = quickActions.slice(0, 3); @@ -24,9 +21,9 @@ export const CalendarFooter: React.FC = ({ justifyContent="space-between" p={12} > - {showClearButton && ( - onClearDate?.()}> - {clearText} + {clear && ( + clear.onClick?.()}> + {clear.text ?? DEFAULT_DATE_PICKER_TRANSLATIONS.clearText} )} diff --git a/packages/gamut/src/DatePicker/Calendar/index.tsx b/packages/gamut/src/DatePicker/Calendar/index.tsx index b40af89a23d..e1fcffcbdc0 100644 --- a/packages/gamut/src/DatePicker/Calendar/index.tsx +++ b/packages/gamut/src/DatePicker/Calendar/index.tsx @@ -4,3 +4,4 @@ export * from './CalendarBody'; export * from './CalendarFooter'; export * from './CalendarNavLastMonth'; export * from './CalendarNavNextMonth'; +export * from './types'; diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/Calendar/types.ts index 59f1ec9d8c2..2c083a47353 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/Calendar/types.ts @@ -1,6 +1,6 @@ import type { IsoWeekday } from '../utils/locale'; -export interface CalendarBaseProps { +interface CalendarBaseProps { /** Used for the currently displayed month and year */ displayDate: Date; /** Called when the displayed month changes. Pass the new date (e.g. setDisplayDate) so the calendar updates. */ @@ -68,16 +68,21 @@ export interface CalendarBodyProps extends CalendarBaseProps { } export interface QuickAction { + /** 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; } export interface CalendarFooterProps { - disabled?: boolean; - showClearButton?: boolean; - clearText?: string; - onClearDate?: () => void; + clear?: { + disabled?: boolean; + onClick?: () => void; + text?: string; + }; /** Max 3 quick actions (e.g. "7 days", "1 month") */ quickActions?: QuickAction[]; } diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts index 17a204e246a..02672cf6a9a 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts @@ -31,6 +31,10 @@ export const getWeekdayOffsetInGrid = ( 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). @@ -44,7 +48,7 @@ export const getMonthGrid = ( month: number, firstWeekday: IsoWeekday ) => { - const first = new Date(year, month, 1); + const first = getFirstOfMonth(new Date(year, month, 1)); const last = new Date(year, month + 1, 0); const firstDayOfWeek = getWeekdayOffsetInGrid(first, firstWeekday); const daysInMonth = last.getDate(); @@ -121,3 +125,7 @@ export const getDatesWithRow = (weeks: (Date | null)[][]) => { }); return result; }; + +/** Add `n` months to the given date. */ +export const addMonths = (date: Date, n: number) => + new Date(date.getFullYear(), date.getMonth() + n, 1); diff --git a/packages/gamut/src/DatePicker/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/Calendar/utils/elements.tsx index c7b77f41ddf..209e21c0a89 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/elements.tsx +++ b/packages/gamut/src/DatePicker/Calendar/utils/elements.tsx @@ -26,7 +26,7 @@ const datecellStates = states({ content: '""', position: 'absolute', bottom: 4, - // Half of dot width (4px) so the marker sits under the centered date numeral. + /** Half of dot width (4px) so the marker sits under the centered date numeral. */ insetInlineStart: 'calc(50% - 2px)', width: 4, height: 4, diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 21687ec0905..d337a461462 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -4,13 +4,13 @@ import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { Box, FlexBox } from '../Box'; import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; -import { DatePickerProvider } from './DatePickerContext'; -import { DatePickerInput } from './DatePickerInput'; -import type { +import { DatePickerContextValue, - DatePickerProps, - OpenCalendarOptions, -} from './types'; + DatePickerProvider, + DatePickerRangeContextValue, +} from './DatePickerContext'; +import { DatePickerInput } from './DatePickerInput'; +import type { DatePickerProps } from './types'; import { isRangeProps } from './utils/dateSelect'; import { useResolvedLocale } from './utils/locale'; import { @@ -28,7 +28,6 @@ export const DatePicker: React.FC = (props) => { const { locale, disabledDates = [], - placeholder, mode, children, translations: translationsProp, @@ -38,9 +37,8 @@ export const DatePicker: React.FC = (props) => { const [isCalendarOpen, setIsCalendarOpen] = useState(false); const [focusGridSignal, setFocusGridSignal] = useState(false); const [gridFocusRequested, setGridFocusRequested] = useState(false); - const [activeRangePart, setActiveRangePart] = useState< - 'start' | 'end' | null - >(null); + const [activeRangePart, setActiveRangePart] = + useState(null); const inputRef = useRef(null); const dialogId = useId(); const calendarDialogId = `datepicker-dialog-${dialogId.replace(/:/g, '')}`; @@ -51,18 +49,11 @@ export const DatePicker: React.FC = (props) => { const resolvedLocale = useResolvedLocale(locale); - const openCalendar = useCallback((options?: OpenCalendarOptions) => { - const moveFocus = options?.moveFocusIntoCalendar ?? false; + const openCalendar = useCallback(() => { setIsCalendarOpen(true); - if (moveFocus) { - setGridFocusRequested(true); - setFocusGridSignal((signal) => !signal); - } else { - setGridFocusRequested(false); - } }, []); - const focusCalendarGrid = useCallback(() => { + const focusCalendar = useCallback(() => { setGridFocusRequested(true); setFocusGridSignal((signal) => !signal); }, []); @@ -82,13 +73,13 @@ export const DatePicker: React.FC = (props) => { : props.selectedDate; const endDate = isRangeProps(props) ? props.endDate : null; - const setSelection = useCallback( - (start: Date | null, end?: Date | null) => { + const onSelection = useCallback( + (date: Date | null, endDate?: Date | null) => { if (isRangeProps(props)) { - props.setStartDate(start); - props.setEndDate(end ?? null); + props.onStartSelected(date); + props.onEndSelected(endDate ?? null); } else { - props.setSelectedDate(start); + props.onSelected(date); } }, [props] @@ -105,10 +96,10 @@ export const DatePicker: React.FC = (props) => { : getDefaultSingleQuickActions(resolvedLocale); const base = { startOrSelectedDate, - setSelection, + onSelection, isCalendarOpen, openCalendar, - focusCalendarGrid, + focusCalendar, focusGridSignal, gridFocusRequested, clearGridFocusRequest, @@ -133,22 +124,22 @@ export const DatePicker: React.FC = (props) => { }; }, [ translationsProp, + quickActions, + mode, + resolvedLocale, startOrSelectedDate, - setSelection, + onSelection, isCalendarOpen, openCalendar, - focusCalendarGrid, + focusCalendar, focusGridSignal, gridFocusRequested, clearGridFocusRequest, closeCalendar, - resolvedLocale, disabledDates, calendarDialogId, - mode, endDate, activeRangePart, - quickActions, ]); const content = @@ -163,28 +154,14 @@ export const DatePicker: React.FC = (props) => { > {mode === 'range' ? ( <> - + - + ) : ( - + )} = ({ const { mode, startOrSelectedDate, - setSelection, + onSelection, disabledDates, locale, closeCalendar, @@ -62,7 +70,7 @@ export const DatePickerCalendar: React.FC = ({ focusGridSignal, gridFocusRequested, clearGridFocusRequest, - quickActions: quickActionsFromContext, + quickActions, } = context; const focusGridSync = useMemo( @@ -76,17 +84,25 @@ export const DatePickerCalendar: React.FC = ({ const isRange = mode === 'range'; const endDate = isRange ? context.endDate : undefined; - const setActiveRangePart = - context.mode === 'range' ? context.setActiveRangePart : undefined; - const firstOfMonth = (date: Date) => - new Date(date.getFullYear(), date.getMonth(), 1); + const setActiveRangePart = isRange ? context.setActiveRangePart : undefined; const [displayDate, setDisplayDate] = useState(() => - firstOfMonth(startOrSelectedDate ?? new Date()) + getFirstOfMonth(startOrSelectedDate ?? new Date()) ); const [focusedDate, setFocusedDate] = useState( () => startOrSelectedDate ?? endDate ?? new Date() ); + const onFocusedDateChange = useCallback( + (date: Date | null) => { + setFocusedDate(date); + }, + [setFocusedDate] + ); + + const focusTarget = + focusedDate ?? startOrSelectedDate ?? endDate ?? new Date(); + const secondMonthDate = addMonths(displayDate, 1); + const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.xs})`); const wasOpenRef = useRef(false); // Sync visible month to selection only when the calendar opens, not on every @@ -97,43 +113,54 @@ export const DatePickerCalendar: React.FC = ({ if (!justOpened) return; const anchor = startOrSelectedDate ?? endDate; if (anchor) { - setDisplayDate(firstOfMonth(anchor)); + setDisplayDate(getFirstOfMonth(anchor)); setFocusedDate(startOrSelectedDate ?? endDate ?? new Date()); } }, [isCalendarOpen, startOrSelectedDate, endDate]); - const onDateSelect = (date: Date) => { - if (!isRange) { - handleDateSelectSingle({ + const onDateSelect = useCallback( + (date: Date) => { + if (!isRange) { + handleDateSelectSingle({ + date, + selectedDate: startOrSelectedDate, + onSelection, + }); + // Defer close so React can commit the new date and the input can sync segments + // before closeCalendar focuses the spinbutton (which blocks segment sync while "focused"). + queueMicrotask(closeCalendar); + return; + } + setActiveRangePart?.(null); + const shouldClose = handleDateSelectRange({ date, - selectedDate: startOrSelectedDate, - setSelection, + activeRangePart: context.activeRangePart, + startDate: startOrSelectedDate, + endDate: context.endDate, + onSelection, + disabledDates, }); - // Defer close so React can commit the new date and the input can sync segments - // before closeCalendar focuses the spinbutton (which blocks segment sync while "focused"). - queueMicrotask(closeCalendar); - return; - } - context.setActiveRangePart(null); - const shouldClose = handleDateSelectRange({ - date, - activeRangePart: context.activeRangePart, - startDate: startOrSelectedDate, - endDate: context.endDate, - setSelection, + if (shouldClose) queueMicrotask(closeCalendar); + }, + [ + isRange, + setActiveRangePart, + context, + startOrSelectedDate, + onSelection, disabledDates, - }); - if (shouldClose) queueMicrotask(closeCalendar); - }; + closeCalendar, + ] + ); - const handleClearDate = () => { - setSelection(null); + const clearDate = useCallback(() => { + onSelection(null); setFocusedDate(displayDate); - }; + }, [onSelection, setFocusedDate, displayDate]); - const footerQuickActions: QuickAction[] = useMemo(() => { + const computedQuickActions: QuickAction[] = useMemo(() => { const safeDisabled = disabledDates ?? []; - return quickActionsFromContext.slice(0, 3).map((action) => ({ + return quickActions.slice(0, 3).map((action) => ({ ...action, onClick: () => { action.onClick?.(); @@ -156,17 +183,17 @@ export const DatePickerCalendar: React.FC = ({ end, clickedDate: end, disabledDates: safeDisabled, - setSelection, + onSelection, }); } else { - setSelection(start, end); + onSelection(start, end); } - setDisplayDate(firstOfMonth(end)); + setDisplayDate(getFirstOfMonth(end)); setFocusedDate(end); queueMicrotask(closeCalendar); } else { - setSelection(start); - setDisplayDate(firstOfMonth(start)); + onSelection(start); + setDisplayDate(getFirstOfMonth(start)); setFocusedDate(start); queueMicrotask(closeCalendar); } @@ -176,20 +203,11 @@ export const DatePickerCalendar: React.FC = ({ closeCalendar, disabledDates, isRange, - quickActionsFromContext, + quickActions, setActiveRangePart, - setSelection, + onSelection, ]); - const focusTarget = - focusedDate ?? startOrSelectedDate ?? endDate ?? new Date(); - - const addMonths = (date: Date, n: number) => - new Date(date.getFullYear(), date.getMonth() + n, 1); - const secondMonthDate = addMonths(displayDate, 1); - - const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.xs})`); - return ( @@ -240,16 +258,21 @@ export const DatePickerCalendar: React.FC = ({ onDateSelect={onDateSelect} onDisplayDateChange={setDisplayDate} onEscapeKeyPress={closeCalendar} - onFocusedDateChange={setFocusedDate} + onFocusedDateChange={onFocusedDateChange} /> ); diff --git a/packages/gamut/src/DatePicker/DatePickerContext.tsx b/packages/gamut/src/DatePicker/DatePickerContext.tsx index 8bff1cf8763..c750ee73ad2 100644 --- a/packages/gamut/src/DatePicker/DatePickerContext.tsx +++ b/packages/gamut/src/DatePicker/DatePickerContext.tsx @@ -1,9 +1,64 @@ import { createContext, useContext } from 'react'; -import type { DatePickerContextValue as DatePickerContextValueType } from './types'; +import type { CalendarBodyProps, QuickAction } from './Calendar/types'; +import type { DatePickerTranslations } from './utils/translations'; -export const DatePickerContext = - createContext(null); +interface DatePickerBaseContextValue + extends Pick { + /** + * Resolved `Intl.Locale` from the `locale` prop (or runtime default). Same instance passed to + * formatters and available for `getWeekInfo()` etc. + */ + locale: Intl.Locale; + isCalendarOpen: boolean; + openCalendar: () => void; + /** Move focus from the input into the grid when the calendar is already open (e.g. ArrowDown). */ + focusCalendar: () => void; + /** + * Flips on each grid focus request so `CalendarBody` effects re-run when `focusTarget` is unchanged. + * Not a semantic true/false — only the change matters; pair with `gridFocusRequested`. + */ + focusGridSignal: boolean; + /** When true, `CalendarBody` runs a one-shot move of DOM focus into the grid if it is not already there. */ + gridFocusRequested: boolean; + /** Clears `gridFocusRequested` after focus has moved into the grid (or call when closing). */ + clearGridFocusRequest: () => void; + closeCalendar: () => void; + calendarDialogId: string; + /** UI string overrides (e.g. clear button). */ + translations: Required; + /** Calendar footer quick actions (max 3 shown). */ + quickActions: QuickAction[]; + /** Start date (range) or selected date (single). */ + startOrSelectedDate: Date | null; + /** Set selection. Single: (date). Range: (start, end). */ + onSelection: (date: Date | null, endDate?: Date | null) => void; +} + +export interface DatePickerSingleContextValue + extends DatePickerBaseContextValue { + mode: 'single'; +} + +type ActiveRangePart = 'start' | 'end' | null; + +export interface DatePickerRangeContextValue + extends DatePickerBaseContextValue { + mode: 'range'; + endDate: Date | null; + /** Which input is active (start/end focused); null = selection mode. */ + activeRangePart: ActiveRangePart; + /** Set which input is active (e.g. when input receives focus). */ + setActiveRangePart: (part: ActiveRangePart) => void; +} + +export type DatePickerContextValue = + | DatePickerSingleContextValue + | DatePickerRangeContextValue; + +export const DatePickerContext = createContext( + null +); /** Provider component; DatePicker uses this to set the context value. */ export const DatePickerProvider = DatePickerContext.Provider; @@ -13,7 +68,7 @@ export const DatePickerProvider = DatePickerContext.Provider; * Must be used inside a DatePicker. For composed layouts, use this to get * openCalendar, closeCalendar, isCalendarOpen, inputRef, calendarDialogId, etc. */ -export const useDatePicker = (): DatePickerContextValueType => { +export const useDatePicker = (): DatePickerContextValue => { const value = useContext(DatePickerContext); if (value == null) { throw new Error('useDatePickerContext must be used within a DatePicker.'); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx index d353014cf2b..a4318188915 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx @@ -4,10 +4,10 @@ import type { DatePartKind } from '../utils'; import { Segment } from './elements'; import { appendSegmentDigit, + getSegmentPlaceholder, getSegmentSpinBounds, parseSegmentNumericString, segmentMaxLength, - segmentPlaceholder, SegmentValues, spinSegment, } from './segmentUtils'; @@ -22,10 +22,10 @@ export type DatePickerInputSegmentProps = { segments: SegmentValues; disabled: boolean; error: boolean; - handleOnFocus: () => void; - focusOrOpenCalendarGrid: () => void; + onFocus: () => void; + onAltArrowDown: () => void; /** Focus a sibling segment; must use refs registered via `assignSegmentRef` (owned by parent). */ - focusSegmentField: (field: DatePartKind) => void; + onSiblingFocus: (field: DatePartKind) => void; assignSegmentRef: AssignSegmentRef; setSegments: Dispatch>; prevField: DatePartKind | null; @@ -41,9 +41,9 @@ export const DatePickerInputSegment: React.FC = ({ segments, disabled, error, - handleOnFocus, - focusOrOpenCalendarGrid, - focusSegmentField, + onFocus, + onAltArrowDown, + onSiblingFocus, assignSegmentRef, setSegments, prevField, @@ -51,28 +51,31 @@ export const DatePickerInputSegment: React.FC = ({ applySegments, }) => { const { min, max } = getSegmentSpinBounds(field, segments); - const n = parseSegmentNumericString(segments[field]); - const ariaValue = segments[field].length > 0 && n != null ? n : undefined; + const numericValue = parseSegmentNumericString(segments[field]); + const ariaValue = + segments[field].length > 0 && numericValue != null + ? numericValue + : undefined; const display = - segments[field].length > 0 ? segments[field] : segmentPlaceholder(field); + segments[field].length > 0 ? segments[field] : getSegmentPlaceholder(field); const inputID = useId(); const inputId = `datepicker-input-${inputID.replace(/:/g, '')}`; - const handleSegmentKeyDown = useCallback( + const onKeyDown = useCallback( (field: DatePartKind) => (e: React.KeyboardEvent) => { if (disabled) return; if (e.altKey && (e.key === 'ArrowDown' || e.key === 'Down')) { e.preventDefault(); e.stopPropagation(); - focusOrOpenCalendarGrid(); + onAltArrowDown(); return; } if (e.key === 'ArrowLeft') { if (prevField) { e.preventDefault(); - focusSegmentField(prevField); + onSiblingFocus(prevField); } return; } @@ -80,7 +83,7 @@ export const DatePickerInputSegment: React.FC = ({ if (e.key === 'ArrowRight') { if (nextField) { e.preventDefault(); - focusSegmentField(nextField); + onSiblingFocus(nextField); } return; } @@ -123,7 +126,7 @@ export const DatePickerInputSegment: React.FC = ({ return next; } if (prevField) { - queueMicrotask(() => focusSegmentField(prevField)); + queueMicrotask(() => onSiblingFocus(prevField)); } return prev; }); @@ -141,7 +144,7 @@ export const DatePickerInputSegment: React.FC = ({ applySegments(next); const maxLen = segmentMaxLength(field); if (next[field].length >= maxLen && nextField) { - queueMicrotask(() => focusSegmentField(nextField)); + queueMicrotask(() => onSiblingFocus(nextField)); } return next; }); @@ -149,9 +152,9 @@ export const DatePickerInputSegment: React.FC = ({ }, [ disabled, - focusOrOpenCalendarGrid, + onAltArrowDown, prevField, - focusSegmentField, + onSiblingFocus, nextField, setSegments, applySegments, @@ -174,8 +177,8 @@ export const DatePickerInputSegment: React.FC = ({ ref={(el) => assignSegmentRef(field, el)} role="spinbutton" tabIndex={disabled ? -1 : 0} - onFocus={handleOnFocus} - onKeyDown={handleSegmentKeyDown(field)} + onFocus={onFocus} + onKeyDown={onKeyDown(field)} > {display} diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts index facfa912298..e3faf940ca6 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts @@ -95,7 +95,7 @@ export const normalizeSegmentValues = ( return { month, day, year }; }; -export const segmentPlaceholder = (field: DatePartKind) => +export const getSegmentPlaceholder = (field: DatePartKind) => field === 'year' ? 'YYYY' : field === 'month' ? 'MM' : 'DD'; /** Digit capacity per field (typing / spinbutton editing). */ diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index 0a5456bd27a..0b86511eaa7 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -58,9 +58,9 @@ export const DatePickerInput = forwardRef( const { mode, startOrSelectedDate, - setSelection, + onSelection, openCalendar, - focusCalendarGrid, + focusCalendar, locale, isCalendarOpen, translations, @@ -87,7 +87,7 @@ export const DatePickerInput = forwardRef( const boundDate = isRange && rangePart === 'end' ? endDate : startOrSelectedDate; - const segmentsFromBound = useCallback( + const segmentsFromBound = useMemo( () => getDateSegmentsFromDate(boundDate), [boundDate] ); @@ -109,14 +109,17 @@ export const DatePickerInput = forwardRef( (field: DatePartKind, el: HTMLSpanElement | null) => { segmentElRefs.current[field] = el; }, - [] + [segmentElRefs] ); - const focusSegmentField = useCallback((field: DatePartKind) => { - segmentElRefs.current[field]?.focus(); - }, []); + const onSiblingSegmentFocus = useCallback( + (field: DatePartKind) => { + segmentElRefs.current[field]?.focus(); + }, + [segmentElRefs] + ); - const setShellRef = useCallback( + const shellRef = useCallback( (el: HTMLDivElement | null) => { containerRef.current = el; if (typeof ref === 'function') ref(el); @@ -127,28 +130,28 @@ export const DatePickerInput = forwardRef( useEffect(() => { if (!isInputFocusedRef.current) { - setSegments(segmentsFromBound()); + setSegments(segmentsFromBound); } }, [segmentsFromBound]); const commitParsedDate = useCallback( (parsed: Date) => { if (isRange && rangePart) { - if (rangePart === 'start') setSelection(parsed, endDate); - else setSelection(startOrSelectedDate, parsed); - } else setSelection(parsed); + if (rangePart === 'start') onSelection(parsed, endDate); + else onSelection(startOrSelectedDate, parsed); + } else onSelection(parsed); }, - [isRange, rangePart, setSelection, endDate, startOrSelectedDate] + [isRange, rangePart, onSelection, endDate, startOrSelectedDate] ); const clearSelection = useCallback(() => { if (isRange && rangePart) { - if (rangePart === 'start') setSelection(null, endDate); - else setSelection(startOrSelectedDate, null); - } else setSelection(null); - }, [isRange, rangePart, setSelection, endDate, startOrSelectedDate]); + if (rangePart === 'start') onSelection(null, endDate); + else onSelection(startOrSelectedDate, null); + } else onSelection(null); + }, [isRange, rangePart, onSelection, endDate, startOrSelectedDate]); - const applySegments = useCallback( + const onSegmentChange = useCallback( (next: SegmentValues) => { const parsed = parseSegmentsToDate(next); if (parsed) commitParsedDate(parsed); @@ -157,49 +160,52 @@ export const DatePickerInput = forwardRef( [clearSelection, commitParsedDate] ); - const handleContainerBlur = (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) { - commitParsedDate(parsed); - return normalized; - } - if (!normalized.month && !normalized.day && !normalized.year) { - clearSelection(); - return getDateSegmentsFromDate(null); - } - return segmentsFromBound(); - }); - }; + 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) { + commitParsedDate(parsed); + return normalized; + } + if (!normalized.month && !normalized.day && !normalized.year) { + clearSelection(); + return getDateSegmentsFromDate(null); + } + return segmentsFromBound; + }); + }, + [containerRef, segmentsFromBound, clearSelection, commitParsedDate] + ); - const setActiveRangePartForField = () => { + const setActiveRangePartForField = useCallback(() => { if (isRange && rangePart) context.setActiveRangePart(rangePart); - }; + }, [isRange, rangePart, context]); - const handleSegmentFocus = () => { + const onSegmentFocus = useCallback(() => { isInputFocusedRef.current = true; setActiveRangePartForField(); - }; + }, [isInputFocusedRef, setActiveRangePartForField]); /** Focus entered the shell (segment, icon, etc.). Range targeting only — does not mark segment editing. */ - const handleShellFocus = () => { + const onShellFocus = useCallback(() => { setActiveRangePartForField(); - }; + }, [setActiveRangePartForField]); /** Pointer activation on the shell (bubbles from segments/icon). Ensures range targeting even if focus order differs from click. */ - const handleShellClick = () => { + const onShellClick = useCallback(() => { if (disabled) return; setActiveRangePartForField(); - openCalendar({ moveFocusIntoCalendar: false }); - }; + openCalendar(); + }, [disabled, setActiveRangePartForField, openCalendar]); - const focusOrOpenCalendarGrid = () => { - if (isCalendarOpen) focusCalendarGrid(); - else openCalendar({ moveFocusIntoCalendar: true }); - }; + const onSegmentAltArrowDown = useCallback(() => { + if (!isCalendarOpen) openCalendar(); + focusCalendar(); + }, [isCalendarOpen, openCalendar, focusCalendar]); return ( ( > @@ -242,19 +248,19 @@ export const DatePickerInput = forwardRef( return ( ); })} diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index 56fe593be76..9bad9002be3 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -2,5 +2,4 @@ export * from './DatePicker'; export * from './DatePickerContext'; export * from './DatePickerCalendar'; export * from './DatePickerInput'; -export type { IsoWeekday } from './utils/locale'; -export type { QuickAction } from './Calendar/types'; +export * from './types'; diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 760f3febcbe..0d3391388cf 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -1,113 +1,156 @@ import { ComponentProps } from 'react'; import { Input } from '../Form/inputs/Input'; -import { CalendarBaseProps, QuickAction } from './Calendar/types'; +import { CalendarBodyProps, QuickAction } from './Calendar/types'; import { DatePickerTranslations } from './utils/translations'; -export interface DatePickerBaseProps - extends Pick { +interface DatePickerBaseProps + extends Pick { /** When provided, only the provider is rendered and children compose Input + Calendar. */ children?: React.ReactNode; - /** Placeholder for the input. */ - placeholder?: string; - /** Override UI strings (e.g. clear button). Merged with defaults. */ + /** 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 (max 3). Omit `onClick` on an action to use default - * range from `num` + `timePeriod` (range mode) or today as single date (single mode). + * 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 QuickAction} for the shape of the quick actions. + * + * @example single mode: + * ```tsx + * + * ``` + * @example range mode: + * ```tsx + * (null); + * + * ``` + */ selectedDate: Date | null; - /** Called when the user selects a date. */ - setSelectedDate: (date: Date | null) => void; - /** Label for the input. */ - label?: string; + /** 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 { mode: 'range'; - /** Controlled start date. */ + /** 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. */ - endDate: Date | null; - /** Called when the user changes the start date. */ - setStartDate: (date: Date | null) => void; - /** Called when the user changes the end date. */ - setEndDate: (date: Date | null) => void; - /** Label for the start date input. */ - startLabel?: string; - /** Label for the end date input. */ - endLabel?: string; -} - -export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; - -export type OpenCalendarOptions = { - /** - * When true, move DOM focus into the date grid after open (keyboard / explicit request). - * When false (default), keep focus on the input so pointer users can type (WCAG 3.2.1). + /** 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); + * + * ``` */ - moveFocusIntoCalendar?: boolean; -}; - -export interface DatePickerBaseContextValue - extends Pick { - /** - * Resolved `Intl.Locale` from the `locale` prop (or runtime default). Same instance passed to - * formatters and available for `getWeekInfo()` etc. + 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); + * + * ``` */ - locale: Intl.Locale; - isCalendarOpen: boolean; - openCalendar: (options?: OpenCalendarOptions) => void; - /** Move focus from the input into the grid when the calendar is already open (e.g. ArrowDown). */ - focusCalendarGrid: () => void; - /** - * Flips on each grid focus request so `CalendarBody` effects re-run when `focusTarget` is unchanged. - * Not a semantic true/false — only the change matters; pair with `gridFocusRequested`. + 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); + * + * ``` */ - focusGridSignal: boolean; - /** When true, `CalendarBody` runs a one-shot move of DOM focus into the grid if it is not already there. */ - gridFocusRequested: boolean; - /** Clears `gridFocusRequested` after focus has moved into the grid (or call when closing). */ - clearGridFocusRequest: () => void; - closeCalendar: () => void; - calendarDialogId: string; - /** UI string overrides (e.g. clear button). */ - translations: Required; - /** Calendar footer quick actions (max 3 shown). */ - quickActions: QuickAction[]; - /** Start date (range) or selected date (single). */ - startOrSelectedDate: Date | null; - /** Set selection. Single: (date). Range: (start, end). */ - setSelection: ( - startOrSelectedDate: Date | null, - endDate?: Date | null - ) => void; + onEndSelected: (date: Date | null) => void; } -export interface DatePickerSingleContextValue - extends DatePickerBaseContextValue { - mode: 'single'; -} - -export type ActiveRangePart = 'start' | 'end' | null; - -export interface DatePickerRangeContextValue - extends DatePickerBaseContextValue { - mode: 'range'; - endDate: Date | null; - /** Which input is active (start/end focused); null = selection mode. */ - activeRangePart: ActiveRangePart; - /** Set which input is active (e.g. when input receives focus). */ - setActiveRangePart: (part: ActiveRangePart) => void; -} - -export type DatePickerContextValue = - | DatePickerSingleContextValue - | DatePickerRangeContextValue; +export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps; diff --git a/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts index f1c76a5991b..82410d014b7 100644 --- a/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts +++ b/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts @@ -8,7 +8,7 @@ import { const createDate = (y: number, month: number, day: number) => new Date(y, month, day); -const mockSetSelection = jest.fn(); +const mockOnSelection = jest.fn(); describe('rangeContainsDisabled', () => { const start = createDate(2024, 0, 10); const end = createDate(2024, 0, 20); @@ -60,9 +60,9 @@ describe('handleDateSelectSingle', () => { handleDateSelectSingle({ date: selected, selectedDate: selected, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(null); + expect(mockOnSelection).toHaveBeenCalledWith(null); }); it('sets selection when no date was previously selected', () => { @@ -70,9 +70,9 @@ describe('handleDateSelectSingle', () => { handleDateSelectSingle({ date: newSelected, selectedDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(newSelected); + expect(mockOnSelection).toHaveBeenCalledWith(newSelected); }); it('sets selection to a new day when a date was previously selected', () => { @@ -80,9 +80,9 @@ describe('handleDateSelectSingle', () => { handleDateSelectSingle({ date: newSelected, selectedDate: createDate(2024, 5, 15), - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(newSelected); + expect(mockOnSelection).toHaveBeenCalledWith(newSelected); }); }); @@ -97,10 +97,10 @@ describe('applyRangeOrNewStart', () => { end, clickedDate: clicked, disabledDates: [createDate(2024, 5, 30)], - setSelection: mockSetSelection, + onSelection: mockOnSelection, }) ).toBe(true); - expect(mockSetSelection).toHaveBeenCalledWith(start, end); + expect(mockOnSelection).toHaveBeenCalledWith(start, end); }); it('sets selection to the clicked date as start and null as end when the range contains a disabled date', () => { @@ -113,28 +113,30 @@ describe('applyRangeOrNewStart', () => { end, clickedDate: clicked, disabledDates: [createDate(2024, 5, 12)], - setSelection: mockSetSelection, + onSelection: mockOnSelection, }) ).toBe(false); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); }); describe('handleDateSelectRange', () => { describe('close calendar return value', () => { it('returns false when only a start date is chosen (calendar mode)', () => { - const setSelection = jest.fn(); expect( handleDateSelectRange({ date: createDate(2024, 5, 10), activeRangePart: null, startDate: null, endDate: null, - setSelection, + onSelection: mockOnSelection, disabledDates: [], }) ).toBe(false); - expect(setSelection).toHaveBeenCalledWith(createDate(2024, 5, 10), null); + expect(mockOnSelection).toHaveBeenCalledWith( + createDate(2024, 5, 10), + null + ); }); it('returns true when end date is chosen after start (calendar mode)', () => { @@ -145,11 +147,11 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: createDate(2024, 5, 10), endDate: null, - setSelection, + onSelection: mockOnSelection, disabledDates: [], }) ).toBe(true); - expect(setSelection).toHaveBeenCalledWith( + expect(mockOnSelection).toHaveBeenCalledWith( createDate(2024, 5, 10), createDate(2024, 5, 20) ); @@ -166,9 +168,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(null, end); + expect(mockOnSelection).toHaveBeenCalledWith(null, end); }); it('sets start date when no end date is set', () => { @@ -179,9 +181,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and clears end date when new start is after end', () => { @@ -193,9 +195,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and keeps end date when new start is before end', () => { @@ -207,9 +209,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); }); it('sets start date and keeps end date when new start is the same as end', () => { @@ -221,9 +223,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { @@ -235,10 +237,10 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, disabledDates: [createDate(2024, 2, 12)], }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); }); describe('start date is not set', () => { @@ -249,9 +251,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and clears end date when new start is after end', () => { @@ -262,9 +264,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and keeps end date when new start is before end', () => { @@ -275,9 +277,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); }); it('sets start date and keeps end date when new start is the same as end', () => { @@ -288,9 +290,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { @@ -301,10 +303,10 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, disabledDates: [createDate(2024, 2, 12)], }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); }); }); @@ -319,9 +321,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(start, null); + expect(mockOnSelection).toHaveBeenCalledWith(start, null); }); it('sets end date when no start date is set', () => { @@ -332,9 +334,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: null, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(null, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and clears start date when new end is before start', () => { @@ -346,9 +348,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(null, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and keeps start date when new end is after start', () => { @@ -360,9 +362,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); }); it('sets end date and keeps start date when new end is the same as start', () => { @@ -374,9 +376,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { @@ -388,10 +390,10 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, disabledDates: [createDate(2024, 2, 12)], }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); }); describe('end date is not set', () => { @@ -402,9 +404,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: null, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(null, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and clears start date when new end is before start', () => { @@ -415,9 +417,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(null, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and keeps start date when new end is after start', () => { @@ -428,9 +430,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); }); it('sets end date and keeps start date when new end is the same as start', () => { @@ -441,9 +443,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { @@ -454,10 +456,10 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, disabledDates: [createDate(2024, 2, 12)], }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); }); }); @@ -470,9 +472,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: day, endDate: day, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(null, null); + expect(mockOnSelection).toHaveBeenCalledWith(null, null); }); it('end date becomes start date when start date is clicked', () => { @@ -483,9 +485,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(end, null); + expect(mockOnSelection).toHaveBeenCalledWith(end, null); }); it('clears end date when end date is clicked', () => { @@ -496,9 +498,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(start, null); + expect(mockOnSelection).toHaveBeenCalledWith(start, null); }); it('updates end date when a date after start date is clicked', () => { @@ -510,9 +512,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); }); it('updates start date to the clicked date and clears end date when the range extending to right would contain a disabled date', () => { @@ -524,10 +526,10 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, disabledDates: [createDate(2024, 2, 12)], }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); it('updates start date when a date before start date is clicked', () => { @@ -539,9 +541,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); }); it('updates start date to the clicked date and clears end date when the range extending to left would contain a disabled date', () => { @@ -553,10 +555,10 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, disabledDates: [createDate(2024, 2, 12)], }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); }); @@ -569,9 +571,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); it('sets end date when clicked date is on or after start date', () => { @@ -582,9 +584,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); }); it('updates start date to the clicked date and does not set end date when the range would contain a disabled date', () => { @@ -595,10 +597,10 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: null, - setSelection: mockSetSelection, + onSelection: mockOnSelection, disabledDates: [createDate(2024, 2, 12)], }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); }); @@ -611,9 +613,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: null, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and clears end date when clicked date is after end date', () => { @@ -624,9 +626,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: null, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); it('updates start date to the clicked date and clears end date when the range would contain a disabled date', () => { @@ -637,10 +639,10 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: null, endDate: end, - setSelection: mockSetSelection, + onSelection: mockOnSelection, disabledDates: [createDate(2024, 2, 12)], }); - expect(mockSetSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); }); }); diff --git a/packages/gamut/src/DatePicker/utils/dateSelect.ts b/packages/gamut/src/DatePicker/utils/dateSelect.ts index ab3d9dcf6bc..a1bb5e67186 100644 --- a/packages/gamut/src/DatePicker/utils/dateSelect.ts +++ b/packages/gamut/src/DatePicker/utils/dateSelect.ts @@ -1,10 +1,9 @@ import { isDateInRange, isSameDay } from '../Calendar/utils/dateGrid'; import type { - DatePickerBaseContextValue, - DatePickerProps, DatePickerRangeContextValue, - DatePickerRangeProps, -} from '../types'; + DatePickerSingleContextValue, +} from '../DatePickerContext'; +import type { DatePickerProps, DatePickerRangeProps } from '../types'; export const isRangeProps = ( props: DatePickerProps @@ -32,21 +31,21 @@ export const rangeContainsDisabled = ({ export type HandleDateSelectSingleParams = { date: Date; - selectedDate: DatePickerBaseContextValue['startOrSelectedDate']; -} & Pick; + selectedDate: DatePickerSingleContextValue['startOrSelectedDate']; +} & Pick; export const handleDateSelectSingle = ({ date, selectedDate, - setSelection, + onSelection, }: HandleDateSelectSingleParams) => { // If clicked date is the same as Start Date: Clear Start Date if (selectedDate && date.getTime() === selectedDate.getTime()) { - setSelection(null); + onSelection(null); return; } // If clicked date is not the same as Start Date: Set Start Date to clicked date - setSelection(date); + onSelection(date); }; type ApplyRangeOrNewStartParams = { @@ -54,7 +53,7 @@ type ApplyRangeOrNewStartParams = { end: Date; clickedDate: Date; disabledDates: Date[]; -} & Pick; +} & Pick; /** @returns whether a full start+end range was committed (calendar may close). */ export const applyRangeOrNewStart = ({ @@ -62,14 +61,14 @@ export const applyRangeOrNewStart = ({ end, clickedDate, disabledDates, - setSelection, + onSelection, }: ApplyRangeOrNewStartParams) => { // if range contains disabled dates, set start date to clicked date and end date to null if (rangeContainsDisabled({ start, end, disabledDates })) { - setSelection(clickedDate, null); + onSelection(clickedDate, null); return false; } - setSelection(start, end); + onSelection(start, end); return true; }; @@ -78,7 +77,7 @@ export type HandleDateSelectRangeParams = { startDate: DatePickerRangeContextValue['startOrSelectedDate']; } & Pick< DatePickerRangeContextValue, - 'activeRangePart' | 'endDate' | 'setSelection' | 'disabledDates' + 'activeRangePart' | 'endDate' | 'onSelection' | 'disabledDates' >; /** @returns whether the calendar should close (full range selected and committed). */ @@ -87,13 +86,13 @@ export const handleDateSelectRange = ({ activeRangePart, startDate, endDate, - setSelection, + onSelection, disabledDates = [], }: HandleDateSelectRangeParams) => { // Range mode: field targeting (start or end input was focused) if (activeRangePart === 'start') { if (date.getTime() === startDate?.getTime()) { - setSelection(null, endDate); + onSelection(null, endDate); return false; } const newEnd = @@ -104,15 +103,15 @@ export const handleDateSelectRange = ({ end: newEnd, clickedDate: date, disabledDates, - setSelection, + onSelection, }); } - setSelection(date, newEnd); + onSelection(date, newEnd); return false; } if (activeRangePart === 'end') { if (date.getTime() === endDate?.getTime()) { - setSelection(startDate, null); + onSelection(startDate, null); return false; } const newStart = @@ -125,10 +124,10 @@ export const handleDateSelectRange = ({ end: date, clickedDate: date, disabledDates, - setSelection, + onSelection, }); } - setSelection(newStart, date); + onSelection(newStart, date); return false; } @@ -139,17 +138,17 @@ export const handleDateSelectRange = ({ startDate.getTime() === endDate.getTime() && date.getTime() === startDate.getTime() ) { - setSelection(null, null); + onSelection(null, null); return false; } // if clicked on start date, end date becomes start date if (date.getTime() === startDate.getTime()) { - setSelection(endDate, null); + onSelection(endDate, null); return false; } // if clicked on end date, clears end date and start remains if (date.getTime() === endDate.getTime()) { - setSelection(startDate, null); + onSelection(startDate, null); return false; } // If clicked date > Start: Updates End Date to new date (Start remains) @@ -159,7 +158,7 @@ export const handleDateSelectRange = ({ end: date, clickedDate: date, disabledDates, - setSelection, + onSelection, }); } // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left @@ -168,14 +167,14 @@ export const handleDateSelectRange = ({ end: endDate, clickedDate: date, disabledDates, - setSelection, + onSelection, }); } // 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()) { - setSelection(date, null); + onSelection(date, null); return false; } // If clicked date > Start: Sets it as End Date (if range valid) @@ -184,9 +183,9 @@ export const handleDateSelectRange = ({ end: date, clickedDate: date, disabledDates, - setSelection, + onSelection, }); } - setSelection(date, null); + onSelection(date, null); return false; }; diff --git a/packages/gamut/src/index.tsx b/packages/gamut/src/index.tsx index fb6689143e0..55192cf5ecc 100644 --- a/packages/gamut/src/index.tsx +++ b/packages/gamut/src/index.tsx @@ -17,7 +17,6 @@ export * from './Coachmark'; export * from './ConnectedForm'; export * from './ContentContainer'; export * from './DatePicker'; -export * from './DatePicker/Calendar'; export * from './DelayedRenderWrapper'; export * from './Disclosure'; export * from './DataList'; diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.mdx b/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.mdx deleted file mode 100644 index 3c8788e8487..00000000000 --- a/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.mdx +++ /dev/null @@ -1,26 +0,0 @@ -import { Canvas, Controls, Meta } from '@storybook/addon-docs/blocks'; - -import { ComponentHeader } from '~styleguide/blocks'; - -import * as CalendarStories from './Calendar.stories'; - -export const parameters = { - title: 'DatePicker/Calendar', - subtitle: `Calendar grid with header (month/year + prev/next), body (day grid), and footer (Clear, Today, quick actions). Used inside DatePickerCalendar.`, - status: 'current', - source: { - repo: 'gamut', - githubLink: - 'https://github.com/Codecademy/gamut/blob/main/packages/gamut/src/DatePicker/Calendar', - }, -}; - - - - - -## Playground - - - - diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.stories.tsx deleted file mode 100644 index c0598aec53f..00000000000 --- a/packages/styleguide/src/lib/Organisms/DatePicker/Calendar.stories.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { - Box, - CalendarBody, - CalendarFooter, - CalendarHeader, - CalendarWrapper, -} from '@codecademy/gamut'; -import type { Meta, StoryObj } from '@storybook/react'; -import { useId, useState } from 'react'; - -const meta: Meta = { - component: CalendarWrapper, - title: 'Organisms/DatePicker/Calendar', -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - render: function CalendarStory() { - const headingId = useId(); - const [displayDate, setDisplayDate] = useState(() => new Date()); - const [selectedDate, setSelectedDate] = useState(null); - const [focusedDate, setFocusedDate] = useState( - () => new Date() - ); - - return ( - - - - - - setSelectedDate(null)} - onTodayClick={() => { - const today = new Date(); - setSelectedDate(today); - setDisplayDate(today); - setFocusedDate(today); - }} - /> - - ); - }, -}; diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx index 9d9c1ed3ac9..0971f9173e5 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -26,9 +26,10 @@ export const Default: Story = { ); @@ -45,7 +46,7 @@ export const WithInitialDate: Story = { inputSize="small" label="Date" selectedDate={selectedDate} - setSelectedDate={setSelectedDate} + onSelected={setSelectedDate} /> ); }, @@ -62,12 +63,12 @@ export const Range: Story = { disabledDates={[new Date(2026, 3, 15)]} endDate={endDate} mode="range" - setEndDate={setEndDate} - setStartDate={setStartDate} startDate={startDate} translations={{ startDateLabel: 'Beginning date', }} + onEndSelected={setEndDate} + onStartSelected={setStartDate} /> ); @@ -88,12 +89,12 @@ export const RangeWithQuickActions: Story = { { num: -1, timePeriod: 'month', displayText: 'Last month' }, { num: -30, timePeriod: 'day', displayText: 'Last 30 days' }, ]} - setEndDate={setEndDate} - setStartDate={setStartDate} startDate={startDate} translations={{ startDateLabel: 'Beginning date', }} + onEndSelected={setEndDate} + onStartSelected={setStartDate} /> ); @@ -111,12 +112,12 @@ export const RangeSmall: Story = { endDate={endDate} inputSize="small" mode="range" - setEndDate={setEndDate} - setStartDate={setStartDate} startDate={startDate} translations={{ startDateLabel: 'Beginning date', }} + onEndSelected={setEndDate} + onStartSelected={setStartDate} /> ); @@ -136,7 +137,7 @@ export const ComposedWithContext: Story = { From decc8ec4f5e2f8209bef9faf06b24c2bd419a7b2 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 15 Apr 2026 11:10:57 -0400 Subject: [PATCH 078/110] reorg files --- .../DatePicker/Calendar/CalendarFooter.tsx | 42 -------------- packages/gamut/src/DatePicker/DatePicker.tsx | 14 ++--- .../Calendar/CalendarBody.tsx | 2 +- .../Calendar/CalendarFooter.tsx | 56 +++++++++++++++++++ .../Calendar/CalendarHeader.tsx | 6 +- .../Calendar/CalendarNavLastMonth.tsx | 4 +- .../Calendar/CalendarNavNextMonth.tsx | 4 +- .../Calendar/CalendarWrapper.tsx | 4 +- .../Calendar/index.tsx | 0 .../Calendar/types.ts | 2 +- .../Calendar/utils/__tests__/dateGrid.test.ts | 0 .../Calendar/utils/__tests__/format.test.ts | 0 .../utils/__tests__/keyHandler.test.tsx | 0 .../Calendar/utils/dateGrid.ts | 2 +- .../Calendar/utils/elements.tsx | 0 .../Calendar/utils/format.ts | 4 +- .../Calendar/utils/keyHandler.ts | 0 .../index.tsx} | 4 +- .../utils/__tests__/dateSelect.test.ts | 0 .../utils/__tests__/quickActions.test.ts | 0 .../utils/dateSelect.ts | 6 +- .../utils/quickActions.ts | 4 +- .../DatePicker/DatePickerContext/index.tsx | 19 +++++++ .../types.ts} | 25 ++------- .../Segment/__tests__/segmentUtils.test.ts | 2 +- .../DatePickerInput/Segment/index.ts | 7 --- .../{DatePickerInputSegment.tsx => index.tsx} | 2 +- .../Segment/{segmentUtils.ts => utils.ts} | 0 .../src/DatePicker/DatePickerInput/index.tsx | 6 +- packages/gamut/src/DatePicker/types.ts | 5 +- .../DatePicker/DatePicker.stories.tsx | 10 +--- 31 files changed, 120 insertions(+), 110 deletions(-) delete mode 100644 packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/CalendarBody.tsx (98%) create mode 100644 packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/CalendarHeader.tsx (90%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/CalendarNavLastMonth.tsx (89%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/CalendarNavNextMonth.tsx (89%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/CalendarWrapper.tsx (87%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/index.tsx (100%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/types.ts (98%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/utils/__tests__/dateGrid.test.ts (100%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/utils/__tests__/format.test.ts (100%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/utils/__tests__/keyHandler.test.tsx (100%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/utils/dateGrid.ts (98%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/utils/elements.tsx (100%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/utils/format.ts (95%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/Calendar/utils/keyHandler.ts (100%) rename packages/gamut/src/DatePicker/{DatePickerCalendar.tsx => DatePickerCalendar/index.tsx} (98%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/utils/__tests__/dateSelect.test.ts (100%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/utils/__tests__/quickActions.test.ts (100%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/utils/dateSelect.ts (97%) rename packages/gamut/src/DatePicker/{ => DatePickerCalendar}/utils/quickActions.ts (95%) create mode 100644 packages/gamut/src/DatePicker/DatePickerContext/index.tsx rename packages/gamut/src/DatePicker/{DatePickerContext.tsx => DatePickerContext/types.ts} (72%) delete mode 100644 packages/gamut/src/DatePicker/DatePickerInput/Segment/index.ts rename packages/gamut/src/DatePicker/DatePickerInput/Segment/{DatePickerInputSegment.tsx => index.tsx} (99%) rename packages/gamut/src/DatePicker/DatePickerInput/Segment/{segmentUtils.ts => utils.ts} (100%) diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx deleted file mode 100644 index f1018ccbaae..00000000000 --- a/packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx +++ /dev/null @@ -1,42 +0,0 @@ -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 = ({ - clear, - quickActions = [], -}) => { - // if there are no quick actions and the clear button is not shown, don't render anything - if (quickActions.length === 0 && !clear) return null; - - const actions = quickActions.slice(0, 3); - - return ( - - {clear && ( - clear.onClick?.()}> - {clear.text ?? DEFAULT_DATE_PICKER_TRANSLATIONS.clearText} - - )} - - {actions.map((action, index) => ( - - {action.displayText} - - ))} - - - ); -}; diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index d337a461462..4a24684157a 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -4,19 +4,19 @@ import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { Box, FlexBox } from '../Box'; import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; +import { isRangeProps } from './DatePickerCalendar/utils/dateSelect'; import { + getDefaultRangeQuickActions, + getDefaultSingleQuickActions, +} from './DatePickerCalendar/utils/quickActions'; +import { DatePickerProvider } from './DatePickerContext'; +import type { DatePickerContextValue, - DatePickerProvider, DatePickerRangeContextValue, -} from './DatePickerContext'; +} from './DatePickerContext/types'; import { DatePickerInput } from './DatePickerInput'; import type { DatePickerProps } from './types'; -import { isRangeProps } from './utils/dateSelect'; import { useResolvedLocale } from './utils/locale'; -import { - getDefaultRangeQuickActions, - getDefaultSingleQuickActions, -} from './utils/quickActions'; import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './utils/translations'; /** diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx similarity index 98% rename from packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx index 9dc414c4bb3..c2ef2a91671 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import * as React from 'react'; -import { useIsoFirstWeekday, useResolvedLocale } from '../utils/locale'; +import { useIsoFirstWeekday, useResolvedLocale } from '../../utils/locale'; import { CalendarBodyProps } from './types'; import { getDatesWithRow, 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..bbea865fb27 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx @@ -0,0 +1,56 @@ +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 = ({ + clear, + quickActions = [], +}) => { + // if there are no quick actions and the clear button is not shown, don't render anything + if (quickActions.length === 0 && !clear) return null; + + const actions = quickActions.slice(0, 3); + + return ( + + {clear && ( + + clear.onClick?.()} + > + {clear.text ?? DEFAULT_DATE_PICKER_TRANSLATIONS.clearText} + + + )} + + {actions.map((action, index) => ( + + {action.displayText} + + ))} + + + ); +}; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx similarity index 90% rename from packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx index 76b2c7f1b7d..87dc02518c5 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx @@ -1,8 +1,8 @@ import * as React from 'react'; -import { FlexBox } from '../../Box'; -import { Text } from '../../Typography'; -import { useResolvedLocale } from '../utils/locale'; +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'; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarNavLastMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx similarity index 89% rename from packages/gamut/src/DatePicker/Calendar/CalendarNavLastMonth.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx index 39d8dfe6c4b..ffe420070ac 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarNavLastMonth.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx @@ -1,8 +1,8 @@ import { MiniChevronLeftIcon } from '@codecademy/gamut-icons'; import * as React from 'react'; -import { IconButton } from '../../Button'; -import { useResolvedLocale } from '../utils/locale'; +import { IconButton } from '../../../Button'; +import { useResolvedLocale } from '../../utils/locale'; import { CalendarNavProps } from './types'; import { getRelativeMonthLabels } from './utils/format'; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarNavNextMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx similarity index 89% rename from packages/gamut/src/DatePicker/Calendar/CalendarNavNextMonth.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx index ac2335c1c58..d7959e18845 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarNavNextMonth.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx @@ -1,8 +1,8 @@ import { MiniChevronRightIcon } from '@codecademy/gamut-icons'; import * as React from 'react'; -import { IconButton } from '../../Button'; -import { useResolvedLocale } from '../utils/locale'; +import { IconButton } from '../../../Button'; +import { useResolvedLocale } from '../../utils/locale'; import { CalendarNavProps } from './types'; import { getRelativeMonthLabels } from './utils/format'; diff --git a/packages/gamut/src/DatePicker/Calendar/CalendarWrapper.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx similarity index 87% rename from packages/gamut/src/DatePicker/Calendar/CalendarWrapper.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx index e1f39944a3a..83a92c02fbc 100644 --- a/packages/gamut/src/DatePicker/Calendar/CalendarWrapper.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarWrapper.tsx @@ -1,8 +1,8 @@ import { CheckerDense } from '@codecademy/gamut-patterns'; import * as React from 'react'; -import { Box } from '../../Box'; -import { WithChildrenProp } from '../../utils'; +import { Box } from '../../../Box'; +import { WithChildrenProp } from '../../../utils'; /** * Outer wrapper for the calendar (header + body + footer). diff --git a/packages/gamut/src/DatePicker/Calendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/index.tsx similarity index 100% rename from packages/gamut/src/DatePicker/Calendar/index.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/index.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/types.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts similarity index 98% rename from packages/gamut/src/DatePicker/Calendar/types.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts index 2c083a47353..a20a7f804d2 100644 --- a/packages/gamut/src/DatePicker/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts @@ -1,4 +1,4 @@ -import type { IsoWeekday } from '../utils/locale'; +import type { IsoWeekday } from '../../utils/locale'; interface CalendarBaseProps { /** Used for the currently displayed month and year */ diff --git a/packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts similarity index 100% rename from packages/gamut/src/DatePicker/Calendar/utils/__tests__/dateGrid.test.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts diff --git a/packages/gamut/src/DatePicker/Calendar/utils/__tests__/format.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts similarity index 100% rename from packages/gamut/src/DatePicker/Calendar/utils/__tests__/format.test.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts diff --git a/packages/gamut/src/DatePicker/Calendar/utils/__tests__/keyHandler.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx similarity index 100% rename from packages/gamut/src/DatePicker/Calendar/utils/__tests__/keyHandler.test.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts similarity index 98% rename from packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts index 02672cf6a9a..4c77b76c49e 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts @@ -3,7 +3,7 @@ * Each row has 7 cells; leading/trailing cells may be null (padding from adjacent months). */ -import type { IsoWeekday } from '../../utils/locale'; +import type { IsoWeekday } from '../../../utils/locale'; const DAYS_PER_WEEK = 7; diff --git a/packages/gamut/src/DatePicker/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx similarity index 100% rename from packages/gamut/src/DatePicker/Calendar/utils/elements.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts similarity index 95% rename from packages/gamut/src/DatePicker/Calendar/utils/format.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts index 0f87fcf4ae4..74abd492935 100644 --- a/packages/gamut/src/DatePicker/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts @@ -1,5 +1,5 @@ -import type { IsoWeekday } from '../../utils/locale'; -import { stringifyLocale } from '../../utils/locale'; +import type { IsoWeekday } from '../../../utils/locale'; +import { stringifyLocale } from '../../../utils/locale'; /** * Capitalize the first character of a string using the locale; rest unchanged (e.g. "next month" → "Next month"). diff --git a/packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts similarity index 100% rename from packages/gamut/src/DatePicker/Calendar/utils/keyHandler.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx similarity index 98% rename from packages/gamut/src/DatePicker/DatePickerCalendar.tsx rename to packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx index ac8fd4dbc11..2ca72ef379c 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -9,7 +9,8 @@ import { } from 'react'; import { useMedia } from 'react-use'; -import { Box, FlexBox } from '../Box'; +import { Box, FlexBox } from '../../Box'; +import { useDatePicker } from '../DatePickerContext'; import { CalendarBody, CalendarFooter, @@ -18,7 +19,6 @@ import { } from './Calendar'; import type { CalendarBodyProps, QuickAction } from './Calendar/types'; import { addMonths, getFirstOfMonth } from './Calendar/utils/dateGrid'; -import { useDatePicker } from './DatePickerContext'; import { applyRangeOrNewStart, handleDateSelectRange, diff --git a/packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts similarity index 100% rename from packages/gamut/src/DatePicker/utils/__tests__/dateSelect.test.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts diff --git a/packages/gamut/src/DatePicker/utils/__tests__/quickActions.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts similarity index 100% rename from packages/gamut/src/DatePicker/utils/__tests__/quickActions.test.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts diff --git a/packages/gamut/src/DatePicker/utils/dateSelect.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts similarity index 97% rename from packages/gamut/src/DatePicker/utils/dateSelect.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts index a1bb5e67186..cb149388241 100644 --- a/packages/gamut/src/DatePicker/utils/dateSelect.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts @@ -1,9 +1,9 @@ -import { isDateInRange, isSameDay } from '../Calendar/utils/dateGrid'; import type { DatePickerRangeContextValue, DatePickerSingleContextValue, -} from '../DatePickerContext'; -import type { DatePickerProps, DatePickerRangeProps } from '../types'; +} from '../../DatePickerContext/types'; +import type { DatePickerProps, DatePickerRangeProps } from '../../types'; +import { isDateInRange, isSameDay } from '../Calendar/utils/dateGrid'; export const isRangeProps = ( props: DatePickerProps diff --git a/packages/gamut/src/DatePicker/utils/quickActions.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts similarity index 95% rename from packages/gamut/src/DatePicker/utils/quickActions.ts rename to packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts index 1f06192031c..c7303b66922 100644 --- a/packages/gamut/src/DatePicker/utils/quickActions.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts @@ -1,7 +1,7 @@ +import { stringifyLocale } from '../../utils/locale'; +import { DatePickerTranslations } from '../../utils/translations'; import { QuickAction } from '../Calendar/types'; import { capitalizeFirst } from '../Calendar/utils/format'; -import { stringifyLocale } from './locale'; -import { DatePickerTranslations } from './translations'; const getRelativeDisplayText = ( num: number, diff --git a/packages/gamut/src/DatePicker/DatePickerContext/index.tsx b/packages/gamut/src/DatePicker/DatePickerContext/index.tsx new file mode 100644 index 00000000000..72d1bf8ea3b --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/index.tsx @@ -0,0 +1,19 @@ +import { useContext } from 'react'; + +import { DatePickerContext, DatePickerContextValue } from './types'; + +/** Provider component; DatePicker uses this to set the context value. */ +export const DatePickerProvider = DatePickerContext.Provider; + +/** + * Returns the DatePicker context value (shared state and callbacks). + * Must be used inside a DatePicker. For composed layouts, use this to get + * openCalendar, closeCalendar, isCalendarOpen, inputRef, calendarDialogId, etc. + */ +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.tsx b/packages/gamut/src/DatePicker/DatePickerContext/types.ts similarity index 72% rename from packages/gamut/src/DatePicker/DatePickerContext.tsx rename to packages/gamut/src/DatePicker/DatePickerContext/types.ts index c750ee73ad2..0fe381563c4 100644 --- a/packages/gamut/src/DatePicker/DatePickerContext.tsx +++ b/packages/gamut/src/DatePicker/DatePickerContext/types.ts @@ -1,7 +1,10 @@ -import { createContext, useContext } from 'react'; +import { createContext } from 'react'; -import type { CalendarBodyProps, QuickAction } from './Calendar/types'; -import type { DatePickerTranslations } from './utils/translations'; +import type { + CalendarBodyProps, + QuickAction, +} from '../DatePickerCalendar/Calendar/types'; +import type { DatePickerTranslations } from '../utils/translations'; interface DatePickerBaseContextValue extends Pick { @@ -59,19 +62,3 @@ export type DatePickerContextValue = export const DatePickerContext = createContext( null ); - -/** Provider component; DatePicker uses this to set the context value. */ -export const DatePickerProvider = DatePickerContext.Provider; - -/** - * Returns the DatePicker context value (shared state and callbacks). - * Must be used inside a DatePicker. For composed layouts, use this to get - * openCalendar, closeCalendar, isCalendarOpen, inputRef, calendarDialogId, etc. - */ -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/DatePickerInput/Segment/__tests__/segmentUtils.test.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts index 8fbd00d7889..aad9ec78dd0 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts @@ -12,7 +12,7 @@ import { parseSegmentNumericString, parseSegmentsToDate, spinSegment, -} from '../segmentUtils'; +} from '../utils'; describe('getDateSegmentsFromDate', () => { it('returns empty strings for null', () => { diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.ts deleted file mode 100644 index 516e29ddae0..00000000000 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { Segment, SegmentLiteral } from './elements'; -export { DatePickerInputSegment } from './DatePickerInputSegment'; -export type { - AssignSegmentRef, - DatePickerInputSegmentProps, -} from './DatePickerInputSegment'; -export * from './segmentUtils'; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx similarity index 99% rename from packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx rename to packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx index a4318188915..78c42b7b0f6 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/DatePickerInputSegment.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx @@ -10,7 +10,7 @@ import { segmentMaxLength, SegmentValues, spinSegment, -} from './segmentUtils'; +} from './utils'; export type AssignSegmentRef = ( field: DatePartKind, diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts similarity index 100% rename from packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts rename to packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index 0b86511eaa7..c3a44991198 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -15,14 +15,14 @@ import { FormGroup } from '../../Form/elements/FormGroup'; import type { InputWrapperProps } from '../../Form/inputs/Input'; import { useDatePicker } from '../DatePickerContext'; import { SegmentedShell } from './elements'; +import { DatePickerInputSegment } from './Segment'; +import { SegmentLiteral } from './Segment/elements'; import { type SegmentValues, - DatePickerInputSegment, getDateSegmentsFromDate, normalizeSegmentValues, parseSegmentsToDate, - SegmentLiteral, -} from './Segment'; +} from './Segment/utils'; import { type DatePartKind, formatDateISO8601DateOnly, diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 0d3391388cf..acbb4dc5a54 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -1,7 +1,10 @@ import { ComponentProps } from 'react'; import { Input } from '../Form/inputs/Input'; -import { CalendarBodyProps, QuickAction } from './Calendar/types'; +import { + CalendarBodyProps, + QuickAction, +} from './DatePickerCalendar/Calendar/types'; import { DatePickerTranslations } from './utils/translations'; interface DatePickerBaseProps diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx index 0971f9173e5..a7b61b74c8f 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -24,7 +24,6 @@ export const Default: Story = { return ( @@ -60,7 +58,7 @@ export const Range: Story = { return ( (null); return ( - + From 5283400fe350da5e15cd60c099e06d961fb0e1c4 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 15 Apr 2026 13:05:22 -0400 Subject: [PATCH 079/110] update disabled date logic --- packages/gamut/src/DatePicker/DatePicker.tsx | 6 +- .../Calendar/CalendarBody.tsx | 12 ++-- .../DatePickerCalendar/Calendar/types.ts | 21 ++++++- .../Calendar/utils/__tests__/dateGrid.test.ts | 33 ++++++++--- .../utils/__tests__/keyHandler.test.tsx | 5 +- .../Calendar/utils/dateGrid.ts | 49 +++++++++++++---- .../Calendar/utils/keyHandler.ts | 6 +- .../DatePicker/DatePickerCalendar/index.tsx | 17 +++--- .../utils/__tests__/dateSelect.test.ts | 44 +++++++++------ .../DatePickerCalendar/utils/dateSelect.ts | 55 ++++++++++++------- .../src/DatePicker/DatePickerContext/types.ts | 2 +- packages/gamut/src/DatePicker/index.tsx | 1 + packages/gamut/src/DatePicker/types.ts | 2 +- .../lib/Organisms/DatePicker/DatePicker.mdx | 18 +++--- .../DatePicker/DatePicker.stories.tsx | 12 +++- 15 files changed, 189 insertions(+), 94 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 4a24684157a..45386f247d3 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -27,7 +27,7 @@ import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './utils/translations'; export const DatePicker: React.FC = (props) => { const { locale, - disabledDates = [], + shouldDisableDate, mode, children, translations: translationsProp, @@ -105,7 +105,7 @@ export const DatePicker: React.FC = (props) => { clearGridFocusRequest, closeCalendar, locale: resolvedLocale, - disabledDates, + shouldDisableDate, calendarDialogId, translations, quickActions: quickActions === null ? [] : resolvedQuickActions, @@ -136,7 +136,7 @@ export const DatePicker: React.FC = (props) => { gridFocusRequested, clearGridFocusRequest, closeCalendar, - disabledDates, + shouldDisableDate, calendarDialogId, endDate, activeRangePart, diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx index c2ef2a91671..cc1820252d7 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -18,7 +18,7 @@ export const CalendarBody: React.FC = ({ displayDate, selectedDate, endDate = null, - disabledDates = [], + shouldDisableDate, onDateSelect, locale, weekStartsOn, @@ -104,7 +104,7 @@ export const CalendarBody: React.FC = ({ datesWithRow, month, year, - disabledDates, + shouldDisableDate, onDateSelect, onEscapeKeyPress, onDisplayDateChange, @@ -116,7 +116,7 @@ export const CalendarBody: React.FC = ({ datesWithRow, month, year, - disabledDates, + shouldDisableDate, onDateSelect, onEscapeKeyPress, onDisplayDateChange, @@ -166,7 +166,7 @@ export const CalendarBody: React.FC = ({ const range = !!selectedDate && !!endDate; const inRange = range && isDateInRange(date, selectedDate, endDate); - const disabled = isDateDisabled(date, disabledDates); + const disabled = isDateDisabled(date, shouldDisableDate); const today = isToday(date); // this is making the selected date a differnet color bc it is focused, look into further const isFocused = @@ -188,7 +188,9 @@ export const CalendarBody: React.FC = ({ ref={(el) => setButtonRef(date, el as HTMLElement | null)} role="gridcell" tabIndex={isFocused ? 0 : -1} - onClick={() => onDateSelect(date)} + onClick={() => { + if (!disabled) onDateSelect(date); + }} onFocus={() => onFocusedDateChange?.(date)} onKeyDown={(e: React.KeyboardEvent) => onKeyDown(e, date)} > diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts index a20a7f804d2..0c9e00c5cbe 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts @@ -10,12 +10,27 @@ interface CalendarBaseProps { * `['en-GB', 'en']`, or a prebuilt `Intl.Locale`). Omitted → runtime default (user agent). */ locale?: Intl.LocalesArgument; - /** Dates that should be disabled (unselectable) */ - disabledDates?: Date[]; + /** + * 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() + * ); + * d < startOfCutoff} /> + * ``` + */ + shouldDisableDate?: (date: Date) => boolean; } export interface CalendarNavProps - extends Omit { + extends Omit { /** Called after navigating to previous month. */ onLastMonthClick?: () => void; /** Called after navigating to next month */ 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 index 043d9629504..a943a2dcd1b 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts @@ -5,6 +5,7 @@ import { isDateDisabled, isDateInRange, isSameDay, + matchDisabledDates, } from '../dateGrid'; describe('getWeekdayOffsetInGrid', () => { @@ -66,18 +67,32 @@ describe('isDateInRange', () => { }); }); -describe('isDateDisabled', () => { - it('returns true when any disabled date matches the day', () => { +describe('matchDisabledDates', () => { + it('returns true when any listed day matches the calendar day', () => { const target = new Date(2024, 4, 10); - const disabled = [new Date(2024, 4, 10, 15, 30)]; - expect(isDateDisabled(target, disabled)).toBe(true); + 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 shouldDisableDate returns true', () => { + expect(isDateDisabled(new Date(2024, 4, 10), () => true)).toBe(true); + expect( + isDateDisabled(new Date(2024, 4, 10), (d) => d.getDate() === 10) + ).toBe(true); }); - it('returns false when the list is empty or no match', () => { - expect(isDateDisabled(new Date(2024, 4, 10), [])).toBe(false); - expect(isDateDisabled(new Date(2024, 4, 10), [new Date(2024, 4, 11)])).toBe( - false - ); + it('returns false when shouldDisableDate is omitted or returns false', () => { + expect(isDateDisabled(new Date(2024, 4, 10))).toBe(false); + expect(isDateDisabled(new Date(2024, 4, 10), () => false)).toBe(false); }); }); 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 index f4138eaa327..5b981d27882 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx @@ -1,6 +1,6 @@ import type { KeyboardEvent } from 'react'; -import { getDatesWithRow, getMonthGrid } from '../dateGrid'; +import { getDatesWithRow, getMonthGrid, matchDisabledDates } from '../dateGrid'; import { keyHandler, KeyHandlerParams } from '../keyHandler'; const makeEvent = ( @@ -33,7 +33,6 @@ const baseParams: Omit = { datesWithRow, month, year, - disabledDates: [], hasAdjacentMonthRight: false, hasAdjacentMonthLeft: false, onDisplayDateChange: mockOnDisplayDateChange, @@ -155,7 +154,7 @@ describe('keyHandler', () => { keyHandler({ ...baseParams, e: makeEvent('Enter'), - disabledDates: [date], + shouldDisableDate: matchDisabledDates([date]), }); expect(mockOnDateSelect).not.toHaveBeenCalled(); }); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts index 4c77b76c49e..f76832ddb7e 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts @@ -87,6 +87,22 @@ export const isSameDay = (a: Date | null, b: Date | null) => { return normalizeDate(a) === normalizeDate(b); }; +/** + * Calendar-ordered local-midnight instants for two possibly unordered `Date` values. + * Matches the bounds used by {@link isDateInRange} (and range selection). + */ +export const getOrderedCalendarEndpoints = (start: Date, end: Date) => { + const startDate = new Date( + start.getFullYear(), + start.getMonth(), + start.getDate() + ); + const endDate = new Date(end.getFullYear(), end.getMonth(), end.getDate()); + return startDate <= endDate + ? { low: startDate, high: endDate } + : { low: endDate, high: startDate }; +}; + /** * Check if `date` is between `start` and `end` (exclusive), ignoring time. */ @@ -96,21 +112,32 @@ export const isDateInRange = ( end: Date | null ) => { if (start === null) return false; - const normalizedDateTime = normalizeDate(date); - const normalizedStartDateTime = normalizeDate(start); - const normalizedEndDateTime = - end !== null ? normalizeDate(end) : normalizedStartDateTime; - const low = Math.min(normalizedStartDateTime, normalizedEndDateTime); - const high = Math.max(normalizedStartDateTime, normalizedEndDateTime); - return normalizedDateTime > low && normalizedDateTime < high; + const endBound = end ?? start; + const { low, high } = getOrderedCalendarEndpoints(start, endBound); + const normalizedDate = normalizeDate(date); + return ( + normalizedDate > normalizeDate(low) && normalizedDate < normalizeDate(high) + ); }; /** - * Check if `date` is in the `disabledDates` list (by calendar day). + * Build a `shouldDisableDate` that disables each listed calendar day (time-of-day ignored). + * + * @example + * ```tsx + * + * ``` */ -export const isDateDisabled = (date: Date, disabledDates: Date[] = []) => { - return disabledDates.some((d) => isSameDay(date, d)); -}; +export const matchDisabledDates = + (dates: readonly Date[] = []) => + (date: Date): boolean => + dates.some((d) => isSameDay(date, d)); + +/** True when `shouldDisableDate` returns true for this calendar day. */ +export const isDateDisabled = ( + date: Date, + shouldDisableDate?: (date: Date) => boolean +) => Boolean(shouldDisableDate?.(date)); /** One visible day in the month grid with its row (for Home/End and keyboard nav). */ export type DateWithRow = { date: Date; rowIndex: number }; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts index ef3ed99fe00..be58eba1a50 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts @@ -10,7 +10,7 @@ export type KeyHandlerParams = Pick< | 'onEscapeKeyPress' | 'hasAdjacentMonthRight' | 'hasAdjacentMonthLeft' - | 'disabledDates' + | 'shouldDisableDate' > & { e: React.KeyboardEvent; /** The date for the day cell that received the key event */ @@ -35,7 +35,7 @@ export const keyHandler = ({ datesWithRow, month, year, - disabledDates = [], + shouldDisableDate, onDateSelect, onEscapeKeyPress, onDisplayDateChange, @@ -141,7 +141,7 @@ export const keyHandler = ({ case 'Enter': case ' ': e.preventDefault(); - if (!isDateDisabled(date, disabledDates)) onDateSelect(date); + if (!isDateDisabled(date, shouldDisableDate)) onDateSelect(date); return; case 'Escape': e.preventDefault(); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx index 2ca72ef379c..8344e717a0b 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -62,7 +62,7 @@ export const DatePickerCalendar: React.FC = ({ mode, startOrSelectedDate, onSelection, - disabledDates, + shouldDisableDate, locale, closeCalendar, isCalendarOpen, @@ -138,7 +138,7 @@ export const DatePickerCalendar: React.FC = ({ startDate: startOrSelectedDate, endDate: context.endDate, onSelection, - disabledDates, + shouldDisableDate, }); if (shouldClose) queueMicrotask(closeCalendar); }, @@ -148,7 +148,7 @@ export const DatePickerCalendar: React.FC = ({ context, startOrSelectedDate, onSelection, - disabledDates, + shouldDisableDate, closeCalendar, ] ); @@ -159,7 +159,6 @@ export const DatePickerCalendar: React.FC = ({ }, [onSelection, setFocusedDate, displayDate]); const computedQuickActions: QuickAction[] = useMemo(() => { - const safeDisabled = disabledDates ?? []; return quickActions.slice(0, 3).map((action) => ({ ...action, onClick: () => { @@ -175,14 +174,14 @@ export const DatePickerCalendar: React.FC = ({ rangeContainsDisabled({ start, end, - disabledDates: safeDisabled, + shouldDisableDate, }) ) { applyRangeOrNewStart({ start, end, clickedDate: end, - disabledDates: safeDisabled, + shouldDisableDate, onSelection, }); } else { @@ -201,7 +200,7 @@ export const DatePickerCalendar: React.FC = ({ })); }, [ closeCalendar, - disabledDates, + shouldDisableDate, isRange, quickActions, setActiveRangePart, @@ -220,7 +219,6 @@ export const DatePickerCalendar: React.FC = ({ onDisplayDateChange={setDisplayDate} /> = ({ labelledById={headingId} locale={locale} selectedDate={startOrSelectedDate} + shouldDisableDate={shouldDisableDate} weekStartsOn={weekStartsOn} onDateSelect={onDateSelect} onDisplayDateChange={setDisplayDate} @@ -245,7 +244,6 @@ export const DatePickerCalendar: React.FC = ({ onDisplayDateChange={setDisplayDate} /> = ({ labelledById={headingId} locale={locale} selectedDate={startOrSelectedDate} + shouldDisableDate={shouldDisableDate} weekStartsOn={weekStartsOn} onDateSelect={onDateSelect} onDisplayDateChange={setDisplayDate} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts index 82410d014b7..691a855cc20 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts @@ -1,3 +1,4 @@ +import { matchDisabledDates } from '../../Calendar/utils/dateGrid'; import { applyRangeOrNewStart, handleDateSelectRange, @@ -18,7 +19,7 @@ describe('rangeContainsDisabled', () => { rangeContainsDisabled({ start, end, - disabledDates: [createDate(2024, 0, 10)], + shouldDisableDate: matchDisabledDates([createDate(2024, 0, 10)]), }) ).toBe(true); }); @@ -28,7 +29,7 @@ describe('rangeContainsDisabled', () => { rangeContainsDisabled({ start, end, - disabledDates: [createDate(2024, 0, 20)], + shouldDisableDate: matchDisabledDates([createDate(2024, 0, 20)]), }) ).toBe(true); }); @@ -38,7 +39,7 @@ describe('rangeContainsDisabled', () => { rangeContainsDisabled({ start, end, - disabledDates: [createDate(2024, 0, 15)], + shouldDisableDate: matchDisabledDates([createDate(2024, 0, 15)]), }) ).toBe(true); }); @@ -48,10 +49,23 @@ describe('rangeContainsDisabled', () => { rangeContainsDisabled({ start, end, - disabledDates: [createDate(2024, 0, 5), createDate(2024, 0, 25)], + shouldDisableDate: matchDisabledDates([ + createDate(2024, 0, 5), + createDate(2024, 0, 25), + ]), }) ).toBe(false); }); + + it('returns true when shouldDisableDate marks a day inside the inclusive range', () => { + expect( + rangeContainsDisabled({ + start, + end, + shouldDisableDate: (d) => d.getDate() === 15, + }) + ).toBe(true); + }); }); describe('handleDateSelectSingle', () => { @@ -96,7 +110,7 @@ describe('applyRangeOrNewStart', () => { start, end, clickedDate: clicked, - disabledDates: [createDate(2024, 5, 30)], + shouldDisableDate: matchDisabledDates([createDate(2024, 5, 30)]), onSelection: mockOnSelection, }) ).toBe(true); @@ -112,7 +126,7 @@ describe('applyRangeOrNewStart', () => { start, end, clickedDate: clicked, - disabledDates: [createDate(2024, 5, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 5, 12)]), onSelection: mockOnSelection, }) ).toBe(false); @@ -130,7 +144,6 @@ describe('handleDateSelectRange', () => { startDate: null, endDate: null, onSelection: mockOnSelection, - disabledDates: [], }) ).toBe(false); expect(mockOnSelection).toHaveBeenCalledWith( @@ -148,7 +161,6 @@ describe('handleDateSelectRange', () => { startDate: createDate(2024, 5, 10), endDate: null, onSelection: mockOnSelection, - disabledDates: [], }) ).toBe(true); expect(mockOnSelection).toHaveBeenCalledWith( @@ -238,7 +250,7 @@ describe('handleDateSelectRange', () => { startDate: start, endDate: end, onSelection: mockOnSelection, - disabledDates: [createDate(2024, 2, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); @@ -304,7 +316,7 @@ describe('handleDateSelectRange', () => { startDate: null, endDate: end, onSelection: mockOnSelection, - disabledDates: [createDate(2024, 2, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); @@ -391,7 +403,7 @@ describe('handleDateSelectRange', () => { startDate: start, endDate: end, onSelection: mockOnSelection, - disabledDates: [createDate(2024, 2, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); @@ -457,7 +469,7 @@ describe('handleDateSelectRange', () => { startDate: start, endDate: null, onSelection: mockOnSelection, - disabledDates: [createDate(2024, 2, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); @@ -527,7 +539,7 @@ describe('handleDateSelectRange', () => { startDate: start, endDate: end, onSelection: mockOnSelection, - disabledDates: [createDate(2024, 2, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); @@ -556,7 +568,7 @@ describe('handleDateSelectRange', () => { startDate: start, endDate: end, onSelection: mockOnSelection, - disabledDates: [createDate(2024, 2, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); @@ -598,7 +610,7 @@ describe('handleDateSelectRange', () => { startDate: start, endDate: null, onSelection: mockOnSelection, - disabledDates: [createDate(2024, 2, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); @@ -640,7 +652,7 @@ describe('handleDateSelectRange', () => { startDate: null, endDate: end, onSelection: mockOnSelection, - disabledDates: [createDate(2024, 2, 12)], + shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); }); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts index cb149388241..ed5bfd24bb3 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts @@ -3,7 +3,11 @@ import type { DatePickerSingleContextValue, } from '../../DatePickerContext/types'; import type { DatePickerProps, DatePickerRangeProps } from '../../types'; -import { isDateInRange, isSameDay } from '../Calendar/utils/dateGrid'; +import { + getOrderedCalendarEndpoints, + isDateDisabled, + isDateInRange, +} from '../Calendar/utils/dateGrid'; export const isRangeProps = ( props: DatePickerProps @@ -12,21 +16,32 @@ export const isRangeProps = ( export type RangeContainsDisabledParams = { start: Date; end: Date; - disabledDates: Date[]; + shouldDisableDate?: (date: Date) => boolean; }; -/** True if any disabled date falls within [start, end] (inclusive, by calendar day). */ +/** True if any disabled day falls within [start, end] (inclusive, by calendar day). */ export const rangeContainsDisabled = ({ start, end, - disabledDates, + shouldDisableDate, }: RangeContainsDisabledParams) => { - return disabledDates.some( - (date) => - isSameDay(date, start) || - isSameDay(date, end) || - isDateInRange(date, start, end) - ); + const { low, high } = getOrderedCalendarEndpoints(start, end); + + if ( + isDateDisabled(low, shouldDisableDate) || + isDateDisabled(high, shouldDisableDate) + ) { + return true; + } + + let date = new Date(low.getFullYear(), low.getMonth(), low.getDate() + 1); + while (isDateInRange(date, start, end)) { + if (isDateDisabled(date, shouldDisableDate)) { + return true; + } + date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1); + } + return false; }; export type HandleDateSelectSingleParams = { @@ -52,7 +67,7 @@ type ApplyRangeOrNewStartParams = { start: Date; end: Date; clickedDate: Date; - disabledDates: Date[]; + shouldDisableDate?: (date: Date) => boolean; } & Pick; /** @returns whether a full start+end range was committed (calendar may close). */ @@ -60,11 +75,11 @@ export const applyRangeOrNewStart = ({ start, end, clickedDate, - disabledDates, + shouldDisableDate, onSelection, }: ApplyRangeOrNewStartParams) => { // if range contains disabled dates, set start date to clicked date and end date to null - if (rangeContainsDisabled({ start, end, disabledDates })) { + if (rangeContainsDisabled({ start, end, shouldDisableDate })) { onSelection(clickedDate, null); return false; } @@ -77,7 +92,7 @@ export type HandleDateSelectRangeParams = { startDate: DatePickerRangeContextValue['startOrSelectedDate']; } & Pick< DatePickerRangeContextValue, - 'activeRangePart' | 'endDate' | 'onSelection' | 'disabledDates' + 'activeRangePart' | 'endDate' | 'onSelection' | 'shouldDisableDate' >; /** @returns whether the calendar should close (full range selected and committed). */ @@ -87,7 +102,7 @@ export const handleDateSelectRange = ({ startDate, endDate, onSelection, - disabledDates = [], + shouldDisableDate, }: HandleDateSelectRangeParams) => { // Range mode: field targeting (start or end input was focused) if (activeRangePart === 'start') { @@ -102,7 +117,7 @@ export const handleDateSelectRange = ({ start: date, end: newEnd, clickedDate: date, - disabledDates, + shouldDisableDate, onSelection, }); } @@ -123,7 +138,7 @@ export const handleDateSelectRange = ({ start: newStart, end: date, clickedDate: date, - disabledDates, + shouldDisableDate, onSelection, }); } @@ -157,7 +172,7 @@ export const handleDateSelectRange = ({ start: startDate, end: date, clickedDate: date, - disabledDates, + shouldDisableDate, onSelection, }); } @@ -166,7 +181,7 @@ export const handleDateSelectRange = ({ start: date, end: endDate, clickedDate: date, - disabledDates, + shouldDisableDate, onSelection, }); } @@ -182,7 +197,7 @@ export const handleDateSelectRange = ({ start: startDate, end: date, clickedDate: date, - disabledDates, + shouldDisableDate, onSelection, }); } diff --git a/packages/gamut/src/DatePicker/DatePickerContext/types.ts b/packages/gamut/src/DatePicker/DatePickerContext/types.ts index 0fe381563c4..e8901f2c697 100644 --- a/packages/gamut/src/DatePicker/DatePickerContext/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerContext/types.ts @@ -7,7 +7,7 @@ import type { import type { DatePickerTranslations } from '../utils/translations'; interface DatePickerBaseContextValue - extends Pick { + extends Pick { /** * Resolved `Intl.Locale` from the `locale` prop (or runtime default). Same instance passed to * formatters and available for `getWeekInfo()` etc. diff --git a/packages/gamut/src/DatePicker/index.tsx b/packages/gamut/src/DatePicker/index.tsx index 9bad9002be3..53bcb00c74d 100644 --- a/packages/gamut/src/DatePicker/index.tsx +++ b/packages/gamut/src/DatePicker/index.tsx @@ -3,3 +3,4 @@ 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/types.ts b/packages/gamut/src/DatePicker/types.ts index acbb4dc5a54..2c8469d253c 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -8,7 +8,7 @@ import { import { DatePickerTranslations } from './utils/translations'; interface DatePickerBaseProps - extends Pick { + extends Pick { /** When provided, only the provider is rendered and children compose Input + Calendar. */ children?: React.ReactNode; /** Override default UI strings for internationalization. diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx index f4575439cf9..ccaa364a76f 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.mdx @@ -21,7 +21,7 @@ export const parameters = { ## Overview -`DatePicker` is a **controlled** component: you own the selected value in React state and pass setters back in. It wraps children in `DatePickerProvider` so subcomponents can read calendar open/close, locale, disabled dates, and selection via **`useDatePicker()`**. +`DatePicker` is a **controlled** component: you own the selected value in React state and pass setters back in. It wraps children in `DatePickerProvider` so subcomponents can read calendar open/close, locale, optional `shouldDisableDate`, and selection via **`useDatePicker()`**. With **no `children`**, it renders the default layout: segmented inputs (and a range arrow in range mode), a calendar icon, and a **`PopoverContainer`**-anchored calendar dialog. Pass **`children`** to compose your own shell (for example a different popover target or layout) while still using **`DatePickerInput`**, **`DatePickerCalendar`**, and the same context. @@ -41,12 +41,12 @@ The lower-level **Calendar** building blocks (month grid, header, footer) are do ## Common props -| Area | Props | -| --------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -| **Locale** | **`locale`**: `string` or `Intl.Locale` — drives date format order in the input (e.g. MM/DD/YYYY vs DD/MM/YYYY) and calendar copy. | -| **Constraints** | **`disabledDates`**: individual days that cannot be selected; range selection avoids ranges that include disabled days. | -| **Copy** | **`translations`**: optional overrides (e.g. clear button, dialog `aria-label`, start/end labels) merged with defaults. | -| **Size** | **`inputSize`**: forwarded to the segmented inputs (`small` vs default). | +| Area | Props | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Locale** | **`locale`**: `string` or `Intl.Locale` — drives date format order in the input (e.g. MM/DD/YYYY vs DD/MM/YYYY) and calendar copy. | +| **Constraints** | **`shouldDisableDate`**: predicate per calendar day; use **`matchDisabledDates([...])`** from `@codecademy/gamut` to disable specific days. Range selection avoids ranges that include disabled days. | +| **Copy** | **`translations`**: optional overrides (e.g. clear button, dialog `aria-label`, start/end labels) merged with defaults. | +| **Size** | **`inputSize`**: forwarded to the segmented inputs (`small` vs default). | ## Subcomponents and context @@ -75,6 +75,8 @@ return ( ### Range ```tsx +import { DatePicker, matchDisabledDates } from '@codecademy/gamut'; + const [startDate, setStartDate] = useState(null); const [endDate, setEndDate] = useState(null); @@ -85,7 +87,7 @@ return ( endDate={endDate} setStartDate={setStartDate} setEndDate={setEndDate} - disabledDates={[someHoliday]} + shouldDisableDate={matchDisabledDates([someHoliday])} /> ); ``` diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx index a7b61b74c8f..daa51c0937a 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -3,6 +3,7 @@ import { DatePicker, DatePickerCalendar, DatePickerInput, + matchDisabledDates, PopoverContainer, useDatePicker, } from '@codecademy/gamut'; @@ -58,9 +59,9 @@ export const Range: Story = { return ( (null); const [endDate, setEndDate] = useState(null); + const today = new Date(); + const startOfToday = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + ); return ( d < startOfToday} startDate={startDate} translations={{ startDateLabel: 'Beginning date', @@ -106,10 +114,10 @@ export const RangeSmall: Story = { return ( Date: Wed, 15 Apr 2026 13:06:03 -0400 Subject: [PATCH 080/110] fix icon button tip sticking around --- .../DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx | 3 +++ .../DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx index ffe420070ac..e33a7be4b6f 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx @@ -14,6 +14,7 @@ export const CalendarNavLastMonth: React.FC = ({ }) => { const resolvedLocale = useResolvedLocale(locale); const { lastMonth } = getRelativeMonthLabels(resolvedLocale); + const buttonRef = React.useRef(null); const handleLastMonth = () => { const lastMonth = new Date( @@ -23,6 +24,7 @@ export const CalendarNavLastMonth: React.FC = ({ ); onDisplayDateChange?.(lastMonth); onLastMonthClick?.(); + buttonRef.current?.blur(); }; return ( @@ -30,6 +32,7 @@ export const CalendarNavLastMonth: React.FC = ({ alignSelf="flex-start" aria-label={lastMonth} icon={MiniChevronLeftIcon} + ref={buttonRef} size="small" tip={lastMonth} onClick={handleLastMonth} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx index d7959e18845..39abb8edab0 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx @@ -14,6 +14,7 @@ export const CalendarNavNextMonth: React.FC = ({ }) => { const resolvedLocale = useResolvedLocale(locale); const { nextMonth } = getRelativeMonthLabels(resolvedLocale); + const buttonRef = React.useRef(null); const handleNextMonth = () => { const nextMonth = new Date( @@ -23,6 +24,7 @@ export const CalendarNavNextMonth: React.FC = ({ ); onDisplayDateChange?.(nextMonth); onNextMonthClick?.(); + buttonRef.current?.blur(); }; return ( @@ -30,6 +32,7 @@ export const CalendarNavNextMonth: React.FC = ({ alignSelf="flex-end" aria-label={nextMonth} icon={MiniChevronRightIcon} + ref={buttonRef} size="small" tip={nextMonth} onClick={handleNextMonth} From e8d24351e5460bf66ad12e28dff2ba99e523ecd3 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 15 Apr 2026 16:35:47 -0400 Subject: [PATCH 081/110] discriminated union context --- packages/gamut/src/DatePicker/DatePicker.tsx | 33 +++------ .../DatePickerCalendar/Calendar/types.ts | 40 ++--------- .../DatePicker/DatePickerCalendar/index.tsx | 71 ++++++++----------- .../src/DatePicker/DatePickerContext/types.ts | 28 ++++---- .../src/DatePicker/DatePickerInput/index.tsx | 28 ++++---- packages/gamut/src/DatePicker/sharedTypes.ts | 36 ++++++++++ packages/gamut/src/DatePicker/types.ts | 22 +++--- 7 files changed, 122 insertions(+), 136 deletions(-) create mode 100644 packages/gamut/src/DatePicker/sharedTypes.ts diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 45386f247d3..7447456796c 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -4,7 +4,6 @@ import { useCallback, useId, useMemo, useRef, useState } from 'react'; import { Box, FlexBox } from '../Box'; import { PopoverContainer } from '../PopoverContainer'; import { DatePickerCalendar } from './DatePickerCalendar'; -import { isRangeProps } from './DatePickerCalendar/utils/dateSelect'; import { getDefaultRangeQuickActions, getDefaultSingleQuickActions, @@ -68,23 +67,6 @@ export const DatePicker: React.FC = (props) => { toFocus?.focus(); }, []); - const startOrSelectedDate = isRangeProps(props) - ? props.startDate - : props.selectedDate; - const endDate = isRangeProps(props) ? props.endDate : null; - - const onSelection = useCallback( - (date: Date | null, endDate?: Date | null) => { - if (isRangeProps(props)) { - props.onStartSelected(date); - props.onEndSelected(endDate ?? null); - } else { - props.onSelected(date); - } - }, - [props] - ); - const contextValue = useMemo(() => { const translations = { ...DEFAULT_DATE_PICKER_TRANSLATIONS, @@ -95,8 +77,6 @@ export const DatePicker: React.FC = (props) => { ? getDefaultRangeQuickActions(translations) : getDefaultSingleQuickActions(resolvedLocale); const base = { - startOrSelectedDate, - onSelection, isCalendarOpen, openCalendar, focusCalendar, @@ -114,21 +94,26 @@ export const DatePicker: React.FC = (props) => { ? { ...base, mode: 'range', - endDate, + 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, - startOrSelectedDate, - onSelection, isCalendarOpen, openCalendar, focusCalendar, @@ -138,7 +123,7 @@ export const DatePicker: React.FC = (props) => { closeCalendar, shouldDisableDate, calendarDialogId, - endDate, + props, activeRangePart, ]); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts index 0c9e00c5cbe..06bfded6659 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts @@ -1,32 +1,14 @@ +import type { + CalendarQuickAction, + DatePickerSharedProps, +} from '../../sharedTypes'; import type { IsoWeekday } from '../../utils/locale'; -interface CalendarBaseProps { +interface CalendarBaseProps extends DatePickerSharedProps { /** Used for the currently displayed month and year */ displayDate: Date; /** Called when the displayed month changes. Pass the new date (e.g. setDisplayDate) so the calendar updates. */ onDisplayDateChange: (newDate: Date) => void; - /** - * 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; - /** - * 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() - * ); - * d < startOfCutoff} /> - * ``` - */ - shouldDisableDate?: (date: Date) => boolean; } export interface CalendarNavProps @@ -82,16 +64,6 @@ export interface CalendarBodyProps extends CalendarBaseProps { }; } -export interface QuickAction { - /** 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; -} export interface CalendarFooterProps { clear?: { disabled?: boolean; @@ -99,5 +71,5 @@ export interface CalendarFooterProps { text?: string; }; /** Max 3 quick actions (e.g. "7 days", "1 month") */ - quickActions?: QuickAction[]; + quickActions?: CalendarQuickAction[]; } diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx index 8344e717a0b..a4e13cdb4ce 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -11,13 +11,14 @@ 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, QuickAction } from './Calendar/types'; +import type { CalendarBodyProps } from './Calendar/types'; import { addMonths, getFirstOfMonth } from './Calendar/utils/dateGrid'; import { applyRangeOrNewStart, @@ -60,8 +61,6 @@ export const DatePickerCalendar: React.FC = ({ const { mode, - startOrSelectedDate, - onSelection, shouldDisableDate, locale, closeCalendar, @@ -83,14 +82,14 @@ export const DatePickerCalendar: React.FC = ({ ); const isRange = mode === 'range'; + const selectedDate = isRange ? context.startDate : context.selectedDate; const endDate = isRange ? context.endDate : undefined; const setActiveRangePart = isRange ? context.setActiveRangePart : undefined; - const [displayDate, setDisplayDate] = useState(() => - getFirstOfMonth(startOrSelectedDate ?? new Date()) + getFirstOfMonth(selectedDate ?? new Date()) ); const [focusedDate, setFocusedDate] = useState( - () => startOrSelectedDate ?? endDate ?? new Date() + () => selectedDate ?? endDate ?? new Date() ); const onFocusedDateChange = useCallback( (date: Date | null) => { @@ -99,9 +98,8 @@ export const DatePickerCalendar: React.FC = ({ [setFocusedDate] ); - const focusTarget = - focusedDate ?? startOrSelectedDate ?? endDate ?? new Date(); - const secondMonthDate = addMonths(displayDate, 1); + const focusTarget = focusedDate ?? selectedDate ?? endDate ?? new Date(); + const secondMonthDate = addMonths({ date: displayDate, n: 1 }); const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.xs})`); const wasOpenRef = useRef(false); @@ -111,20 +109,20 @@ export const DatePickerCalendar: React.FC = ({ const justOpened = isCalendarOpen && !wasOpenRef.current; wasOpenRef.current = isCalendarOpen; if (!justOpened) return; - const anchor = startOrSelectedDate ?? endDate; + const anchor = selectedDate ?? endDate; if (anchor) { setDisplayDate(getFirstOfMonth(anchor)); - setFocusedDate(startOrSelectedDate ?? endDate ?? new Date()); + setFocusedDate(selectedDate ?? endDate ?? new Date()); } - }, [isCalendarOpen, startOrSelectedDate, endDate]); + }, [isCalendarOpen, selectedDate, endDate]); const onDateSelect = useCallback( (date: Date) => { if (!isRange) { handleDateSelectSingle({ date, - selectedDate: startOrSelectedDate, - onSelection, + selectedDate: context.selectedDate, + onSelection: context.onSelection, }); // Defer close so React can commit the new date and the input can sync segments // before closeCalendar focuses the spinbutton (which blocks segment sync while "focused"). @@ -135,40 +133,33 @@ export const DatePickerCalendar: React.FC = ({ const shouldClose = handleDateSelectRange({ date, activeRangePart: context.activeRangePart, - startDate: startOrSelectedDate, + startDate: context.startDate, endDate: context.endDate, - onSelection, + onRangeSelection: context.onRangeSelection, shouldDisableDate, }); if (shouldClose) queueMicrotask(closeCalendar); }, - [ - isRange, - setActiveRangePart, - context, - startOrSelectedDate, - onSelection, - shouldDisableDate, - closeCalendar, - ] + [isRange, setActiveRangePart, context, shouldDisableDate, closeCalendar] ); const clearDate = useCallback(() => { - onSelection(null); + if (isRange) context.onRangeSelection(null, null); + else context.onSelection(null); setFocusedDate(displayDate); - }, [onSelection, setFocusedDate, displayDate]); + }, [isRange, context, setFocusedDate, displayDate]); - const computedQuickActions: QuickAction[] = useMemo(() => { + const computedQuickActions: CalendarQuickAction[] = useMemo(() => { return quickActions.slice(0, 3).map((action) => ({ ...action, onClick: () => { action.onClick?.(); setActiveRangePart?.(null); - const { start, end } = computeQuickAction( - action.num, - action.timePeriod, - isRange - ); + const { start, end } = computeQuickAction({ + num: action.num, + timePeriod: action.timePeriod, + isRange, + }); if (isRange) { if ( rangeContainsDisabled({ @@ -182,16 +173,16 @@ export const DatePickerCalendar: React.FC = ({ end, clickedDate: end, shouldDisableDate, - onSelection, + onRangeSelection: context.onRangeSelection, }); } else { - onSelection(start, end); + context.onRangeSelection(start, end); } setDisplayDate(getFirstOfMonth(end)); setFocusedDate(end); queueMicrotask(closeCalendar); } else { - onSelection(start); + context.onSelection(start); setDisplayDate(getFirstOfMonth(start)); setFocusedDate(start); queueMicrotask(closeCalendar); @@ -204,7 +195,7 @@ export const DatePickerCalendar: React.FC = ({ isRange, quickActions, setActiveRangePart, - onSelection, + context, ]); return ( @@ -226,7 +217,7 @@ export const DatePickerCalendar: React.FC = ({ hasAdjacentMonthRight={isTwoMonthsVisible} labelledById={headingId} locale={locale} - selectedDate={startOrSelectedDate} + selectedDate={selectedDate} shouldDisableDate={shouldDisableDate} weekStartsOn={weekStartsOn} onDateSelect={onDateSelect} @@ -251,7 +242,7 @@ export const DatePickerCalendar: React.FC = ({ hasAdjacentMonthLeft={isTwoMonthsVisible} labelledById={headingId} locale={locale} - selectedDate={startOrSelectedDate} + selectedDate={selectedDate} shouldDisableDate={shouldDisableDate} weekStartsOn={weekStartsOn} onDateSelect={onDateSelect} @@ -265,7 +256,7 @@ export const DatePickerCalendar: React.FC = ({ clear={ isRange ? { - disabled: startOrSelectedDate === null && endDate === null, + disabled: context.startDate === null && endDate === null, onClick: clearDate, text: translations.clearText, } diff --git a/packages/gamut/src/DatePicker/DatePickerContext/types.ts b/packages/gamut/src/DatePicker/DatePickerContext/types.ts index e8901f2c697..2388e01a66e 100644 --- a/packages/gamut/src/DatePicker/DatePickerContext/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerContext/types.ts @@ -1,13 +1,11 @@ import { createContext } from 'react'; -import type { - CalendarBodyProps, - QuickAction, -} from '../DatePickerCalendar/Calendar/types'; +import { CalendarQuickAction, DatePickerSharedProps } from '../sharedTypes'; import type { DatePickerTranslations } from '../utils/translations'; -interface DatePickerBaseContextValue - extends Pick { +interface DatePickerBaseContextValue + extends Pick { + mode: Mode; /** * Resolved `Intl.Locale` from the `locale` prop (or runtime default). Same instance passed to * formatters and available for `getWeekInfo()` etc. @@ -31,24 +29,26 @@ interface DatePickerBaseContextValue /** UI string overrides (e.g. clear button). */ translations: Required; /** Calendar footer quick actions (max 3 shown). */ - quickActions: QuickAction[]; - /** Start date (range) or selected date (single). */ - startOrSelectedDate: Date | null; + quickActions: CalendarQuickAction[]; + // /** Start date (range) or selected date (single). */ + // startOrSelectedDate: Date | null; /** Set selection. Single: (date). Range: (start, end). */ - onSelection: (date: Date | null, endDate?: Date | null) => void; + // onSelection: (date: Date | null, endDate?: Date | null) => void; } export interface DatePickerSingleContextValue - extends DatePickerBaseContextValue { - mode: 'single'; + extends DatePickerBaseContextValue<'single'> { + selectedDate: Date | null; + onSelection: (date: Date | null) => void; } type ActiveRangePart = 'start' | 'end' | null; export interface DatePickerRangeContextValue - extends DatePickerBaseContextValue { - mode: 'range'; + extends DatePickerBaseContextValue<'range'> { + startDate: Date | null; endDate: Date | null; + onRangeSelection: (startDate: Date | null, endDate: Date | null) => void; /** Which input is active (start/end focused); null = selection mode. */ activeRangePart: ActiveRangePart; /** Set which input is active (e.g. when input receives focus). */ diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index c3a44991198..08a5391d496 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -57,8 +57,6 @@ export const DatePickerInput = forwardRef( const { mode, - startOrSelectedDate, - onSelection, openCalendar, focusCalendar, locale, @@ -68,6 +66,7 @@ export const DatePickerInput = forwardRef( 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, '')}`; @@ -85,8 +84,7 @@ export const DatePickerInput = forwardRef( ? translations.endDateLabel : translations.startDateLabel; - const boundDate = - isRange && rangePart === 'end' ? endDate : startOrSelectedDate; + const boundDate = isRange && rangePart === 'end' ? endDate : date; const segmentsFromBound = useMemo( () => getDateSegmentsFromDate(boundDate), [boundDate] @@ -136,20 +134,26 @@ export const DatePickerInput = forwardRef( const commitParsedDate = useCallback( (parsed: Date) => { + if (!isRange) { + context.onSelection(parsed); + } if (isRange && rangePart) { - if (rangePart === 'start') onSelection(parsed, endDate); - else onSelection(startOrSelectedDate, parsed); - } else onSelection(parsed); + if (rangePart === 'start') context.onRangeSelection(parsed, endDate); + else context.onRangeSelection(date, parsed); + } }, - [isRange, rangePart, onSelection, endDate, startOrSelectedDate] + [isRange, rangePart, context, endDate, date] ); const clearSelection = useCallback(() => { + if (!isRange) { + context.onSelection(null); + } if (isRange && rangePart) { - if (rangePart === 'start') onSelection(null, endDate); - else onSelection(startOrSelectedDate, null); - } else onSelection(null); - }, [isRange, rangePart, onSelection, endDate, startOrSelectedDate]); + if (rangePart === 'start') context.onRangeSelection(null, endDate); + else context.onRangeSelection(date, null); + } + }, [isRange, rangePart, context, endDate, date]); const onSegmentChange = useCallback( (next: SegmentValues) => { diff --git a/packages/gamut/src/DatePicker/sharedTypes.ts b/packages/gamut/src/DatePicker/sharedTypes.ts new file mode 100644 index 00000000000..4848727a425 --- /dev/null +++ b/packages/gamut/src/DatePicker/sharedTypes.ts @@ -0,0 +1,36 @@ +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() + * ); + * d < startOfCutoff} /> + * ``` + */ + shouldDisableDate?: (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 index 2c8469d253c..368593ea060 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -1,14 +1,12 @@ import { ComponentProps } from 'react'; import { Input } from '../Form/inputs/Input'; -import { - CalendarBodyProps, - QuickAction, -} from './DatePickerCalendar/Calendar/types'; +import { CalendarQuickAction, DatePickerSharedProps } from './sharedTypes'; import { DatePickerTranslations } from './utils/translations'; -interface DatePickerBaseProps - extends Pick { +interface DatePickerBaseProps + extends DatePickerSharedProps { + mode: Mode; /** When provided, only the provider is rendered and children compose Input + Calendar. */ children?: React.ReactNode; /** Override default UI strings for internationalization. @@ -30,6 +28,7 @@ interface DatePickerBaseProps * @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. @@ -37,7 +36,7 @@ interface DatePickerBaseProps * @default for single mode: Yesterday, Today, Tomorrow * for range mode: Last 7 days, Last 30 days, Last 90 days * - * @see {@link QuickAction} for the shape of the quick actions. + * @see {@link CalendarQuickAction} for the shape of the quick actions. * * @example single mode: * ```tsx @@ -60,11 +59,11 @@ interface DatePickerBaseProps * ]} * ``` */ - quickActions?: QuickAction[] | null; + quickActions?: CalendarQuickAction[] | null; } -export interface DatePickerSingleProps extends DatePickerBaseProps { - mode?: 'single'; +export interface DatePickerSingleProps + extends DatePickerBaseProps<'single' | undefined> { /** Controlled selected date. Pass `null` to not have a default selected date. Pass a `Date` to have a default selected date. * * @example @@ -91,8 +90,7 @@ export interface DatePickerSingleProps extends DatePickerBaseProps { onSelected: (date: Date | null) => void; } -export interface DatePickerRangeProps extends DatePickerBaseProps { - mode: 'range'; +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 From 589f1e5176c8d295ff5e583d02cf9b8310ddd0f6 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 15 Apr 2026 16:37:34 -0400 Subject: [PATCH 082/110] change to object syntax --- .../Calendar/CalendarBody.tsx | 32 ++-- .../Calendar/CalendarHeader.tsx | 2 +- .../Calendar/utils/__tests__/dateGrid.test.ts | 49 +++-- .../Calendar/utils/__tests__/format.test.ts | 42 +++-- .../utils/__tests__/keyHandler.test.tsx | 2 +- .../Calendar/utils/dateGrid.ts | 67 ++++--- .../Calendar/utils/format.ts | 44 +++-- .../Calendar/utils/keyHandler.ts | 2 +- .../utils/__tests__/dateSelect.test.ts | 158 ++++++++-------- .../utils/__tests__/quickActions.test.ts | 49 ++++- .../DatePickerCalendar/utils/dateSelect.ts | 69 +++---- .../DatePickerCalendar/utils/quickActions.ts | 49 +++-- .../{segmentUtils.test.ts => utils.test.ts} | 169 +++++++++++++----- .../DatePickerInput/Segment/index.tsx | 12 +- .../DatePickerInput/Segment/utils.ts | 73 +++++--- 15 files changed, 536 insertions(+), 283 deletions(-) rename packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/{segmentUtils.test.ts => utils.test.ts} (71%) diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx index cc1820252d7..412313a1531 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -35,13 +35,17 @@ export const CalendarBody: React.FC = ({ const firstWeekday = useIsoFirstWeekday(resolvedLocale, weekStartsOn); const year = displayDate.getFullYear(); const month = displayDate.getMonth(); - const weeks = getMonthGrid(year, month, firstWeekday); - const weekdayLabels = getWeekdayNames('short', resolvedLocale, firstWeekday); - const weekdayFullNames = getWeekdayNames( - 'long', - resolvedLocale, - firstWeekday - ); + 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); @@ -165,8 +169,13 @@ export const CalendarBody: React.FC = ({ isSameDay(date, selectedDate) || isSameDay(date, endDate); const range = !!selectedDate && !!endDate; const inRange = - range && isDateInRange(date, selectedDate, endDate); - const disabled = isDateDisabled(date, shouldDisableDate); + range && + isDateInRange({ + date, + start: selectedDate, + end: endDate, + }); + const disabled = isDateDisabled({ date, shouldDisableDate }); const today = isToday(date); // this is making the selected date a differnet color bc it is focused, look into further const isFocused = @@ -176,7 +185,10 @@ export const CalendarBody: React.FC = ({ = ({ id={headingId} textAlign="center" > - {formatMonthYear(displayDate, resolvedLocale)} + {formatMonthYear({ date: displayDate, locale: resolvedLocale })} {!hideNextNav && ( 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 index a943a2dcd1b..a320d7e59c9 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts @@ -11,24 +11,26 @@ import { describe('getWeekdayOffsetInGrid', () => { it('returns 0 when the 1st matches the grid first weekday (Monday)', () => { const first = new Date(2024, 0, 1); - expect(getWeekdayOffsetInGrid(first, 1)).toBe(0); + 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(first, 7)).toBeGreaterThan(0); + expect( + getWeekdayOffsetInGrid({ date: first, firstWeekday: 7 }) + ).toBeGreaterThan(0); }); }); describe('getMonthGrid', () => { it('includes exactly the number of days in the month', () => { - const weeks = getMonthGrid(2024, 2, 1); + 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(2024, 2, 1); + const weeks = getMonthGrid({ year: 2024, month: 2, firstWeekday: 1 }); weeks.forEach((row) => { expect(row).toHaveLength(7); }); @@ -53,17 +55,23 @@ describe('isDateInRange', () => { const end = new Date(2024, 2, 20); it('returns true strictly between start and end', () => { - expect(isDateInRange(new Date(2024, 2, 15), start, end)).toBe(true); + expect(isDateInRange({ date: new Date(2024, 2, 15), start, end })).toBe( + true + ); }); it('returns false on start, end, or outside', () => { - expect(isDateInRange(start, start, end)).toBe(false); - expect(isDateInRange(end, start, end)).toBe(false); - expect(isDateInRange(new Date(2024, 2, 5), start, end)).toBe(false); + expect(isDateInRange({ date: start, start, end })).toBe(false); + expect(isDateInRange({ date: end, start, end })).toBe(false); + expect(isDateInRange({ date: new Date(2024, 2, 5), start, end })).toBe( + false + ); }); it('returns false when start is null', () => { - expect(isDateInRange(new Date(2024, 2, 15), null, end)).toBe(false); + expect( + isDateInRange({ date: new Date(2024, 2, 15), start: null, end }) + ).toBe(false); }); }); @@ -84,21 +92,34 @@ describe('matchDisabledDates', () => { describe('isDateDisabled', () => { it('returns true when shouldDisableDate returns true', () => { - expect(isDateDisabled(new Date(2024, 4, 10), () => true)).toBe(true); expect( - isDateDisabled(new Date(2024, 4, 10), (d) => d.getDate() === 10) + isDateDisabled({ + date: new Date(2024, 4, 10), + shouldDisableDate: () => true, + }) + ).toBe(true); + expect( + isDateDisabled({ + date: new Date(2024, 4, 10), + shouldDisableDate: (d) => d.getDate() === 10, + }) ).toBe(true); }); it('returns false when shouldDisableDate is omitted or returns false', () => { - expect(isDateDisabled(new Date(2024, 4, 10))).toBe(false); - expect(isDateDisabled(new Date(2024, 4, 10), () => false)).toBe(false); + expect(isDateDisabled({ date: new Date(2024, 4, 10) })).toBe(false); + expect( + isDateDisabled({ + date: new Date(2024, 4, 10), + shouldDisableDate: () => false, + }) + ).toBe(false); }); }); describe('getDatesWithRow', () => { it('lists only non-null dates with row indices', () => { - const weeks = getMonthGrid(2024, 0, 1); + const weeks = getMonthGrid({ year: 2024, month: 0, firstWeekday: 1 }); const withRow = getDatesWithRow(weeks); expect(withRow.length).toBe(31); expect(withRow[0].rowIndex).toBe(0); 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 index 3a452f150af..3fe4353b50a 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/format.test.ts @@ -12,23 +12,23 @@ const frFR = new Intl.Locale('fr-FR'); describe('capitalizeFirst', () => { it('uppercases the first character per locale', () => { - expect(capitalizeFirst('hello', enUS)).toBe('Hello'); + expect(capitalizeFirst({ str: 'hello', locale: enUS })).toBe('Hello'); }); it('returns empty string unchanged', () => { - expect(capitalizeFirst('', enUS)).toBe(''); + expect(capitalizeFirst({ str: '', locale: enUS })).toBe(''); }); }); describe('formatMonthYear', () => { it('formats month in long format and year in numeric format', () => { - const text = formatMonthYear(new Date(2026, 0, 15), enUS); + 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(new Date(2026, 0, 15), frFR); + const text = formatMonthYear({ date: new Date(2026, 0, 15), locale: frFR }); expect(text).toMatch(/2026/); expect(text.toLowerCase()).toContain('janvier'); }); @@ -36,13 +36,21 @@ describe('formatMonthYear', () => { describe('getWeekdayNames', () => { it('returns short weekday names when format is short', () => { - const short = getWeekdayNames('short', enUS, 1); + 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('long', enUS, 7); + const long = getWeekdayNames({ + format: 'long', + locale: enUS, + firstWeekday: 7, + }); expect(long).toHaveLength(7); expect(long).toEqual([ 'Sunday', @@ -56,7 +64,11 @@ describe('getWeekdayNames', () => { }); it('returns the correct weekday name order for the given firstWeekday', () => { - const short = getWeekdayNames('short', frFR, 1); + const short = getWeekdayNames({ + format: 'short', + locale: frFR, + firstWeekday: 1, + }); expect(short).toEqual([ 'lun.', 'mar.', @@ -66,7 +78,11 @@ describe('getWeekdayNames', () => { 'sam.', 'dim.', ]); - const long = getWeekdayNames('long', frFR, 7); + const long = getWeekdayNames({ + format: 'long', + locale: frFR, + firstWeekday: 7, + }); expect(long).toEqual([ 'dimanche', 'lundi', @@ -105,14 +121,20 @@ describe('getRelativeTodayLabel', () => { 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(new Date(2026, 1, 14), enUS); + 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(new Date(2026, 1, 14), frFR); + 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 index 5b981d27882..8ddc8f18793 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx @@ -21,7 +21,7 @@ const mockOnDisplayDateChange = jest.fn(); const year = 2024; const month = 2; const firstWeekday = 1 as const; -const weeks = getMonthGrid(year, month, firstWeekday); +const weeks = getMonthGrid({ year, month, firstWeekday }); const datesWithRow = getDatesWithRow(weeks); const midIdx = Math.floor(datesWithRow.length / 2); const { date } = datesWithRow[midIdx]; diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts index f76832ddb7e..58e34cdee99 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts @@ -22,10 +22,13 @@ const normalizeDate = (date: Date) => { * 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: Date, - firstWeekday: IsoWeekday -) => { +export const getWeekdayOffsetInGrid = ({ + date, + firstWeekday, +}: { + date: Date; + firstWeekday: IsoWeekday; +}) => { const js = date.getDay(); const iso = js === 0 ? 7 : js; return (iso - firstWeekday + 14) % 7; @@ -43,14 +46,18 @@ export const getFirstOfMonth = (date: Date) => { * @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: number, - month: number, - firstWeekday: IsoWeekday -) => { +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(first, firstWeekday); + const firstDayOfWeek = getWeekdayOffsetInGrid({ date: first, firstWeekday }); const daysInMonth = last.getDate(); const weeks: (Date | null)[][] = []; @@ -91,7 +98,13 @@ export const isSameDay = (a: Date | null, b: Date | null) => { * Calendar-ordered local-midnight instants for two possibly unordered `Date` values. * Matches the bounds used by {@link isDateInRange} (and range selection). */ -export const getOrderedCalendarEndpoints = (start: Date, end: Date) => { +export const getOrderedCalendarEndpoints = ({ + start, + end, +}: { + start: Date; + end: Date; +}) => { const startDate = new Date( start.getFullYear(), start.getMonth(), @@ -106,14 +119,21 @@ export const getOrderedCalendarEndpoints = (start: Date, end: Date) => { /** * Check if `date` is between `start` and `end` (exclusive), ignoring time. */ -export const isDateInRange = ( - date: Date, - start: Date | null, - end: Date | null -) => { +export const isDateInRange = ({ + date, + start, + end, +}: { + date: Date; + start: Date | null; + end: Date | null; +}) => { if (start === null) return false; const endBound = end ?? start; - const { low, high } = getOrderedCalendarEndpoints(start, endBound); + const { low, high } = getOrderedCalendarEndpoints({ + start, + end: endBound, + }); const normalizedDate = normalizeDate(date); return ( normalizedDate > normalizeDate(low) && normalizedDate < normalizeDate(high) @@ -134,10 +154,13 @@ export const matchDisabledDates = dates.some((d) => isSameDay(date, d)); /** True when `shouldDisableDate` returns true for this calendar day. */ -export const isDateDisabled = ( - date: Date, - shouldDisableDate?: (date: Date) => boolean -) => Boolean(shouldDisableDate?.(date)); +export const isDateDisabled = ({ + date, + shouldDisableDate, +}: { + date: Date; + shouldDisableDate?: (date: Date) => boolean; +}) => Boolean(shouldDisableDate?.(date)); /** One visible day in the month grid with its row (for Home/End and keyboard nav). */ export type DateWithRow = { date: Date; rowIndex: number }; @@ -154,5 +177,5 @@ export const getDatesWithRow = (weeks: (Date | null)[][]) => { }; /** Add `n` months to the given date. */ -export const addMonths = (date: Date, n: number) => +export const addMonths = ({ date, n }: { date: Date; n: number }) => new Date(date.getFullYear(), date.getMonth() + n, 1); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts index 74abd492935..414a565ba3e 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/format.ts @@ -4,7 +4,13 @@ import { stringifyLocale } from '../../../utils/locale'; /** * Capitalize the first character of a string using the locale; rest unchanged (e.g. "next month" → "Next month"). */ -export const capitalizeFirst = (str: string, locale: Intl.Locale) => +export const capitalizeFirst = ({ + str, + locale, +}: { + str: string; + locale: Intl.Locale; +}) => str.length === 0 ? str : str[0].toLocaleUpperCase(stringifyLocale(locale)) + str.slice(1); @@ -12,7 +18,13 @@ export const capitalizeFirst = (str: string, locale: Intl.Locale) => /** * Format month and year for the calendar header (e.g. "February 2026"). */ -export const formatMonthYear = (date: Date, locale: Intl.Locale) => { +export const formatMonthYear = ({ + date, + locale, +}: { + date: Date; + locale: Intl.Locale; +}) => { return new Intl.DateTimeFormat(stringifyLocale(locale), { month: 'long', year: 'numeric', @@ -24,11 +36,15 @@ export const formatMonthYear = (date: Date, locale: Intl.Locale) => { * 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: 'short' | 'long', - locale: Intl.Locale, - firstWeekday: IsoWeekday -) => { +export const getWeekdayNames = ({ + format, + locale, + firstWeekday, +}: { + format: 'short' | 'long'; + locale: Intl.Locale; + firstWeekday: IsoWeekday; +}) => { const formatter = new Intl.DateTimeFormat(stringifyLocale(locale), { weekday: format, }); @@ -53,8 +69,8 @@ export const getRelativeMonthLabels = (locale: Intl.Locale) => { numeric: 'auto', }); return { - nextMonth: capitalizeFirst(rtf.format(1, 'month'), locale), - lastMonth: capitalizeFirst(rtf.format(-1, 'month'), locale), + nextMonth: capitalizeFirst({ str: rtf.format(1, 'month'), locale }), + lastMonth: capitalizeFirst({ str: rtf.format(-1, 'month'), locale }), }; }; @@ -65,10 +81,16 @@ export const getRelativeTodayLabel = (locale: Intl.Locale) => { const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { numeric: 'auto', }); - return capitalizeFirst(rtf.format(0, 'day'), locale); + return capitalizeFirst({ str: rtf.format(0, 'day'), locale }); }; -export const formatDateForAriaLabel = (date: Date, locale: Intl.Locale) => { +export const formatDateForAriaLabel = ({ + date, + locale, +}: { + date: Date; + locale: Intl.Locale; +}) => { return new Intl.DateTimeFormat(stringifyLocale(locale), { month: 'long', day: 'numeric', diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts index be58eba1a50..63a6bfde707 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/keyHandler.ts @@ -141,7 +141,7 @@ export const keyHandler = ({ case 'Enter': case ' ': e.preventDefault(); - if (!isDateDisabled(date, shouldDisableDate)) onDateSelect(date); + if (!isDateDisabled({ date, shouldDisableDate })) onDateSelect(date); return; case 'Escape': e.preventDefault(); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts index 691a855cc20..45502e9db62 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/dateSelect.test.ts @@ -10,6 +10,7 @@ const createDate = (y: number, month: number, day: number) => new Date(y, month, day); const mockOnSelection = jest.fn(); +const mockOnRangeSelection = jest.fn(); describe('rangeContainsDisabled', () => { const start = createDate(2024, 0, 10); const end = createDate(2024, 0, 20); @@ -111,10 +112,10 @@ describe('applyRangeOrNewStart', () => { end, clickedDate: clicked, shouldDisableDate: matchDisabledDates([createDate(2024, 5, 30)]), - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }) ).toBe(true); - expect(mockOnSelection).toHaveBeenCalledWith(start, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, end); }); it('sets selection to the clicked date as start and null as end when the range contains a disabled date', () => { @@ -127,10 +128,10 @@ describe('applyRangeOrNewStart', () => { end, clickedDate: clicked, shouldDisableDate: matchDisabledDates([createDate(2024, 5, 12)]), - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }) ).toBe(false); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); }); @@ -143,27 +144,26 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: null, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }) ).toBe(false); - expect(mockOnSelection).toHaveBeenCalledWith( + expect(mockOnRangeSelection).toHaveBeenCalledWith( createDate(2024, 5, 10), null ); }); it('returns true when end date is chosen after start (calendar mode)', () => { - const setSelection = jest.fn(); expect( handleDateSelectRange({ date: createDate(2024, 5, 20), activeRangePart: null, startDate: createDate(2024, 5, 10), endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }) ).toBe(true); - expect(mockOnSelection).toHaveBeenCalledWith( + expect(mockOnRangeSelection).toHaveBeenCalledWith( createDate(2024, 5, 10), createDate(2024, 5, 20) ); @@ -180,9 +180,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(null, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, end); }); it('sets start date when no end date is set', () => { @@ -193,9 +193,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and clears end date when new start is after end', () => { @@ -207,9 +207,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and keeps end date when new start is before end', () => { @@ -221,9 +221,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); }); it('sets start date and keeps end date when new start is the same as end', () => { @@ -235,9 +235,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { @@ -249,10 +249,10 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); }); describe('start date is not set', () => { @@ -263,9 +263,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and clears end date when new start is after end', () => { @@ -276,9 +276,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and keeps end date when new start is before end', () => { @@ -289,9 +289,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); }); it('sets start date and keeps end date when new start is the same as end', () => { @@ -302,9 +302,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { @@ -315,10 +315,10 @@ describe('handleDateSelectRange', () => { activeRangePart: 'start', startDate: null, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); }); }); @@ -333,9 +333,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(start, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, null); }); it('sets end date when no start date is set', () => { @@ -346,9 +346,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: null, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(null, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and clears start date when new end is before start', () => { @@ -360,9 +360,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(null, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and keeps start date when new end is after start', () => { @@ -374,9 +374,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); }); it('sets end date and keeps start date when new end is the same as start', () => { @@ -388,9 +388,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { @@ -402,10 +402,10 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); }); describe('end date is not set', () => { @@ -416,9 +416,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: null, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(null, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and clears start date when new end is before start', () => { @@ -429,9 +429,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(null, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and keeps start date when new end is after start', () => { @@ -442,9 +442,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); }); it('sets end date and keeps start date when new end is the same as start', () => { @@ -455,9 +455,9 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { @@ -468,10 +468,10 @@ describe('handleDateSelectRange', () => { activeRangePart: 'end', startDate: start, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); }); }); @@ -484,9 +484,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: day, endDate: day, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(null, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, null); }); it('end date becomes start date when start date is clicked', () => { @@ -497,9 +497,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(end, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(end, null); }); it('clears end date when end date is clicked', () => { @@ -510,9 +510,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(start, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, null); }); it('updates end date when a date after start date is clicked', () => { @@ -524,9 +524,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); }); it('updates start date to the clicked date and clears end date when the range extending to right would contain a disabled date', () => { @@ -538,10 +538,10 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('updates start date when a date before start date is clicked', () => { @@ -553,9 +553,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); }); it('updates start date to the clicked date and clears end date when the range extending to left would contain a disabled date', () => { @@ -567,10 +567,10 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); }); @@ -583,9 +583,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets end date when clicked date is on or after start date', () => { @@ -596,9 +596,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); }); it('updates start date to the clicked date and does not set end date when the range would contain a disabled date', () => { @@ -609,10 +609,10 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: start, endDate: null, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); }); @@ -625,9 +625,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: null, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and clears end date when clicked date is after end date', () => { @@ -638,9 +638,9 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: null, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + 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', () => { @@ -651,10 +651,10 @@ describe('handleDateSelectRange', () => { activeRangePart: null, startDate: null, endDate: end, - onSelection: mockOnSelection, + onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); - expect(mockOnSelection).toHaveBeenCalledWith(clicked, null); + 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 index 9dbecf15f57..166b2cacb8f 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts @@ -5,19 +5,34 @@ describe('computeQuickAction', () => { describe('range mode (isRange: true)', () => { it('returns [start, end] with end = anchor day when range is entirely in the past', () => { - const { start, end } = computeQuickAction(-30, 'day', true, fixed); + const { start, end } = computeQuickAction({ + num: -30, + timePeriod: 'day', + isRange: true, + now: fixed, + }); expect(end).toEqual(new Date(2026, 4, 15)); expect(start).toEqual(new Date(2026, 3, 15)); }); it('applies month as num * 30 rolling days', () => { - const { start, end } = computeQuickAction(-1, 'month', true, fixed); + const { start, end } = computeQuickAction({ + num: -1, + timePeriod: 'month', + isRange: true, + now: fixed, + }); expect(end).toEqual(new Date(2026, 4, 15)); expect(start).toEqual(new Date(2026, 3, 15)); }); it('applies multiple months as additional 30-day steps', () => { - const { start, end } = computeQuickAction(-2, 'month', true, fixed); + const { start, end } = computeQuickAction({ + num: -2, + timePeriod: 'month', + isRange: true, + now: fixed, + }); expect(end).toEqual(new Date(2026, 4, 15)); const expected = new Date(2026, 4, 15); expected.setDate(expected.getDate() - 60); @@ -25,7 +40,12 @@ describe('computeQuickAction', () => { }); it('applies year as num * 365 rolling days', () => { - const { start, end } = computeQuickAction(-1, 'year', true, fixed); + const { start, end } = computeQuickAction({ + num: -1, + timePeriod: 'year', + isRange: true, + now: fixed, + }); expect(end).toEqual(new Date(2026, 4, 15)); const expected = new Date(2026, 4, 15); expected.setDate(expected.getDate() - 365); @@ -33,7 +53,12 @@ describe('computeQuickAction', () => { }); it('when start is after anchor day, orders as [anchor, start] so the range is forward', () => { - const { start, end } = computeQuickAction(5, 'day', true, fixed); + const { start, end } = computeQuickAction({ + num: 5, + timePeriod: 'day', + isRange: true, + now: fixed, + }); expect(start).toEqual(new Date(2026, 4, 15)); expect(end).toEqual(new Date(2026, 4, 20)); }); @@ -41,13 +66,23 @@ describe('computeQuickAction', () => { describe('single mode (isRange: false)', () => { it('does not swap when start is after anchor; end is still the anchor day', () => { - const { start, end } = computeQuickAction(1, 'day', false, fixed); + const { start, end } = computeQuickAction({ + num: 1, + timePeriod: 'day', + isRange: false, + now: fixed, + }); expect(end).toEqual(new Date(2026, 4, 15)); expect(start).toEqual(new Date(2026, 4, 16)); }); it('returns past start with end = anchor for yesterday', () => { - const { start, end } = computeQuickAction(-1, 'day', false, fixed); + const { start, end } = computeQuickAction({ + num: -1, + timePeriod: 'day', + isRange: false, + now: fixed, + }); expect(end).toEqual(new Date(2026, 4, 15)); expect(start).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 index ed5bfd24bb3..9e4ba2b1bca 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts @@ -2,6 +2,7 @@ import type { DatePickerRangeContextValue, DatePickerSingleContextValue, } from '../../DatePickerContext/types'; +import { DatePickerSharedProps } from '../../sharedTypes'; import type { DatePickerProps, DatePickerRangeProps } from '../../types'; import { getOrderedCalendarEndpoints, @@ -13,11 +14,10 @@ export const isRangeProps = ( props: DatePickerProps ): props is DatePickerRangeProps => props.mode === 'range'; -export type RangeContainsDisabledParams = { +type RangeContainsDisabledParams = { start: Date; end: Date; - shouldDisableDate?: (date: Date) => boolean; -}; +} & Pick; /** True if any disabled day falls within [start, end] (inclusive, by calendar day). */ export const rangeContainsDisabled = ({ @@ -25,18 +25,18 @@ export const rangeContainsDisabled = ({ end, shouldDisableDate, }: RangeContainsDisabledParams) => { - const { low, high } = getOrderedCalendarEndpoints(start, end); + const { low, high } = getOrderedCalendarEndpoints({ start, end }); if ( - isDateDisabled(low, shouldDisableDate) || - isDateDisabled(high, shouldDisableDate) + isDateDisabled({ date: low, shouldDisableDate }) || + isDateDisabled({ date: high, shouldDisableDate }) ) { return true; } let date = new Date(low.getFullYear(), low.getMonth(), low.getDate() + 1); - while (isDateInRange(date, start, end)) { - if (isDateDisabled(date, shouldDisableDate)) { + while (isDateInRange({ date, start, end })) { + if (isDateDisabled({ date, shouldDisableDate })) { return true; } date = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1); @@ -44,10 +44,9 @@ export const rangeContainsDisabled = ({ return false; }; -export type HandleDateSelectSingleParams = { +type HandleDateSelectSingleParams = { date: Date; - selectedDate: DatePickerSingleContextValue['startOrSelectedDate']; -} & Pick; +} & Pick; export const handleDateSelectSingle = ({ date, @@ -67,8 +66,7 @@ type ApplyRangeOrNewStartParams = { start: Date; end: Date; clickedDate: Date; - shouldDisableDate?: (date: Date) => boolean; -} & Pick; +} & Pick; /** @returns whether a full start+end range was committed (calendar may close). */ export const applyRangeOrNewStart = ({ @@ -76,23 +74,26 @@ export const applyRangeOrNewStart = ({ end, clickedDate, shouldDisableDate, - onSelection, + onRangeSelection, }: ApplyRangeOrNewStartParams) => { // if range contains disabled dates, set start date to clicked date and end date to null if (rangeContainsDisabled({ start, end, shouldDisableDate })) { - onSelection(clickedDate, null); + onRangeSelection(clickedDate, null); return false; } - onSelection(start, end); + onRangeSelection(start, end); return true; }; -export type HandleDateSelectRangeParams = { +type HandleDateSelectRangeParams = { date: Date; - startDate: DatePickerRangeContextValue['startOrSelectedDate']; } & Pick< DatePickerRangeContextValue, - 'activeRangePart' | 'endDate' | 'onSelection' | 'shouldDisableDate' + | 'activeRangePart' + | 'endDate' + | 'onRangeSelection' + | 'shouldDisableDate' + | 'startDate' >; /** @returns whether the calendar should close (full range selected and committed). */ @@ -101,13 +102,13 @@ export const handleDateSelectRange = ({ activeRangePart, startDate, endDate, - onSelection, + onRangeSelection, shouldDisableDate, }: HandleDateSelectRangeParams) => { // Range mode: field targeting (start or end input was focused) if (activeRangePart === 'start') { if (date.getTime() === startDate?.getTime()) { - onSelection(null, endDate); + onRangeSelection(null, endDate); return false; } const newEnd = @@ -118,15 +119,15 @@ export const handleDateSelectRange = ({ end: newEnd, clickedDate: date, shouldDisableDate, - onSelection, + onRangeSelection, }); } - onSelection(date, newEnd); + onRangeSelection(date, newEnd); return false; } if (activeRangePart === 'end') { if (date.getTime() === endDate?.getTime()) { - onSelection(startDate, null); + onRangeSelection(startDate, null); return false; } const newStart = @@ -139,10 +140,10 @@ export const handleDateSelectRange = ({ end: date, clickedDate: date, shouldDisableDate, - onSelection, + onRangeSelection, }); } - onSelection(newStart, date); + onRangeSelection(newStart, date); return false; } @@ -153,17 +154,17 @@ export const handleDateSelectRange = ({ startDate.getTime() === endDate.getTime() && date.getTime() === startDate.getTime() ) { - onSelection(null, null); + onRangeSelection(null, null); return false; } // if clicked on start date, end date becomes start date if (date.getTime() === startDate.getTime()) { - onSelection(endDate, null); + onRangeSelection(endDate, null); return false; } // if clicked on end date, clears end date and start remains if (date.getTime() === endDate.getTime()) { - onSelection(startDate, null); + onRangeSelection(startDate, null); return false; } // If clicked date > Start: Updates End Date to new date (Start remains) @@ -173,7 +174,7 @@ export const handleDateSelectRange = ({ end: date, clickedDate: date, shouldDisableDate, - onSelection, + onRangeSelection, }); } // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left @@ -182,14 +183,14 @@ export const handleDateSelectRange = ({ end: endDate, clickedDate: date, shouldDisableDate, - onSelection, + 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()) { - onSelection(date, null); + onRangeSelection(date, null); return false; } // If clicked date > Start: Sets it as End Date (if range valid) @@ -198,9 +199,9 @@ export const handleDateSelectRange = ({ end: date, clickedDate: date, shouldDisableDate, - onSelection, + onRangeSelection, }); } - onSelection(date, null); + 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 index c7303b66922..30e947d16fb 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts @@ -1,42 +1,50 @@ +import { CalendarQuickAction } from '../../sharedTypes'; import { stringifyLocale } from '../../utils/locale'; import { DatePickerTranslations } from '../../utils/translations'; -import { QuickAction } from '../Calendar/types'; import { capitalizeFirst } from '../Calendar/utils/format'; -const getRelativeDisplayText = ( - num: number, - timePeriod: QuickAction['timePeriod'], - locale: Intl.Locale -) => { +const getRelativeDisplayText = ({ + num, + timePeriod, + locale, +}: { + num: number; + timePeriod: CalendarQuickAction['timePeriod']; + locale: Intl.Locale; +}) => { const rtf = new Intl.RelativeTimeFormat(stringifyLocale(locale), { numeric: 'auto', }); - return capitalizeFirst(rtf.format(num, timePeriod), locale); + return capitalizeFirst({ str: rtf.format(num, timePeriod), locale }); }; export const getDefaultSingleQuickActions = ( locale: Intl.Locale -): QuickAction[] => [ +): CalendarQuickAction[] => [ { num: -1, timePeriod: 'day', - displayText: getRelativeDisplayText(-1, 'day', locale), + displayText: getRelativeDisplayText({ + num: -1, + timePeriod: 'day', + locale, + }), }, { num: 0, timePeriod: 'day', - displayText: getRelativeDisplayText(0, 'day', locale), + displayText: getRelativeDisplayText({ num: 0, timePeriod: 'day', locale }), }, { num: 1, timePeriod: 'day', - displayText: getRelativeDisplayText(1, 'day', locale), + displayText: getRelativeDisplayText({ num: 1, timePeriod: 'day', locale }), }, ]; export const getDefaultRangeQuickActions = ( translations: Required -): QuickAction[] => [ +): CalendarQuickAction[] => [ { num: -7, timePeriod: 'day', @@ -71,12 +79,17 @@ export const getDefaultRangeQuickActions = ( * * **Single mode:** same return shape; the calendar uses `start` as the selected day. */ -export const computeQuickAction = ( - num: number, - timePeriod: QuickAction['timePeriod'], - isRange: boolean, - now = new Date() -) => { +export const computeQuickAction = ({ + num, + timePeriod, + isRange, + now = new Date(), +}: { + num: number; + timePeriod: CalendarQuickAction['timePeriod']; + isRange: boolean; + now?: Date; +}) => { const date = new Date(now.getFullYear(), now.getMonth(), now.getDate()); let start: Date; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts similarity index 71% rename from packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts rename to packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts index aad9ec78dd0..49d1da3376f 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/segmentUtils.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/utils.test.ts @@ -189,28 +189,43 @@ describe('normalizeSegmentValues', () => { describe('getSegmentSpinBounds', () => { it('bounds month to 1-12', () => { expect( - getSegmentSpinBounds('month', { month: '', day: '', year: '' }) + getSegmentSpinBounds({ + field: 'month', + segments: { month: '', day: '', year: '' }, + }) ).toEqual({ min: 1, max: 12 }); }); it('bounds year to 1-9999', () => { expect( - getSegmentSpinBounds('year', { month: '', day: '', year: '' }) + getSegmentSpinBounds({ + field: 'year', + segments: { month: '', day: '', year: '' }, + }) ).toEqual({ min: 1, max: 9999 }); }); it('bounds day using parsed month and four-digit year', () => { expect( - getSegmentSpinBounds('day', { month: '02', year: '2024', day: '' }) + getSegmentSpinBounds({ + field: 'day', + segments: { month: '02', year: '2024', day: '' }, + }) ).toEqual({ min: 1, max: 29 }); expect( - getSegmentSpinBounds('day', { month: '02', year: '2023', day: '' }) + 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('day', { month: '02', year: '20', day: '' }) + getSegmentSpinBounds({ + field: 'day', + segments: { month: '02', year: '20', day: '' }, + }) ).toEqual({ min: 1, max: 29 }); }); }); @@ -229,34 +244,46 @@ describe('parseSegmentNumericString', () => { describe('padSegmentNumber', () => { it('pads year to four digits', () => { - expect(padSegmentNumber('year', 123)).toBe('0123'); + expect(padSegmentNumber({ field: 'year', numericValue: 123 })).toBe('0123'); }); it('pads month to two digits', () => { - expect(padSegmentNumber('month', 3)).toBe('03'); + expect(padSegmentNumber({ field: 'month', numericValue: 3 })).toBe('03'); }); it('pads day to two digits', () => { - expect(padSegmentNumber('day', 1)).toBe('01'); + expect(padSegmentNumber({ field: 'day', numericValue: 1 })).toBe('01'); }); }); describe('appendSegmentDigit', () => { it('ignores non-digit characters', () => { - expect(appendSegmentDigit('month', '01', 'x')).toBe('01'); + expect(appendSegmentDigit({ field: 'month', prev: '01', digit: 'x' })).toBe( + '01' + ); }); it('appends until max length', () => { - expect(appendSegmentDigit('month', '', '1')).toBe('1'); - expect(appendSegmentDigit('month', '1', '2')).toBe('12'); + 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('month', '12', '5')).toBe('5'); - expect(appendSegmentDigit('year', '2024', '9')).toBe('9'); + 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('day', '1a', '2')).toBe('12'); + expect(appendSegmentDigit({ field: 'day', prev: '1a', digit: '2' })).toBe( + '12' + ); }); }); @@ -272,49 +299,89 @@ describe('spinSegment', () => { }); it('uses current calendar year when stepping up', () => { - expect(spinSegment('year', empty, 1)).toBe('2024'); + expect(spinSegment({ field: 'year', segments: empty, delta: 1 })).toBe( + '2024' + ); }); it('uses max year when stepping down', () => { - expect(spinSegment('year', empty, -1)).toBe('9999'); + expect(spinSegment({ field: 'year', segments: empty, delta: -1 })).toBe( + '9999' + ); }); it('steps month up from empty to min', () => { - expect(spinSegment('month', { month: '', day: '', year: '' }, 1)).toBe( - '01' - ); + expect( + spinSegment({ + field: 'month', + segments: { month: '', day: '', year: '' }, + delta: 1, + }) + ).toBe('01'); }); it('steps month down from empty to max', () => { - expect(spinSegment('month', { month: '', day: '', year: '' }, -1)).toBe( - '12' - ); + expect( + spinSegment({ + field: 'month', + segments: { month: '', day: '', year: '' }, + delta: -1, + }) + ).toBe('12'); }); it('steps day up from empty to min', () => { - expect(spinSegment('day', { month: '', day: '', year: '' }, 1)).toBe('01'); + expect( + spinSegment({ + field: 'day', + segments: { month: '', day: '', year: '' }, + delta: 1, + }) + ).toBe('01'); }); it('steps day down from empty to max', () => { - expect(spinSegment('day', { month: '', day: '', year: '' }, -1)).toBe('31'); + expect( + spinSegment({ + field: 'day', + segments: { month: '', day: '', year: '' }, + delta: -1, + }) + ).toBe('31'); }); it('increments within bounds', () => { - expect(spinSegment('month', { month: '06', day: '', year: '' }, 1)).toBe( - '07' - ); - expect(spinSegment('month', { month: '12', day: '', year: '' }, 1)).toBe( - '12' - ); + 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('month', { month: '06', day: '', year: '' }, -1)).toBe( - '05' - ); - expect(spinSegment('month', { month: '01', day: '', year: '' }, -1)).toBe( - '01' - ); + 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'); }); }); @@ -329,10 +396,10 @@ describe('buildCombinedFromSegments', () => { ]; expect( - buildCombinedFromSegments( - { month: '03', day: '15', year: '2024' }, - usLayout - ) + buildCombinedFromSegments({ + segments: { month: '03', day: '15', year: '2024' }, + layout: usLayout, + }) ).toBe('03/15/2024'); }); @@ -346,17 +413,22 @@ describe('buildCombinedFromSegments', () => { ]; expect( - buildCombinedFromSegments( - { month: '03', day: '15', year: '2024' }, - ukLayout - ) + 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('03152024', ['month', 'day', 'year'])).toEqual({ + expect( + digitsToSegments({ + digits: '03152024', + fieldOrder: ['month', 'day', 'year'], + }) + ).toEqual({ month: '03', day: '15', year: '2024', @@ -364,7 +436,12 @@ describe('digitsToSegments', () => { }); it('splits digit string by field order (DMY)', () => { - expect(digitsToSegments('15032024', ['day', 'month', 'year'])).toEqual({ + expect( + digitsToSegments({ + digits: '15032024', + fieldOrder: ['day', 'month', 'year'], + }) + ).toEqual({ month: '03', day: '15', year: '2024', diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx index 78c42b7b0f6..45fda155f21 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx @@ -50,7 +50,7 @@ export const DatePickerInputSegment: React.FC = ({ nextField, applySegments, }) => { - const { min, max } = getSegmentSpinBounds(field, segments); + const { min, max } = getSegmentSpinBounds({ field, segments }); const numericValue = parseSegmentNumericString(segments[field]); const ariaValue = segments[field].length > 0 && numericValue != null @@ -93,7 +93,7 @@ export const DatePickerInputSegment: React.FC = ({ setSegments((prev) => { const next = { ...prev, - [field]: spinSegment(field, prev, 1), + [field]: spinSegment({ field, segments: prev, delta: 1 }), }; applySegments(next); return next; @@ -106,7 +106,7 @@ export const DatePickerInputSegment: React.FC = ({ setSegments((prev) => { const next = { ...prev, - [field]: spinSegment(field, prev, -1), + [field]: spinSegment({ field, segments: prev, delta: -1 }), }; applySegments(next); return next; @@ -139,7 +139,11 @@ export const DatePickerInputSegment: React.FC = ({ setSegments((prev) => { const next = { ...prev, - [field]: appendSegmentDigit(field, prev[field], e.key), + [field]: appendSegmentDigit({ + field, + prev: prev[field], + digit: e.key, + }), }; applySegments(next); const maxLen = segmentMaxLength(field); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts index e3faf940ca6..106359f6590 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts @@ -106,10 +106,13 @@ export const segmentMaxLength = (field: DatePartKind): number => * Min/max for spinbutton `aria-*` and ArrowUp/ArrowDown stepping (month/day/year). * Day max uses month/year when available so February etc. behave correctly. */ -export const getSegmentSpinBounds = ( - field: DatePartKind, - segments: SegmentValues -): { min: number; max: number } => { +export const getSegmentSpinBounds = ({ + field, + segments, +}: { + field: DatePartKind; + segments: SegmentValues; +}): { min: number; max: number } => { switch (field) { case 'month': return { min: 1, max: 12 }; @@ -137,7 +140,13 @@ export const parseSegmentNumericString = (str: string) => { return Number.isFinite(numericValue) ? numericValue : null; }; -export const padSegmentNumber = (field: DatePartKind, numericValue: number) => { +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'); @@ -147,11 +156,15 @@ export const padSegmentNumber = (field: DatePartKind, numericValue: number) => { }; /** Append one digit to a segment string (max length enforced). */ -export const appendSegmentDigit = ( - field: DatePartKind, - prev: string, - digit: string -) => { +export const appendSegmentDigit = ({ + field, + prev, + digit, +}: { + field: DatePartKind; + prev: string; + digit: string; +}) => { // if the digit is not a single digit, return the previous value if (!/^\d$/.test(digit)) return prev; const maxLen = segmentMaxLength(field); @@ -168,12 +181,16 @@ export const appendSegmentDigit = ( /** * Step a segment up/down (ArrowUp / ArrowDown). Empty year steps from the current calendar year. */ -export const spinSegment = ( - field: DatePartKind, - segments: SegmentValues, - delta: 1 | -1 -) => { - const { min, max } = getSegmentSpinBounds(field, segments); +export const spinSegment = ({ + field, + segments, + delta, +}: { + field: DatePartKind; + segments: SegmentValues; + delta: 1 | -1; +}) => { + const { min, max } = getSegmentSpinBounds({ field, segments }); let cur = parseSegmentNumericString(segments[field]); if (cur == null) { @@ -190,23 +207,29 @@ export const spinSegment = ( } cur = Math.min(max, Math.max(min, cur)); - return padSegmentNumber(field, cur); + return padSegmentNumber({ field, numericValue: cur }); }; /** Build the visible date string from segment state in locale layout order (includes literal separators). */ -export const buildCombinedFromSegments = ( - segments: SegmentValues, - layout: DateFormatLayoutItem[] -) => +export const buildCombinedFromSegments = ({ + segments, + layout, +}: { + segments: SegmentValues; + layout: DateFormatLayoutItem[]; +}) => layout .map((item) => (item.kind === 'literal' ? item.text : segments[item.field])) .join(''); /** Map a digit-only string into segment fields following locale field order (2 / 2 / 4). */ -export const digitsToSegments = ( - digits: string, - fieldOrder: DatePartKind[] -): SegmentValues => { +export const digitsToSegments = ({ + digits, + fieldOrder, +}: { + digits: string; + fieldOrder: DatePartKind[]; +}): SegmentValues => { let rest = digits; const out: SegmentValues = { month: '', day: '', year: '' }; for (const field of fieldOrder) { From 0e746bc0eba0cd44c419ff9832b0f793a5f03352 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 16 Apr 2026 11:42:34 -0400 Subject: [PATCH 083/110] PR feedback --- packages/gamut/src/DatePicker/DatePicker.tsx | 1 + .../Calendar/CalendarFooter.tsx | 16 +- .../Calendar/utils/__tests__/dateGrid.test.ts | 28 +- .../Calendar/utils/dateGrid.ts | 51 ++-- .../Calendar/utils/elements.tsx | 2 +- .../DatePicker/DatePickerCalendar/index.tsx | 36 ++- .../utils/__tests__/dateSelect.test.ts | 274 +++++++++--------- .../utils/__tests__/quickActions.test.ts | 50 ++-- .../DatePickerCalendar/utils/dateSelect.ts | 80 +++-- .../DatePickerCalendar/utils/quickActions.ts | 38 +-- .../DatePicker/DatePickerContext/index.tsx | 2 +- .../src/DatePicker/DatePickerContext/types.ts | 2 +- .../DatePickerInput/Segment/elements.tsx | 46 ++- .../DatePickerInput/Segment/index.tsx | 4 +- .../DatePickerInput/Segment/utils.ts | 29 +- .../DatePicker/DatePickerInput/elements.tsx | 41 ++- .../src/DatePicker/DatePickerInput/index.tsx | 6 +- .../src/DatePicker/utils/translations.ts | 4 +- 18 files changed, 363 insertions(+), 347 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 7447456796c..064e19157e3 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -141,6 +141,7 @@ export const DatePicker: React.FC = (props) => { <> + {/* TODO: Adjust for RTL */} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx index bbea865fb27..07d39b727c1 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarFooter.tsx @@ -6,11 +6,10 @@ import { DEFAULT_DATE_PICKER_TRANSLATIONS } from '../../utils/translations'; import { CalendarFooterProps } from './types'; export const CalendarFooter: React.FC = ({ - clear, + clearButton, quickActions = [], }) => { - // if there are no quick actions and the clear button is not shown, don't render anything - if (quickActions.length === 0 && !clear) return null; + if (quickActions.length === 0 && !clearButton) return null; const actions = quickActions.slice(0, 3); @@ -20,17 +19,18 @@ export const CalendarFooter: React.FC = ({ borderTop={1} flexDirection={{ _: 'column', xs: 'row' }} gap={12} - justifyContent={clear ? 'space-between' : 'flex-end'} + justifyContent={clearButton ? 'space-between' : 'flex-end'} p={12} > - {clear && ( + {clearButton && ( clear.onClick?.()} + disabled={clearButton.disabled} + onClick={() => clearButton.onClick?.()} > - {clear.text ?? DEFAULT_DATE_PICKER_TRANSLATIONS.clearText} + {clearButton.text ?? + DEFAULT_DATE_PICKER_TRANSLATIONS.clearButtonText} )} 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 index a320d7e59c9..1c4dd7178c7 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts @@ -51,26 +51,26 @@ describe('isSameDay', () => { }); describe('isDateInRange', () => { - const start = new Date(2024, 2, 10); - const end = new Date(2024, 2, 20); + const startDate = new Date(2024, 2, 10); + const endDate = new Date(2024, 2, 20); - it('returns true strictly between start and end', () => { - expect(isDateInRange({ date: new Date(2024, 2, 15), start, end })).toBe( - true - ); + it('returns true strictly between startDate and endDate', () => { + expect( + isDateInRange({ date: new Date(2024, 2, 15), startDate, endDate }) + ).toBe(true); }); - it('returns false on start, end, or outside', () => { - expect(isDateInRange({ date: start, start, end })).toBe(false); - expect(isDateInRange({ date: end, start, end })).toBe(false); - expect(isDateInRange({ date: new Date(2024, 2, 5), start, end })).toBe( - false - ); + 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 start is null', () => { + it('returns false when startDate is null', () => { expect( - isDateInRange({ date: new Date(2024, 2, 15), start: null, end }) + isDateInRange({ date: new Date(2024, 2, 15), startDate: null, endDate }) ).toBe(false); }); }); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts index 58e34cdee99..03e68a4f0ba 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts @@ -29,8 +29,8 @@ export const getWeekdayOffsetInGrid = ({ date: Date; firstWeekday: IsoWeekday; }) => { - const js = date.getDay(); - const iso = js === 0 ? 7 : js; + const dayOfWeek = date.getDay(); + const iso = dayOfWeek === 0 ? 7 : dayOfWeek; return (iso - firstWeekday + 14) % 7; }; @@ -75,7 +75,6 @@ export const getMonthGrid = ({ } } - // Pad end of last week with nulls if (currentWeek.length > 0) { while (currentWeek.length < DAYS_PER_WEEK) { currentWeek.push(null); @@ -99,40 +98,44 @@ export const isSameDay = (a: Date | null, b: Date | null) => { * Matches the bounds used by {@link isDateInRange} (and range selection). */ export const getOrderedCalendarEndpoints = ({ - start, - end, + startDate, + endDate, }: { - start: Date; - end: Date; + startDate: Date; + endDate: Date; }) => { - const startDate = new Date( - start.getFullYear(), - start.getMonth(), - start.getDate() + const normalizedStartDate = new Date( + startDate.getFullYear(), + startDate.getMonth(), + startDate.getDate() ); - const endDate = new Date(end.getFullYear(), end.getMonth(), end.getDate()); - return startDate <= endDate - ? { low: startDate, high: endDate } - : { low: endDate, high: startDate }; + 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 `start` and `end` (exclusive), ignoring time. + * Check if `date` is between `startDate` and `endDate` (exclusive), ignoring time. */ export const isDateInRange = ({ date, - start, - end, + startDate, + endDate, }: { date: Date; - start: Date | null; - end: Date | null; + startDate: Date | null; + endDate: Date | null; }) => { - if (start === null) return false; - const endBound = end ?? start; + if (startDate === null) return false; + const endBound = endDate ?? startDate; const { low, high } = getOrderedCalendarEndpoints({ - start, - end: endBound, + startDate, + endDate: endBound, }); const normalizedDate = normalizeDate(date); return ( diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx index 209e21c0a89..b5a7354e8d7 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/elements.tsx @@ -69,7 +69,7 @@ const datecellStates = states({ cursor: 'not-allowed', userSelect: 'none', textDecoration: 'line-through', - '&:hover': { + '&:hover, &:focus': { color: 'text-disabled', bg: 'transparent', textDecoration: 'line-through', diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx index a4e13cdb4ce..382a909362b 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -53,7 +53,7 @@ export const DatePickerCalendar: React.FC = ({ )}`; const headingId = dialogId ?? context?.calendarDialogId ?? fallbackDialogId; - if (context == null) { + if (context === null) { throw new Error( 'DatePickerCalendar must be used inside a DatePicker (it reads shared state from context).' ); @@ -102,6 +102,8 @@ export const DatePickerCalendar: React.FC = ({ const secondMonthDate = addMonths({ date: displayDate, n: 1 }); const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.xs})`); const wasOpenRef = useRef(false); + /** Wraps both month grids so keyboard focus can move between them without treating it as “outside” the calendar. */ + const calendarKeyboardSurfaceRef = useRef(null); // Sync visible month to selection only when the calendar opens, not on every // date click. Otherwise clicking a date in the second month would jump the view. @@ -155,7 +157,7 @@ export const DatePickerCalendar: React.FC = ({ onClick: () => { action.onClick?.(); setActiveRangePart?.(null); - const { start, end } = computeQuickAction({ + const { startDate, endDate } = computeQuickAction({ num: action.num, timePeriod: action.timePeriod, isRange, @@ -163,28 +165,28 @@ export const DatePickerCalendar: React.FC = ({ if (isRange) { if ( rangeContainsDisabled({ - start, - end, + startDate, + endDate, shouldDisableDate, }) ) { applyRangeOrNewStart({ - start, - end, - clickedDate: end, + startDate, + endDate, + clickedDate: endDate, shouldDisableDate, onRangeSelection: context.onRangeSelection, }); } else { - context.onRangeSelection(start, end); + context.onRangeSelection(startDate, endDate); } - setDisplayDate(getFirstOfMonth(end)); - setFocusedDate(end); + setDisplayDate(getFirstOfMonth(endDate)); + setFocusedDate(endDate); queueMicrotask(closeCalendar); } else { - context.onSelection(start); - setDisplayDate(getFirstOfMonth(start)); - setFocusedDate(start); + context.onSelection(startDate); + setDisplayDate(getFirstOfMonth(startDate)); + setFocusedDate(startDate); queueMicrotask(closeCalendar); } }, @@ -200,7 +202,7 @@ export const DatePickerCalendar: React.FC = ({ return ( - + = ({ onDisplayDateChange={setDisplayDate} /> = ({ onDisplayDateChange={setDisplayDate} /> = ({ const mockOnSelection = jest.fn(); const mockOnRangeSelection = jest.fn(); describe('rangeContainsDisabled', () => { - const start = createDate(2024, 0, 10); - const end = createDate(2024, 0, 20); + 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({ - start, - end, + startDate, + endDate, shouldDisableDate: matchDisabledDates([createDate(2024, 0, 10)]), }) ).toBe(true); @@ -28,18 +28,18 @@ describe('rangeContainsDisabled', () => { it('returns true when a disabled date is the end date', () => { expect( rangeContainsDisabled({ - start, - end, + startDate, + endDate, shouldDisableDate: matchDisabledDates([createDate(2024, 0, 20)]), }) ).toBe(true); }); - it('returns true when a disabled date is strictly between start and end', () => { + it('returns true when a disabled date is strictly between startDate and endDate', () => { expect( rangeContainsDisabled({ - start, - end, + startDate, + endDate, shouldDisableDate: matchDisabledDates([createDate(2024, 0, 15)]), }) ).toBe(true); @@ -48,8 +48,8 @@ describe('rangeContainsDisabled', () => { it('returns false when no disabled date touches the inclusive range', () => { expect( rangeContainsDisabled({ - start, - end, + startDate, + endDate, shouldDisableDate: matchDisabledDates([ createDate(2024, 0, 5), createDate(2024, 0, 25), @@ -61,8 +61,8 @@ describe('rangeContainsDisabled', () => { it('returns true when shouldDisableDate marks a day inside the inclusive range', () => { expect( rangeContainsDisabled({ - start, - end, + startDate, + endDate, shouldDisableDate: (d) => d.getDate() === 15, }) ).toBe(true); @@ -103,29 +103,29 @@ describe('handleDateSelectSingle', () => { describe('applyRangeOrNewStart', () => { it('sets selection to the start and end date when the range does not contain a disabled date', () => { - const start = createDate(2024, 5, 10); - const end = createDate(2024, 5, 20); + const startDate = createDate(2024, 5, 10); + const endDate = createDate(2024, 5, 20); const clicked = createDate(2024, 5, 10); expect( applyRangeOrNewStart({ - start, - end, + startDate, + endDate, clickedDate: clicked, shouldDisableDate: matchDisabledDates([createDate(2024, 5, 30)]), onRangeSelection: mockOnRangeSelection, }) ).toBe(true); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, end); + 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 start = createDate(2024, 5, 10); - const end = createDate(2024, 5, 20); + const startDate = createDate(2024, 5, 10); + const endDate = createDate(2024, 5, 20); const clicked = createDate(2024, 5, 10); expect( applyRangeOrNewStart({ - start, - end, + startDate, + endDate, clickedDate: clicked, shouldDisableDate: matchDisabledDates([createDate(2024, 5, 12)]), onRangeSelection: mockOnRangeSelection, @@ -173,25 +173,25 @@ describe('handleDateSelectRange', () => { describe('activeRangePart === start', () => { describe('start date is set', () => { it('clears start when the start date is clicked again', () => { - const start = createDate(2024, 2, 10); - const end = createDate(2024, 2, 20); + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 20); handleDateSelectRange({ - date: start, + date: startDate, activeRangePart: 'start', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(null, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(null, endDate); }); it('sets start date when no end date is set', () => { - const start = createDate(2024, 2, 10); + const startDate = createDate(2024, 2, 10); const clicked = createDate(2024, 2, 15); handleDateSelectRange({ date: clicked, activeRangePart: 'start', - startDate: start, + startDate, endDate: null, onRangeSelection: mockOnRangeSelection, }); @@ -199,56 +199,56 @@ describe('handleDateSelectRange', () => { }); it('sets start date and clears end date when new start is after end', () => { - const start = createDate(2024, 2, 2); - const end = createDate(2024, 2, 12); + const startDate = createDate(2024, 2, 2); + const endDate = createDate(2024, 2, 12); const clicked = createDate(2024, 2, 20); handleDateSelectRange({ date: clicked, activeRangePart: 'start', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and keeps end date when new start is before end', () => { - const start = createDate(2024, 2, 10); - const end = createDate(2024, 2, 25); + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); const clicked = createDate(2024, 2, 15); handleDateSelectRange({ date: clicked, activeRangePart: 'start', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); }); it('sets start date and keeps end date when new start is the same as end', () => { - const start = createDate(2024, 2, 10); - const end = createDate(2024, 2, 25); + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); const clicked = createDate(2024, 2, 25); handleDateSelectRange({ date: clicked, activeRangePart: 'start', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { - const start = createDate(2024, 2, 2); - const end = createDate(2024, 2, 20); + const startDate = createDate(2024, 2, 2); + const endDate = createDate(2024, 2, 20); const clicked = createDate(2024, 2, 10); handleDateSelectRange({ date: clicked, activeRangePart: 'start', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); @@ -269,52 +269,52 @@ describe('handleDateSelectRange', () => { }); it('sets start date and clears end date when new start is after end', () => { - const end = createDate(2024, 2, 12); + const endDate = createDate(2024, 2, 12); const clicked = createDate(2024, 2, 20); handleDateSelectRange({ date: clicked, activeRangePart: 'start', startDate: null, - endDate: end, + endDate, onRangeSelection: mockOnRangeSelection, }); expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and keeps end date when new start is before end', () => { - const end = createDate(2024, 2, 25); + const endDate = createDate(2024, 2, 25); const clicked = createDate(2024, 2, 15); handleDateSelectRange({ date: clicked, activeRangePart: 'start', startDate: null, - endDate: end, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); }); it('sets start date and keeps end date when new start is the same as end', () => { - const end = createDate(2024, 2, 25); + const endDate = createDate(2024, 2, 25); const clicked = createDate(2024, 2, 25); handleDateSelectRange({ date: clicked, activeRangePart: 'start', startDate: null, - endDate: end, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); + expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, endDate); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { - const end = createDate(2024, 2, 20); + const endDate = createDate(2024, 2, 20); const clicked = createDate(2024, 2, 10); handleDateSelectRange({ date: clicked, activeRangePart: 'start', startDate: null, - endDate: end, + endDate, onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); @@ -326,82 +326,82 @@ describe('handleDateSelectRange', () => { describe('activeRangePart === end', () => { describe('end date is set', () => { it('clears end date when the end date is clicked again', () => { - const start = createDate(2024, 2, 5); - const end = createDate(2024, 2, 18); + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 18); handleDateSelectRange({ - date: end, + date: endDate, activeRangePart: 'end', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, null); }); it('sets end date when no start date is set', () => { - const end = createDate(2024, 2, 18); + const endDate = createDate(2024, 2, 18); const clicked = createDate(2024, 2, 19); handleDateSelectRange({ date: clicked, activeRangePart: 'end', startDate: null, - endDate: end, + endDate, onRangeSelection: mockOnRangeSelection, }); expect(mockOnRangeSelection).toHaveBeenCalledWith(null, clicked); }); it('sets end date and clears start date when new end is before start', () => { - const start = createDate(2024, 2, 20); - const end = createDate(2024, 2, 12); + const startDate = createDate(2024, 2, 20); + const endDate = createDate(2024, 2, 12); const clicked = createDate(2024, 2, 18); handleDateSelectRange({ date: clicked, activeRangePart: 'end', - startDate: start, - endDate: 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 start = createDate(2024, 2, 10); - const end = createDate(2024, 2, 25); + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); const clicked = createDate(2024, 2, 15); handleDateSelectRange({ date: clicked, activeRangePart: 'end', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); }); it('sets end date and keeps start date when new end is the same as start', () => { - const start = createDate(2024, 2, 10); - const end = createDate(2024, 2, 25); + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 25); const clicked = createDate(2024, 2, 10); handleDateSelectRange({ date: clicked, activeRangePart: 'end', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { - const start = createDate(2024, 2, 10); - const end = createDate(2024, 2, 20); + const startDate = createDate(2024, 2, 10); + const endDate = createDate(2024, 2, 20); const clicked = createDate(2024, 2, 15); handleDateSelectRange({ date: clicked, activeRangePart: 'end', - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); @@ -422,12 +422,12 @@ describe('handleDateSelectRange', () => { }); it('sets end date and clears start date when new end is before start', () => { - const start = createDate(2024, 2, 20); + const startDate = createDate(2024, 2, 20); const clicked = createDate(2024, 2, 18); handleDateSelectRange({ date: clicked, activeRangePart: 'end', - startDate: start, + startDate, endDate: null, onRangeSelection: mockOnRangeSelection, }); @@ -435,38 +435,38 @@ describe('handleDateSelectRange', () => { }); it('sets end date and keeps start date when new end is after start', () => { - const start = createDate(2024, 2, 10); + const startDate = createDate(2024, 2, 10); const clicked = createDate(2024, 2, 15); handleDateSelectRange({ date: clicked, activeRangePart: 'end', - startDate: start, + startDate, endDate: null, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); }); it('sets end date and keeps start date when new end is the same as start', () => { - const start = createDate(2024, 2, 10); + const startDate = createDate(2024, 2, 10); const clicked = createDate(2024, 2, 10); handleDateSelectRange({ date: clicked, activeRangePart: 'end', - startDate: start, + startDate, endDate: null, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, clicked); }); it('sets start date to the clicked date when the range would contain a disabled date', () => { - const start = createDate(2024, 2, 10); + const startDate = createDate(2024, 2, 10); const clicked = createDate(2024, 2, 15); handleDateSelectRange({ date: clicked, activeRangePart: 'end', - startDate: start, + startDate, endDate: null, onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), @@ -490,54 +490,54 @@ describe('handleDateSelectRange', () => { }); it('end date becomes start date when start date is clicked', () => { - const start = createDate(2024, 2, 5); - const end = createDate(2024, 2, 28); + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 28); handleDateSelectRange({ - date: start, + date: startDate, activeRangePart: null, - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(end, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(endDate, null); }); it('clears end date when end date is clicked', () => { - const start = createDate(2024, 2, 5); - const end = createDate(2024, 2, 28); + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 28); handleDateSelectRange({ - date: end, + date: endDate, activeRangePart: null, - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, null); + expect(mockOnRangeSelection).toHaveBeenCalledWith(startDate, null); }); it('updates end date when a date after start date is clicked', () => { - const start = createDate(2024, 2, 5); - const end = createDate(2024, 2, 10); + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 10); const clicked = createDate(2024, 2, 18); handleDateSelectRange({ date: clicked, activeRangePart: null, - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); + 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 start = createDate(2024, 2, 5); - const end = createDate(2024, 2, 10); + const startDate = createDate(2024, 2, 5); + const endDate = createDate(2024, 2, 10); const clicked = createDate(2024, 2, 18); handleDateSelectRange({ date: clicked, activeRangePart: null, - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); @@ -545,28 +545,28 @@ describe('handleDateSelectRange', () => { }); it('updates start date when a date before start date is clicked', () => { - const start = createDate(2024, 2, 15); - const end = createDate(2024, 2, 25); + const startDate = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 25); const clicked = createDate(2024, 2, 8); handleDateSelectRange({ date: clicked, activeRangePart: null, - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, end); + 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 start = createDate(2024, 2, 15); - const end = createDate(2024, 2, 25); + const startDate = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 25); const clicked = createDate(2024, 2, 8); handleDateSelectRange({ date: clicked, activeRangePart: null, - startDate: start, - endDate: end, + startDate, + endDate, onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); @@ -576,12 +576,12 @@ describe('handleDateSelectRange', () => { describe('start date set, end date empty, selection mode', () => { it('updates start date when clicked date is before start date', () => { - const start = createDate(2024, 2, 15); + const startDate = createDate(2024, 2, 15); const clicked = createDate(2024, 2, 8); handleDateSelectRange({ date: clicked, activeRangePart: null, - startDate: start, + startDate, endDate: null, onRangeSelection: mockOnRangeSelection, }); @@ -589,25 +589,25 @@ describe('handleDateSelectRange', () => { }); it('sets end date when clicked date is on or after start date', () => { - const start = createDate(2024, 2, 15); + const startDate = createDate(2024, 2, 15); const clicked = createDate(2024, 2, 22); handleDateSelectRange({ date: clicked, activeRangePart: null, - startDate: start, + startDate, endDate: null, onRangeSelection: mockOnRangeSelection, }); - expect(mockOnRangeSelection).toHaveBeenCalledWith(start, clicked); + 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 start = createDate(2024, 2, 15); + const startDate = createDate(2024, 2, 15); const clicked = createDate(2024, 2, 8); handleDateSelectRange({ date: clicked, activeRangePart: null, - startDate: start, + startDate, endDate: null, onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), @@ -618,39 +618,39 @@ describe('handleDateSelectRange', () => { describe('start date empty, end date set, selection mode', () => { it('sets start date and clears end datewhen clicked date is before end date', () => { - const end = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 15); const clicked = createDate(2024, 2, 8); handleDateSelectRange({ date: clicked, activeRangePart: null, startDate: null, - endDate: end, + endDate, onRangeSelection: mockOnRangeSelection, }); expect(mockOnRangeSelection).toHaveBeenCalledWith(clicked, null); }); it('sets start date and clears end date when clicked date is after end date', () => { - const end = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 15); const clicked = createDate(2024, 2, 22); handleDateSelectRange({ date: clicked, activeRangePart: null, startDate: null, - endDate: end, + 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 end = createDate(2024, 2, 15); + const endDate = createDate(2024, 2, 15); const clicked = createDate(2024, 2, 8); handleDateSelectRange({ date: clicked, activeRangePart: null, startDate: null, - endDate: end, + endDate, onRangeSelection: mockOnRangeSelection, shouldDisableDate: matchDisabledDates([createDate(2024, 2, 12)]), }); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts index 166b2cacb8f..bd5a664e3ab 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/__tests__/quickActions.test.ts @@ -4,87 +4,87 @@ describe('computeQuickAction', () => { const fixed = new Date(2026, 4, 15); // May 15, 2026 local (“today” anchor) describe('range mode (isRange: true)', () => { - it('returns [start, end] with end = anchor day when range is entirely in the past', () => { - const { start, end } = computeQuickAction({ + 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(end).toEqual(new Date(2026, 4, 15)); - expect(start).toEqual(new Date(2026, 3, 15)); + 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 { start, end } = computeQuickAction({ + const { startDate, endDate } = computeQuickAction({ num: -1, timePeriod: 'month', isRange: true, now: fixed, }); - expect(end).toEqual(new Date(2026, 4, 15)); - expect(start).toEqual(new Date(2026, 3, 15)); + 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 { start, end } = computeQuickAction({ + const { startDate, endDate } = computeQuickAction({ num: -2, timePeriod: 'month', isRange: true, now: fixed, }); - expect(end).toEqual(new Date(2026, 4, 15)); + expect(endDate).toEqual(new Date(2026, 4, 15)); const expected = new Date(2026, 4, 15); expected.setDate(expected.getDate() - 60); - expect(start).toEqual(expected); + expect(startDate).toEqual(expected); }); it('applies year as num * 365 rolling days', () => { - const { start, end } = computeQuickAction({ + const { startDate, endDate } = computeQuickAction({ num: -1, timePeriod: 'year', isRange: true, now: fixed, }); - expect(end).toEqual(new Date(2026, 4, 15)); + expect(endDate).toEqual(new Date(2026, 4, 15)); const expected = new Date(2026, 4, 15); expected.setDate(expected.getDate() - 365); - expect(start).toEqual(expected); + expect(startDate).toEqual(expected); }); - it('when start is after anchor day, orders as [anchor, start] so the range is forward', () => { - const { start, end } = computeQuickAction({ + 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(start).toEqual(new Date(2026, 4, 15)); - expect(end).toEqual(new Date(2026, 4, 20)); + 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 start is after anchor; end is still the anchor day', () => { - const { start, end } = computeQuickAction({ + 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(end).toEqual(new Date(2026, 4, 15)); - expect(start).toEqual(new Date(2026, 4, 16)); + expect(endDate).toEqual(new Date(2026, 4, 15)); + expect(startDate).toEqual(new Date(2026, 4, 16)); }); - it('returns past start with end = anchor for yesterday', () => { - const { start, end } = computeQuickAction({ + it('returns past startDate with endDate = anchor for yesterday', () => { + const { startDate, endDate } = computeQuickAction({ num: -1, timePeriod: 'day', isRange: false, now: fixed, }); - expect(end).toEqual(new Date(2026, 4, 15)); - expect(start).toEqual(new Date(2026, 4, 14)); + 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 index 9e4ba2b1bca..3448579e09a 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/dateSelect.ts @@ -8,6 +8,7 @@ import { getOrderedCalendarEndpoints, isDateDisabled, isDateInRange, + isSameDay, } from '../Calendar/utils/dateGrid'; export const isRangeProps = ( @@ -15,17 +16,17 @@ export const isRangeProps = ( ): props is DatePickerRangeProps => props.mode === 'range'; type RangeContainsDisabledParams = { - start: Date; - end: Date; + startDate: Date; + endDate: Date; } & Pick; -/** True if any disabled day falls within [start, end] (inclusive, by calendar day). */ +/** True if any disabled day falls within [startDate, endDate] (inclusive, by calendar day). */ export const rangeContainsDisabled = ({ - start, - end, + startDate, + endDate, shouldDisableDate, }: RangeContainsDisabledParams) => { - const { low, high } = getOrderedCalendarEndpoints({ start, end }); + const { low, high } = getOrderedCalendarEndpoints({ startDate, endDate }); if ( isDateDisabled({ date: low, shouldDisableDate }) || @@ -35,7 +36,7 @@ export const rangeContainsDisabled = ({ } let date = new Date(low.getFullYear(), low.getMonth(), low.getDate() + 1); - while (isDateInRange({ date, start, end })) { + while (isDateInRange({ date, startDate, endDate })) { if (isDateDisabled({ date, shouldDisableDate })) { return true; } @@ -54,7 +55,7 @@ export const handleDateSelectSingle = ({ onSelection, }: HandleDateSelectSingleParams) => { // If clicked date is the same as Start Date: Clear Start Date - if (selectedDate && date.getTime() === selectedDate.getTime()) { + if (isSameDay(date, selectedDate)) { onSelection(null); return; } @@ -63,25 +64,25 @@ export const handleDateSelectSingle = ({ }; type ApplyRangeOrNewStartParams = { - start: Date; - end: Date; + startDate: Date; + endDate: Date; clickedDate: Date; } & Pick; -/** @returns whether a full start+end range was committed (calendar may close). */ +/** @returns whether a full startDate+endDate range was committed (calendar may close). */ export const applyRangeOrNewStart = ({ - start, - end, + startDate, + endDate, clickedDate, shouldDisableDate, onRangeSelection, }: ApplyRangeOrNewStartParams) => { // if range contains disabled dates, set start date to clicked date and end date to null - if (rangeContainsDisabled({ start, end, shouldDisableDate })) { + if (rangeContainsDisabled({ startDate, endDate, shouldDisableDate })) { onRangeSelection(clickedDate, null); return false; } - onRangeSelection(start, end); + onRangeSelection(startDate, endDate); return true; }; @@ -107,71 +108,68 @@ export const handleDateSelectRange = ({ }: HandleDateSelectRangeParams) => { // Range mode: field targeting (start or end input was focused) if (activeRangePart === 'start') { - if (date.getTime() === startDate?.getTime()) { + if (isSameDay(date, startDate)) { onRangeSelection(null, endDate); return false; } - const newEnd = - endDate != null && date.getTime() <= endDate.getTime() ? endDate : null; - if (newEnd != null) { + const newEndDate = + endDate !== null && date.getTime() <= endDate.getTime() ? endDate : null; + if (newEndDate !== null) { return applyRangeOrNewStart({ - start: date, - end: newEnd, + startDate: date, + endDate: newEndDate, clickedDate: date, shouldDisableDate, onRangeSelection, }); } - onRangeSelection(date, newEnd); + onRangeSelection(date, newEndDate); return false; } if (activeRangePart === 'end') { - if (date.getTime() === endDate?.getTime()) { + if (isSameDay(date, endDate)) { onRangeSelection(startDate, null); return false; } - const newStart = - startDate != null && date.getTime() >= startDate.getTime() + const newStartDate = + startDate !== null && date.getTime() >= startDate.getTime() ? startDate : null; - if (newStart != null) { + if (newStartDate !== null) { return applyRangeOrNewStart({ - start: newStart, - end: date, + startDate: newStartDate, + endDate: date, clickedDate: date, shouldDisableDate, onRangeSelection, }); } - onRangeSelection(newStart, date); + onRangeSelection(newStartDate, date); return false; } // Range selection mode (no field focused: calendar drives both) if (startDate && endDate) { // if start date is end date and is clicked, clears everything - if ( - startDate.getTime() === endDate.getTime() && - date.getTime() === startDate.getTime() - ) { + if (isSameDay(startDate, endDate) && isSameDay(date, startDate)) { onRangeSelection(null, null); return false; } // if clicked on start date, end date becomes start date - if (date.getTime() === startDate.getTime()) { + if (isSameDay(date, startDate)) { onRangeSelection(endDate, null); return false; } // if clicked on end date, clears end date and start remains - if (date.getTime() === endDate.getTime()) { + 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({ - start: startDate, - end: date, + startDate, + endDate: date, clickedDate: date, shouldDisableDate, onRangeSelection, @@ -179,8 +177,8 @@ export const handleDateSelectRange = ({ } // If clicked date < Start: Updates Start Date to new date (End remains) - extends range to the left return applyRangeOrNewStart({ - start: date, - end: endDate, + startDate: date, + endDate, clickedDate: date, shouldDisableDate, onRangeSelection, @@ -195,8 +193,8 @@ export const handleDateSelectRange = ({ } // If clicked date > Start: Sets it as End Date (if range valid) return applyRangeOrNewStart({ - start: startDate, - end: date, + startDate, + endDate: date, clickedDate: date, shouldDisableDate, onRangeSelection, diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts index 30e947d16fb..c9652aeb7b2 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/utils/quickActions.ts @@ -63,8 +63,8 @@ export const getDefaultRangeQuickActions = ( ]; /** - * Computes [start, end] for footer quick actions when `onClick` is omitted. - * `date` (local calendar day of `now`) is the anchor — returned as `end` unless the range + * Computes `[startDate, endDate]` for footer quick actions when `onClick` is omitted. + * `anchorDate` (local calendar day of `now`) is the anchor — returned as `endDate` unless the range * is reordered (see below). * * **Rolling** offsets (no month/year boundaries): @@ -73,11 +73,11 @@ export const getDefaultRangeQuickActions = ( * - **month**: `num × 30` days. * - **year**: `num × 365` days. * - * **Range mode (`isRange: true`):** if `start` is after the anchor day, returns - * `{ start: anchor, end: computed }` so the interval runs forward. Otherwise - * `{ start: computed, end: anchor }` (past through “today”). + * **Range mode (`isRange: true`):** if the computed day is after the anchor day, returns + * `{ startDate: anchor, endDate: computed }` so the interval runs forward. Otherwise + * `{ startDate: computed, endDate: anchor }` (past through “today”). * - * **Single mode:** same return shape; the calendar uses `start` as the selected day. + * **Single mode:** same return shape; the calendar uses `startDate` as the selected day. */ export const computeQuickAction = ({ num, @@ -90,34 +90,34 @@ export const computeQuickAction = ({ isRange: boolean; now?: Date; }) => { - const date = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - let start: Date; + const anchorDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let startDate: Date; switch (timePeriod) { case 'day': { - start = new Date(date); - start.setDate(start.getDate() + num); + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num); break; } case 'week': { - start = new Date(date); - start.setDate(start.getDate() + num * 7); + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 7); break; } case 'month': { - start = new Date(date); - start.setDate(start.getDate() + num * 30); + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 30); break; } case 'year': { - start = new Date(date); - start.setDate(start.getDate() + num * 365); + startDate = new Date(anchorDate); + startDate.setDate(startDate.getDate() + num * 365); break; } } - if (isRange && start.getTime() > date.getTime()) { - return { start: date, end: start }; + if (isRange && startDate.getTime() > anchorDate.getTime()) { + return { startDate: anchorDate, endDate: startDate }; } - return { start, end: date }; + return { startDate, endDate: anchorDate }; }; diff --git a/packages/gamut/src/DatePicker/DatePickerContext/index.tsx b/packages/gamut/src/DatePicker/DatePickerContext/index.tsx index 72d1bf8ea3b..85159a14ab2 100644 --- a/packages/gamut/src/DatePicker/DatePickerContext/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerContext/index.tsx @@ -12,7 +12,7 @@ export const DatePickerProvider = DatePickerContext.Provider; */ export const useDatePicker = (): DatePickerContextValue => { const value = useContext(DatePickerContext); - if (value == null) { + 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 index 2388e01a66e..44328544a63 100644 --- a/packages/gamut/src/DatePicker/DatePickerContext/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerContext/types.ts @@ -32,7 +32,7 @@ interface DatePickerBaseContextValue quickActions: CalendarQuickAction[]; // /** Start date (range) or selected date (single). */ // startOrSelectedDate: Date | null; - /** Set selection. Single: (date). Range: (start, end). */ + /** Set selection. Single: (date). Range: (startDate, endDate). */ // onSelection: (date: Date | null, endDate?: Date | null) => void; } diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx index 97b98dfd3a2..c5e994661f6 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx @@ -2,37 +2,35 @@ import { css, states } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; -import { DatePartKind } from '../utils'; - const segmentStyles = states({ - default: { + isEmpty: { color: 'text-secondary', }, + isYear: { + minWidth: '4ch', + }, }); -type SegmentStyleProps = StyleProps & { - field: DatePartKind; -}; +type SegmentStyleProps = StyleProps; export const Segment = styled.span( - ({ field }) => - css({ - display: 'inline-block', - textAlign: 'center', - minWidth: field === 'year' ? '4ch' : '2ch', - padding: 0, - margin: 0, - color: 'text', - cursor: 'text', - '&:focus': { - bg: 'primary', - color: 'background', - borderRadius: 'md', - }, - '&:focus-visible': { - outline: 'none', - }, - }), + 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 ); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx index 45fda155f21..a44cb85238c 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx @@ -175,9 +175,9 @@ export const DatePickerInputSegment: React.FC = ({ aria-valuenow={ariaValue} aria-valuetext={display} data-segment={field} - default={segments[field].length === 0} - field={field} id={`${inputId}-${field}`} + isEmpty={segments[field].length === 0} + isYear={field === 'year'} ref={(el) => assignSegmentRef(field, el)} role="spinbutton" tabIndex={disabled ? -1 : 0} diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts index 106359f6590..c2b1575fc66 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/utils.ts @@ -7,7 +7,7 @@ export type SegmentValues = { }; export const getDateSegmentsFromDate = (date: Date | null): SegmentValues => { - if (date == null) return { month: '', day: '', year: '' }; + if (date === null) return { month: '', day: '', year: '' }; return { month: String(date.getMonth() + 1).padStart(2, '0'), day: String(date.getDate()).padStart(2, '0'), @@ -117,13 +117,14 @@ export const getSegmentSpinBounds = ({ case 'month': return { min: 1, max: 12 }; case 'day': { - const y = segments.year.length === 4 ? parseInt(segments.year, 10) : 2024; - const m = + 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 maxD = new Date(y, m, 0).getDate(); - return { min: 1, max: Number.isFinite(maxD) ? maxD : 31 }; + const maxDay = new Date(year, month, 0).getDate(); + return { min: 1, max: Number.isFinite(maxDay) ? maxDay : 31 }; } case 'year': return { min: 1, max: 9999 }; @@ -191,10 +192,10 @@ export const spinSegment = ({ delta: 1 | -1; }) => { const { min, max } = getSegmentSpinBounds({ field, segments }); - let cur = parseSegmentNumericString(segments[field]); + let currentSegementValue = parseSegmentNumericString(segments[field]); - if (cur == null) { - cur = + if (currentSegementValue === null) { + currentSegementValue = field === 'year' ? delta > 0 ? new Date().getFullYear() @@ -203,11 +204,11 @@ export const spinSegment = ({ ? min : max; } else { - cur += delta; + currentSegementValue += delta; } - cur = Math.min(max, Math.max(min, cur)); - return padSegmentNumber({ field, numericValue: cur }); + currentSegementValue = Math.min(max, Math.max(min, currentSegementValue)); + return padSegmentNumber({ field, numericValue: currentSegementValue }); }; /** Build the visible date string from segment state in locale layout order (includes literal separators). */ @@ -231,11 +232,11 @@ export const digitsToSegments = ({ fieldOrder: DatePartKind[]; }): SegmentValues => { let rest = digits; - const out: SegmentValues = { month: '', day: '', year: '' }; + const segments: SegmentValues = { month: '', day: '', year: '' }; for (const field of fieldOrder) { const maxLen = field === 'year' ? 4 : 2; - out[field] = rest.slice(0, maxLen); + segments[field] = rest.slice(0, maxLen); rest = rest.slice(maxLen); } - return out; + return segments; }; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx index 592b829b9de..ae1d3739e3c 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx @@ -1,18 +1,39 @@ -import { css, theme } from '@codecademy/gamut-styles'; +import { css, theme, variant } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; import { FlexBox } from '../../Box'; import { - conditionalStyles, 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 {} + extends StyleProps, + StyleProps {} /** * Shell uses the same style stack as `Input`. `formFieldStyles` targets `&:focus`, but the host is a @@ -20,16 +41,6 @@ interface SegmentedShellProps */ export const SegmentedShell = styled(FlexBox)( formFieldStyles, - conditionalStyles, inputSizeStyles, - ({ variant }) => - css({ - '&:focus-within': - variant === 'error' - ? { - borderColor: 'feedback-error', - boxShadow: `inset 0 0 0 1px ${theme.colors['feedback-error']}`, - } - : formFieldFocusStyles, - }) + shellFocusStyles ); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index 08a5391d496..4b13c6d6957 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -49,7 +49,7 @@ export const DatePickerInput = forwardRef( ) => { const context = useDatePicker(); - if (context == null) { + if (context === null) { throw new Error( 'DatePickerInput must be used inside a DatePicker (it reads shared state from context).' ); @@ -222,10 +222,10 @@ export const DatePickerInput = forwardRef( width="fit-content" > = { - clearText: 'Clear', + clearButtonText: 'Clear', dateLabel: 'Date', startDateLabel: 'Start date', endDateLabel: 'End date', From de8c1f636b49d53f907fee040f5889627ee8e436 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 16 Apr 2026 11:49:07 -0400 Subject: [PATCH 084/110] fix date keyboard nav --- .../Calendar/CalendarBody.tsx | 83 ++++++++++++++----- .../DatePickerCalendar/Calendar/types.ts | 9 +- .../DatePicker/DatePickerInput/elements.tsx | 2 +- 3 files changed, 69 insertions(+), 25 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx index 412313a1531..d17975a223d 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import * as React from 'react'; import { useIsoFirstWeekday, useResolvedLocale } from '../../utils/locale'; @@ -30,6 +30,7 @@ export const CalendarBody: React.FC = ({ hasAdjacentMonthRight, hasAdjacentMonthLeft, focusGridSync, + calendarKeyboardSurfaceRef, }) => { const resolvedLocale = useResolvedLocale(locale); const firstWeekday = useIsoFirstWeekday(resolvedLocale, weekStartsOn); @@ -70,7 +71,7 @@ export const CalendarBody: React.FC = ({ return true; }, []); - useEffect(() => { + useLayoutEffect(() => { // Keep the roving tabindex / focused day aligned with `focusTarget` when it makes sense for a11y. if (focusTarget === null) return; @@ -80,24 +81,62 @@ export const CalendarBody: React.FC = ({ return; } - const inGrid = tableRef.current?.contains(document.activeElement); + const activeEl = document.activeElement; + const inThisGrid = tableRef.current?.contains(activeEl) ?? false; + const focusInSharedSurface = + calendarKeyboardSurfaceRef?.current?.contains(activeEl) ?? false; const requested = focusGridSync.gridFocusRequested; - // Focus is already in this grid (keyboard nav): update which day is focused as `focusTarget` changes. - if (inGrid) { - focusButton(focusTarget); - return; - } + // Month navigation unmounts the active cell; focus often lands on , the dialog shell, + // or another non-grid node — not inside calendarKeyboardSurfaceRef, so we must still sync. + const surfaceEl = calendarKeyboardSurfaceRef?.current; + const focusLostFromCellUnmount = + activeEl === document.body || + activeEl === document.documentElement || + (activeEl instanceof HTMLElement && + surfaceEl != null && + surfaceEl.contains(activeEl) === false && + activeEl.contains(surfaceEl)); + + // 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 || + focusInSharedSurface || + (focusLostFromCellUnmount && surfaceEl != null); + + if (!shouldSyncFocus) return; - // DatePicker opened via keyboard / ArrowDown: parent asked to move focus into the grid once. - if (requested) { - const success = focusButton(focusTarget); - if (success) { + 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); + }); } - // If !inGrid && !requested (e.g. calendar opened with the mouse): leave focus on the input — do not call focusButton. - }, [focusTarget, focusButton, focusGridSync]); + }, [ + focusTarget, + focusButton, + focusGridSync, + calendarKeyboardSurfaceRef, + /** Re-run when the month grid remounts so we can re-attach roving focus after displayDate changes. */ + year, + month, + ]); const onKeyDown = useCallback( (e: React.KeyboardEvent, date: Date) => @@ -130,13 +169,13 @@ export const CalendarBody: React.FC = ({ ); const setButtonRef = useCallback((date: Date, el: HTMLElement | null) => { - const k = new Date( + const normalizedDateTime = new Date( date.getFullYear(), date.getMonth(), date.getDate() ).getTime(); - if (el) buttonRefs.current.set(k, el); - else buttonRefs.current.delete(k); + if (el) buttonRefs.current.set(normalizedDateTime, el); + else buttonRefs.current.delete(normalizedDateTime); }, []); return ( @@ -156,10 +195,9 @@ export const CalendarBody: React.FC = ({ {week.map((date, colIndex) => { if (date === null) { return ( - // eslint-disable-next-line jsx-a11y/control-has-associated-label + // eslint-disable-next-line jsx-a11y/control-has-associated-label -- this is a false positive @@ -172,12 +210,11 @@ export const CalendarBody: React.FC = ({ range && isDateInRange({ date, - start: selectedDate, - end: endDate, + startDate: selectedDate, + endDate, }); const disabled = isDateDisabled({ date, shouldDisableDate }); const today = isToday(date); - // this is making the selected date a differnet color bc it is focused, look into further const isFocused = focusTarget !== null && isSameDay(date, focusTarget); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts index 06bfded6659..0799b1b52f7 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts @@ -1,3 +1,5 @@ +import type { RefObject } from 'react'; + import type { CalendarQuickAction, DatePickerSharedProps, @@ -62,10 +64,15 @@ export interface CalendarBodyProps extends CalendarBaseProps { signal: boolean; onGridFocusRequestHandled: () => void; }; + /** + * Region that contains all month grids (e.g. two-month layout). When focus is inside this + * subtree, roving tabindex may move DOM focus between grids; omit so only this table counts. + */ + calendarKeyboardSurfaceRef?: RefObject; } export interface CalendarFooterProps { - clear?: { + clearButton?: { disabled?: boolean; onClick?: () => void; text?: string; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx index ae1d3739e3c..133202bd49c 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx @@ -1,4 +1,4 @@ -import { css, theme, variant } from '@codecademy/gamut-styles'; +import { variant } from '@codecademy/gamut-styles'; import { StyleProps } from '@codecademy/variance'; import styled from '@emotion/styled'; From dda6e478e1b8c14113279cbf9dbb5d835f52129a Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 16 Apr 2026 16:17:13 -0400 Subject: [PATCH 085/110] more tests & tweaks --- .../Calendar/__tests__/CalendarBody.test.tsx | 139 -------- .../__tests__/CalendarFooter.test.tsx | 55 --- .../Calendar/CalendarBody.tsx | 13 +- .../Calendar/__tests__/CalendarBody.test.tsx | 314 ++++++++++++++++++ .../__tests__/CalendarFooter.test.tsx | 95 ++++++ .../__tests__/CalendarHeader.test.tsx | 48 +++ .../Calendar/__tests__/CalendarNav.test.tsx | 47 +++ .../utils/__tests__/keyHandler.test.tsx | 158 +++++++++ .../Calendar/utils/dateGrid.ts | 28 +- .../DatePickerInput/Segment/index.tsx | 1 + packages/gamut/src/DatePicker/types.ts | 1 - .../DatePicker/DatePicker.stories.tsx | 4 +- 12 files changed, 695 insertions(+), 208 deletions(-) delete mode 100644 packages/gamut/src/DatePicker/Calendar/__tests__/CalendarBody.test.tsx delete mode 100644 packages/gamut/src/DatePicker/Calendar/__tests__/CalendarFooter.test.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarFooter.test.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarHeader.test.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarNav.test.tsx diff --git a/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarBody.test.tsx b/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarBody.test.tsx deleted file mode 100644 index f08fd2e95b0..00000000000 --- a/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarBody.test.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { setupRtl } from '@codecademy/gamut-tests'; -import userEvent from '@testing-library/user-event'; - -import { CalendarBody } from '../CalendarBody'; -import { formatDateForAriaLabel } from '../utils/format'; -import * as keyHandlerModule from '../utils/keyHandler'; - -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 renderView = setupRtl(CalendarBody, { - displayDate, - selectedDate: null, - focusedDate, - labelledById: 'cal-heading', - locale: 'en-US', - onDateSelect: mockOnDateSelect, - onFocusedDateChange: mockOnFocusedDateChange, - onDisplayDateChange: mockOnDisplayDateChange, - focusGridSync: { - gridFocusRequested: false, - signal: false, - onGridFocusRequestHandled: mockOnGridFocusRequestHandled, - }, -}); - -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('calls keyHandler when a key is pressed on a focused day cell', async () => { - const keyHandlerSpy = jest.spyOn(keyHandlerModule, 'keyHandler'); - - const { view } = renderView(); - const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); - day20.focus(); - await userEvent.keyboard('{ArrowLeft}'); - - expect(keyHandlerSpy).toHaveBeenCalledTimes(1); - keyHandlerSpy.mockRestore(); - }); - - 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(today, 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({ disabledDates: [new Date(2024, 2, 1)] }); - - const disabled = view.getByRole('gridcell', { name: /March 1, 2024/i }); - expect(disabled).toHaveAttribute('aria-disabled', 'true'); - }); - - 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('calls onGridFocusRequestHandled when grid focus is requested', () => { - renderView({ - focusGridSync: { - gridFocusRequested: true, - signal: false, - onGridFocusRequestHandled: mockOnGridFocusRequestHandled, - }, - }); - - expect(mockOnGridFocusRequestHandled).toHaveBeenCalled(); - }); -}); diff --git a/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarFooter.test.tsx b/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarFooter.test.tsx deleted file mode 100644 index fec4fc17921..00000000000 --- a/packages/gamut/src/DatePicker/Calendar/__tests__/CalendarFooter.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -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 mockOnTodayClick = jest.fn(); - -const renderView = setupRtl(CalendarFooter, { - locale: 'en-US', - onClearDate: mockOnClearDate, - onTodayClick: mockOnTodayClick, -}); - -describe('CalendarFooter', () => { - it('calls onTodayClick when the today button is clicked', async () => { - const { view } = renderView(); - - await userEvent.click(view.getByRole('button', { name: /today/i })); - - expect(mockOnTodayClick).toHaveBeenCalledTimes(1); - }); - - it('renders clear button when showClearButton is true and calls onClearDate when clicked', async () => { - const { view } = renderView({ - showClearButton: true, - }); - - await userEvent.click( - view.getByRole('button', { - name: DEFAULT_DATE_PICKER_TRANSLATIONS.clearText, - }) - ); - - expect(mockOnClearDate).toHaveBeenCalledTimes(1); - }); - - it('does not render clear button when showClearButton is false', () => { - const { view } = renderView({ showClearButton: false }); - - expect( - view.queryByRole('button', { name: /clear/i }) - ).not.toBeInTheDocument(); - }); - - it('renders passed clearText when provided', () => { - const { view } = renderView({ - showClearButton: true, - clearText: 'Clear dates', - }); - - view.getByRole('button', { name: 'Clear dates' }); - }); -}); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx index d17975a223d..190c810661e 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -9,6 +9,7 @@ import { isDateDisabled, isDateInRange, isSameDay, + normalizeDate, } from './utils/dateGrid'; import { CalendarTable, DateCell, TableHeader } from './utils/elements'; import { formatDateForAriaLabel, getWeekdayNames } from './utils/format'; @@ -60,11 +61,7 @@ export const CalendarBody: React.FC = ({ const focusButton = useCallback((date: Date | null): boolean => { if (date === null) return false; - const key = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate() - ).getTime(); + const key = normalizeDate(date); const el = buttonRefs.current.get(key); if (!el) return false; el.focus(); @@ -169,11 +166,7 @@ export const CalendarBody: React.FC = ({ ); const setButtonRef = useCallback((date: Date, el: HTMLElement | null) => { - const normalizedDateTime = new Date( - date.getFullYear(), - date.getMonth(), - date.getDate() - ).getTime(); + const normalizedDateTime = normalizeDate(date); if (el) buttonRefs.current.set(normalizedDateTime, el); else buttonRefs.current.delete(normalizedDateTime); }, []); 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..d3d4ce59524 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/__tests__/CalendarBody.test.tsx @@ -0,0 +1,314 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import { fireEvent, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +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 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, + }, +}); + +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({ + shouldDisableDate: (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({ + shouldDisableDate: (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({ + shouldDisableDate: (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, + }, + }); + + 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 { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{ArrowLeft}'); + + expect(mockOnFocusedDateChange).toHaveBeenCalledWith( + new Date(2024, 2, 19) + ); + }); + + it('updates focus and the visible month via PageDown through keyHandler', async () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{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 { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{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 { view } = renderView({ + shouldDisableDate: (date) => + date.getFullYear() === 2024 && + date.getMonth() === 2 && + date.getDate() === 1, + }); + + const day1 = view.getByRole('gridcell', { name: /March 1, 2024/i }); + day1.focus(); + await userEvent.keyboard('{Enter}'); + + expect(mockOnDateSelect).not.toHaveBeenCalled(); + }); + + it('calls onEscapeKeyPress on Escape', async () => { + const { view } = renderView(); + + const day20 = view.getByRole('gridcell', { name: /March 20, 2024/i }); + day20.focus(); + await userEvent.keyboard('{Escape}'); + + expect(mockOnEscapeKeyPress).toHaveBeenCalled(); + }); + + it('does not move focus after an unhandled key (beyond focus sync)', async () => { + 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 userEvent.keyboard('{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/utils/__tests__/keyHandler.test.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx index 8ddc8f18793..e79d20f0d50 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/keyHandler.test.tsx @@ -249,4 +249,162 @@ describe('keyHandler', () => { 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(' '), + shouldDisableDate: 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 index 03e68a4f0ba..c28669e1531 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts @@ -10,7 +10,7 @@ const DAYS_PER_WEEK = 7; /** * Normalize to start of day in local time for comparison. */ -const normalizeDate = (date: Date) => { +export const normalizeDate = (date: Date) => { return new Date( date.getFullYear(), date.getMonth(), @@ -182,3 +182,29 @@ export const getDatesWithRow = (weeks: (Date | null)[][]) => { /** Add `n` months to the given date. */ export const addMonths = ({ date, n }: { date: Date; n: number }) => new Date(date.getFullYear(), date.getMonth() + n, 1); + +/** + * True when `date` falls in the left visible month (`displayDate`) or, when two months are + * shown, the following month. Used to avoid jumping the strip when the user selects a day + * already visible, while still syncing when the selection moves off-strip (e.g. typed input). + */ +export const isDateInVisibleCalendarStrip = ({ + date, + displayDate, + showTwoMonths, +}: { + date: Date; + displayDate: Date; + showTwoMonths: boolean; +}): boolean => { + const y = date.getFullYear(); + const m = date.getMonth(); + const d0y = displayDate.getFullYear(); + const d0m = displayDate.getMonth(); + if (y === d0y && m === d0m) return true; + if (showTwoMonths) { + const second = addMonths({ date: displayDate, n: 1 }); + return y === second.getFullYear() && m === second.getMonth(); + } + return false; +}; diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx index a44cb85238c..d1d84920cd0 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx @@ -136,6 +136,7 @@ export const DatePickerInputSegment: React.FC = ({ // if the key is a single digit and is a number, append the digit to the segment if (e.key.length === 1 && /^\d$/.test(e.key)) { e.preventDefault(); + e.stopPropagation(); setSegments((prev) => { const next = { ...prev, diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 368593ea060..5d0dd4bd446 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -28,7 +28,6 @@ interface DatePickerBaseProps * @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. diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx index daa51c0937a..aa4bcc8f3de 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -28,7 +28,7 @@ export const Default: Story = { locale="de-DE" quickActions={null} selectedDate={selectedDate} - translations={{ clearText: 'Löschen' }} + translations={{ clearButtonText: 'Löschen' }} onSelected={setSelectedDate} /> @@ -61,7 +61,7 @@ export const Range: Story = { Date: Thu, 16 Apr 2026 18:06:11 -0400 Subject: [PATCH 086/110] more tests --- packages/gamut/src/DatePicker/DatePicker.tsx | 12 +- .../__tests__/DatePickerCalendar.test.tsx | 177 ++++++++++++++++++ .../__tests__/DatePickerContext.test.tsx | 78 ++++++++ .../__tests__/mockContexts.ts | 52 +++++ .../__tests__/DatePickerInputSegment.test.tsx | 27 ++- .../__tests__/DatePickerInput.test.tsx | 171 +++++++++++++++++ .../DatePicker/__tests__/DatePicker.test.tsx | 119 ++++++++++++ packages/gamut/src/DatePicker/sharedTypes.ts | 7 +- packages/gamut/src/DatePicker/types.ts | 14 +- .../lib/Organisms/DatePicker/DatePicker.mdx | 23 ++- .../DatePicker/DatePicker.stories.tsx | 10 +- 11 files changed, 656 insertions(+), 34 deletions(-) create mode 100644 packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerContext/__tests__/DatePickerContext.test.tsx create mode 100644 packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts create mode 100644 packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx create mode 100644 packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 064e19157e3..1ee115b813a 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -18,17 +18,12 @@ import type { DatePickerProps } from './types'; import { useResolvedLocale } from './utils/locale'; import { DEFAULT_DATE_PICKER_TRANSLATIONS } from './utils/translations'; -/** - * DatePicker: single-date or range. Holds shared state and provides it via context. - * Single: selectedDate, setSelectedDate. Range: startDate, endDate, setStartDate, setEndDate. - * With no children, renders default layout (input + calendar popover). - */ export const DatePicker: React.FC = (props) => { const { locale, shouldDisableDate, - mode, children, + mode, translations: translationsProp, inputSize, quickActions, @@ -73,9 +68,10 @@ export const DatePicker: React.FC = (props) => { ...translationsProp, }; const resolvedQuickActions = - quickActions ?? mode === 'range' + quickActions ?? + (mode === 'range' ? getDefaultRangeQuickActions(translations) - : getDefaultSingleQuickActions(resolvedLocale); + : getDefaultSingleQuickActions(resolvedLocale)); const base = { isCalendarOpen, openCalendar, 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..4a7788ba591 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/__tests__/DatePickerCalendar.test.tsx @@ -0,0 +1,177 @@ +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 { 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 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 userEvent.keyboard('{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/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..aa1faef2e06 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts @@ -0,0 +1,52 @@ +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(), + calendarDialogId: 'test-datepicker-dialog', + 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(), + calendarDialogId: 'test-datepicker-dialog', + 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/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx index ac067248bc1..23efda99da4 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/__tests__/DatePickerInputSegment.test.tsx @@ -1,14 +1,10 @@ -/* eslint-disable simple-import-sort/imports -- import sort vs consistent-type-imports for relative paths */ - -import { useCallback, useState, type FC } from 'react'; - import { setupRtl } from '@codecademy/gamut-tests'; import userEvent from '@testing-library/user-event'; +import { type FC, useCallback, useState } from 'react'; -import { DatePickerInputSegment } from '../DatePickerInputSegment'; import type { DatePartKind } from '../../utils'; -import type { AssignSegmentRef } from '../DatePickerInputSegment'; -import type { SegmentValues } from '../segmentUtils'; +import { type AssignSegmentRef, DatePickerInputSegment } from '..'; +import type { SegmentValues } from '../utils'; const noop = () => undefined; @@ -46,13 +42,13 @@ const SegmentHarness: FC = ({ disabled={disabled} error={error} field={field} - focusOrOpenCalendarGrid={focusOrOpenCalendarGrid} - focusSegmentField={noopFocusSegmentField} - handleOnFocus={noop} nextField={null} prevField={null} segments={segments} setSegments={setSegments} + onAltArrowDown={focusOrOpenCalendarGrid} + onFocus={noop} + onSiblingFocus={noopFocusSegmentField} /> ); }; @@ -90,6 +86,17 @@ describe('DatePickerInputSegment', () => { 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 user.keyboard('5'); + + expect(month).toHaveAttribute('aria-valuetext', 'MM'); + }); + it('increments month with ArrowUp from empty', async () => { const user = userEvent.setup(); const { view } = renderView({}); 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..901d25d5916 --- /dev/null +++ b/packages/gamut/src/DatePicker/DatePickerInput/__tests__/DatePickerInput.test.tsx @@ -0,0 +1,171 @@ +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 { 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 user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(day); + + await user.keyboard('{ArrowRight}'); + expect(document.activeElement).toBe(year); + + await user.keyboard('{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 user.keyboard('03'); + expect(document.activeElement).toBe(day); + await user.keyboard('15'); + expect(document.activeElement).toBe(year); + await user.keyboard('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 user.keyboard('03'); + await user.keyboard('15'); + await user.keyboard('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/__tests__/DatePicker.test.tsx b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx new file mode 100644 index 00000000000..f142fda6188 --- /dev/null +++ b/packages/gamut/src/DatePicker/__tests__/DatePicker.test.tsx @@ -0,0 +1,119 @@ +import { setupRtl } from '@codecademy/gamut-tests'; +import userEvent from '@testing-library/user-event'; +import type { FC } from 'react'; + +import { DatePicker } from '../DatePicker'; +import type { DatePickerTranslations } from '../utils/translations'; + +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, {}); + +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('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(); + }); +}); diff --git a/packages/gamut/src/DatePicker/sharedTypes.ts b/packages/gamut/src/DatePicker/sharedTypes.ts index 4848727a425..ab5144ecc1c 100644 --- a/packages/gamut/src/DatePicker/sharedTypes.ts +++ b/packages/gamut/src/DatePicker/sharedTypes.ts @@ -12,7 +12,12 @@ export interface DatePickerSharedProps { * cutoff.getMonth(), * cutoff.getDate() * ); - * d < startOfCutoff} /> + * {}} + * shouldDisableDate={(d) => d < startOfCutoff} + * /> * ``` */ shouldDisableDate?: (date: Date) => boolean; diff --git a/packages/gamut/src/DatePicker/types.ts b/packages/gamut/src/DatePicker/types.ts index 5d0dd4bd446..5954074ee2f 100644 --- a/packages/gamut/src/DatePicker/types.ts +++ b/packages/gamut/src/DatePicker/types.ts @@ -4,8 +4,9 @@ import { Input } from '../Form/inputs/Input'; import { CalendarQuickAction, DatePickerSharedProps } from './sharedTypes'; import { DatePickerTranslations } from './utils/translations'; -interface DatePickerBaseProps +interface DatePickerBaseProps extends DatePickerSharedProps { + /** Discriminator: set to `"single"` or `"range"`; both are required on `DatePicker`. */ mode: Mode; /** When provided, only the provider is rendered and children compose Input + Calendar. */ children?: React.ReactNode; @@ -16,6 +17,7 @@ interface DatePickerBaseProps * @example * ```tsx * * @example single mode: * ```tsx * quickActions?: CalendarQuickAction[] | null; } -export interface DatePickerSingleProps - extends DatePickerBaseProps<'single' | undefined> { +export interface DatePickerSingleProps extends DatePickerBaseProps<'single'> { /** 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); * @@ -81,6 +84,7 @@ export interface DatePickerSingleProps * ```tsx * const [selectedDate, setSelectedDate] = useState(null); * @@ -98,6 +102,7 @@ export interface DatePickerRangeProps extends DatePickerBaseProps<'range'> { * const [endDate, setEndDate] = useState(null); * * { * const [endDate, setEndDate] = useState(null); * const [startDate, setStartDate] = useState(null); * { * const [startDate, setStartDate] = useState(null); * const [endDate, setEndDate] = useState(null); * { * const [endDate, setEndDate] = useState(null); * const [startDate, setStartDate] = useState(null); * (null); return ( ); ``` @@ -85,8 +88,8 @@ return ( mode="range" startDate={startDate} endDate={endDate} - setStartDate={setStartDate} - setEndDate={setEndDate} + onStartSelected={setStartDate} + onEndSelected={setEndDate} shouldDisableDate={matchDisabledDates([someHoliday])} /> ); @@ -94,7 +97,7 @@ return ( ### Custom layout (`children`) -Render **`DatePicker`** with **`children`** only (no default input/calendar). Inside the tree, call **`useDatePicker()`** and place **`DatePickerInput`** and **`DatePickerCalendar`** wherever you need, and wire your own **`PopoverContainer`** (or other overlay) to **`openCalendar`**, **`closeCalendar`**, and **`calendarDialogId`**. See the **Composed with context** story below. +Render **`DatePicker`** with **`mode`** (e.g. **`mode="single"`**) and **`children`** only (no default input/calendar). Inside the tree, call **`useDatePicker()`** and place **`DatePickerInput`** and **`DatePickerCalendar`** wherever you need, and wire your own **`PopoverContainer`** (or other overlay) to **`openCalendar`**, **`closeCalendar`**, and **`calendarDialogId`**. See the **Composed with context** story below. ## Accessibility notes diff --git a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx index aa4bcc8f3de..86fdad608c7 100644 --- a/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx +++ b/packages/styleguide/src/lib/Organisms/DatePicker/DatePicker.stories.tsx @@ -26,6 +26,7 @@ export const Default: Story = { @@ -131,7 +133,7 @@ export const RangeSmall: Story = { }; /** - * Composed usage: DatePicker with children provides shared state via context. + * Composed usage: DatePicker with `mode="single"` and children provides shared state via context. * The child uses useDatePicker() to get open/close and inputRef, then composes * DatePickerInput and DatePickerCalendar with a custom PopoverContainer layout. */ @@ -140,7 +142,11 @@ export const ComposedWithContext: Story = { const [selectedDate, setSelectedDate] = useState(null); return ( - + From 94c7a2d08e1f2b0b7f1a574402edbd8dda3e7059 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Thu, 16 Apr 2026 18:06:42 -0400 Subject: [PATCH 087/110] sync segment input and calendar month shown --- .../Calendar/utils/__tests__/dateGrid.test.ts | 36 +++++++++++ .../Calendar/utils/dateGrid.ts | 44 ++++++++------ .../DatePicker/DatePickerCalendar/index.tsx | 59 ++++++++++++++----- 3 files changed, 107 insertions(+), 32 deletions(-) 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 index 1c4dd7178c7..2cb917908d9 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/__tests__/dateGrid.test.ts @@ -4,6 +4,7 @@ import { getWeekdayOffsetInGrid, isDateDisabled, isDateInRange, + isDateWithinVisibleMonths, isSameDay, matchDisabledDates, } from '../dateGrid'; @@ -117,6 +118,41 @@ describe('isDateDisabled', () => { }); }); +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 }); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts index c28669e1531..2c4563485a3 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/utils/dateGrid.ts @@ -148,7 +148,12 @@ export const isDateInRange = ({ * * @example * ```tsx - * + * {}} + * shouldDisableDate={matchDisabledDates([new Date(2026, 3, 14)])} + * /> * ``` */ export const matchDisabledDates = @@ -184,27 +189,30 @@ export const addMonths = ({ date, n }: { date: Date; n: number }) => new Date(date.getFullYear(), date.getMonth() + n, 1); /** - * True when `date` falls in the left visible month (`displayDate`) or, when two months are - * shown, the following month. Used to avoid jumping the strip when the user selects a day - * already visible, while still syncing when the selection moves off-strip (e.g. typed input). + * True if `date` falls in the left visible month, or—when `showSecondMonth`—in the + * month shown in the second column. Used to avoid shifting the visible month pair when + * the committed date is already on screen (e.g. a click in the right-hand month). */ -export const isDateInVisibleCalendarStrip = ({ +export const isDateWithinVisibleMonths = ({ date, - displayDate, - showTwoMonths, + startOfLeftVisibleMonth, + showSecondMonth, }: { date: Date; - displayDate: Date; - showTwoMonths: boolean; -}): boolean => { - const y = date.getFullYear(); - const m = date.getMonth(); - const d0y = displayDate.getFullYear(); - const d0m = displayDate.getMonth(); - if (y === d0y && m === d0m) return true; - if (showTwoMonths) { - const second = addMonths({ date: displayDate, n: 1 }); - return y === second.getFullYear() && m === second.getMonth(); + /** 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/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx index 382a909362b..7a03a739c26 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -19,7 +19,11 @@ import { CalendarWrapper, } from './Calendar'; import type { CalendarBodyProps } from './Calendar/types'; -import { addMonths, getFirstOfMonth } from './Calendar/utils/dateGrid'; +import { + addMonths, + getFirstOfMonth, + isDateWithinVisibleMonths, +} from './Calendar/utils/dateGrid'; import { applyRangeOrNewStart, handleDateSelectRange, @@ -85,8 +89,17 @@ export const DatePickerCalendar: React.FC = ({ 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; + + /** Committed value that should drive the visible month when it changes (input, grid, or quick actions). */ + 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(selectedDate ?? new Date()) + getFirstOfMonth(anchorDate ?? selectedDate ?? endDate ?? new Date()) ); const [focusedDate, setFocusedDate] = useState( () => selectedDate ?? endDate ?? new Date() @@ -101,22 +114,36 @@ export const DatePickerCalendar: React.FC = ({ const focusTarget = focusedDate ?? selectedDate ?? endDate ?? new Date(); const secondMonthDate = addMonths({ date: displayDate, n: 1 }); const isTwoMonthsVisible = useMedia(`(min-width: ${breakpoints.xs})`); - const wasOpenRef = useRef(false); + /** 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; /** Wraps both month grids so keyboard focus can move between them without treating it as “outside” the calendar. */ const calendarKeyboardSurfaceRef = useRef(null); - // Sync visible month to selection only when the calendar opens, not on every - // date click. Otherwise clicking a date in the second month would jump the view. + // When the committed anchor changes while the popover is open (typed input, grid, quick action), + // move focus to that day. Shift the visible month pair only if the anchor is not already shown + // (including the second column in a two-month layout), so picking in the right-hand month does + // not jump the view. useEffect(() => { - const justOpened = isCalendarOpen && !wasOpenRef.current; - wasOpenRef.current = isCalendarOpen; - if (!justOpened) return; - const anchor = selectedDate ?? endDate; - if (anchor) { + if (!isCalendarOpen) { + return; + } + const anchor = anchorDate; + if (!anchor) { + return; + } + + const alreadyVisible = isDateWithinVisibleMonths({ + date: anchor, + startOfLeftVisibleMonth: startOfLeftVisibleMonthRef.current, + showSecondMonth: isTwoMonthsVisible, + }); + + if (!alreadyVisible) { setDisplayDate(getFirstOfMonth(anchor)); - setFocusedDate(selectedDate ?? endDate ?? new Date()); } - }, [isCalendarOpen, selectedDate, endDate]); + setFocusedDate(anchor); + }, [isCalendarOpen, anchorDate, isTwoMonthsVisible]); const onDateSelect = useCallback( (date: Date) => { @@ -235,7 +262,9 @@ export const DatePickerCalendar: React.FC = ({ headingId={headingId} hideLastNav locale={locale} - onDisplayDateChange={setDisplayDate} + onDisplayDateChange={() => + setDisplayDate((prev) => addMonths({ date: prev, n: 1 })) + } /> = ({ shouldDisableDate={shouldDisableDate} weekStartsOn={weekStartsOn} onDateSelect={onDateSelect} - onDisplayDateChange={setDisplayDate} + onDisplayDateChange={() => + setDisplayDate((prev) => addMonths({ date: prev, n: 1 })) + } onEscapeKeyPress={closeCalendar} onFocusedDateChange={onFocusedDateChange} /> From 885bb2d01d7e61ba0fa6b93d1234904bd1377b70 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Tue, 21 Apr 2026 10:21:44 -0400 Subject: [PATCH 088/110] pr feedback --- .../gamut/__tests__/__snapshots__/gamut.test.ts.snap | 10 ++-------- .../gamut/src/DatePicker/DatePickerInput/elements.tsx | 2 +- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap index f9500f06cd6..7bdb1824d89 100644 --- a/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap +++ b/packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Gamut Exported Keys 1`] = ` [ @@ -14,12 +14,6 @@ exports[`Gamut Exported Keys 1`] = ` "BodyPortal", "Box", "Breadcrumbs", - "CalendarBody", - "CalendarFooter", - "CalendarHeader", - "CalendarNavLastMonth", - "CalendarNavNextMonth", - "CalendarWrapper", "Card", "Checkbox", "Coachmark", @@ -41,7 +35,6 @@ exports[`Gamut Exported Keys 1`] = ` "DataTable", "DatePicker", "DatePickerCalendar", - "DatePickerContext", "DatePickerInput", "DatePickerProvider", "DelayedRenderWrapper", @@ -80,6 +73,7 @@ exports[`Gamut Exported Keys 1`] = ` "ListCol", "ListRow", "Markdown", + "matchDisabledDates", "Menu", "MenuItem", "MenuSeparator", diff --git a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx index 133202bd49c..ebd8c007349 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/elements.tsx @@ -12,7 +12,7 @@ import { const shellFocusStyles = variant({ variants: { error: { - // borderColor: 'feedback-error', + borderColor: 'feedback-error', '&:hover': { borderColor: 'feedback-error', }, From fbcdf1389ad4af4a4eb81c868d6ec98c843a6e60 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 22 Apr 2026 13:40:35 -0400 Subject: [PATCH 089/110] fix bugs --- .../Calendar/CalendarBody.tsx | 12 +++- .../Calendar/CalendarHeader.tsx | 6 ++ .../Calendar/CalendarNavLastMonth.tsx | 19 ++++- .../Calendar/CalendarNavNextMonth.tsx | 19 ++++- .../DatePickerCalendar/Calendar/types.ts | 15 ++++ .../DatePicker/DatePickerCalendar/index.tsx | 70 ++++++++++++++----- .../DatePickerInput/Segment/index.tsx | 16 +++-- .../src/DatePicker/DatePickerInput/index.tsx | 35 ++++++++-- 8 files changed, 156 insertions(+), 36 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx index 190c810661e..cbc26dca4ce 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx @@ -32,6 +32,7 @@ export const CalendarBody: React.FC = ({ hasAdjacentMonthLeft, focusGridSync, calendarKeyboardSurfaceRef, + pauseGridRoving, }) => { const resolvedLocale = useResolvedLocale(locale); const firstWeekday = useIsoFirstWeekday(resolvedLocale, weekStartsOn); @@ -83,6 +84,13 @@ export const CalendarBody: React.FC = ({ const focusInSharedSurface = calendarKeyboardSurfaceRef?.current?.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 calendarKeyboardSurfaceRef, so we must still sync. @@ -133,6 +141,7 @@ export const CalendarBody: React.FC = ({ /** Re-run when the month grid remounts so we can re-attach roving focus after displayDate changes. */ year, month, + pauseGridRoving, ]); const onKeyDown = useCallback( @@ -210,6 +219,7 @@ export const CalendarBody: React.FC = ({ const today = isToday(date); const isFocused = focusTarget !== null && isSameDay(date, focusTarget); + const rovingTabIndex = pauseGridRoving ? -1 : isFocused ? 0 : -1; return ( = ({ key={date.getTime()} ref={(el) => setButtonRef(date, el as HTMLElement | null)} role="gridcell" - tabIndex={isFocused ? 0 : -1} + tabIndex={rovingTabIndex} onClick={() => { if (!disabled) onDateSelect(date); }} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx index e18c66de9cc..9b7e03011b1 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarHeader.tsx @@ -17,6 +17,8 @@ export const CalendarHeader: React.FC = ({ hideNextNav, onLastMonthClick, onNextMonthClick, + interceptTabToGrid, + onTabIntoGrid, }) => { const resolvedLocale = useResolvedLocale(locale); @@ -25,9 +27,11 @@ export const CalendarHeader: React.FC = ({ {!hideLastNav && ( )} @@ -44,9 +48,11 @@ export const CalendarHeader: React.FC = ({ {!hideNextNav && ( )} diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx index e33a7be4b6f..9f548623e7a 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavLastMonth.tsx @@ -10,13 +10,15 @@ 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 handleLastMonth = () => { + const handleClick = (e: React.MouseEvent) => { const lastMonth = new Date( displayDate.getFullYear(), displayDate.getMonth() - 1, @@ -24,18 +26,29 @@ export const CalendarNavLastMonth: React.FC = ({ ); onDisplayDateChange?.(lastMonth); onLastMonthClick?.(); - buttonRef.current?.blur(); + 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 index 39abb8edab0..5a84e5341d5 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarNavNextMonth.tsx @@ -10,13 +10,15 @@ 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 handleNextMonth = () => { + const handleClick = (e: React.MouseEvent) => { const nextMonth = new Date( displayDate.getFullYear(), displayDate.getMonth() + 1, @@ -24,18 +26,29 @@ export const CalendarNavNextMonth: React.FC = ({ ); onDisplayDateChange?.(nextMonth); onNextMonthClick?.(); - buttonRef.current?.blur(); + 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/types.ts b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts index 0799b1b52f7..2308c0b3628 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/types.ts @@ -19,6 +19,16 @@ export interface CalendarNavProps onLastMonthClick?: () => void; /** Called after navigating to next month */ onNextMonthClick?: () => void; + /** + * When true, Tab (forward) on a chevron runs `onTabIntoGrid` instead of the default order. + * Set when the user just changed the month and the grid is still "paused" until they enter it. + */ + interceptTabToGrid?: boolean; + /** + * When `interceptTabToGrid` is set: move focus into the day grid with an appropriate roving date. + * Omit in standalone calendar stories. + */ + onTabIntoGrid?: () => void; } export interface CalendarHeaderProps extends CalendarNavProps { @@ -69,6 +79,11 @@ export interface CalendarBodyProps extends CalendarBaseProps { * subtree, roving tabindex may move DOM focus between grids; omit so only this table counts. */ calendarKeyboardSurfaceRef?: RefObject; + /** + * After a header month change: all day cells stay tabIndex -1 until the user moves into the grid + * (Tab on a chevron, etc.). + */ + pauseGridRoving?: boolean; } export interface CalendarFooterProps { diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx index 7a03a739c26..4048077d242 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -104,12 +104,6 @@ export const DatePickerCalendar: React.FC = ({ const [focusedDate, setFocusedDate] = useState( () => selectedDate ?? endDate ?? new Date() ); - const onFocusedDateChange = useCallback( - (date: Date | null) => { - setFocusedDate(date); - }, - [setFocusedDate] - ); const focusTarget = focusedDate ?? selectedDate ?? endDate ?? new Date(); const secondMonthDate = addMonths({ date: displayDate, n: 1 }); @@ -120,29 +114,60 @@ export const DatePickerCalendar: React.FC = ({ /** Wraps both month grids so keyboard focus can move between them without treating it as “outside” the calendar. */ const calendarKeyboardSurfaceRef = useRef(null); + /** Set after header month chevrons; cleared when the user enters the grid or the anchor updates. */ + 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(() => { + calendarKeyboardSurfaceRef.current + ?.querySelector('[role="gridcell"][tabindex="0"]') + ?.focus(); + }); + }, [displayDate, resumeGridRoving]); + // When the committed anchor changes while the popover is open (typed input, grid, quick action), // move focus to that day. Shift the visible month pair only if the anchor is not already shown // (including the second column in a two-month layout), so picking in the right-hand month does // not jump the view. useEffect(() => { - if (!isCalendarOpen) { - return; - } - const anchor = anchorDate; - if (!anchor) { + if (!isCalendarOpen || !anchorDate) { return; } const alreadyVisible = isDateWithinVisibleMonths({ - date: anchor, + date: anchorDate, startOfLeftVisibleMonth: startOfLeftVisibleMonthRef.current, showSecondMonth: isTwoMonthsVisible, }); if (!alreadyVisible) { - setDisplayDate(getFirstOfMonth(anchor)); + setDisplayDate(getFirstOfMonth(anchorDate)); } - setFocusedDate(anchor); + setFocusedDate(anchorDate); }, [isCalendarOpen, anchorDate, isTwoMonthsVisible]); const onDateSelect = useCallback( @@ -175,8 +200,9 @@ export const DatePickerCalendar: React.FC = ({ const clearDate = useCallback(() => { if (isRange) context.onRangeSelection(null, null); else context.onSelection(null); + resumeGridRoving(); setFocusedDate(displayDate); - }, [isRange, context, setFocusedDate, displayDate]); + }, [isRange, context, setFocusedDate, displayDate, resumeGridRoving]); const computedQuickActions: CalendarQuickAction[] = useMemo(() => { return quickActions.slice(0, 3).map((action) => ({ @@ -235,8 +261,10 @@ export const DatePickerCalendar: React.FC = ({ displayDate={displayDate} headingId={headingId} hideNextNav={isTwoMonthsVisible} + interceptTabToGrid={pauseGridRoving} locale={locale} - onDisplayDateChange={setDisplayDate} + onDisplayDateChange={(next) => beginHeaderMonthChange(() => next)} + onTabIntoGrid={onTabFromMonthNav} /> = ({ hasAdjacentMonthRight={isTwoMonthsVisible} labelledById={headingId} locale={locale} + pauseGridRoving={pauseGridRoving} selectedDate={selectedDate} shouldDisableDate={shouldDisableDate} weekStartsOn={weekStartsOn} onDateSelect={onDateSelect} onDisplayDateChange={setDisplayDate} onEscapeKeyPress={closeCalendar} - onFocusedDateChange={setFocusedDate} + onFocusedDateChange={onFocusedDateChangeFromGrid} /> @@ -261,10 +290,12 @@ export const DatePickerCalendar: React.FC = ({ displayDate={secondMonthDate} headingId={headingId} hideLastNav + interceptTabToGrid={pauseGridRoving} locale={locale} onDisplayDateChange={() => - setDisplayDate((prev) => addMonths({ date: prev, n: 1 })) + beginHeaderMonthChange((prev) => addMonths({ date: prev, n: 1 })) } + onTabIntoGrid={onTabFromMonthNav} /> = ({ hasAdjacentMonthLeft={isTwoMonthsVisible} labelledById={headingId} locale={locale} + pauseGridRoving={pauseGridRoving} selectedDate={selectedDate} shouldDisableDate={shouldDisableDate} weekStartsOn={weekStartsOn} @@ -283,7 +315,7 @@ export const DatePickerCalendar: React.FC = ({ setDisplayDate((prev) => addMonths({ date: prev, n: 1 })) } onEscapeKeyPress={closeCalendar} - onFocusedDateChange={onFocusedDateChange} + onFocusedDateChange={onFocusedDateChangeFromGrid} />
diff --git a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx index d1d84920cd0..abd35f679fd 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx @@ -95,7 +95,9 @@ export const DatePickerInputSegment: React.FC = ({ ...prev, [field]: spinSegment({ field, segments: prev, delta: 1 }), }; - applySegments(next); + queueMicrotask(() => { + applySegments(next); + }); return next; }); return; @@ -108,7 +110,9 @@ export const DatePickerInputSegment: React.FC = ({ ...prev, [field]: spinSegment({ field, segments: prev, delta: -1 }), }; - applySegments(next); + queueMicrotask(() => { + applySegments(next); + }); return next; }); return; @@ -122,7 +126,9 @@ export const DatePickerInputSegment: React.FC = ({ ...prev, [field]: prev[field].slice(0, -1), }; - applySegments(next); + queueMicrotask(() => { + applySegments(next); + }); return next; } if (prevField) { @@ -146,7 +152,9 @@ export const DatePickerInputSegment: React.FC = ({ digit: e.key, }), }; - applySegments(next); + queueMicrotask(() => { + applySegments(next); + }); const maxLen = segmentMaxLength(field); if (next[field].length >= maxLen && nextField) { queueMicrotask(() => onSiblingFocus(nextField)); diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index 4b13c6d6957..bdce6907458 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -13,6 +13,8 @@ import { 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'; @@ -62,6 +64,7 @@ export const DatePickerInput = forwardRef( locale, isCalendarOpen, translations, + shouldDisableDate, } = context; const isRange = mode === 'range'; @@ -138,11 +141,17 @@ export const DatePickerInput = forwardRef( context.onSelection(parsed); } if (isRange && rangePart) { - if (rangePart === 'start') context.onRangeSelection(parsed, endDate); - else context.onRangeSelection(date, parsed); + handleDateSelectRange({ + date: parsed, + activeRangePart: rangePart, + startDate: date, + endDate, + onRangeSelection: context.onRangeSelection, + shouldDisableDate, + }); } }, - [isRange, rangePart, context, endDate, date] + [isRange, rangePart, context, endDate, date, shouldDisableDate] ); const clearSelection = useCallback(() => { @@ -172,17 +181,31 @@ export const DatePickerInput = forwardRef( const normalized = normalizeSegmentValues(prev); const parsed = parseSegmentsToDate(normalized); if (parsed) { - commitParsedDate(parsed); + const sameAsBound = isSameDay(parsed, boundDate); + if (isCalendarOpen && !sameAsBound) { + queueMicrotask(() => { + commitParsedDate(parsed); + }); + } return normalized; } if (!normalized.month && !normalized.day && !normalized.year) { - clearSelection(); + queueMicrotask(() => { + clearSelection(); + }); return getDateSegmentsFromDate(null); } return segmentsFromBound; }); }, - [containerRef, segmentsFromBound, clearSelection, commitParsedDate] + [ + containerRef, + boundDate, + segmentsFromBound, + clearSelection, + commitParsedDate, + isCalendarOpen, + ] ); const setActiveRangePartForField = useCallback(() => { From 2475b6b2a4fe3ff34775fedb835f2dc666101fb3 Mon Sep 17 00:00:00 2001 From: Amy Resnik Date: Wed, 22 Apr 2026 17:30:36 -0400 Subject: [PATCH 090/110] id clean up --- packages/gamut/src/DatePicker/DatePicker.tsx | 2 -- .../src/DatePicker/DatePickerCalendar/index.tsx | 17 ++++++++++------- .../DatePickerContext/__tests__/mockContexts.ts | 2 -- .../src/DatePicker/DatePickerContext/types.ts | 1 - .../src/DatePicker/DatePickerInput/index.tsx | 5 ++--- .../Organisms/DatePicker/DatePicker.stories.tsx | 7 +++---- 6 files changed, 15 insertions(+), 19 deletions(-) diff --git a/packages/gamut/src/DatePicker/DatePicker.tsx b/packages/gamut/src/DatePicker/DatePicker.tsx index 1ee115b813a..dc702af3c8d 100644 --- a/packages/gamut/src/DatePicker/DatePicker.tsx +++ b/packages/gamut/src/DatePicker/DatePicker.tsx @@ -82,7 +82,6 @@ export const DatePicker: React.FC = (props) => { closeCalendar, locale: resolvedLocale, shouldDisableDate, - calendarDialogId, translations, quickActions: quickActions === null ? [] : resolvedQuickActions, }; @@ -118,7 +117,6 @@ export const DatePicker: React.FC = (props) => { clearGridFocusRequest, closeCalendar, shouldDisableDate, - calendarDialogId, props, activeRangePart, ]); diff --git a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx index 4048077d242..4bc57779b09 100644 --- a/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerCalendar/index.tsx @@ -51,11 +51,14 @@ export const DatePickerCalendar: React.FC = ({ }) => { const context = useDatePicker(); const generatedId = useId(); - const fallbackDialogId = `datepicker-calendar-${generatedId.replace( + const headingLeftId = `datepicker-calendar-left-month-heading-${generatedId.replace( + /:/g, + '' + )}`; + const headingRightId = `datepicker-calendar-right-month-heading-${generatedId.replace( /:/g, '' )}`; - const headingId = dialogId ?? context?.calendarDialogId ?? fallbackDialogId; if (context === null) { throw new Error( @@ -254,12 +257,12 @@ export const DatePickerCalendar: React.FC = ({ ]); return ( - + = ({ focusGridSync={focusGridSync} focusedDate={focusTarget} hasAdjacentMonthRight={isTwoMonthsVisible} - labelledById={headingId} + labelledById={headingLeftId} locale={locale} pauseGridRoving={pauseGridRoving} selectedDate={selectedDate} @@ -288,7 +291,7 @@ export const DatePickerCalendar: React.FC = ({ = ({ focusGridSync={focusGridSync} focusedDate={focusTarget} hasAdjacentMonthLeft={isTwoMonthsVisible} - labelledById={headingId} + labelledById={headingRightId} locale={locale} pauseGridRoving={pauseGridRoving} selectedDate={selectedDate} diff --git a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts index aa1faef2e06..33ea42ef641 100644 --- a/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts +++ b/packages/gamut/src/DatePicker/DatePickerContext/__tests__/mockContexts.ts @@ -17,7 +17,6 @@ export function createMockSingleContext( gridFocusRequested: false, clearGridFocusRequest: jest.fn(), closeCalendar: jest.fn(), - calendarDialogId: 'test-datepicker-dialog', translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, quickActions: [], selectedDate: new Date(2024, 2, 15), @@ -39,7 +38,6 @@ export function createMockRangeContext( gridFocusRequested: false, clearGridFocusRequest: jest.fn(), closeCalendar: jest.fn(), - calendarDialogId: 'test-datepicker-dialog', translations: { ...DEFAULT_DATE_PICKER_TRANSLATIONS }, quickActions: [], startDate: null, diff --git a/packages/gamut/src/DatePicker/DatePickerContext/types.ts b/packages/gamut/src/DatePicker/DatePickerContext/types.ts index 44328544a63..0b6c69d1215 100644 --- a/packages/gamut/src/DatePicker/DatePickerContext/types.ts +++ b/packages/gamut/src/DatePicker/DatePickerContext/types.ts @@ -25,7 +25,6 @@ interface DatePickerBaseContextValue /** Clears `gridFocusRequested` after focus has moved into the grid (or call when closing). */ clearGridFocusRequest: () => void; closeCalendar: () => void; - calendarDialogId: string; /** UI string overrides (e.g. clear button). */ translations: Required; /** Calendar footer quick actions (max 3 shown). */ diff --git a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx index bdce6907458..054c457ee1e 100644 --- a/packages/gamut/src/DatePicker/DatePickerInput/index.tsx +++ b/packages/gamut/src/DatePicker/DatePickerInput/index.tsx @@ -78,8 +78,6 @@ export const DatePickerInput = forwardRef( const layout = getDateFormatLayout(locale); return { layout, fieldOrder: getDateFieldOrder(layout) }; }, [locale]); - const firstField = fieldOrder[0]; - const firstFieldId = `${inputId}-${firstField}`; const defaultLabel = !isRange ? translations.dateLabel @@ -236,7 +234,7 @@ export const DatePickerInput = forwardRef( return ( ( width="fit-content" > (null); return ( @@ -179,8 +178,8 @@ function ComposedDatePickerLayout() { targetRef={inputRef} onRequestClose={closeCalendar} > -