feat(Date Picker): Add Date Picker component#2660
Conversation
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
…mmon Move the month-navigation hook and the shared date math out of Calendar into packages/react/src/common, so calendar-style components (the upcoming Date Picker) can reuse them instead of duplicating the logic. No behaviour change to Calendar.
Add DatePicker, an accessible role=grid widget for selecting a single date or a date range, in Components/Forms. It is controlled (value/onChange) with a single | range mode union, supports minDate/maxDate bounds and per-date disabling, and implements the WAI-ARIA grid keyboard pattern with focus that follows across month boundaries. Its tokens and CSS alias the Calendar tokens so the two stay visually in step.
Coverage Report for React components
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Size Change: +3.19 kB (+0.89%) Total Size: 361 kB 📦 View Changed
ℹ️ View Unchanged
|
There was a problem hiding this comment.
Pull request overview
Adds a new DatePicker form component to the design system, including its React implementation (with keyboard-focused ARIA grid behavior), CSS styling, design tokens, Storybook documentation/stories, and unit tests—completing the Calendar vs DatePicker split described in the PR.
Changes:
- Introduces
DatePickerReact component with header + grid + day subcomponents, date/range selection logic, and keyboard navigation helpers. - Adds CSS + design tokens for Date Picker (largely aliased to Calendar tokens) and wires the new stylesheet into the CSS bundle.
- Adds Storybook stories/docs and comprehensive unit tests for the component and its utilities.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| storybook/src/components/DatePicker/DatePicker.test.stories.tsx | Adds non-dev “test variants” story for visual/regression coverage. |
| storybook/src/components/DatePicker/DatePicker.stories.tsx | Adds interactive single/range/disabled/limited examples for Storybook. |
| storybook/src/components/DatePicker/DatePicker.docs.mdx | Adds documentation page content + token table for Date Picker. |
| packages/react/src/index.ts | Exports DatePicker from the React package barrel. |
| packages/react/src/DatePicker/utils.ts | Adds date/range utilities + keyboard focus date computation helpers. |
| packages/react/src/DatePicker/utils.test.ts | Adds unit tests for the DatePicker utility functions. |
| packages/react/src/DatePicker/README.md | Adds React package README that links to CSS docs. |
| packages/react/src/DatePicker/index.ts | Adds component/module entrypoint exports for DatePicker + types. |
| packages/react/src/DatePicker/DatePickerHeader.tsx | Adds month/year navigation header for DatePicker with IconButtons. |
| packages/react/src/DatePicker/DatePickerHeader.test.tsx | Adds tests for header caption, labels, disabling, and handlers. |
| packages/react/src/DatePicker/DatePickerGrid.tsx | Adds ARIA grid rendering of weekdays + weeks + day cells. |
| packages/react/src/DatePicker/DatePickerGrid.test.tsx | Adds tests for grid labeling, weekday headers, and week structure. |
| packages/react/src/DatePicker/DatePickerDay.tsx | Adds a selectable day button with accessible full-date label. |
| packages/react/src/DatePicker/DatePickerDay.test.tsx | Adds tests for day rendering, selection semantics, and click behavior. |
| packages/react/src/DatePicker/DatePicker.tsx | Adds the main controlled DatePicker with selection + keyboard navigation. |
| packages/react/src/DatePicker/DatePicker.test.tsx | Adds integration-style tests for selection, bounds, and keyboard focus behavior. |
| packages/css/src/components/index.scss | Includes Date Picker styles in the CSS components bundle. |
| packages/css/src/components/date-picker/README.md | Adds CSS component documentation/guidelines for Date Picker. |
| packages/css/src/components/date-picker/date-picker.scss | Adds Date Picker BEM styles for header/grid/day states. |
| packages-proprietary/tokens/src/components/ams/date-picker.tokens.json | Adds design tokens for Date Picker, largely aliasing Calendar tokens. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
# Conflicts: # packages/css/src/components/calendar/README.md # packages/react/src/Calendar/Calendar.tsx # packages/react/src/Calendar/CalendarBody.tsx # packages/react/src/Calendar/CalendarDay.tsx # packages/react/src/Calendar/CalendarHeader.tsx # packages/react/src/common/useMonthNavigation.tsx # storybook/src/components/Calendar/Calendar.docs.mdx # storybook/src/components/Calendar/Calendar.stories.tsx
- Replace display:contents on role="row" elements with CSS subgrid so rows stay in the accessibility tree - Call preventDefault() for any recognised navigation key, even when the target date is out of bounds, to prevent unintended page scroll - Correct the onChange docstring: single mode never calls onChange(null) - Add the Monday-anchor comment to the weekday header date, matching CalendarBody - Add a test covering unrecognised keys on the grid
|
/Chromatic test |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
The nested grid approach (display:grid + grid-column:1/-1) broke the 7-column layout. display:contents keeps cells as direct participants in the parent grid, which is the only reliable approach for this structure. Modern browsers (Chrome 85+, Firefox 98+, Safari 17.4+) keep elements with explicit ARIA roles in the accessibility tree through display:contents, so the role="row" semantics are preserved.
Applies the documentation content model from develop: - Replaces README import + Markdown block with Title + Description pulled from TSDoc - Adds Usage guidelines (when/when not/how to use), Features, and Accessibility sections - Moves keyboard navigation into a dedicated Features subsection - Rewrites See also entries with the dash separator format - Deletes the CSS and React README files now that content is in the MDX
RubenSibon
left a comment
There was a problem hiding this comment.
Review in progress...
| Use a [Date Input](/docs/components-forms-date-input--docs) instead when the user knows the date and can type it into a form field. | ||
| Use a [Calendar](/docs/components-navigation-calendar--docs) instead to browse content grouped by date, such as an events calendar. |
There was a problem hiding this comment.
| Use a [Date Input](/docs/components-forms-date-input--docs) instead when the user knows the date and can type it into a form field. | |
| Use a [Calendar](/docs/components-navigation-calendar--docs) instead to browse content grouped by date, such as an events calendar. | |
| - Use a [Date Input](/docs/components-forms-date-input--docs) when the user knows the date and can type it into a form field. | |
| - Use a [Calendar](/docs/components-navigation-calendar--docs) to browse content grouped by date, such as an events calendar. |
“Instead” is not needed because the heading ‘When not to use’ already implies that these are alternative use-cases.
I think that making this a list adds clarity.
I’ve considered switching the use-case and the imperative form:
- When the user knows the date and can type it into a form field use a [Date Input](/docs/components-forms-date-input--docs).
But I guess it’s better the way it was.
| Use `minDate` and `maxDate` to bound the selectable period. | ||
| Dates outside the range cannot be selected, and the month and year navigation buttons stop at the edges of the range. | ||
|
|
||
| <Canvas of={DatePickerStories.LimitedToOneMonth} /> |
There was a problem hiding this comment.
Maybe it would be nice if this example or another example showed what the Date Picker looks like when the minDate or maxDate are inside the current month. What do the not-selectable dates look like?
It’s not possible to play around with the min- and maxDate in the controls. Same for many of the other props. Why is this so?
EDIT: I’ve learned why.
There was a problem hiding this comment.
Clicking on the Show code buttons in the demo takes me to an empty Storybook view. What’s going on?
|
|
||
| <Primary /> | ||
|
|
||
| <Controls /> |
There was a problem hiding this comment.
Why are many of the controls not editable?
EDIT: I’ve learned why.
|
|
||
| ### Range selection | ||
|
|
||
| <Canvas of={DatePickerStories.Range} /> |
There was a problem hiding this comment.
I think this example could use a bit more guidelines. How will users know that this a date range selection? Developers or content editors need to add that context with text. The component in its bare state does not communicate “I’m a date range picker!”.
| mode: { control: false }, // The story wrapper owns this prop. | ||
| onChange: { control: false }, // The story wrapper owns this prop. | ||
| value: { control: false }, // The story wrapper owns this prop. | ||
| }, |
There was a problem hiding this comment.
Okay, so now I understand why the controls are not editable. But users will not see these comments, so it might be confusing why they cannot play around with them.
And if we could have some kind of workaround to get these controls working, that would be a very nice feature. Disabling controls because of some internal technical reason is not optimal.
…t/DES-1856-date-picker
There was a problem hiding this comment.
Nice! Our own date utils instead of a third-party lib.
| export const isOutOfBounds = (date: Date, minDate?: Date, maxDate?: Date) => | ||
| (minDate !== undefined && startOfDay(date) < startOfDay(minDate)) || | ||
| (maxDate !== undefined && startOfDay(date) > startOfDay(maxDate)) |
There was a problem hiding this comment.
I guess our linter allows this, but I don’t like arrow functions with an implicit return were the return block is on the next (multiple) lines (see also arrow-body-style#as-needed). I would prefer:
| export const isOutOfBounds = (date: Date, minDate?: Date, maxDate?: Date) => | |
| (minDate !== undefined && startOfDay(date) < startOfDay(minDate)) || | |
| (maxDate !== undefined && startOfDay(date) > startOfDay(maxDate)) | |
| export const isOutOfBounds = (date: Date, minDate?: Date, maxDate?: Date) => { | |
| return (minDate !== undefined && startOfDay(date) < startOfDay(minDate)) || | |
| (maxDate !== undefined && startOfDay(date) > startOfDay(maxDate)) | |
| } |
Same for the other functions with this pattern.
| readonly goToNextMonth: () => void | ||
| readonly goToNextYear: () => void | ||
| readonly goToPreviousMonth: () => void | ||
| readonly goToPreviousYear: () => void | ||
| readonly month: Date |
There was a problem hiding this comment.
Why do these props not have comments?
| /** Selects a single date. This is the default mode. */ | ||
| readonly mode?: 'single' | ||
| /** Called when the user selects a date. */ | ||
| readonly onChange: (value: Date | null) => void |
There was a problem hiding this comment.
DatePickerSingleProps.onChange is typed (value: Date | null) => void, but implementation only ever calls it with a Date.
Why it matters: public API type/documentation mismatch; consumers may expect null events that never happen.
Suggestion: either narrow to (value: Date) => void or explicitly document future/intentional nullable callback behaviour.
Credits: GitHub Copilot.
| if (start !== null && isSameDay(date, start)) { | ||
| return rangeStartAccessibleName | ||
| } | ||
|
|
||
| if (end !== null && isSameDay(date, end)) { | ||
| return rangeEndAccessibleName | ||
| } |
There was a problem hiding this comment.
In range mode, if user picks the same date twice (start === end), nextRange creates a complete range, but the accessible name only gets rangeStartAccessibleName (not end), because getBoundaryLabel returns early on start match.
packages/react/src/DatePicker/utils.ts(nextRange)packages/react/src/DatePicker/DatePicker.tsx(getBoundaryLabel)
Why it matters: this makes a completed one-day range indistinguishable (for screen readers) from an incomplete range with only start set.
Suggestion: when start and end are both set and same day, append both labels (e.g. "startdatum, einddatum"), and add a test for this case.
At the moment, in these stories, a user cannot input the same date for both the start and end with mouse pointer, touch or keyboard. But maybe we still should make the underlying logic even more robust?
Good question — and yes, I’d still make it robust.
I do think this case is currently possible in the component itself:
- In range mode, first pick sets
{ start: date, end: null }. - Picking the same date again goes through
nextRangeand returns{ start: date, end: date }. - That can happen via click/tap or pressing
Enter/Spacetwice on the focused day.
Even if your product flow usually avoids that, DatePicker is controlled, so a parent can also pass { start, end } with equal dates directly.
So I’d recommend a defensive fix anyway. Two valid approaches:
-
Allow one-day ranges (recommended)
Keep current selection logic, but ifstart === end, announce both boundaries in the accessible name (and add a test). -
Disallow one-day ranges
Normalize innextRangeso a second pick on the same date does not setend(or restarts range), and document that rule.
Credits: GitHub Copilot
There was a problem hiding this comment.
Right now sharing is mostly date-picker -> calendar aliasing. That works, but conceptually it couples DatePicker to Calendar naming.
A cleaner long-term model is an internal foundation layer, e.g. ams.date-grid.*:
ams.date-grid.font-*ams.date-grid.gapams.date-grid.inline-sizeams.date-grid.day.current.font-weightams.date-grid.day.outline-offset
Then:
ams.calendar.*aliasesams.date-grid.*ams.date-picker.*aliasesams.date-grid.*- component-specific tokens stay local (
calendar.day.link.*,date-picker.day.selected.*, etc.)
This gives shared styling without implying Calendar is the “source of truth” for DatePicker.
Add Date Picker component
Links
What
Adds
DatePicker, a form control for selecting a single date or a date range, in Components / Forms.Why
It completes the Calendar / Date Picker split. Calendar covers browsing content grouped by date; the Date Picker is the form control for choosing a date or range — a grid of day buttons with full keyboard support — which is a distinct concern with different markup and interaction.
How
modediscriminated union: single mode takes aDate | null, range mode (mode="range") takes aDateRange({ start, end }).The first pick sets the start, the second sets the end; picking before the current start begins a new range.
minDateandmaxDatebound both selection and month navigation;isDateDisabledmarks individual dates unavailable while keeping them reachable by keyboard.role="grid"with a rovingtabindex, arrow / Home / End / Page navigation, and Enter or Space to select. Focus follows keyboard navigation and click across month boundaries. The grid takes its accessible name from the visible month caption; each day button is labelled with its full date including weekday.aria-multiselectable="true"on the grid and marks every date within the selection asaria-selected. The start and end dates appendrangeStartAccessibleName/rangeEndAccessibleNameto their accessible names (default:'startdatum'/'einddatum'), so screen readers distinguish them from intermediate selected dates.Highlight/HighlightTextso the selection remains visible when the OS overrides colours.useMonthNavigationhook and date helpers shared with Calendar.Checklist
/chromatic testand verify visual regression tests passAdditional notes
rgb(0 70 153 / 12.5%)value — there is no neutral-surface token yet, so this is a temporary workaround.