Skip to content

feat(react-headless-components-preview): add headless DatePicker - #36988

Draft
Dmytro Kirpa (dmytrokirpa) wants to merge 4 commits into
microsoft:masterfrom
dmytrokirpa:claude/headless-datepicker-54af02
Draft

Dmytro Kirpa (dmytrokirpa) wants to merge 4 commits into
microsoft:masterfrom
dmytrokirpa:claude/headless-datepicker-54af02

Conversation

@dmytrokirpa

@dmytrokirpa Dmytro Kirpa (dmytrokirpa) commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Previous Behavior

The headless package had Calendar (#36752) but no DatePicker. The earlier headless attempt (#36919) depended on the unmerged styled DatePicker preview base hook (#36920) and used a modal native dialog, which conflicts with the DatePicker spec (non-modal, no focus trap, Tab out closes).

Calendar grids delegated arrow navigation to native focusgroup. Chromium lists Focusgroup V2 grid only as Proposed, and the polyfill's grid support (microsoft/polyfills#64) is merged but unreleased (latest @microsoft/focusgroup-polyfill is 1.6.0). In every shipping browser, arrows did nothing in the day, month and year grids and every available day was a tab stop (42 in Chrome).

New Behavior

DatePicker

Adds @fluentui/react-headless-components-preview/date-picker: DatePicker, useDatePicker, renderDatePicker, defaultFormatDate, defaultParseDate and public types. It composes the headless Calendar and has no dependency on react-calendar-preview, react-datepicker-preview, Griffel or Portal. Date state and validation are adapted from #36920.

Field

  • Native <button> trigger by default; native <input type="text"> with allowTextInput. Native props and the forwarded ref target the field control; className/style go to the root wrapper.
  • aria-haspopup="dialog", aria-controls while open, aria-invalid + aria-describedby for typed-date errors, Field integration via useFieldControlProps_unstable.
  • The trigger is named with aria-labelledby (label + displayed date or placeholder), including inside a Field.
  • Optional icon slot: decorative in the trigger; with allowTextInput, a click-only toggle with no tab stop.

Popup

  • Non-modal role="dialog" named by the visible month-year, shown in the top layer with popover="manual" and anchored below the field (start-aligned, flips above) via the headless positioning hook.
  • Opening focuses the selected day → today → first available day of the month; Calendar falls back to Next month.
  • Selection and Escape return focus to the field. The Escape that closes the calendar is cancelled and stopped, so a surrounding dialog or popover stays open.
  • Tab out and outside clicks close and keep the new focus destination; Alt + Arrow Down opens from the field.
  • Month/year cells navigate only; the day heading opens the year-range grid; 3-column month/year grids; Go-to-today only with showGoToToday.
  • Controlled open keeps reporting close requests even when the consumer ignores one.

Typed dates

  • Validated on Enter and when focus leaves the date picker (not when moving between field and calendar).
  • Default parser accepts YYYY-MM-DD, full/abbreviated English month + day + year (optional comma), and M/D/YYYY, as local calendar dates with round-trip validation (Feb 30 is rejected). formatDate, parseDate and formatErrorMessage allow localization.
  • Rejected or out-of-range text restores the previous value and shows a polite errorMessage until an accepted date, calendar selection, or optional clear; repeated blur does not re-report.

State attributes on root: data-open, data-disabled, data-invalid, data-editable, data-placeholder.

Calendar

  • Roving grid navigation replaces focusgroup. Day, month and year grids keep exactly one tabindex="0" cell, remembered until the grid shows a different date or column layout.
    • Day grid: Arrow Left/Right wrap across weeks, Up/Down keep the weekday, Home/End reach the first/last available day of the week. Unavailable days are skipped; an available adjacent-month day loads that month; moving past the visible days pages to the next available date. PageUp/PageDown and Enter/Space are unchanged.
    • Month and year grids: Left/Right stay within the row, Up/Down keep the column, Home/End reach the first/last available cell. Focus movement never selects or navigates.
    • RTL, allFocusable, consumer cancellation, and nested editors/grids/toolbars are respected.
    • Removed the focusgroup* attributes and the focusgroupstart/focusgrouprow cell/row prop types.
  • Month/year cells containing today expose aria-current="date".
  • CalendarMonthHandle.showYearPicker() so the day heading can open the year-range grid.

The Calendar changes are isolated in their own commits and can be split into a separate PR if preferred.

Intentional deviations from the design spec

  • No aria-expanded on the editable input (unsupported on textbox; combobox is excluded by the spec). The icon button exposes the expanded state.
  • No aria-required on the button trigger (unsupported on button); the Field conveys required.
  • Go-to-today navigates only; selecting today is left to product choice.
  • Month/year Arrow Left/Right do not wrap across rows (existing Calendar behavior); the spec only requires wrapping for days.
  • Visual tokens, motion and contrast-theme rules belong to a styled layer; the CSS Module stories demonstrate them.

Validation

  • react-headless-components-preview and -stories: type-check, lint, test — passed (1858 tests, none skipped). The previously skipped focusgroup keyboard suites are enabled; a few assertions were updated for the current gridcell-wraps-button markup.
  • Chrome Cypress: Calendar.cy.tsx 43/43 and DatePicker.cy.tsx 11/11 — including real Tab order, roving arrows, month crossing, PageDown, Escape inside a modal <dialog>, focus return, and outside click.
  • Stories checked manually in Storybook. bundle-size was not run locally.

Related Issue(s)

🤖 Generated with Claude Code

Add a headless DatePicker that composes the headless Calendar in a
non-modal popover="manual" dialog anchored to a button or text-input
field. It owns the committed value, typed-date parsing and validation,
popup lifecycle and focus management, and exposes data-open,
data-disabled, data-invalid, data-editable and data-placeholder.

Calendar: emit aria-current="date" on current month/year cells and add
CalendarMonthHandle.showYearPicker() so the day heading can open the
year-range grid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

📊 Bundle size report

Package & Exports Baseline (minified/GZIP) PR Change
react-headless-components-preview
react-headless-components-preview: entire library
269.204 kB
80.184 kB
280.034 kB
83.936 kB
10.83 kB
3.752 kB
react-headless-components-preview
react-headless-components-preview: Calendar
42.084 kB
13.907 kB
43.781 kB
14.529 kB
1.697 kB
622 B
react-headless-components-preview
react-headless-components-preview: DatePicker
0 B
0 B
59.647 kB
19.791 kB
🆕 New entry
Unchanged fixtures
Package & Exports Size (minified/GZIP)
react-headless-components-preview
@fluentui/react-headless-components-preview/tag-picker
50.254 kB
16.375 kB
react-headless-components-preview
@fluentui/react-headless-components-preview/teaching-popover
33.766 kB
11.09 kB
🤖 This report was generated against 6d3e9d686434d69ac8fc9ca4c4a93f3afaa3d676

@github-actions

Copy link
Copy Markdown

Pull request demo site: URL

@dmytrokirpa
Dmytro Kirpa (dmytrokirpa) marked this pull request as draft October 23, 2026 17:12
… naming and controlled close requests correct

- Cancel and stop the Escape that closes the calendar so a surrounding
  dialog or popover stays open.
- Name the trigger with aria-labelledby inside a Field so the displayed
  date stays in its accessible name.
- Guard only re-entrant open requests, so a controlled consumer that
  ignores a close request still receives later ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…with roving grid navigation

Native focusgroup grid support is only proposed in Chromium and the
polyfill's grid support is unreleased, so Calendar grids had no arrow
navigation and every available day was a tab stop.

- Day, month and year grids keep a single roving tab stop that
  remembers the last focused cell.
- Arrows, Home and End move focus and skip unavailable cells; day
  arrows wrap across weeks, load adjacent months and page past the
  visible days; month/year arrows stay within rows.
- Remove focusgroup attributes and their prop types, enable the
  previously skipped keyboard suites, and add a DatePicker keyboard
  browser test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ith real Tab presses

With one roving tab stop per Calendar grid, the browser test can tab
through each grid and control and verify that leaving the calendar
closes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

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.

🟡 Changes recommended

Public control typing, popup initialization, and Calendar focus/navigation behavior have unresolved correctness defects.

6 open issues
What changed in this PR

Adds an unstyled DatePicker that composes the headless Calendar, replacing the earlier modal approach with a non-modal popup. It also replaces Calendar’s unreleased focusgroup dependency with built-in keyboard navigation.

Changes:

  • Adds DatePicker controls, date parsing and validation, popup positioning, and focus management.
  • Introduces roving tab stops and arrow navigation across Calendar grids.
  • Adds public exports, API reports, stories, unit/browser coverage, bundle fixtures, and release metadata.

Paths below are relative to packages/react-components/react-headless-components-preview/, except the root-level change file.

File Description
stories/​src/​DatePicker/​index.stories.tsx Registers DatePicker stories and documentation.
stories/​src/​DatePicker/​DatePickerDisabled.stories.tsx Demonstrates disabled state.
stories/​src/​DatePicker/​DatePickerDescription.md Documents composition, behavior, and accessibility.
stories/​src/​DatePicker/​DatePickerDefault.stories.tsx Demonstrates controlled date selection.
stories/​src/​DatePicker/​DatePickerDateBoundaries.stories.tsx Demonstrates date restrictions and required input.
stories/​src/​DatePicker/​datePickerAppearance.ts Shares story slot styling and icons.
stories/​src/​DatePicker/​DatePickerAllowTextInput.stories.tsx Demonstrates editable date input.
stories/​src/​DatePicker/​date-picker.module.css Styles examples, including contrast and reduced-motion modes.
stories/​src/​Calendar/​CalendarDescription.md Documents built-in grid navigation.
library/​src/​date-picker.ts Exposes the DatePicker subpath API.
library/​src/​components/​DatePicker/​useDatePickerDateState.ts Manages committed dates, draft text, and validation.
library/​src/​components/​DatePicker/​useDatePicker.tsx Implements controls, popup lifecycle, and Calendar composition.
library/​src/​components/​DatePicker/​renderDatePicker.tsx Renders controls, errors, and conditional popup.
library/​src/​components/​DatePicker/​index.ts Exports DatePicker implementation and types.
library/​src/​components/​DatePicker/​datePickerFormatting.ts Adds default date formatting and parsing.
library/​src/​components/​DatePicker/​datePickerFormatting.test.ts Covers supported formats and invalid dates.
library/​src/​components/​DatePicker/​DatePicker.types.ts Defines public props, slots, state, and events.
library/​src/​components/​DatePicker/​DatePicker.tsx Composes the public component.
library/​src/​components/​DatePicker/​DatePicker.test.tsx Covers selection, validation, focus, and accessibility semantics.
library/​src/​components/​DatePicker/​DatePicker.cy.tsx Adds browser interaction and popup tests.
library/​src/​components/​Calendar/​hooks/​useCalendarGrid.ts Implements shared roving focus and navigation.
library/​src/​components/​Calendar/​hooks/​useCalendarGrid.test.ts Tests navigation targets and tab-stop properties.
library/​src/​components/​Calendar/​CalendarYear/​useCalendarYear.tsx Integrates year-grid roving focus.
library/​src/​components/​Calendar/​CalendarYear/​CalendarYear.types.ts Removes focusgroup-specific props and updates documentation.
library/​src/​components/​Calendar/​CalendarYear/​CalendarYear.test.tsx Enables and updates year navigation tests.
library/​src/​components/​Calendar/​CalendarMonth/​useCalendarMonth.tsx Adds roving focus and imperative year-picker opening.
library/​src/​components/​Calendar/​CalendarMonth/​CalendarMonth.types.ts Updates navigation types and imperative handle.
library/​src/​components/​Calendar/​CalendarMonth/​CalendarMonth.test.tsx Enables and updates month navigation tests.
library/​src/​components/​Calendar/​CalendarDay/​useCalendarDay.tsx Implements day-grid roving focus and arrow movement.
library/​src/​components/​Calendar/​CalendarDay/​CalendarDay.types.ts Removes focusgroup props and documents navigation.
library/​src/​components/​Calendar/​CalendarDay/​CalendarDay.test.tsx Enables keyboard tests and checks roving tab stops.
library/​src/​components/​Calendar/​Calendar.test.tsx Updates integrated grid-focus assertions.
library/​src/​components/​Calendar/​Calendar.cy.tsx Enables browser keyboard-navigation coverage.
library/​package.json Adds DatePicker import and require exports.
library/​etc/​date-picker.api.md Records the new public DatePicker API.
library/​etc/​calendar.api.md Records Calendar type and handle changes.
library/​bundle-size/​DatePicker.fixture.js Adds a dedicated DatePicker bundle fixture.
library/​bundle-size/​AllComponents.fixture.js Includes DatePicker in aggregate measurements.
change/​@fluentui-react-headless-components-preview-0fb1b4ca-99a7-4871-9749-705b1af0b70e.json (repository root) Records DatePicker and Calendar release changes.

Review effort: Balanced

Comment on lines +525 to +527
if (!target?.originalDate) {
if (ev.key !== Home && ev.key !== End) {
navigateOutsideVisibleDays(ev, day.originalDate!);
Comment on lines +105 to +108
const showYearPicker = (): void => {
focusOnUpdate.current = true;
setIsYearPickerVisible(true);
};
*
* The field needs an accessible name from a Field label, `aria-labelledby` or `aria-label`.
*/
export const DatePicker: ForwardRefComponent<DatePickerProps> = /*#__PURE__*/ composeComponent(
Comment on lines +185 to +187
if (nextOpen) {
setDisplayedDate(getInitialDisplayedDate());
focusOnOpen.current = true;

if (dayPickerProps !== null) {
calendar.dayPicker = {
onHeaderSelect: monthPickerProps === null ? undefined : onDayPickerHeaderSelect,
imperativeRef: mergedMonthPickerRef,
getCellProps: month => {
const overrides = getMonthCellProps?.(month);
const date = new Date(displayedDate.getFullYear(), month.index, 1);

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants