Skip to content

feat(DatePicker): New DatePicker component#3286

Open
aresnik11 wants to merge 96 commits intomainfrom
ajr-datepicker-localization
Open

feat(DatePicker): New DatePicker component#3286
aresnik11 wants to merge 96 commits intomainfrom
ajr-datepicker-localization

Conversation

@aresnik11
Copy link
Copy Markdown
Contributor

@aresnik11 aresnik11 commented Mar 17, 2026

Overview

Adds DatePicker to Gamut: a locale-aware, accessible date (or date range) picker with segmented inputs, a popover calendar, keyboard support, and optional composition via context.

Modes

  • Single dateselectedDate / onSelected.
  • Date rangestartDate, endDate, onStartSelected, onEndSelected;
  • Calendar closes once a single date or date range is selected.

Default UI vs composition

  • Default — text input(s) + calendar in a popover under the input.
  • Custom — pass children for layout only; compose DatePickerInput and DatePickerCalendar (calendar requires DatePicker context).

Segmented inputs

  • Segmented entry (month / day / year) with locale-driven order and separators (Intl-based layout).
  • spinbutton pattern (role="spinbutton"), which matches Arrow Up/Down stepping and numeric constraints.
  • Live typing with blur normalization when input is invalid or partial.
  • Empty input clears the relevant selection (single or range bound).
  • Hidden input submits an ISO 8601 date-only value for forms (name / form supported).

Calendar & layout

  • Responsivetwo adjacent months from the xs breakpoint up; one month on smaller viewports.
  • Month navigation with nav adjusted for two-month view.
  • Week starts from Intl.Locale#getWeekInfo() (polyfill when needed), optional weekStartsOn on DatePickerCalendar.

Selection behavior

Disabled dates

  • shouldDisableDate — callback prop that should return true for any dates that are disabled; integrated into range validation.

Footer

  • Today — select today and align visible month(s).
  • Clearrange mode; clears range; disabled when empty.

Keyboard & focus

  • Each input segment is a role="spinbutton" span (tabIndex={0} when enabled). Focus moves with Tab / Shift+Tab like normal focusable controls. **Arrow Left / Right ** moves focus within the segments. Arrow Up / Arrow Down steps the current segment up or down, clamped to min/max for that field. Month: 1–12. Day: 1–last day of month when month/year are known. Year: 1–9999; if empty, stepping uses sensible defaults (e.g. current year when stepping up from empty on year).
  • Alt + ArrowDown from input opens calendar and moves focus into the grid (or focuses grid if already open).
  • Open via click keeps focus on the input (pointer-friendly / WCAG-oriented).
  • Grid — arrows (day/week), Home / End (row), PageUp / PageDown (month; Shift for year), Enter / Space to select, Escape closes and returns focus to input.
  • Two-month — horizontal arrows can move between visible months appropriately.

Accessibility

  • Calendar role="dialog" with configurable aria-label.
  • Input shell uses role="group"; FormGroup associates the visible label with the first segment via htmlFor / id.
  • Visual focus: The shell uses :focus-within so the field still shows focus when any inner segment is focused.
  • Input segments use role="spinbutton", matching Arrow Up/Down stepping and numeric min/max.
  • Input segments include:
    • aria-valuemin / aria-valuemax — match spin bounds (day max depends on month/year when known).
    • aria-valuenow — when there is a numeric value; omitted when empty.
    • aria-valuetext — display string (digits or placeholders like MM / DD / YYYY).
    • aria-label — field name (month, day, year).
    • aria-invalid — validation/error state.
    • aria-disabled and tabIndex={-1} when disabled.
  • Grid tied to month heading and per-day accessible names.

Internationalization

  • locale usesIntl.LocalesArgument, defaults to runtime locale but ability to override via locale prop
  • translations for clear button, field labels, and dialog label. default values in English but ability to override via translations prop
  • weekStartsOn uses Intl.Locale#getWeekInfo() (polyfill when needed) but ability to override via weekStartsOn prop
  • Calendar month/year, weekday table headers, placeholder date format (MM/DD/YYYY), date cell aria labels, are automatically localized to the locale via Intl.DateTimeFormat
  • Last month/next month tip text and today button text are automatically localized to the locale via Intl.RelativeTimeFormat

Other

  • inputSize passes through to Input size in the default layout.

PR Checklist

Testing Instructions

Single select - mouse

  1. Go to DatePicker default story or initial date story
  2. Confirm clicking anywhere on the input, calendar icon, and segments opens the calendar
  3. Confirm 2 months are shown in the calendar
  4. Hover over dates in calendar and confirm they have a hover state
  5. Find todays date in the calendar and confirm it has a little dot
  6. Click next or previous month arrows and confirm calendar moves 1 month to the left or right - i.e. if May June are shown and you click next, it should be June July. Confirm the tooltips don't hang around
  7. Make your screen smaller, confirm 1 month is shown in the calendar
  8. Click next or previous month arrows and confirm calendar moves 1 month to the left or right
  9. Confirm clicking outside of the calendar closes it and focuses the segment
  10. With calendar open, select a day. Confirm calendar closes and the input shows the correct date
  11. Click to open calendar and confirm selected date is styled and shown as selected
  12. Click next or previous month and confirm calendar moves but selected date stays the same
  13. Click a new date in calendar, confirm selected date updates in calendar and in input
  14. Click the currently selected date in calendar, confirm date is unselected and input is empty
  15. In default story confirm there are no quick actions and there is no footer
  16. In initial date story confirm there are quick actions and that clicking one one selects the correct date

Single select - keyboard

  1. Go to DatePicker default story or initial date story
  2. Navigating with your keyboard, use tab to focus the input. The input should have a hyper border and the first segment should be highlighted
  3. Confirm you are able to navigate between segments using tab and arrow keys
  4. Confirm you are able to use the spin buttons with arrow up/down keys
  5. Confirm you are able to type into the segments
  6. Confirm Alt+ArrowDown or Option+ArrowDown opens the calendar and moves focus into the calendar to todays date if no date selected or the selected date. There should be a visible focus outline.
  7. Navigate via arrow keys and confirm up/down/left/right move as expected. Confirm arrow down from last row moves to first row of next month (and opposite for arrow up). Confirm arrow right from last day moves to first day of next month (and opposite for arrow left). If moving from left calendar to right calendar, no display date changes should occur. If moving left from left month or right from right month, calendar display should update accordingly (new month shown with correct header)
  8. Confirm enter or space on a focused date selects that date. The calendar should close and the input shows the selected date
  9. Reopen calendar via keyboard and confirm the selected date is denoted as such
  10. Confirm you can navigate via tab through the input, next previous month arrows, one calendar day cell (the focused one), and quick actions if they exist
  11. Confirm you can select a date via segment spin buttons using arrow up/down keys. The year/month/date should be bound as appropriate
  12. Confirm you can select a date via typing into segments. Validation happens on blur and will either clear the date or set it to the closest valid date.
  13. Open the calendar and confirm the selected date is denoted as such
  14. Via keyboard, hit enter on the next or previous month arrow. Confirm the calendar moves 1 month to the left or right. Confirm focus stays on the arrow button and you can keep moving the month
  15. Tab and confirm you are taken into the currently shown month and the first of the month shown is focused (test this when the selected date is not visibly shown or there is no selected date)
  16. Confirm clicking escape closes the calendar

Range - mouse

  1. Go to DatePicker range story
  2. "Single select - mouse" section 2-9 should still be true
  3. Confirm clear button is disabled
  4. With calendar open, select a day. Confirm the day shows as selected in the calendar and the start date input updates accordingly. the calendar should still be open
  5. Click a second date that is after the first date (and doesnt include any disabled dates). Confirm calendar closes and the end date input updated accordingly. both inputs should have the correct selected dates.
  6. Open the calendar and see the date range is highlighted according to the designs (hover state as well)
  7. Confirm the clear button is now able to be clicked
  8. Click the clear button. Confirm the selected date range in the calendar and both inputs are cleared
  9. Open the calendar and select a start date. so start date is set and end is empty
  10. Select an end date that is before the start date. Confirm that clicked date becomes the start date and the end date is empty
  11. Click a date after the start date and confirm the range is set and calendar closes
  12. Open calendar (range is set) and click a date that is after the start date. the start date should remain and the end date updates to the new clicked date
  13. Open calendar (range is set) and click a date that is before the start date. the start date should update to the new clicked date and the end date should remain
  14. Open calendar (range is set) and click the start date. the start date should be cleared and the end date (if set) becomes the new start date
  15. Open calendar (range is set) and click the end date. the end date should be cleared and the start date remains
  16. Select a single day range (start and end are same date). Open calendar and click the start/end date. Both fields should clear
  17. If you click into a specific field, that field is "active" and there is different logic. If start date is active, then clicking a new date sets that date as the start date. if new Start > current End, End is cleared. if new Start ≤ current End, End remains unchanged. If current start date is clicked then start date is cleared and end remains.
  18. If you click into a specific field, that field is "active" and there is different logic. If end date is active, then clicking a new date sets that date as the end date. If new End < current Start, Start is cleared. if new End ≥ current Start, Start remains unchanged. If current end date is clicked then end date is cleared and start remains.
  19. If you try to select a range that contains a disabled date, it will set the start date to the second clicked date and end date will be empty
  20. You should never be able to have a start date after an end date
  21. Confirm there are quick actions and clicking them selects the date range as expected

Range - keyboard

  1. Go to DatePicker range story
  2. "Single select - keyboard" section 2-7 should still be true. With the addition that tab after first input of segments moves to the second input of segments
  3. Confirm enter or space on a focused date selects that date. The calendar should stay open. And the selected date should appear as so in the calendar and in the input
  4. Navigate via arrow keys and confirm up/down/left/right move as expected. Confirm arrow down from last row moves to first row of next month (and opposite for arrow up). Confirm arrow right from last day moves to first day of next month (and opposite for arrow left). If moving from left calendar to right calendar, no display date changes should occur. If moving left from left month or right from right month, calendar display should update accordingly (new month shown with correct header)
  5. Confirm enter or space on a focused date selects that date. The calendar should close. Both inputs should show the dates selected.
  6. Reopen calendar via keyboard and confirm the selected date range is denoted as such
  7. Confirm you can navigate via tab through the inputs, next previous month arrows, one calendar day cell (the focused one), and quick actions if they exist
  8. Confirm you can select a date via segment spin buttons using arrow up/down keys. The year/month/date should be bound as appropriate
  9. Confirm you can select a date via typing into segments. Validation happens on blur and will either clear the date or set it to the closest valid date.
  10. Open the calendar and confirm the selected date is denoted as such
  11. Repeat "Range - mouse" section Button refactor #12-19 but via keyboard. You should never be able to have a start date after an end date
  12. Via keyboard, hit enter on the next or previous month arrow. Confirm the calendar moves 1 month to the left or right. Confirm focus stays on the arrow button and you can keep moving the month
  13. Tab and confirm you are taken into the currently shown month and the first of the month shown is focused (test this when the selected date is not visibly shown or there is no selected date)
  14. Confirm clicking escape closes the calendar

Locale & translations

  1. Default story is setting locale to "de-DE", all other stories are using runtime locale
  2. Go to default story ("de-DE" locale)
  3. Confirm placeholder is DD MM YYYY and uses . instead of /
  4. Open calendar and confirm month headers and days of week are in German
  5. Confirm week starts on Monday (confirm in Firefox that this works as well)
  6. Hover over next/previous month arrows and confirm tip text is in German
  7. Go to any other story
  8. Confirm placeholder is MM DD YYYY and uses /
  9. Open calendar and confirm month headers and days of week are in English
  10. Confirm week starts on Sunday (confirm in Firefox that this works as well)
  11. Hover over next/previous month arrows and confirm tip text is in English
  12. Confirm all translations text is in English
  13. Confirm any passed in overrides work as expected

Screen reader (robot wrote this so might not be 100% accurate)

  1. Discoverability & name
  • Focus the control: role and accessible name are clear (e.g. “Start date” / “Date” / not empty).
  • Calendar button (if present): name is not only “button” (e.g. “Choose date” / has label association).
  • Required / invalid (if applicable): aria-invalid / aria-describedby to error not broken by PR.
  1. Open / close
  • On open, announcement tells user they’re in a grid (or dialog / application as implemented). Note exact pattern for release notes.
  • On close, focus and announcement return to a predictable place (not silent dump to body if avoidable).
  • If aria-expanded (or aria-controls) is used, it toggles in sync with visibility.
  1. Live regions & month changes
  • Change month (chevrons): month/year is announced; not re-announced in a spammy way on every key.
  • If a live region is used: one clear update; no double announcement from duplicate IDs or two polite regions fighting.
  1. Day grid
  • In the grid, cell role / label includes day number and, if designed, weekday or full date (e.g. “15, Tuesday, April 15, 2026” — exact pattern depends on implementation).
  • Today (if distinct): “today” in name or state if spec requires it.
  • Selected day: selected state (or equivalent) is exposed; for range, start, end, and in-range (if you expose them) match visuals.
  • Disabled day: disabled and announced as unavailable.
  • Navigate* with arrows: every move announces the new focused day.
  1. Two month titles & labels (if two grids)
  • Each table has a label (aria-labelledby to visible month heading) and the two headings have unique ids in the document (no duplicate id in the accessibility tree when both months visible).
  • If user moves from left grid to right, screen reader context (which month) is still clear (heading association or table caption path).

PR Links and Envs

Repository PR Link
Monolith Monolith PR
Mono Mono PR

Comment thread packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx Outdated
Comment thread packages/gamut/src/DatePicker/DatePickerInput/Segment/index.tsx
Copy link
Copy Markdown
Contributor

@dreamwasp dreamwasp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i also had an issue opening the Calendar with the keyboard, but everything else looks + sounds good on VO.

i'd like to see some form integration tests (Gamut DatePicker / calendar in a form and assert submitted (or controlled) field data). i will prob look over this again tomorrow once i've let it percolate a little bit more ☕

Comment thread packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts Outdated
Comment thread packages/gamut/src/DatePicker/Calendar/utils/elements.tsx Outdated
Comment thread packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx Outdated
Comment thread packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx Outdated
Comment thread packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx Outdated
Comment on lines +1 to +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<CalendarNavProps> = ({
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 (
<IconButton
alignSelf="flex-start"
aria-label={lastMonth}
icon={MiniChevronLeftIcon}
size="small"
tip={lastMonth}
onClick={handleLastMonth}
/>
);
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i feel like CalendarNavLastMonth + NextMonths could be one component + DRYED up

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah ive been fussing with this a bunch. do you think the keyboard nav/tab order makes sense? its something like left arrow left calendar body right arrow. or should it be left arrow right arrow calendar. i was updating to make the calendar headers more attached to the calendar body in the reading order

Comment thread packages/gamut/src/DatePicker/DatePickerInput/Segment/elements.tsx
Comment thread packages/gamut/src/DatePicker/DatePickerInput/elements.tsx
Comment thread packages/gamut/src/DatePicker/DatePicker.tsx Outdated
Copy link
Copy Markdown
Member

@sh0ji sh0ji left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

heads up that I only really reviewed the API, not the implementation. looks really good in general! but I did have quite a few questions and there are three things that could use refinement:

  1. every prop needs a description and we should be very picky about those descriptions. prop descriptions are our most important docs since they can clarify usage, help in situations where naming is hard, and because they're our best just-in-time docs since they're visible during development thanks to IDE type hinting.
  2. naming conventions: I'm of the opinion that callback props should always be named on${Event}, and that seems to be the case across Gamut. it's possible that some of the props I commented on aren't actually callback props, which would reinforce my first point (better descriptions).
  3. the focus management API feels too big to me. happy to brainstorm ideas to solve it, but my instinct is to remove as much of it as possible and just handle it internally. it's also easier to add props later than it is to remove them after they're out.

Comment thread packages/gamut/src/DatePicker/index.tsx Outdated
Comment thread packages/gamut/src/index.tsx Outdated
Comment thread packages/gamut/src/DatePicker/types.ts Outdated
Comment thread packages/gamut/src/DatePicker/types.ts Outdated
Comment thread packages/gamut/src/DatePicker/types.ts
Comment thread packages/gamut/src/DatePicker/types.ts Outdated
Comment on lines +68 to +78
/** 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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these four props plus moveFocusIntoCalendar in OpenCalendarOptions is five props just for focus management, which feels like a huge API for something that I don't personally think developers want to spend that much time thinking about. how much of it do we need to expose to users?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i dont think any of this needs to be exposed to users

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah this is the context props so we're handling all of this internally within DatePicker, this is what is within the context. i can move this type into a different file if thats clearer?

Comment thread packages/gamut/src/DatePicker/types.ts Outdated
Comment thread packages/gamut/src/DatePicker/types.ts Outdated
/** 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;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm confused. "active" = focused? and "part" = the two different inputs (start/end)? the semantics and names of these props could use some refinement. and if these are part of the focus management API, I also wonder if there's just some better way to handle this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes this is for the logic when you specifically click on one of the inputs and then select a date in the calendar

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lmk if you have a better naming suggestion or way to handle this

Comment thread packages/gamut/src/DatePicker/types.ts Outdated
Comment thread packages/gamut/src/DatePicker/types.ts Outdated
@aresnik11
Copy link
Copy Markdown
Contributor Author

heads up that I only really reviewed the API, not the implementation. looks really good in general! but I did have quite a few questions and there are three things that could use refinement:

  1. every prop needs a description and we should be very picky about those descriptions. prop descriptions are our most important docs since they can clarify usage, help in situations where naming is hard, and because they're our best just-in-time docs since they're visible during development thanks to IDE type hinting.
  2. naming conventions: I'm of the opinion that callback props should always be named on${Event}, and that seems to be the case across Gamut. it's possible that some of the props I commented on aren't actually callback props, which would reinforce my first point (better descriptions).
  3. the focus management API feels too big to me. happy to brainstorm ideas to solve it, but my instinct is to remove as much of it as possible and just handle it internally. it's also easier to add props later than it is to remove them after they're out.

@sh0ji will update prop descriptions, naming conventions, and clean up what we're exporting (i think this will help with the focus management stuff too)

Copy link
Copy Markdown
Contributor

@LinKCoding LinKCoding left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't review the docs fwiw -- but I did test the functionality (not including VO) and it worked well using mouse!

I think my review can be grouped into 3 things:

  1. Clean-up around naming and comments (comments esp could be part of a later ticket)
  2. Some RTL clean-up, albeit, you're blocked by current RTL fixes
  3. Echoing Cass's point about using objects in function parameters/arguments

All in all, looking really good :)

Comment thread packages/gamut/src/DatePicker/Calendar/utils/dateGrid.ts Outdated
Comment thread packages/gamut/src/DatePicker/Calendar/types.ts Outdated
Comment thread packages/gamut/src/DatePicker/utils/dateSelect.ts Outdated
Comment thread packages/gamut/src/DatePicker/utils/dateSelect.ts Outdated
Comment thread packages/gamut/src/DatePicker/utils/dateSelect.ts Outdated
Comment thread packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts Outdated
Comment thread packages/gamut/src/DatePicker/DatePickerInput/Segment/segmentUtils.ts Outdated
Comment thread packages/gamut/src/DatePicker/DatePicker.tsx
Comment thread packages/gamut/src/DatePicker/DatePickerCalendar/Calendar/CalendarBody.tsx Outdated
Comment thread packages/gamut/src/DatePicker/Calendar/CalendarFooter.tsx Outdated
Copy link
Copy Markdown
Contributor

@dreamwasp dreamwasp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

couple of nits + sent some Qs in Slack about keyboard/VO ✨

Comment thread packages/gamut/__tests__/__snapshots__/gamut.test.ts.snap Outdated
Comment thread packages/gamut/src/DatePicker/DatePickerInput/elements.tsx Outdated
@codecademydev
Copy link
Copy Markdown
Collaborator

📬 Published Alpha Packages:

Package Version npm Diff
@codecademy/gamut 68.2.3-alpha.8e8677.0 npm diff
@codecademy/gamut-icons 9.57.3-alpha.8e8677.0 npm diff
@codecademy/gamut-illustrations 0.58.10-alpha.8e8677.0 npm diff
@codecademy/gamut-kit 0.6.593-alpha.8e8677.0 npm diff
@codecademy/gamut-patterns 0.10.29-alpha.8e8677.0 npm diff
@codecademy/gamut-styles 17.13.2-alpha.8e8677.0 npm diff
@codecademy/gamut-tests 5.3.4-alpha.8e8677.0 npm diff
@codecademy/variance 0.26.2-alpha.8e8677.0 npm diff
eslint-plugin-gamut 2.4.4-alpha.8e8677.0 npm diff

@github-actions
Copy link
Copy Markdown
Contributor

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants