Skip to content

feat(DatePicker): New DatePicker component#3286

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

feat(DatePicker): New DatePicker component#3286
aresnik11 wants to merge 81 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 / setSelectedDate.
  • Date rangestartDate, endDate, setStartDate, setEndDate; optional startLabel / endLabel.
  • 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

  • disabledDates — unselectable days; 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.

Things I know are missing/not completely working:

  • calendar is supposed to close after selecting a date(s)
  • tests
  • calendar quick actions

PR Checklist

Testing Instructions

Don't make me tap the sign.

  1. Go to DatePicker
  2. Play around with DatePicker and make sure all the functionality matches the description above
  3. Do that something in dark mode
  4. Check it with VO
  5. Finish and do a celebratory dance

PR Links and Envs

Repository PR Link
Monolith Monolith PR
Mono Mono PR

@aresnik11 aresnik11 marked this pull request as ready for review April 10, 2026 18:02
@aresnik11 aresnik11 requested a review from a team as a code owner April 10, 2026 18:02
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 ☕

@@ -0,0 +1,123 @@
/**
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.

nit: i really prefer object syntax for function arg props for stuff like this so they can't get mixed up

@@ -0,0 +1,77 @@
import type { IsoWeekday } from '../../utils/locale';
import { stringifyLocale } from '../../utils/locale';

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.

same comment of obj arg stucture

// eslint-disable-next-line jsx-a11y/control-has-associated-label
<td
// fix this error
// eslint-disable-next-line react/no-array-index-key, jsx-a11y/control-has-associated-label
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.

curious about the a11y error here

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
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.

leftover comment

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 on lines +7 to +11
const segmentStyles = states({
default: {
color: 'text-secondary',
},
});
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.

this seems weird - can this just be a css style

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.

default is a terrible descriptor but the text color changes when its the placeholder MM/DD/YYYY vs when you have typed or selected and there's an actual date. but yes i can do this in css too

* 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)<SegmentedShellProps>(
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.

this could be done with a state instead of css

@codecademydev
Copy link
Copy Markdown
Collaborator

📬 Published Alpha Packages:

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

@github-actions
Copy link
Copy Markdown
Contributor

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.

placeholder?: string;
/** Override UI strings (e.g. clear button). Merged with defaults. */
translations?: DatePickerTranslations;
inputSize?: ComponentProps<typeof Input>['size'];
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've never used this prop borrowing syntax. will it carry the comments over from

size?: 'base' | 'small';
in type-aware contexts like IDEs? if so, let's add a description there. if not, we need one here to explain the role of this prop.

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.

looks like it shows the prop comments if i hover over size but not directly for inputSize. will add a comment

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 on lines +85 to +86
/** Start date (range) or selected date (single). */
startOrSelectedDate: Date | null;
Copy link
Copy Markdown
Member

@sh0ji sh0ji Apr 13, 2026

Choose a reason for hiding this comment

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

I know we discussed the awkwardness of this but it's especially evident in this one prop. I like how you resolved it for the DatePickerProps discriminated union. is there any reason we couldn't do that for the context as well?

might even be able to make it generic to determine the right props (e.g., useDatePicker<Mode extends 'single' | 'range' | undefined>()) rather than trying to unify them, but that might not be necessary.

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.

we can and i tried it at one point, but then we're going to have a bunch more conditionals and branches for single vs range whereas this seemed cleaner to me. i can try it again now that the code is a little more settled

/** 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

import { CalendarBaseProps, QuickAction } from './Calendar/types';
import { DatePickerTranslations } from './utils/translations';

export interface DatePickerBaseProps
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.

big semantics nit, but I think mode should be part of DatePickerBaseProps. by re-declaring it on both DatePickerSingleProps & DatePickerRangeProps, it's not guaranteed to be recognize as the same prop, even though they're named the same. this may or may not affect inference in some edge cases.

export interface DatePickerBaseProps<Mode extends 'single' | 'range' | undefined>
  extends Pick<CalendarBaseProps, 'locale' | 'disabledDates'> {
  mode: Mode;
  ...
}

export interface DatePickerSingleProps extends DatePickerBaseProps<'single' | undefined> {
  /* don't declare mode here */
  ...
}

export interface DatePickerRangeProps extends DatePickerBaseProps<'range'> {
  /* don't declare mode here */
  ...
}

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.

oh also, I don't think DatePickerBaseProps should be exported. it feels like an internal type—the public types are DatePickerSingleProps, DatePickerRangeProps, and DatePickerProps.

endLabel?: string;
}

export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps;
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 quite like the way you compose these types together! it results in a great type experience where my IDE can infer that when mode = 'single', the startDate prop is invalid. nicely done.

@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)

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