feat(ui): redesign date and time pickers (#43090)

This commit is contained in:
yyh authored and GitHub committed 2026-09-29 02:14:10 +00:00
1 parent 1545517fe8
commit 90f7fbd468
210 files changed
+6997 -6758

No files matched your search

+11 -7
View File
@@ -45,10 +45,12 @@ Import `styles.css` once from the consumer's root stylesheet or entrypoint.
| Media | `./avatar` |
| Navigation | `./breadcrumb`, `./file-tree`, `./pagination`, `./tabs` |
| Overlay and menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./infotip`, `./popover`, `./preview-card`, `./tooltip` |
| Search and pick | `./autocomplete`, `./combobox`, `./select` |
| Search and pick | `./autocomplete`, `./combobox`, `./select`, `./date-picker`, `./time-picker`, `./date-time-picker` |
Utilities:
- `./direction-provider` exports Base UI `DirectionProvider` and `useDirection`. Set HTML `dir` as well for CSS layout; pickers propagate their resolved direction to their trigger and portaled content.
- `./cn` re-exports `cn` from the `cn` package through Dify UI's public subpath.
- `./styles.css` provides design tokens, theme variables, and shared utilities.
@@ -60,12 +62,13 @@ Upstream behavior remains owned by the [Base UI documentation].
### Component guides
| Guide | Dify-owned contract |
| ------------- | ------------------------------------------------------------------------------------------- |
| [Button] | Action semantics, submit and link choices, loading versus disabled, and content spacing. |
| [Icon Button] | Accessible names, decorative glyphs, appearance ownership, and primitive composition. |
| [Infotip] | Explanation triggers, concise dialog names, and hint surface ownership. |
| [Input Group] | Compound input anatomy, shared-surface ownership, DOM order, focus, and interactive addons. |
| Guide | Dify-owned contract |
| ----------------------- | ------------------------------------------------------------------------------------------- |
| [Date and time pickers] | Value semantics, composition, draft commits, localization and native forms. |
| [Button] | Action semantics, submit and link choices, loading versus disabled, and content spacing. |
| [Icon Button] | Accessible names, decorative glyphs, appearance ownership, and primitive composition. |
| [Infotip] | Explanation triggers, concise dialog names, and hint surface ownership. |
| [Input Group] | Compound input anatomy, shared-surface ownership, DOM order, focus, and interactive addons. |
### Cross-component guides
@@ -88,6 +91,7 @@ For a known contract, go directly to its guide above.
[Base UI documentation]: https://base-ui.com/llms.txt
[Base UI]: https://base-ui.com/react
[Button]: ./src/button/README.md
[Date and time pickers]: ./src/date-time/README.md
[Forms]: ./docs/forms.md
[Icon Button]: ./src/icon-button/README.md
[Infotip]: ./src/infotip/README.md
+4
View File
@@ -44,6 +44,9 @@ Choose the label primitive by the control:
- Text-like inputs, `Textarea`, input-based `Combobox` and `Autocomplete`, a single `Checkbox`,
each `Radio` option, `Switch`, and `NumberField` use `FieldLabel`.
- Trigger-based `Select` fields use `SelectLabel`.
- Date/time pickers use their own `DatePickerLabel`, `TimePickerLabel` or `DateTimePickerLabel`
to focus the trigger without opening it. They currently support native forms, not Base UI
Field/Form registration; see the [picker field contract].
- `Slider` fields follow the [Base UI Slider anatomy] and use `SliderLabel`; only multi-thumb
sliders add per-thumb `aria-label` to distinguish the thumbs.
- `SelectGroupLabel` and `AutocompleteGroupLabel` label option groups inside popup content. They
@@ -85,3 +88,4 @@ public field and control props instead of replacing the semantic structure.
[Base UI forms handbook]: https://base-ui.com/react/handbook/forms
[`Button`]: ../src/button/README.md
[`InputGroup`]: ../src/input-group/README.md
[picker field contract]: ../src/date-time/README.md#anatomy-and-labels
+3
View File
@@ -13,6 +13,8 @@ domain value type through its public API.
- [`Autocomplete`] accepts free-form text with optional suggestions.
- [`Combobox`] selects and remembers one or more values from a searchable collection.
- [`Select`] chooses from a closed, scannable list without text entry.
- [Date and time pickers] select civil dates, wall times or instants. Their fixed value models
do not use collection generics; the family guide owns anatomy and commit semantics.
Multiple-selection comboboxes follow the Base UI chips composition: chips and the input share the
input group, chips wrap, and the group grows vertically.
@@ -149,6 +151,7 @@ current result window, or provide an ID-only label fallback.
`CheckboxGroup` follows Base UI and uses `string[]`. Model stronger business ID distinctions at
the domain boundary unless the upstream primitive contract changes.
[Date and time pickers]: ../src/date-time/README.md
[`Autocomplete`]: https://base-ui.com/react/components/autocomplete
[`Combobox`]: https://base-ui.com/react/components/combobox
[`RadioGroup`]: https://base-ui.com/react/components/radio
+13
View File
@@ -36,6 +36,19 @@ Color contrast is the only globally disabled rule because it is a known design-t
add another global exception. Keep a temporary exception local to the affected story, and do not
use a `play` test in place of an accessibility fix.
## Type contracts
Keep compile-only public API fixtures in `__tests__/types.tsx`; package type checking includes them
without registering runtime tests. Import public subpaths, verify useful inference with
`expectTypeOf`, and use `@ts-expect-error` for intentional API boundaries. Cover nullable callbacks,
value models and unsupported composition, not every forwarded prop. Runtime assertions cannot
prove TypeScript inference, and type fixtures cannot prove focus, DOM semantics or interaction.
A documented example and its `play` belong in one story; do not create an automatic duplicate just
to run the same render. Keep Docs autoplay off for examples that move focus or open portals. Retain
explicit open previews when needed for accessibility checks on popup content. Regressions that do
not teach a distinct usage belong in the unit project rather than extra sidebar entries.
## Animation setup
Base UI can wait for `element.getAnimations()` before unmounting transition-driven components.
+20 -1
View File
@@ -61,6 +61,14 @@
"types": "./src/dialog/index.tsx",
"import": "./src/dialog/index.tsx"
},
"./date-picker": {
"types": "./src/date-time/date-picker/index.tsx",
"import": "./src/date-time/date-picker/index.tsx"
},
"./date-time-picker": {
"types": "./src/date-time/date-time-picker/index.tsx",
"import": "./src/date-time/date-time-picker/index.tsx"
},
"./drawer": {
"types": "./src/drawer/index.tsx",
"import": "./src/drawer/index.tsx"
@@ -157,6 +165,10 @@
"types": "./src/textarea/index.tsx",
"import": "./src/textarea/index.tsx"
},
"./time-picker": {
"types": "./src/date-time/time-picker/index.tsx",
"import": "./src/date-time/time-picker/index.tsx"
},
"./toast": {
"types": "./src/toast/index.tsx",
"import": "./src/toast/index.tsx"
@@ -176,6 +188,10 @@
"./infotip": {
"types": "./src/infotip/index.tsx",
"import": "./src/infotip/index.tsx"
},
"./direction-provider": {
"types": "./src/direction-provider/index.tsx",
"import": "./src/direction-provider/index.tsx"
}
},
"scripts": {
@@ -188,7 +204,10 @@
},
"dependencies": {
"@base-ui/utils": "catalog:",
"cn": "catalog:"
"@date-fns/tz": "catalog:",
"@daypicker/react": "catalog:",
"cn": "catalog:",
"date-fns": "catalog:"
},
"devDependencies": {
"@base-ui/react": "catalog:",
+144
View File
@@ -0,0 +1,144 @@
# Date and time pickers
These three families share field anatomy, a non-modal popover and keyboard navigation. Choose
from the value's meaning; they do not accept free-form text or arbitrary collection values.
| Public subpath | Value / `onValueChange` | Native form value | Selection commits |
| -------------------------------------- | ---------------------------------------- | ------------------- | ----------------- |
| `@langgenius/dify-ui/date-picker` | `string \| null`, Gregorian `YYYY-MM-DD` | `YYYY-MM-DD` | Selecting a day |
| `@langgenius/dify-ui/time-picker` | `string \| null`, 24-hour `HH:mm` | `HH:mm` | OK |
| `@langgenius/dify-ui/date-time-picker` | `Date \| null`, an instant | ISO 8601 UTC string | OK |
`DateTimePicker` requires an IANA `timeZone` for display and editing. `TimePicker.timeZone` only
controls Now and the initial empty draft; it never converts a stored wall time. Date-only values
have no time zone. Parse API payloads and perform business conversions outside this package.
These fixed value models do not need generics. The public prop types own the complete API.
## Anatomy and labels
Each subpath exports its root, Label, Trigger, Value, Clear, Content and corresponding prop types.
Use one family consistently:
```tsx
import {
DatePicker,
DatePickerClear,
DatePickerContent,
DatePickerLabel,
DatePickerTrigger,
} from '@langgenius/dify-ui/date-picker'
;<form>
<DatePicker name="start" defaultValue="2025-01-15">
<DatePickerLabel>Start date</DatePickerLabel>
<div className="flex items-center gap-1">
<DatePickerTrigger />
<DatePickerClear aria-label="Clear start date" />
</div>
<DatePickerContent />
</DatePicker>
</form>
```
Provide an accessible name at the call site. Root has no labeling props and does not enforce
Label composition through its types. Prefer one visible Label with its text as children; it names
both Trigger and Content through `aria-labelledby`. Clicking plain label text focuses the actual
trigger without opening it. Links and controls inside a custom label keep their own behavior.
Do not use native `label[for]` or `FieldLabel` for the trigger: native activation can click its button.
For compact layouts, a Label with `className="sr-only"` shares one name without visible text.
Without a Label, name Trigger and Content explicitly using standard ARIA attributes:
```tsx
<DatePicker>
<DatePickerTrigger aria-label="Start date" aria-describedby="start-help" />
<DatePickerContent aria-label="Choose start date" />
</DatePicker>
```
External text can name both parts through `aria-labelledby`; click forwarding remains with its
owner. Explicit names override the automatic Label association. When a Trigger references label
text, its committed Value is appended to the name. An explicit `aria-label` supplies the whole name.
Trigger's `aria-describedby` is combined with required and validation feedback. Do not rely on
placeholder or selected text alone to identify the field.
Trigger is a native button. Its default children contain Value and a decorative icon. If replacing
those children, include exactly one Value; its render callback receives the formatted committed
text (or the placeholder), not the raw value. Put meaningful annotations such as a time-zone suffix
inside Value so they participate in the accessible name. Keep decorative icons `aria-hidden`.
Clear inherits IconButton's `aria-label` / `aria-labelledby` naming contract. Trigger and Clear may
share a visual field container; their placement is caller-owned. Do not nest Clear inside the
native Trigger button.
Content owns the portal, initial focus, calendar, columns and actions. Its props derive from the
Dify Popover content contract, excluding replacement children, `render` and `initialFocus`.
Use `className`/`style` for deliberate presentation overrides. Normal content keeps its natural
height, including the calendar's week count, and flips above or below its trigger. It does not
shrink itself on mobile. An explicit `maxHeight` opts into scrolling the body/columns while keeping
actions visible. A viewport too short for natural content needs a consumer layout decision.
## State and interaction
Use `defaultValue` for uncontrolled fields or `value` with `onValueChange` for controlled fields.
`null` means empty; clearing emits `null`. Do not switch controlledness during a field's lifetime.
`open`/`defaultOpen` follow the same ownership choice. `onOpenChange` details support `cancel()`.
Time and date-time edits, including Now, stay in the open session until OK. Switching date/time
views preserves the draft. Dismissal discards it; reopening starts from the committed value.
Changing the controlled value or time zone starts a fresh draft. Unchanged confirmation does not
emit a duplicate value change. In DatePicker, a day selection commits immediately. The month/year
selector only changes the displayed month when confirmed; it does not select a date.
The calendar uses DayPicker's grid navigation. Adjacent-month dates remain selectable and, while
the popup remains open, switch the view to their month. `minDate`, `maxDate` and
`isDateUnavailable` restrict dates. Time availability is evaluated on the complete combination:
intermediate unavailable drafts remain navigable, but OK is disabled with an announced message.
Time and month/year columns use single-select listboxes with `aria-selected` and roving DOM focus.
Selection follows focus within the draft, not the committed field value:
- Up/Down move one option; Page Up/Down move five; Home/End reach the endpoints. Navigation clamps
at the ends; no `loopFocus` API is exposed.
- Numeric locating accepts ASCII and localized digits. Month names also support typeahead.
- Tab moves between columns and actions, not every option. Escape leaves month/year selection
first; Escape from the main picker dismisses and restores trigger focus. Tab can leave the popup.
- Hover does not select. Keyboard changes scroll immediately; pointer selection may scroll
smoothly, respecting reduced motion. Wheel/touch scrolling updates the draft and aligns the
nearest option at the top when it settles.
`hourCycle` selects 12-hour (default) or 24-hour display; stored wall time remains 24-hour.
TimePicker alone supports `minuteStep` (1, 5, 10, 15, 20 or 30; default 1). Now rounds down to that
step. DateTimePicker edits whole minutes, rejects nonexistent daylight-saving times, preserves an
unchanged instant (including a repeated time), and resolves an edited ambiguous time to the earlier
instant. Business rules can further restrict it through `isTimeUnavailable`.
## Localization and native forms
DatePicker and DateTimePicker accept DayPicker locale objects; TimePicker accepts Intl locale
identifiers. Locale controls formatting and digits, not the calendar system or serialized values.
Action labels and validation copy have English defaults; consumers translate `labels`, field and
clear labels, placeholders, `requiredLabel` and `validationMessage`. Direction inherits
`DirectionProvider` or a local `direction` override and reaches the portaled popup independently of
locale. Set HTML `dir` for the surrounding layout too.
With `name`, the committed value participates in native FormData; an empty field submits `''`.
`form` can associate an external form. Required/invalid fields block native validated submission
and focus the first invalid trigger. Reset restores `defaultValue` and closes the popup; a
controlled owner must accept the reset callback. Disabled fields are omitted from submission;
read-only fields remain focusable and submit their value without allowing edits. These pickers do
not register with Base UI Field/Form. See [Forms] for the surrounding form boundary.
## Examples and verification
The colocated stories own documented configurations and primary interaction flows. Docs leave
popups closed; explicit open previews exercise calendar, month/year and time content with the
configured axe checks. Use the Storybook theme toolbar for light/dark inspection.
Browser unit tests own regressions: label composition, controlled sessions, date constraints,
scroll interruption, sizing, localized input and daylight-saving conversion. The shared
`__tests__/types.tsx` fixture checks public imports, inferred values, nullable callbacks and rejected
configurations during type checking; it is not a runtime test. See [Testing] for commands and
ownership. Automated axe and Chromium tests do not replace physical touch and screen-reader checks.
[Forms]: ../../docs/forms.md
[Testing]: ../../docs/testing.md
@@ -0,0 +1,199 @@
import type * as React from 'react'
import {
DatePicker,
DatePickerContent,
DatePickerLabel,
DatePickerTrigger,
} from '@langgenius/dify-ui/date-picker'
import {
DateTimePicker,
DateTimePickerContent,
DateTimePickerLabel,
DateTimePickerTrigger,
} from '@langgenius/dify-ui/date-time-picker'
import {
TimePicker,
TimePickerContent,
TimePickerLabel,
TimePickerTrigger,
} from '@langgenius/dify-ui/time-picker'
import { createPortal } from 'react-dom'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
const pickers = [
{
name: 'DatePicker',
wrap: (children: React.ReactNode) => <DatePicker>{children}</DatePicker>,
Label: DatePickerLabel,
Trigger: DatePickerTrigger,
Content: DatePickerContent,
},
{
name: 'TimePicker',
wrap: (children: React.ReactNode) => <TimePicker>{children}</TimePicker>,
Label: TimePickerLabel,
Trigger: TimePickerTrigger,
Content: TimePickerContent,
},
{
name: 'DateTimePicker',
wrap: (children: React.ReactNode) => <DateTimePicker timeZone="UTC">{children}</DateTimePicker>,
Label: DateTimePickerLabel,
Trigger: DateTimePickerTrigger,
Content: DateTimePickerContent,
},
]
it.each(pickers)(
'$name label focuses only from its text and preserves a nested action',
async ({ wrap, Label, Trigger, Content }) => {
const help = vi.fn()
const screen = await render(
wrap(
<>
<Label>
<span>Departure</span>
<button type="button" onClick={help}>
<span>Help</span>
</button>
</Label>
<Trigger />
<Content />
</>,
),
)
const trigger = screen.getByRole('button', { name: 'Departure Help' })
await screen.getByText('Departure', { exact: true }).click()
await expect.element(trigger).toHaveFocus()
await expect.element(trigger).toHaveAttribute('aria-expanded', 'false')
const action = screen.getByRole('button', { name: 'Help', exact: true })
await screen.getByText('Help', { exact: true }).click()
await expect.element(action).toHaveFocus()
expect(help).toHaveBeenCalledTimes(1)
await userEvent.keyboard('{Enter}')
expect(help).toHaveBeenCalledTimes(2)
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
await userEvent.tab()
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByRole('dialog', { name: 'Departure Help' })).toBeInTheDocument()
},
)
it('preserves native focus and typing in label children', async () => {
const screen = await render(
<DatePicker>
<DatePickerLabel>
Departure
<a href="https://example.com/help" onClick={(event) => event.preventDefault()}>
Help
</a>
<input aria-label="Note" />
</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>,
)
const link = screen.getByRole('link', { name: 'Help' })
await link.click()
await expect.element(link).toHaveFocus()
const input = screen.getByRole('textbox', { name: 'Note' })
await input.click()
await expect.element(input).toHaveFocus()
await userEvent.keyboard('Booking')
await expect.element(input).toHaveValue('Booking')
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
})
it('does not forward clicks from portalled label content to the trigger', async () => {
const screen = await render(
<DatePicker>
<DatePickerLabel>
Departure
{createPortal(<input aria-label="Help search" />, document.body)}
</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>,
)
const input = screen.getByRole('textbox', { name: 'Help search' })
await input.click()
await expect.element(input).toHaveFocus()
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
})
it('allows a consumer to cancel label focus forwarding', async () => {
const screen = await render(
<>
<button type="button">Previous control</button>
<DatePicker>
<DatePickerLabel onClick={(event) => event.preventDefault()}>Departure</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>
</>,
)
const previous = screen.getByRole('button', { name: 'Previous control' })
await previous.click()
await screen.getByText('Departure', { exact: true }).click()
await expect.element(previous).toHaveFocus()
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
})
it.each(pickers)(
'$name supports explicitly named parts without a Label',
async ({ wrap, Trigger, Content }) => {
const screen = await render(
wrap(
<>
<Trigger aria-label="Departure" />
<Content aria-label="Choose departure" />
</>,
),
)
const trigger = screen.getByRole('button', { name: 'Departure' })
await trigger.click()
await expect.element(screen.getByRole('dialog', { name: 'Choose departure' })).toBeVisible()
await userEvent.keyboard('{Escape}')
await expect.element(trigger).toHaveFocus()
},
)
it('combines external label, value and descriptions without losing validation feedback', async () => {
const screen = await render(
<>
<span id="departure-label">Departure</span>
<p id="departure-help">Choose a working day.</p>
<DatePicker
defaultValue="2025-01-15"
required
invalid
validationMessage="This date is unavailable."
>
<DatePickerTrigger aria-labelledby="departure-label" aria-describedby="departure-help" />
<DatePickerContent aria-labelledby="departure-label" />
</DatePicker>
</>,
)
const trigger = screen.getByRole('button', { name: 'Departure Jan 15, 2025' })
await expect
.element(trigger)
.toHaveAccessibleDescription('Choose a working day. Required This date is unavailable.')
await trigger.click()
await expect.element(screen.getByRole('dialog', { name: 'Departure' })).toBeVisible()
})
it('honors explicit names when a Label is also composed', async () => {
const screen = await render(
<DatePicker defaultValue="2025-01-15">
<DatePickerLabel>Departure</DatePickerLabel>
<DatePickerTrigger aria-label="Departure date" />
<DatePickerContent aria-label="Choose departure date" />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Departure date' }).click()
await expect.element(screen.getByRole('dialog', { name: 'Choose departure date' })).toBeVisible()
})
@@ -0,0 +1,155 @@
import { arTN } from '@daypicker/react/locale/ar-TN'
import { enUS } from '@daypicker/react/locale/en-US'
import { faIR } from '@daypicker/react/locale/fa-IR'
import { zhCN } from '@daypicker/react/locale/zh-CN'
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { DatePicker, DatePickerContent, DatePickerLabel, DatePickerTrigger } from '../date-picker'
import {
DateTimePicker,
DateTimePickerContent,
DateTimePickerLabel,
DateTimePickerTrigger,
} from '../date-time-picker'
import { TimePicker, TimePickerContent, TimePickerLabel, TimePickerTrigger } from '../time-picker'
// Vitest's Playwright provider inserts non-US text without keydown; these dispatches test
// the localized KeyboardEvent contract, not an installed Persian keyboard layout.
async function typeNativeDigits(text: string) {
for (const key of text) {
await React.act(() => {
document.activeElement!.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }),
)
})
}
}
it.each([
[enUS, '15', '2025', '10', '05'],
[zhCN, '15', '2025', '10', '05'],
[arTN, '15', '2025', '10', '05'],
[faIR, '۱۵', '۲۰۲۵', '۱۰', '۰۵'],
] as const)(
'uses consistent numerals across date, year and time views for $0.code',
async (locale, day, year, hour, minute) => {
const screen = await render(
<DateTimePicker
locale={locale}
timeZone="UTC"
hourCycle={24}
defaultValue={new Date('2025-01-15T10:05:00Z')}
>
<DateTimePickerLabel className="sr-only">Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>,
)
const trigger = screen.getByRole('button', { name: /^Meeting / })
await expect.element(trigger).toMatchTextContent(`${hour}:${minute}`)
await trigger.click()
await expect.element(screen.getByRole('gridcell', { selected: true })).toHaveTextContent(day)
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
await expect
.element(screen.getByRole('listbox', { name: 'Year' }).getByRole('option', { name: year }))
.toHaveAttribute('aria-selected', 'true')
await screen.getByRole('button', { name: 'Cancel', exact: true }).click()
await screen.getByRole('button', { name: /Pick Time:/ }).click()
await expect
.element(screen.getByRole('listbox', { name: 'Hour' }).getByRole('option', { name: hour }))
.toHaveAttribute('aria-selected', 'true')
await expect
.element(
screen.getByRole('listbox', { name: 'Minute' }).getByRole('option', { name: minute }),
)
.toHaveAttribute('aria-selected', 'true')
},
)
it('locates Persian month/year numbers while preserving canonical civil-date submission', async () => {
const changed = vi.fn()
const screen = await render(
<form aria-label="Booking">
<DatePicker locale={faIR} name="date" defaultValue="2025-01-15" onValueChange={changed}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>
</form>,
)
await screen.getByRole('button', { name: /^Date / }).click()
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
await typeNativeDigits('۱۲')
await expect
.element(screen.getByRole('listbox', { name: 'Month' }).getByRole('option', { name: 'دسامبر' }))
.toHaveFocus()
await userEvent.tab()
await typeNativeDigits('۲۰۲۶')
const year = screen.getByRole('option', { name: '۲۰۲۶', exact: true })
await expect.element(year).toHaveFocus()
await userEvent.keyboard('{Home}2026')
await expect.element(year).toHaveFocus()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).not.toHaveBeenCalled()
await screen.getByRole('grid').getByRole('button', { name: /۱۵/ }).click()
expect(changed).toHaveBeenCalledWith('2026-12-15')
expect(
new FormData(screen.getByRole('form', { name: 'Booking' }).element() as HTMLFormElement).get(
'date',
),
).toBe('2026-12-15')
})
it('updates an open locale without committing or discarding the time draft and clears the locating buffer', async () => {
const changed = vi.fn()
const view = (locale: string) => (
<form aria-label="Schedule">
<TimePicker
locale={locale}
hourCycle={24}
name="time"
defaultValue="10:05"
onValueChange={changed}
>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>
</form>
)
const screen = await render(view('en-US'))
await screen.getByRole('button', { name: 'Time 10:05' }).click()
await userEvent.keyboard('1')
await screen.rerender(view('fa-IR'))
await expect
.element(
screen.getByRole('listbox', { name: 'Hour' }).getByRole('option', { name: '۱', exact: true }),
)
.toHaveFocus()
await typeNativeDigits('۲')
await expect
.element(
screen.getByRole('listbox', { name: 'Hour' }).getByRole('option', { name: '۲', exact: true }),
)
.toHaveFocus()
expect(changed).not.toHaveBeenCalled()
await userEvent.keyboard('{Escape}')
await screen.getByRole('button', { name: 'Time ۱۰:۰۵' }).click()
await expect
.element(screen.getByRole('listbox', { name: 'Hour' }).getByRole('option', { name: '۱۰' }))
.toHaveAttribute('aria-selected', 'true')
await userEvent.tab()
await typeNativeDigits('۱۲')
const minute = screen.getByRole('listbox', { name: 'Minute' }).getByRole('option', { name: '۱۲' })
await expect.element(minute).toHaveFocus()
await userEvent.keyboard('{Home}12')
await expect.element(minute).toHaveFocus()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('10:12')
expect(
new FormData(screen.getByRole('form', { name: 'Schedule' }).element() as HTMLFormElement).get(
'time',
),
).toBe('10:12')
})
@@ -0,0 +1,97 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { DatePicker, DatePickerContent, DatePickerLabel, DatePickerTrigger } from '../date-picker'
import { TimePicker, TimePickerContent, TimePickerLabel, TimePickerTrigger } from '../time-picker'
async function openMonthPicker() {
const changed = vi.fn()
const screen = await render(
<DatePicker defaultValue="2025-01-15" onValueChange={changed}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date Jan 15, 2025' }).click()
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
const months = screen.getByRole('listbox', { name: 'Month', exact: true })
await expect.element(months.getByRole('option', { name: 'January' })).toHaveFocus()
return { screen, months, changed }
}
it('keeps a multi-letter month match anchored and recovers immediately after a typo', async () => {
const { months, changed } = await openMonthPicker()
await userEvent.keyboard('j')
await expect.element(months.getByRole('option', { name: 'June' })).toHaveFocus()
await userEvent.keyboard('u')
await expect.element(months.getByRole('option', { name: 'June' })).toHaveFocus()
await userEvent.keyboard('n')
await expect.element(months.getByRole('option', { name: 'June' })).toHaveFocus()
await userEvent.keyboard('z')
await expect.element(months.getByRole('option', { name: 'June' })).toHaveFocus()
await userEvent.keyboard('mar')
const march = months.getByRole('option', { name: 'March' })
await expect.element(march).toHaveFocus()
await expect.element(march).toHaveAttribute('aria-selected', 'true')
expect(changed).not.toHaveBeenCalled()
})
it('cycles matching months on repeated letters regardless of letter case', async () => {
const { months, changed } = await openMonthPicker()
for (const [key, name] of [
['j', 'June'],
['J', 'July'],
['j', 'January'],
] as const) {
await userEvent.keyboard(key)
const option = months.getByRole('option', { name })
await expect.element(option).toHaveFocus()
await expect.element(option).toHaveAttribute('aria-selected', 'true')
}
expect(changed).not.toHaveBeenCalled()
})
it('clears an unfinished year prefix when Tab leaves the column', async () => {
const { screen, changed } = await openMonthPicker()
await userEvent.tab()
const years = screen.getByRole('listbox', { name: 'Year', exact: true })
await userEvent.keyboard('20')
await expect.element(years.getByRole('option', { name: '2025' })).toHaveFocus()
await userEvent.tab({ shift: true })
await userEvent.tab()
await userEvent.keyboard('2037')
await expect.element(years.getByRole('option', { name: '2037' })).toHaveFocus()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
await expect
.element(screen.getByRole('button', { name: 'Choose month and year: January 2037' }))
.toBeInTheDocument()
expect(changed).not.toHaveBeenCalled()
})
it('preserves repeated digits and starts fresh after returning to a time column', async () => {
const changed = vi.fn()
const screen = await render(
<TimePicker defaultValue="13:30" onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
const trigger = screen.getByRole('button', { name: 'Time 1:30 PM' })
await trigger.click()
const hours = screen.getByRole('listbox', { name: 'Hour', exact: true })
await userEvent.keyboard('11')
await expect.element(hours.getByRole('option', { name: '11', exact: true })).toHaveFocus()
await userEvent.tab()
await userEvent.keyboard('55')
await expect
.element(screen.getByRole('listbox', { name: 'Minute' }).getByRole('option', { name: '55' }))
.toHaveFocus()
await userEvent.tab({ shift: true })
await userEvent.keyboard('2')
await expect.element(hours.getByRole('option', { name: '2', exact: true })).toHaveFocus()
expect(changed).not.toHaveBeenCalled()
await expect.element(trigger).toHaveTextContent('1:30 PM')
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('14:55')
})
@@ -0,0 +1,230 @@
import type * as React from 'react'
import {
DatePicker,
DatePickerClear,
DatePickerContent,
DatePickerLabel,
DatePickerTrigger,
DatePickerValue,
} from '@langgenius/dify-ui/date-picker'
import {
DateTimePicker,
DateTimePickerContent,
DateTimePickerTrigger,
DateTimePickerValue,
} from '@langgenius/dify-ui/date-time-picker'
import {
TimePicker,
TimePickerContent,
TimePickerTrigger,
TimePickerValue,
} from '@langgenius/dify-ui/time-picker'
import { expectTypeOf } from 'vite-plus/test'
export function pickerTypeContracts(
triggerRef: React.Ref<HTMLButtonElement>,
contentRef: React.Ref<HTMLDivElement>,
) {
const date = (
<DatePicker
value="2026-09-28"
onValueChange={(value) => {
expectTypeOf(value).toEqualTypeOf<string | null>()
value?.toUpperCase()
}}
>
<DatePickerLabel ref={contentRef} className="mb-1">
Date
</DatePickerLabel>
<DatePickerTrigger
ref={triggerRef}
className={(state) => (state.open ? 'text-text-accent' : '')}
>
<DatePickerValue>{(value) => value.toUpperCase()}</DatePickerValue>
</DatePickerTrigger>
<DatePickerContent ref={contentRef} style={(state) => ({ opacity: state.open ? 1 : 0 })} />
</DatePicker>
)
const time = (
<TimePicker
locale="zh-CN"
value="22:23"
onValueChange={(value) => {
expectTypeOf(value).toEqualTypeOf<string | null>()
value?.toUpperCase()
}}
>
<TimePickerTrigger ref={triggerRef}>
<TimePickerValue>{(value) => `${value} UTC+8`}</TimePickerValue>
</TimePickerTrigger>
</TimePicker>
)
const instant = (
<DateTimePicker
timeZone="Asia/Shanghai"
value={new Date()}
onValueChange={(value) => {
expectTypeOf(value).toEqualTypeOf<Date | null>()
value?.toISOString()
}}
>
<DateTimePickerTrigger ref={triggerRef} />
<DateTimePickerContent ref={contentRef} />
</DateTimePicker>
)
const invalidDate = (
// @ts-expect-error Civil dates cannot accept instants.
<DatePicker value={new Date()}>{null}</DatePicker>
)
const invalidTime = (
// @ts-expect-error Wall times cannot accept instants.
<TimePicker value={new Date()}>{null}</TimePicker>
)
const invalidInstant = (
// @ts-expect-error Date-time values are instants, not wire strings.
<DateTimePicker timeZone="UTC" value="2026-09-28">
{null}
</DateTimePicker>
)
// @ts-expect-error The caller owns the time zone for an instant.
const missingZone = <DateTimePicker>{null}</DateTimePicker>
const ariaNamedTime = (
<TimePicker>
<TimePickerTrigger aria-label="Start time" aria-describedby="time-help" />
<TimePickerContent aria-label="Choose start time" />
</TimePicker>
)
// @ts-expect-error Naming belongs to Label or the named parts, not Root.
const rootLabel = <TimePicker label="Time">{null}</TimePicker>
// @ts-expect-error External labels use aria-labelledby on the named parts.
const rootLabelledBy = <DatePicker labelledBy="date-label">{null}</DatePicker>
// @ts-expect-error Descriptions belong to the trigger.
const rootDescribedBy = <DatePicker describedBy="date-help">{null}</DatePicker>
const invalidCallback = (
// @ts-expect-error Date callbacks must accept civil strings and clearing.
<DatePicker onValueChange={(value: Date) => value.getTime()}>{null}</DatePicker>
)
const partialCalendarLocale = (
<DatePicker locale={{ code: 'en-US', labels: { labelNav: 'Calendar navigation' } }}>
{null}
</DatePicker>
)
const intlLocale = <TimePicker locale={new Intl.Locale('zh-CN')}>{null}</TimePicker>
const localeFallbacks = <TimePicker locale={['en-GB', 'en-US']}>{null}</TimePicker>
const openChange = (
<DatePicker
onOpenChange={(_open, details) => {
details.cancel()
// @ts-expect-error The picker owns its animation lifecycle and exposes no unmount action.
details.preventUnmountOnClose()
}}
>
{null}
</DatePicker>
)
// @ts-expect-error The generated selection may be any valid date, not a caller-defined subset.
const genericDate = <DatePicker<'2026-09-28'>>{null}</DatePicker>
// @ts-expect-error Hover opening is not part of the picker contract.
const hoverDelay = <DatePickerTrigger delay={100} />
// @ts-expect-error Picker triggers always render native buttons.
const customTrigger = <DatePickerTrigger render={<div />} />
// @ts-expect-error The value ID belongs to the field's accessible-name relationship.
const customValueId = <DatePickerValue id="unrelated" />
// @ts-expect-error The label ID belongs to the field's accessible-name relationship.
const customLabelId = <DatePickerLabel id="unrelated" />
// @ts-expect-error Clear button dimensions belong to the picker anatomy.
const clearSize = <DatePickerClear aria-label="Clear date" size="sm" />
const clearNamed = <DatePickerClear aria-label="Clear date" ref={triggerRef} />
const clearLabelledBy = <DatePickerClear aria-labelledby="clear-date-text" />
// @ts-expect-error Clear inherits IconButton's required accessible name.
const unnamedClear = <DatePickerClear />
// @ts-expect-error Use the inherited ARIA props instead of a custom label alias.
const legacyClearLabel = <DatePickerClear label="Clear date" />
// @ts-expect-error IconButton accepts one accessible-name source.
const conflictingClearNames = <DatePickerClear aria-label="Clear" aria-labelledby="clear-text" />
// @ts-expect-error Content focus on entry belongs to the picker.
const customInitialFocus = <DatePickerContent initialFocus={false} />
const readonlyPredicate = (
<DateTimePicker
timeZone="UTC"
isTimeUnavailable={(wall) => {
// @ts-expect-error An availability predicate must not mutate the active draft.
wall.time = '00:00'
return false
}}
>
{null}
</DateTimePicker>
)
const nonNullableChange = (
<TimePicker
defaultValue="13:30"
// @ts-expect-error Clearing is part of the callback contract even with an initial value.
onValueChange={(value: string) => value.toUpperCase()}
>
{null}
</TimePicker>
)
const invalidStep = (
// @ts-expect-error Only steps that divide an hour are supported.
<TimePicker minuteStep={7}>{null}</TimePicker>
)
const invalidHourCycle = (
// @ts-expect-error Hour cycle is a numeric display choice, not an Intl option string.
<TimePicker hourCycle="h23">{null}</TimePicker>
)
const instantStep = (
// @ts-expect-error Date-time selection has minute precision and does not expose schedule steps.
<DateTimePicker timeZone="UTC" minuteStep={15}>
{null}
</DateTimePicker>
)
const formattedInstant = (
<DateTimePicker timeZone="UTC">
<DateTimePickerTrigger>
<DateTimePickerValue>
{(value) => {
expectTypeOf(value).toEqualTypeOf<string>()
return value
}}
</DateTimePickerValue>
</DateTimePickerTrigger>
</DateTimePicker>
)
return {
nonNullableChange,
invalidStep,
invalidHourCycle,
instantStep,
formattedInstant,
partialCalendarLocale,
intlLocale,
localeFallbacks,
openChange,
genericDate,
hoverDelay,
customTrigger,
customValueId,
customLabelId,
clearSize,
clearNamed,
clearLabelledBy,
unnamedClear,
legacyClearLabel,
conflictingClearNames,
customInitialFocus,
readonlyPredicate,
date,
time,
instant,
invalidDate,
invalidTime,
invalidInstant,
missingZone,
ariaNamedTime,
rootLabel,
rootLabelledBy,
rootDescribedBy,
invalidCallback,
}
}
@@ -0,0 +1,438 @@
import { arTN } from '@daypicker/react/locale/ar-TN'
import { faIR } from '@daypicker/react/locale/fa-IR'
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { DirectionProvider } from '../../../direction-provider'
import {
DatePicker,
DatePickerClear,
DatePickerContent,
DatePickerLabel,
DatePickerTrigger,
} from '../index'
function Parts() {
return (
<>
<DatePickerTrigger />
<DatePickerClear aria-label="Clear date" />
<DatePickerContent />
</>
)
}
describe('DatePicker keyboard and form contract', () => {
it('keeps a focus-only label associated across label mounting and custom trigger IDs', async () => {
function Example() {
const [visible, setVisible] = React.useState(true)
return (
<>
<button type="button" onClick={() => setVisible(!visible)}>
Toggle label
</button>
<DatePicker defaultValue="2025-01-15">
{visible && <DatePickerLabel>Departure</DatePickerLabel>}
<DatePickerTrigger id="custom-departure" aria-label={visible ? undefined : 'Date'} />
<DatePickerContent aria-label={visible ? undefined : 'Choose date'} />
</DatePicker>
</>
)
}
const screen = await render(<Example />)
const toggle = screen.getByRole('button', { name: 'Toggle label' })
const trigger = screen.getByRole('button', { name: 'Departure Jan 15, 2025' })
await screen.getByText('Departure', { exact: true }).click()
await expect.element(trigger).toHaveFocus()
await expect.element(trigger).toHaveAttribute('aria-expanded', 'false')
await toggle.click()
await expect.element(screen.getByRole('button', { name: 'Date' })).toBeInTheDocument()
await toggle.click()
await userEvent.tab()
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard(' ')
await expect
.element(screen.getByRole('dialog', { name: 'Departure', exact: true }))
.toBeInTheDocument()
})
it.each(['disabled', 'readOnly'] as const)(
'respects %s when activating the label',
async (state) => {
const screen = await render(
<DatePicker {...{ [state]: true }}>
<DatePickerLabel>Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByText('Date', { exact: true }).click()
const trigger = screen.getByRole('button', { name: 'Date', exact: true })
if (state === 'disabled') await expect.element(trigger).not.toHaveFocus()
else await expect.element(trigger).toHaveFocus()
await expect.element(trigger).toHaveAttribute('aria-expanded', 'false')
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
},
)
it('explains an unavailable month draft and lets the user repair it without committing a date', async () => {
const changed = vi.fn()
const screen = await render(
<DatePicker
defaultValue="2025-04-15"
minDate="2025-03-01"
maxDate="2025-12-31"
onValueChange={changed}
>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date Apr 15, 2025' }).click()
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
await userEvent.keyboard('{Home}')
await expect.element(screen.getByRole('button', { name: 'OK' })).toBeDisabled()
await expect.element(screen.getByRole('status')).toHaveTextContent('This date is unavailable.')
await expect
.element(screen.getByRole('group', { name: 'Choose month and year' }))
.toHaveAccessibleDescription('This date is unavailable.')
await userEvent.keyboard('{ArrowDown}{ArrowDown}')
await expect.element(screen.getByRole('button', { name: 'OK' })).toBeEnabled()
await expect.element(screen.getByRole('status')).not.toBeInTheDocument()
await screen.getByRole('button', { name: 'OK' }).click()
await expect
.element(screen.getByRole('button', { name: 'Choose month and year: March 2025' }))
.toBeInTheDocument()
expect(changed).not.toHaveBeenCalled()
})
it('toggles month selection from the title without applying its draft or resizing the popup', async () => {
const changed = vi.fn()
const screen = await render(
<DatePicker defaultValue="2025-01-15" onValueChange={changed}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date Jan 15, 2025' }).click()
const popup = screen.getByRole('dialog', { name: 'Date', exact: true })
await expect.poll(() => popup.element().getBoundingClientRect().height).toBe(252)
const height = popup.element().getBoundingClientRect().height
const title = screen.getByRole('button', { name: 'Choose month and year: January 2025' })
const titleBounds = title.element().getBoundingClientRect()
await expect.element(title).toHaveAttribute('aria-expanded', 'false')
await title.click()
await expect.element(title).toHaveAttribute('aria-expanded', 'true')
const columns = screen.getByRole('group', { name: 'Choose month and year' })
expect(title.element().getAttribute('aria-controls')).toBe(columns.element().id)
expect(popup.element().getBoundingClientRect().height).toBe(height)
expect(title.element().getBoundingClientRect().top).toBe(titleBounds.top)
expect(title.element().getBoundingClientRect().left).toBe(titleBounds.left)
await userEvent.keyboard('{ArrowDown}')
await title.click()
await expect.element(title).toHaveFocus()
await expect.element(title).toHaveAttribute('aria-expanded', 'false')
await expect.element(screen.getByRole('grid')).toBeInTheDocument()
expect(popup.element().getBoundingClientRect().height).toBe(height)
await userEvent.keyboard('{Enter}')
await expect
.element(
screen.getByRole('listbox', { name: 'Month' }).getByRole('option', { name: 'January' }),
)
.toHaveFocus()
await userEvent.tab({ shift: true })
await expect.element(title).toHaveFocus()
await userEvent.keyboard(' ')
await expect.element(title).toHaveAttribute('aria-expanded', 'false')
await expect.element(title).toHaveFocus()
expect(changed).not.toHaveBeenCalled()
})
it('moves into month selection, locates a year numerically, and returns focus to the calendar', async () => {
const screen = await render(
<DatePicker defaultValue="2025-01-15">
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date Jan 15, 2025' }).click()
await screen.getByRole('button', { name: /Choose month and year/ }).click()
const months = screen.getByRole('listbox', { name: 'Month' })
await expect.element(months.getByRole('option', { name: 'January', exact: true })).toHaveFocus()
await userEvent.keyboard('12')
await expect
.element(months.getByRole('option', { name: 'December', exact: true }))
.toHaveAttribute('aria-selected', 'true')
await userEvent.keyboard('{Home}mar')
await expect.element(months.getByRole('option', { name: 'March', exact: true })).toHaveFocus()
await userEvent.keyboard('{End}')
await userEvent.tab()
await userEvent.keyboard('2037')
await expect.element(screen.getByRole('option', { name: '2037', exact: true })).toHaveFocus()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
await expect.element(screen.getByRole('grid')).toBeInTheDocument()
await expect.poll(() => document.activeElement?.closest('[role="grid"]') !== null).toBe(true)
await expect
.element(screen.getByRole('button', { name: /Choose month and year: December 2037/ }))
.toBeInTheDocument()
})
it('keeps a single tab stop in the grid and skips unavailable days', async () => {
const screen = await render(
<DatePicker
defaultValue="2025-01-15"
minDate="2025-01-14"
maxDate="2025-01-31"
isDateUnavailable={(date) => date === '2025-01-16'}
>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: /^Date Jan/ }).click()
await userEvent.keyboard('{ArrowRight}')
expect(document.activeElement?.getAttribute('aria-label')).toMatch(/January 17/)
expect(document.querySelectorAll('[role="grid"] button[tabindex="0"]')).toHaveLength(1)
await expect.element(screen.getByRole('button', { name: 'Previous month' })).toBeDisabled()
await expect.element(screen.getByRole('button', { name: 'Next month' })).toBeDisabled()
await userEvent.keyboard('{Escape}')
await expect.element(screen.getByRole('button', { name: /^Date Jan/ })).toHaveFocus()
})
it('validates a required value, serializes a civil date, and resets to the initial value', async () => {
const submitted = vi.fn()
const screen = await render(
<form
onSubmit={(event) => {
event.preventDefault()
submitted(new FormData(event.currentTarget).get('date'))
}}
>
<DatePicker name="date" required validationMessage="Choose a date">
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>
<button type="submit">Submit</button>
<button type="reset">Reset</button>
</form>,
)
await screen.getByRole('button', { name: 'Submit' }).click()
expect(submitted).not.toHaveBeenCalled()
await expect.element(screen.getByRole('button', { name: 'Date', exact: true })).toHaveFocus()
await expect.element(screen.getByRole('alert')).toHaveTextContent('Choose a date')
await screen.getByRole('button', { name: 'Date', exact: true }).click()
await userEvent.keyboard('{Enter}')
await screen.getByRole('button', { name: 'Submit' }).click()
expect(submitted).toHaveBeenCalledWith(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/))
await screen.getByRole('button', { name: 'Reset' }).click()
await expect
.element(screen.getByRole('button', { name: 'Date', exact: true }))
.toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Clear date' })).not.toBeInTheDocument()
})
it.each(['aria-label', 'aria-labelledby'] as const)(
'clears through a %s named button and restores trigger focus',
async (naming) => {
const changed = vi.fn()
const screen = await render(
<DatePicker defaultValue="2025-01-15" onValueChange={changed}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<div>
<DatePickerTrigger />
<span id="clear-date-text" className="sr-only">
Clear date
</span>
<DatePickerClear
{...(naming === 'aria-label'
? { 'aria-label': 'Clear date' }
: { 'aria-labelledby': 'clear-date-text' })}
/>
</div>
<DatePickerContent />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Clear date' }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith(null)
await expect.element(screen.getByRole('button', { name: 'Date', exact: true })).toHaveFocus()
await expect
.element(screen.getByRole('button', { name: 'Clear date' }))
.not.toBeInTheDocument()
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
},
)
it('keeps read-only values in form data while preventing edits', async () => {
const onChange = vi.fn()
const screen = await render(
<form id="readonly-form">
<DatePicker name="date" readOnly defaultValue="2025-01-15" onValueChange={onChange}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>
</form>,
)
const trigger = screen.getByRole('button', { name: /^Date Jan/ })
await userEvent.tab()
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
expect(
new FormData(document.querySelector<HTMLFormElement>('#readonly-form')!).get('date'),
).toBe('2025-01-15')
expect(onChange).not.toHaveBeenCalled()
})
})
it('moves across an adjacent month with the keyboard', async () => {
const screen = await render(
<DatePicker defaultValue="2025-01-31">
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date Jan 31, 2025' }).click()
await userEvent.keyboard('{ArrowRight}')
await expect
.element(screen.getByRole('button', { name: 'Saturday, February 1st, 2025', exact: true }))
.toHaveFocus()
await expect.element(screen.getByRole('status')).toHaveTextContent('February 2025')
expect(
Array.from(document.querySelectorAll('[aria-live="polite"]')).filter(
(element) => element.textContent === 'February 2025',
),
).toHaveLength(1)
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByRole('button', { name: 'Date Feb 1, 2025' })).toHaveFocus()
})
it('opens on the allowed month and retains a visible focus target if every date is unavailable', async () => {
const screen = await render(
<DatePicker minDate="2037-02-01" maxDate="2037-02-28" isDateUnavailable={() => true}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date', exact: true }).click()
await expect
.element(screen.getByRole('button', { name: 'Choose month and year: February 2037' }))
.toHaveFocus()
})
it('keeps adjacent-month dates selectable by pointer', async () => {
const screen = await render(
<DatePicker defaultValue="2025-01-15">
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date Jan 15, 2025' }).click()
await screen.getByRole('button', { name: 'Saturday, February 1st, 2025', exact: true }).click()
await expect.element(screen.getByRole('button', { name: 'Date Feb 1, 2025' })).toHaveFocus()
})
it('supports month/year paging and week endpoints without committing during navigation', async () => {
const changed = vi.fn()
const screen = await render(
<DatePicker defaultValue="2025-01-31" onValueChange={changed}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date Jan 31, 2025' }).click()
await userEvent.keyboard('{PageDown}')
await expect
.element(screen.getByRole('button', { name: 'Friday, February 28th, 2025', exact: true }))
.toHaveFocus()
await userEvent.keyboard('{Shift>}{PageDown}{/Shift}')
await expect
.element(screen.getByRole('button', { name: 'Saturday, February 28th, 2026', exact: true }))
.toHaveFocus()
await userEvent.keyboard('{Home}')
await expect
.element(screen.getByRole('button', { name: 'Sunday, February 22nd, 2026', exact: true }))
.toHaveFocus()
await userEvent.keyboard('{End}')
await expect
.element(screen.getByRole('button', { name: 'Saturday, February 28th, 2026', exact: true }))
.toHaveFocus()
expect(changed).not.toHaveBeenCalled()
})
it('localizes full dates and reverses horizontal calendar navigation in RTL', async () => {
const screen = await render(
<DirectionProvider direction="rtl">
<DatePicker locale={arTN} defaultValue="2025-01-15">
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>
</DirectionProvider>,
)
await screen.getByRole('button', { name: /^Date / }).click()
await expect
.element(screen.getByRole('dialog', { name: 'Date', exact: true }))
.toHaveAttribute('dir', 'rtl')
await userEvent.keyboard('{ArrowLeft}')
const nextLabel = 'الخميس، 16 جانفي 2025'
await expect.element(screen.getByRole('button', { name: nextLabel, exact: true })).toHaveFocus()
})
it('focuses the first invalid picker inside a native fieldset', async () => {
const screen = await render(
<form>
<fieldset>
<legend>Trip</legend>
<DatePicker required>
<DatePickerLabel className="sr-only">Departure</DatePickerLabel>
<Parts />
</DatePicker>
<DatePicker required>
<DatePickerLabel className="sr-only">Return</DatePickerLabel>
<Parts />
</DatePicker>
</fieldset>
<button type="submit">Submit trip</button>
</form>,
)
await screen.getByRole('button', { name: 'Submit trip' }).click()
await expect.element(screen.getByRole('button', { name: 'Departure', exact: true })).toHaveFocus()
})
it('lets a picker override inherited direction independently of its formatting locale', async () => {
const screen = await render(
<DirectionProvider direction="rtl">
<DatePicker direction="ltr" locale={arTN} defaultValue="2025-01-15">
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>
</DirectionProvider>,
)
const trigger = screen.getByRole('button', { name: /^Date / })
await expect.element(trigger).toHaveAttribute('dir', 'ltr')
await trigger.click()
await expect
.element(screen.getByRole('dialog', { name: 'Date', exact: true }))
.toHaveAttribute('dir', 'ltr')
await userEvent.keyboard('{ArrowRight}')
const nextLabel = 'الخميس، 16 جانفي 2025'
await expect.element(screen.getByRole('button', { name: nextLabel, exact: true })).toHaveFocus()
})
it('keeps Persian month labels in the Gregorian calendar used by the value', async () => {
const changed = vi.fn()
const screen = await render(
<DatePicker locale={faIR} defaultValue="2025-01-15" onValueChange={changed}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<Parts />
</DatePicker>,
)
await screen.getByRole('button', { name: /^Date / }).click()
await screen.getByRole('button', { name: /Choose month and year/ }).click()
await expect
.element(screen.getByRole('option', { name: 'ژانویه' }))
.toHaveAttribute('aria-selected', 'true')
await screen.getByRole('option', { name: 'فوریه' }).click()
await screen.getByRole('button', { name: 'OK' }).click()
const dateName = 'شنبه ۱۵ فوریه ۲۰۲۵'
await screen.getByRole('button', { name: dateName }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('2025-02-15')
})
@@ -0,0 +1,15 @@
import { formatDateValue, parseDateValue } from '../date-value'
it.each(['2025-02-30', '2025-02', '2025-2-03', '2025-02-03T00:00:00Z', '0000-01-01'])(
'rejects noncanonical or impossible civil dates: %s',
(value) => {
expect(parseDateValue(value)).toBeNull()
},
)
it.each(['2024-02-29', '2025-02-28', '0099-01-01'])(
'roundtrips valid civil dates without native Date year coercion: %s',
(value) => {
expect(formatDateValue(parseDateValue(value)!)).toBe(value)
},
)
@@ -0,0 +1,92 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { DatePicker, DatePickerContent, DatePickerLabel, DatePickerTrigger } from '../index'
async function openMonths() {
const changed = vi.fn()
const screen = await render(
<DatePicker
defaultValue="2025-01-15"
minDate="2020-01-01"
maxDate="2030-12-31"
onValueChange={changed}
>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent style={{ maxHeight: 210 }} />
</DatePicker>,
)
await screen.getByRole('button', { name: 'Date Jan 15, 2025' }).click()
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
const months = screen.getByRole('listbox', { name: 'Month' })
const years = screen.getByRole('listbox', { name: 'Year' })
await expect.element(months.getByRole('option', { name: 'January' })).toHaveFocus()
return { screen, months, years, changed }
}
// Synthetic intent with real Chromium scrolling, matching the time-column gesture tests.
function startTouch(column: Element, top: number) {
column.dispatchEvent(new Event('touchstart'))
column.scrollTo({ top, behavior: 'instant' })
column.dispatchEvent(new Event('scroll'))
}
it('shares top alignment across click, keyboard and touch and applies both moving month/year columns', async () => {
const { screen, months, years, changed } = await openMonths()
const popup = screen.getByRole('dialog', { name: 'Date', exact: true }).element()
const bounds = popup.getBoundingClientRect()
const apply = screen.getByRole('button', { name: 'OK', exact: true })
const cancel = screen.getByRole('button', { name: 'Cancel', exact: true })
for (const button of [apply, cancel]) {
const rect = button.element().getBoundingClientRect()
expect(rect.top).toBeGreaterThanOrEqual(bounds.top)
expect(rect.bottom).toBeLessThanOrEqual(bounds.bottom)
}
expect(popup.scrollHeight).toBe(popup.clientHeight)
expect(months.element().clientHeight).toBeLessThan(192)
await months.getByRole('option', { name: 'April' }).click({ scroll: 'none' })
await expect.poll(() => months.element().scrollTop).toBe(78)
await userEvent.keyboard('{ArrowDown}')
await expect.element(months.getByRole('option', { name: 'May' })).toHaveFocus()
expect(months.element().scrollTop).toBe(104)
startTouch(months.element(), 136)
startTouch(years.element(), 188)
await expect
.element(months.getByRole('option', { name: 'June' }))
.toHaveAttribute('aria-selected', 'true')
await expect
.element(years.getByRole('option', { name: '2027' }))
.toHaveAttribute('aria-selected', 'true')
expect(months.element().scrollTop).toBe(136)
expect(years.element().scrollTop).toBe(188)
await apply.click()
await expect
.element(screen.getByRole('button', { name: 'Choose month and year: June 2027' }))
.toBeInTheDocument()
expect(changed).not.toHaveBeenCalled()
await expect
.element(screen.getByRole('button', { name: 'Date Jan 15, 2025' }))
.toBeInTheDocument()
})
it('settles month gestures without losing a concurrent year draft and cancels them without applying', async () => {
const { screen, months, years, changed } = await openMonths()
startTouch(months.element(), 136)
startTouch(years.element(), 188)
for (const column of [months.element(), years.element()]) {
column.dispatchEvent(new Event('touchend'))
column.dispatchEvent(new Event('scrollend'))
}
await expect
.element(months.getByRole('option', { name: 'June' }))
.toHaveAttribute('aria-selected', 'true')
await expect
.element(years.getByRole('option', { name: '2027' }))
.toHaveAttribute('aria-selected', 'true')
await expect.poll(() => months.element().scrollTop).toBe(130)
await screen.getByRole('button', { name: 'Cancel', exact: true }).click()
await expect
.element(screen.getByRole('button', { name: 'Choose month and year: January 2025' }))
.toHaveFocus()
expect(changed).not.toHaveBeenCalled()
})
@@ -0,0 +1,47 @@
import { zhCN } from '@daypicker/react/locale/zh-CN'
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { DatePicker, DatePickerContent, DatePickerLabel, DatePickerTrigger } from '../index'
it('confirms the selected day without clearing it or emitting a duplicate change', async () => {
const changed = vi.fn()
const screen = await render(
<DatePicker defaultValue="2025-01-15" onValueChange={changed}>
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>,
)
const trigger = screen.getByRole('button', { name: 'Date Jan 15, 2025' })
await trigger.click()
await userEvent.keyboard('{Enter}')
await expect.element(trigger).toHaveAttribute('aria-expanded', 'false')
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
await expect.element(trigger).toHaveFocus()
expect(changed).not.toHaveBeenCalled()
})
it('preserves localized today and selection semantics in the styled day button', async () => {
vi.setSystemTime(new Date('2026-09-28T12:00:00Z'))
try {
const screen = await render(
<DatePicker locale={zhCN} defaultValue="2026-09-28">
<DatePickerLabel className="sr-only">Date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>,
)
await screen.getByRole('button', { name: /^Date / }).click()
const today = screen.getByRole('button', { name: '今天,2026年9月28日 星期一,已选择' })
await expect.element(today).toHaveAttribute('aria-current', 'date')
await expect.element(today).toHaveTextContent('28')
await expect.element(today).toHaveFocus()
await userEvent.keyboard('{ArrowRight}')
await expect
.element(screen.getByRole('button', { name: '2026年9月29日 星期二' }))
.not.toHaveAttribute('aria-current')
} finally {
vi.useRealTimers()
}
})
@@ -0,0 +1,191 @@
import type { DayPickerProps, NavProps } from '@daypicker/react'
import { TZDate } from '@date-fns/tz'
import { DayButton, DayFlag, DayPicker, SelectionState, UI } from '@daypicker/react'
import * as React from 'react'
import { useDirection } from '../../direction-provider'
import { IconButton } from '../../icon-button'
import { formatDateValue, parseDateValue } from './date-value'
type CalendarGridProps = {
numberingSystem: string
navigation: {
monthLabel: string
monthTrigger: React.ReactNode
previousMonth: string
nextMonth: string
}
value: string | null
onSelect: (value: string) => void
displayMonth: string
onDisplayMonthChange: (value: string) => void
locale?: DayPickerProps['locale']
timeZone?: string
focusOnMount?: boolean
minDate?: string
maxDate?: string
isDateUnavailable?: (date: string) => boolean
}
function toCalendarDate(value: string): Date {
const parsed = parseDateValue(value)
if (!parsed) throw new RangeError(`Invalid calendar date: ${value}`)
return parsed
}
function fromCalendarDate(date: Date): string {
return formatDateValue(new TZDate(date.getTime(), 'UTC'))
}
function StyledDayButton(props: React.ComponentProps<typeof DayButton>) {
return (
<DayButton {...props} aria-current={props.modifiers.today ? 'date' : undefined}>
{props.children}
{props.modifiers.today && !props.modifiers.selected && (
<span
aria-hidden="true"
className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-components-button-primary-bg"
/>
)}
</DayButton>
)
}
const NavigationContext = React.createContext<CalendarGridProps['navigation'] | null>(null)
function CalendarNavigation({
previousMonth,
nextMonth,
onPreviousClick,
onNextClick,
...navProps
}: NavProps) {
const navigation = React.useContext(NavigationContext)!
return (
<nav {...navProps} className="flex h-10 items-center justify-between px-2 pt-2">
{navigation.monthTrigger}
<div className="flex">
<IconButton
size="lg"
aria-label={navigation.previousMonth}
disabled={!previousMonth}
onClick={onPreviousClick}
>
<span
className="i-ri-arrow-up-s-line size-4 forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
aria-hidden="true"
/>
</IconButton>
<IconButton
size="lg"
aria-label={navigation.nextMonth}
disabled={!nextMonth}
onClick={onNextClick}
>
<span
className="i-ri-arrow-down-s-line size-4 forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
aria-hidden="true"
/>
</IconButton>
</div>
</nav>
)
}
const calendarClassNames = {
[UI.Root]: 'w-63',
[UI.Months]: 'w-full',
[UI.Month]: 'w-full',
[UI.MonthCaption]: 'sr-only',
[UI.MonthGrid]: 'block w-full',
[UI.Weekdays]: 'flex h-7 items-center gap-0.5 border-b-[0.5px] border-divider-regular px-2',
[UI.Weekday]: 'flex h-7 w-8 items-center justify-center text-text-tertiary system-2xs-medium',
[UI.Weeks]: 'flex flex-col gap-0.5 p-2',
[UI.Week]: 'flex h-8 gap-0.5',
[UI.Day]: 'flex size-8 items-center justify-center p-0 text-center',
[UI.DayButton]:
'relative flex size-8 items-center justify-center rounded-lg text-text-secondary system-sm-medium hover:bg-state-base-hover focus-visible:outline-2 focus-visible:outline-state-accent-solid disabled:cursor-default',
[SelectionState.selected]:
'[&>button]:bg-components-button-primary-bg [&>button]:text-components-button-primary-text [&>button]:hover:bg-components-button-primary-bg forced-colors:[&>button]:outline forced-colors:[&>button]:outline-[Highlight]',
[DayFlag.outside]:
'[&:not([aria-selected=true])>button]:text-text-tertiary [&:not([aria-selected=true])>button]:hover:text-text-secondary',
[DayFlag.disabled]:
'[&>button]:cursor-not-allowed [&>button]:text-text-quaternary [&>button]:hover:bg-transparent',
} as const
function CalendarGrid({
numberingSystem,
navigation,
value,
onSelect,
displayMonth,
onDisplayMonthChange,
locale,
timeZone,
focusOnMount,
minDate,
maxDate,
isDateUnavailable,
}: CalendarGridProps) {
const [today] = React.useState(() =>
toCalendarDate(formatDateValue(timeZone ? new TZDate(Date.now(), timeZone) : new Date())),
)
const direction = useDirection()
const selected = value ? toCalendarDate(value) : undefined
const calendarRef = React.useRef<HTMLDivElement>(null)
const restoreSelectionFocusRef = React.useRef(false)
React.useLayoutEffect(() => {
if (!restoreSelectionFocusRef.current) return
restoreSelectionFocusRef.current = false
// DayPicker keys day buttons by both date and display month, so outside-day selection remounts them.
calendarRef.current?.querySelector<HTMLElement>('[role="grid"] button[tabindex="0"]')?.focus()
}, [displayMonth])
/* oxlint-disable jsx-a11y/no-autofocus -- DayPicker restores focus after an explicit view change, rather than autofocusing a page input. */
return (
<NavigationContext value={navigation}>
<div ref={calendarRef} className="min-h-0 overflow-y-auto overscroll-none">
<DayPicker
mode="single"
required
dir={direction}
selected={selected}
onSelect={(date) => {
if (!date) return
const next = fromCalendarDate(date)
const nextMonth = `${next.slice(0, 7)}-01`
if (nextMonth !== displayMonth) {
restoreSelectionFocusRef.current = true
onDisplayMonthChange(nextMonth)
}
onSelect(next)
}}
month={toCalendarDate(displayMonth)}
onMonthChange={(month) =>
onDisplayMonthChange(`${fromCalendarDate(month).slice(0, 7)}-01`)
}
timeZone="UTC"
today={today}
autoFocus={focusOnMount}
startMonth={minDate ? toCalendarDate(minDate) : toCalendarDate('0001-01-01')}
endMonth={maxDate ? toCalendarDate(maxDate) : toCalendarDate('9999-12-31')}
locale={locale}
// DayPicker forwards this to Intl; its literal union lists only a subset of valid Intl identifiers.
numerals={numberingSystem as NonNullable<DayPickerProps['numerals']>}
showOutsideDays
disabled={(date) => {
const day = fromCalendarDate(date)
return Boolean(
(minDate && day < minDate) || (maxDate && day > maxDate) || isDateUnavailable?.(day),
)
}}
components={{ DayButton: StyledDayButton, Nav: CalendarNavigation }}
classNames={calendarClassNames}
/>
</div>
</NavigationContext>
)
/* oxlint-enable jsx-a11y/no-autofocus */
}
export { CalendarGrid, fromCalendarDate, toCalendarDate }
export type { CalendarGridProps }
@@ -0,0 +1,255 @@
import type { DayPickerProps } from '@daypicker/react'
import type { OptionColumnHandle } from '../internal/option-column'
import * as React from 'react'
import { Button } from '../../button'
import { OptionColumn } from '../internal/option-column'
import { PickerColumns, PickerPanel, PickerPanelHeader } from '../internal/picker-panel'
import { usePickerNumbers } from '../internal/use-picker-numbers'
import { CalendarGrid, toCalendarDate } from './calendar-grid'
type CalendarPanelLabels = {
previousMonth: string
nextMonth: string
chooseMonthAndYear: string
month: string
year: string
cancel: string
apply: string
unavailable: string
}
type CalendarPanelProps = {
value: string | null
onSelect: (date: string) => void
displayMonth: string
onDisplayMonthChange: (month: string) => void
locale?: DayPickerProps['locale']
timeZone?: string
minDate?: string
maxDate?: string
isDateUnavailable?: (date: string) => boolean
labels: CalendarPanelLabels
footer?: React.ReactNode
}
function CalendarPanel({
value,
onSelect,
displayMonth,
onDisplayMonthChange,
locale,
timeZone,
minDate,
maxDate,
isDateUnavailable,
labels,
footer,
}: CalendarPanelProps) {
const [monthPickerHeight, setMonthPickerHeight] = React.useState<number | null>(null)
const choosingMonth = monthPickerHeight !== null
const monthPickerId = React.useId()
const errorId = React.useId()
const [draftMonth, setDraftMonth] = React.useState(displayMonth)
const [focusCalendar, setFocusCalendar] = React.useState(false)
const panelRef = React.useRef<HTMLDivElement>(null)
const headerRef = React.useRef<HTMLButtonElement>(null)
const monthRef = React.useRef<OptionColumnHandle>(null)
const yearRef = React.useRef<OptionColumnHandle>(null)
React.useEffect(() => {
if (choosingMonth || !focusCalendar) return
const frame = requestAnimationFrame(() => {
if (!panelRef.current?.querySelector('[role="grid"] button[tabindex="0"]'))
headerRef.current?.focus()
})
return () => cancelAnimationFrame(frame)
}, [choosingMonth, focusCalendar])
const year = Number(draftMonth.slice(0, 4))
const month = Number(draftMonth.slice(5, 7))
const minYear = minDate
? Number(minDate.slice(0, 4))
: Math.min(1900, Number(displayMonth.slice(0, 4)))
const maxYear = maxDate
? Number(maxDate.slice(0, 4))
: Math.max(2100, Number(displayMonth.slice(0, 4)))
const numbers = usePickerNumbers(locale?.code)
const monthFormatting = React.useMemo(() => {
const options = { month: 'long', calendar: 'gregory', timeZone: 'UTC' } as const
const formatter = new Intl.DateTimeFormat(locale?.code ?? 'en-US', options)
return {
caption: new Intl.DateTimeFormat(locale?.code ?? 'en-US', { ...options, year: 'numeric' }),
options: Array.from({ length: 12 }, (_, index) => ({
value: index + 1,
label: formatter.format(new Date(Date.UTC(2025, index, 1))),
})),
}
}, [locale?.code])
const monthLabel = monthFormatting.caption.format(toCalendarDate(displayMonth))
function outOfBounds(candidate: string) {
return Boolean(
(minDate && candidate.slice(0, 7) < minDate.slice(0, 7)) ||
(maxDate && candidate.slice(0, 7) > maxDate.slice(0, 7)),
)
}
function handleEscape(event: React.KeyboardEvent<HTMLElement>) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
finish(false)
}
}
const unavailable = outOfBounds(draftMonth)
function focusPopup() {
panelRef.current?.closest<HTMLElement>('[role="dialog"]')?.focus({ preventScroll: true })
}
function finish(apply: boolean) {
let next = draftMonth
if (apply) {
next = `${String(yearRef.current?.settle() ?? year).padStart(4, '0')}-${String(monthRef.current?.settle() ?? month).padStart(2, '0')}-01`
setDraftMonth(next)
if (outOfBounds(next)) return
}
focusPopup()
setFocusCalendar(apply)
if (apply) onDisplayMonthChange(next)
setMonthPickerHeight(null)
if (!apply) requestAnimationFrame(() => headerRef.current?.focus())
}
const monthTrigger = (
<Button
ref={headerRef}
variant="ghost"
type="button"
aria-label={`${labels.chooseMonthAndYear}: ${monthLabel}`}
aria-expanded={choosingMonth}
aria-controls={choosingMonth ? monthPickerId : undefined}
className="gap-0.5 px-2 text-sm leading-5 font-semibold text-text-primary"
onKeyDown={choosingMonth ? handleEscape : undefined}
onClick={() => {
if (choosingMonth) {
finish(false)
return
}
const height = panelRef.current?.offsetHeight
if (height === undefined) return
focusPopup()
setDraftMonth(displayMonth)
setMonthPickerHeight(height)
}}
>
{monthLabel}
<span
aria-hidden="true"
className={`${choosingMonth ? 'i-ri-arrow-up-s-line' : 'i-ri-arrow-down-s-line'} size-4 forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none`}
/>
</Button>
)
return (
<PickerPanel
ref={panelRef}
style={monthPickerHeight === null ? undefined : { height: monthPickerHeight }}
header={
choosingMonth && (
<PickerPanelHeader className="items-start px-2 pt-2">{monthTrigger}</PickerPanelHeader>
)
}
message={
choosingMonth &&
unavailable && (
<p id={errorId} role="status" className="px-2 system-xs-regular text-text-destructive">
{labels.unavailable}
</p>
)
}
footer={
choosingMonth ? (
<>
<Button
size="small"
variant="secondary"
className="flex-1"
onKeyDown={handleEscape}
onClick={() => finish(false)}
>
{labels.cancel}
</Button>
<Button
size="small"
variant="primary"
className="flex-1"
disabled={unavailable}
aria-describedby={unavailable ? errorId : undefined}
onKeyDown={handleEscape}
onClick={() => finish(true)}
>
{labels.apply}
</Button>
</>
) : (
footer
)
}
>
{choosingMonth ? (
<PickerColumns
id={monthPickerId}
role="group"
aria-label={labels.chooseMonthAndYear}
aria-describedby={unavailable ? errorId : undefined}
>
<OptionColumn
ref={monthRef}
onEscape={() => finish(false)}
focusOnMount
normalizeDigits={numbers.normalizeDigits}
label={labels.month}
value={month}
options={monthFormatting.options}
onValueChange={(next) =>
setDraftMonth(
(previous) => `${previous.slice(0, 4)}-${String(next).padStart(2, '0')}-01`,
)
}
/>
<OptionColumn
ref={yearRef}
onEscape={() => finish(false)}
normalizeDigits={numbers.normalizeDigits}
label={labels.year}
value={year}
options={Array.from({ length: maxYear - minYear + 1 }, (_, index) => ({
value: minYear + index,
label: numbers.formatInteger(minYear + index),
}))}
onValueChange={(next) =>
setDraftMonth(
(previous) => `${String(next).padStart(4, '0')}-${previous.slice(5, 7)}-01`,
)
}
/>
</PickerColumns>
) : (
<CalendarGrid
numberingSystem={numbers.numberingSystem}
navigation={{
monthLabel,
monthTrigger,
previousMonth: labels.previousMonth,
nextMonth: labels.nextMonth,
}}
focusOnMount={focusCalendar}
value={value}
onSelect={onSelect}
displayMonth={displayMonth}
onDisplayMonthChange={onDisplayMonthChange}
locale={locale}
timeZone={timeZone}
minDate={minDate}
maxDate={maxDate}
isDateUnavailable={isDateUnavailable}
/>
)}
</PickerPanel>
)
}
export { CalendarPanel }
export type { CalendarPanelLabels, CalendarPanelProps }
@@ -0,0 +1,27 @@
import { tz } from '@date-fns/tz'
import { format, isValid, parseISO } from 'date-fns'
function parseDateValue(value: string): Date | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || value.startsWith('0000')) return null
const date = parseISO(value, { in: tz('UTC') })
return isValid(date) ? date : null
}
function formatDateValue(date: Date): string {
return format(date, 'yyyy-MM-dd')
}
function requireDateValue(value: string | null): Date | null {
if (value === null) return null
const date = parseDateValue(value)
if (!date) throw new RangeError(`Invalid calendar date: ${value}`)
return date
}
function getInitialMonth(date: string, minDate?: string, maxDate?: string): string {
const visibleDate =
minDate && date < minDate ? minDate : maxDate && date > maxDate ? maxDate : date
return `${visibleDate.slice(0, 7)}-01`
}
export { formatDateValue, getInitialMonth, parseDateValue, requireDateValue }
@@ -0,0 +1,279 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { arTN } from '@daypicker/react/locale/ar-TN'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import { Button } from '../../button'
import { DirectionProvider } from '../../direction-provider'
import {
DateTimePicker,
DateTimePickerContent,
DateTimePickerLabel,
DateTimePickerTrigger,
} from '../date-time-picker'
import { TimePicker, TimePickerContent, TimePickerLabel, TimePickerTrigger } from '../time-picker'
import {
DatePicker,
DatePickerClear,
DatePickerContent,
DatePickerLabel,
DatePickerTrigger,
} from './index'
const meta = {
title: 'Base/Form/Date Picker',
component: DatePicker,
args: { children: null },
parameters: {
layout: 'centered',
docs: { story: { autoplay: false } },
a11y: { config: { rules: [{ id: 'color-contrast', enabled: true }] } },
},
tags: ['autodocs'],
} satisfies Meta<typeof DatePicker>
export default meta
type Story = StoryObj<typeof meta>
function DatePickerDemo() {
const [value, setValue] = React.useState<string | null>('2025-01-15')
return (
<div className="grid gap-1">
<DatePicker value={value} onValueChange={setValue}>
<DatePickerLabel>Start date</DatePickerLabel>
<div className="flex items-center gap-1">
<DatePickerTrigger />
<DatePickerClear aria-label="Clear start date" />
</div>
<DatePickerContent />
</DatePicker>
</div>
)
}
export const Default: Story = {
render: () => <DatePickerDemo />,
play: async ({ canvasElement, userEvent }) => {
const canvas = within(canvasElement)
const body = within(canvasElement.ownerDocument.body)
const trigger = canvas.getByRole('button', { name: 'Start date Jan 15, 2025' })
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await userEvent.click(trigger)
await waitFor(() =>
expect(body.getByRole('button', { name: /Wednesday, January 15th, 2025/ })).toHaveFocus(),
)
await userEvent.keyboard('{ArrowRight}{Enter}')
await waitFor(() => expect(trigger).toHaveFocus())
await expect(trigger).toHaveAccessibleName('Start date Jan 16, 2025')
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
},
}
function NativeFormDemo() {
const id = React.useId()
const [submitted, setSubmitted] = React.useState<Record<string, FormDataEntryValue> | null>(null)
return (
<form
aria-label="Schedule"
className="grid w-80 max-w-full gap-4"
onSubmit={(event) => {
event.preventDefault()
setSubmitted(Object.fromEntries(new FormData(event.currentTarget)))
}}
onReset={() => setSubmitted(null)}
>
<fieldset className="grid min-w-0 gap-3">
<legend className="mb-2 system-md-semibold text-text-primary">Schedule</legend>
<div className="grid gap-1">
<DatePicker name="date" required validationMessage="Choose a start date.">
<DatePickerLabel>Start date (required)</DatePickerLabel>
<DatePickerTrigger aria-describedby={`${id}-date-help`} />
<DatePickerContent />
</DatePicker>
<p id={`${id}-date-help`} className="system-xs-regular text-text-secondary">
Choose a calendar date; no time zone is stored.
</p>
</div>
<div className="grid gap-1">
<TimePicker name="time" defaultValue="13:30">
<TimePickerLabel>Start time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>
</div>
<div className="grid gap-1">
<DateTimePicker
name="reminder"
timeZone="UTC"
defaultValue={new Date('2025-01-15T13:30:00Z')}
>
<DateTimePickerLabel>Reminder (UTC)</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>
</div>
</fieldset>
<div className="flex gap-2">
<Button type="submit">Save</Button>
<Button type="reset" variant="secondary">
Reset
</Button>
</div>
{submitted && (
<output aria-label="Submitted values" className="system-xs-regular break-all">
{JSON.stringify(submitted)}
</output>
)}
</form>
)
}
export const NativeForm: Story = {
render: () => <NativeFormDemo />,
parameters: {
docs: {
description: {
story:
'Native form, fieldset, picker labels and FormData. Labels focus their triggers without opening the popup; descriptions and committed values are announced with the visible label. Save validates the required date, and Reset restores defaults. No Base UI Field or Form registration is involved.',
},
},
},
play: async ({ canvasElement, userEvent }) => {
const canvas = within(canvasElement)
const body = within(canvasElement.ownerDocument.body)
const trigger = canvas.getByRole('button', { name: 'Start date (required)' })
await userEvent.click(canvas.getByRole('button', { name: 'Save' }))
await expect(canvas.queryByRole('status', { name: 'Submitted values' })).not.toBeInTheDocument()
await expect(trigger).toHaveFocus()
await expect(trigger).toHaveAttribute('aria-invalid', 'true')
await expect(trigger).toHaveAccessibleDescription(
/Choose a calendar date; no time zone is stored.*Choose a start date/,
)
for (const [label, name] of [
['Start date (required)', 'Start date (required)'],
['Start time', 'Start time 1:30 PM'],
['Reminder (UTC)', 'Reminder (UTC) Jan 15, 2025, 1:30 PM'],
] as const) {
await userEvent.click(canvas.getByText(label, { exact: true }))
const field = canvas.getByRole('button', { name })
await expect(field).toHaveFocus()
await expect(field).toHaveAttribute('aria-expanded', 'false')
await expect(body.queryByRole('dialog')).not.toBeInTheDocument()
}
await userEvent.click(canvas.getByText('Start date (required)', { exact: true }))
await userEvent.keyboard('{Enter}')
const dialog = await body.findByRole('dialog', { name: 'Start date (required)' })
await waitFor(() => expect(dialog).toBeVisible())
await waitFor(() =>
expect(dialog.contains(canvasElement.ownerDocument.activeElement)).toBe(true),
)
await userEvent.keyboard('{Enter}')
await waitFor(() => expect(trigger).toHaveFocus())
await userEvent.click(canvas.getByRole('button', { name: 'Save' }))
const submitted = canvas.getByRole('status', { name: 'Submitted values' })
const values = JSON.parse(submitted.textContent!)
await expect(values).toEqual({
date: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
time: '13:30',
reminder: '2025-01-15T13:30:00.000Z',
})
await userEvent.click(canvas.getByRole('button', { name: 'Reset' }))
await waitFor(() => expect(trigger).toHaveAccessibleName('Start date (required)'))
await expect(trigger).not.toHaveAttribute('aria-invalid', 'true')
await expect(canvas.queryByRole('status', { name: 'Submitted values' })).not.toBeInTheDocument()
},
}
export const Empty: Story = {
render: () => (
<DatePicker>
<DatePickerLabel className="sr-only">Start date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>
),
}
export const Disabled: Story = {
render: () => (
<DatePicker defaultValue="2025-01-15" disabled>
<DatePickerLabel className="sr-only">Start date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerClear aria-label="Clear start date" />
<DatePickerContent />
</DatePicker>
),
}
export const ReadOnly: Story = {
render: () => (
<DatePicker defaultValue="2025-01-15" readOnly>
<DatePickerLabel className="sr-only">Start date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>
),
}
export const Calendar: Story = {
...Default,
name: 'Calendar (open preview)',
tags: ['!autodocs'],
play: async ({ canvasElement, userEvent }) => {
await userEvent.click(
within(canvasElement).getByRole('button', { name: 'Start date Jan 15, 2025' }),
)
const grid = await within(canvasElement.ownerDocument.body).findByRole('grid')
await waitFor(() => expect(grid).toBeVisible())
},
}
export const RTL: Story = {
parameters: {
docs: {
description: {
story:
'DirectionProvider supplies interaction direction; dir sets the surrounding HTML direction. The picker carries direction into its portaled popup. Calendar dates use the Arabic locale; action labels retain the English defaults.',
},
},
},
render: () => (
<div dir="rtl">
<DirectionProvider direction="rtl">
<DatePicker locale={arTN} defaultValue="2025-01-15">
<DatePickerLabel className="sr-only">Start date</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>
</DirectionProvider>
</div>
),
play: async ({ canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
const trigger = within(canvasElement).getByRole('button', { name: /^Start date / })
await userEvent.click(trigger)
const dialog = await body.findByRole('dialog', { name: 'Start date' })
await expect(dialog).toHaveAttribute('dir', 'rtl')
await waitFor(() =>
expect(body.getByRole('button', { name: /^الأربعاء، 15 جانفي 2025/ })).toHaveFocus(),
)
await userEvent.keyboard('{ArrowLeft}')
await expect(body.getByRole('button', { name: 'الخميس، 16 جانفي 2025' })).toHaveFocus()
await userEvent.keyboard('{ArrowRight}')
await expect(body.getByRole('button', { name: /^الأربعاء، 15 جانفي 2025/ })).toHaveFocus()
const heading = body.getByRole('button', { name: /Choose month and year:/ })
await userEvent.click(heading)
const months = await body.findByRole('listbox', { name: 'Month' })
const years = body.getByRole('listbox', { name: 'Year' })
await expect(months.getBoundingClientRect().left).toBeGreaterThan(
years.getBoundingClientRect().left,
)
await userEvent.keyboard('{Escape}')
await waitFor(() =>
expect(body.getByRole('button', { name: /Choose month and year:/ })).toHaveFocus(),
)
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
await userEvent.keyboard('{Enter}')
await waitFor(() => expect(body.getByRole('grid')).toBeVisible())
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
},
}
@@ -0,0 +1,180 @@
'use client'
import type { CalendarPanelLabels, CalendarPanelProps } from '../date-picker/calendar-panel'
import type {
PickerClearProps,
PickerContentProps,
PickerFieldProps,
PickerLabelProps,
PickerTriggerProps,
PickerValueProps,
} from '../internal/picker-parts'
import { useControlled } from '@base-ui/utils/useControlled'
import { enUS } from '@daypicker/react/locale/en-US'
import * as React from 'react'
import { CalendarPanel } from '../date-picker/calendar-panel'
import { formatDateValue, getInitialMonth, requireDateValue } from '../date-picker/date-value'
import {
PickerClear,
PickerContent,
PickerLabel,
PickerRoot,
PickerTrigger,
PickerValue,
usePickerContext,
} from '../internal/picker-parts'
type DatePickerProps = PickerFieldProps &
Pick<CalendarPanelProps, 'locale' | 'minDate' | 'maxDate' | 'isDateUnavailable'> & {
/** A Gregorian YYYY-MM-DD civil date; null represents an empty field. */
value?: string | null
defaultValue?: string | null
/** Called on day confirmation, clearing or form reset. */
onValueChange?: (value: string | null) => void
labels?: Partial<CalendarPanelLabels>
}
type ContextValue = Omit<
DatePickerProps,
'value' | 'defaultValue' | 'onValueChange' | 'children'
> & {
value: Exclude<DatePickerProps['value'], undefined>
commit: NonNullable<DatePickerProps['onValueChange']>
}
const Context = React.createContext<ContextValue | null>(null)
function useContext() {
const context = React.useContext(Context)
if (!context) throw new Error('DatePicker parts require DatePicker.')
return context
}
const defaultLabels: CalendarPanelLabels = {
previousMonth: 'Previous month',
nextMonth: 'Next month',
chooseMonthAndYear: 'Choose month and year',
month: 'Month',
year: 'Year',
cancel: 'Cancel',
apply: 'OK',
unavailable: 'This date is unavailable.',
}
function DatePicker({
value: valueProp,
defaultValue = null,
onValueChange,
children,
...props
}: DatePickerProps) {
const [value, setValue] = useControlled({
controlled: valueProp,
default: defaultValue,
name: 'DatePicker',
})
requireDateValue(value)
if (props.minDate) requireDateValue(props.minDate)
if (props.maxDate) requireDateValue(props.maxDate)
if (props.minDate && props.maxDate && props.minDate > props.maxDate)
throw new RangeError('minDate exceeds maxDate')
const unavailable = Boolean(
value &&
((props.minDate && value < props.minDate) ||
(props.maxDate && value > props.maxDate) ||
props.isDateUnavailable?.(value)),
)
function commit(next: string | null) {
if (props.disabled || props.readOnly) return
if (next !== value) {
setValue(next)
onValueChange?.(next)
}
}
return (
<Context.Provider value={{ ...props, value, commit }}>
<PickerRoot
{...props}
invalid={props.invalid || unavailable}
validationMessage={
unavailable
? (props.labels?.unavailable ?? defaultLabels.unavailable)
: props.validationMessage
}
placeholder={props.placeholder ?? 'Select date'}
displayValue={
value
? new Intl.DateTimeFormat(props.locale?.code ?? 'en-US', {
year: 'numeric',
calendar: 'gregory',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
}).format(requireDateValue(value)!)
: ''
}
serializedValue={value ?? ''}
onClear={() => commit(null)}
onReset={() => {
setValue(defaultValue)
if (defaultValue !== value) onValueChange?.(defaultValue)
}}
>
{children}
</PickerRoot>
</Context.Provider>
)
}
type DatePickerTriggerProps = PickerTriggerProps
type DatePickerContentProps = PickerContentProps
type DatePickerClearProps = PickerClearProps
type DatePickerLabelProps = PickerLabelProps
type DatePickerValueProps = PickerValueProps
const DatePickerTrigger = PickerTrigger
const DatePickerClear = PickerClear
const DatePickerLabel = PickerLabel
const DatePickerValue = PickerValue
function DatePickerContent(props: DatePickerContentProps) {
const { value } = useContext()
return (
<PickerContent {...props}>
<Session key={value ?? ''} />
</PickerContent>
)
}
function Session() {
const { value, commit, locale = enUS, minDate, maxDate, isDateUnavailable, labels } = useContext()
const { close } = usePickerContext()
const [displayMonth, setDisplayMonth] = React.useState(() =>
getInitialMonth(value ?? formatDateValue(new Date()), minDate, maxDate),
)
return (
<CalendarPanel
value={value}
onSelect={(next) => {
commit(next)
close()
}}
displayMonth={displayMonth}
onDisplayMonthChange={setDisplayMonth}
locale={locale}
minDate={minDate}
maxDate={maxDate}
isDateUnavailable={isDateUnavailable}
labels={{ ...defaultLabels, ...labels }}
/>
)
}
export {
DatePicker,
DatePickerClear,
DatePickerContent,
DatePickerLabel,
DatePickerTrigger,
DatePickerValue,
}
export type {
DatePickerClearProps,
DatePickerContentProps,
DatePickerLabelProps,
DatePickerProps,
DatePickerTriggerProps,
DatePickerValueProps,
}
@@ -0,0 +1,263 @@
import * as React from 'react'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import {
DateTimePicker,
DateTimePickerContent,
DateTimePickerLabel,
DateTimePickerTrigger,
} from '../index'
it('keeps the natural calendar and month-picker height around a centered mobile trigger', async () => {
const viewport = { width: window.innerWidth, height: window.innerHeight }
await page.viewport(390, 640)
try {
const screen = await render(
<div style={{ position: 'fixed', top: 318, left: 40 }}>
<DateTimePicker timeZone="UTC" defaultValue={new Date('2025-01-15T13:30:00Z')}>
<DateTimePickerLabel className="sr-only">Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>
</div>,
)
const trigger = screen.getByRole('button', { name: /^Meeting / })
await trigger.click()
const popup = screen.getByRole('dialog', { name: 'Meeting', exact: true }).element()
await expect.poll(() => popup.getBoundingClientRect().height).toBe(292)
function expectVisiblePanel() {
const bounds = popup.getBoundingClientRect()
expect(bounds.top).toBeGreaterThanOrEqual(0)
expect(bounds.bottom).toBeLessThanOrEqual(window.innerHeight)
const anchor = trigger.element().getBoundingClientRect()
expect(bounds.bottom <= anchor.top - 8 || bounds.top >= anchor.bottom + 8).toBe(true)
expect(popup.scrollHeight).toBe(popup.clientHeight)
const apply = screen
.getByRole('button', { name: 'OK', exact: true })
.element()
.getBoundingClientRect()
expect(apply.bottom).toBeLessThanOrEqual(bounds.bottom)
}
expectVisiblePanel()
for (const element of popup.querySelectorAll<HTMLElement>('*')) {
if (getComputedStyle(element).overflowY === 'auto')
expect(element.scrollHeight).toBe(element.clientHeight)
}
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
expect(popup.getBoundingClientRect().height).toBe(292)
expectVisiblePanel()
expect(screen.getByRole('listbox', { name: 'Month' }).element().clientHeight).toBe(192)
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
await screen.getByRole('button', { name: /Pick Time:/ }).click()
expectVisiblePanel()
expect(screen.getByRole('listbox', { name: 'Hour' }).element().clientHeight).toBe(192)
await userEvent.keyboard('{Escape}')
await expect.element(screen.getByRole('button', { name: /^Meeting / })).toHaveFocus()
} finally {
await page.viewport(viewport.width, viewport.height)
}
})
it.each([
['Tuesday, December 31st, 2024', 'December 2024', '2024-12-31'],
['Saturday, February 1st, 2025', 'February 2025', '2025-02-01'],
])('navigates to %s on selection and commits only on OK', async (day, month, date) => {
const changed = vi.fn()
const screen = await render(
<DateTimePicker
timeZone="UTC"
defaultValue={new Date('2025-01-15T13:30:00Z')}
onValueChange={changed}
>
<DateTimePickerLabel className="sr-only">Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>,
)
const trigger = screen.getByRole('button', { name: 'Meeting Jan 15, 2025, 1:30 PM' })
await trigger.click()
await screen.getByRole('button', { name: day, exact: true }).click()
await expect.element(screen.getByRole('grid', { name: month })).toBeInTheDocument()
await expect.element(screen.getByRole('status')).toHaveTextContent(month)
await expect.element(screen.getByRole('button', { name: `${day}, selected` })).toHaveFocus()
await expect.element(trigger).toBeInTheDocument()
expect(changed).not.toHaveBeenCalled()
await userEvent.keyboard('{Escape}')
await expect.element(trigger).toHaveFocus()
await trigger.click()
await expect.element(screen.getByRole('grid', { name: 'January 2025' })).toBeInTheDocument()
await screen.getByRole('button', { name: day, exact: true }).click()
await screen.getByRole('button', { name: 'OK' }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith(new Date(`${date}T13:30:00Z`))
})
it.each([
['2026-02-15', 258],
['2025-01-15', 292],
['2025-03-15', 326],
] as const)(
'preserves the %s calendar height when toggling month selection',
async (date, height) => {
const screen = await render(
<DateTimePicker timeZone="UTC" defaultValue={new Date(`${date}T13:30:00Z`)}>
<DateTimePickerLabel className="sr-only">Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>,
)
await screen.getByRole('button', { name: /^Meeting / }).click()
const popup = screen.getByRole('dialog', { name: 'Meeting', exact: true }).element()
await expect.poll(() => popup.getBoundingClientRect().height).toBe(height)
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
expect(popup.getBoundingClientRect().height).toBe(height)
expect(popup.scrollHeight).toBe(popup.clientHeight)
const footer = screen.getByRole('button', { name: 'OK' }).element().getBoundingClientRect()
expect(footer.bottom).toBeLessThanOrEqual(popup.getBoundingClientRect().bottom)
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
await expect.element(screen.getByRole('grid')).toBeInTheDocument()
expect(popup.getBoundingClientRect().height).toBe(height)
},
)
it.each(['ltr', 'rtl'] as const)(
'keeps focused time options visible after switching a height-limited %s popup',
async (direction) => {
const screen = await render(
<DateTimePicker
direction={direction}
timeZone="UTC"
defaultValue={new Date('2025-01-15T13:30:00Z')}
>
<DateTimePickerLabel className="sr-only">Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent style={{ maxHeight: 210 }} />
</DateTimePicker>,
)
await screen.getByRole('button', { name: /^Meeting / }).click()
const calendarPopup = screen.getByRole('dialog', { name: 'Meeting', exact: true }).element()
await expect.poll(() => calendarPopup.getBoundingClientRect().height).toBe(210)
const calendarHeight = calendarPopup.getBoundingClientRect().height
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
expect(calendarPopup.getBoundingClientRect().height).toBe(calendarHeight)
await screen.getByRole('button', { name: /Choose month and year:/ }).click()
await screen.getByRole('button', { name: /Pick Time:/ }).click()
const hours = screen.getByRole('listbox', { name: 'Hour' })
const first = hours.getByRole('option', { name: '1', exact: true })
await expect.element(first).toHaveFocus()
const popup = screen.getByRole('dialog', { name: 'Meeting', exact: true })
function fullyVisible(element: Element) {
const bounds = popup.element().getBoundingClientRect()
const option = element.getBoundingClientRect()
return option.top >= bounds.top && option.bottom <= bounds.bottom
}
await expect.poll(() => fullyVisible(first.element())).toBe(true)
expect(fullyVisible(screen.getByRole('button', { name: 'Now', exact: true }).element())).toBe(
true,
)
expect(fullyVisible(screen.getByRole('button', { name: 'OK', exact: true }).element())).toBe(
true,
)
expect(popup.element().scrollHeight).toBe(popup.element().clientHeight)
expect(hours.element().clientHeight).toBeLessThan(192)
await userEvent.keyboard('{End}')
const last = hours.getByRole('option', { name: '12', exact: true })
await expect.element(last).toHaveFocus()
await expect.poll(() => fullyVisible(last.element())).toBe(true)
expect(hours.element().scrollTop).toBe((last.element() as HTMLElement).offsetTop)
},
)
describe('DateTimePicker', () => {
it('discards a draft on Escape and leaves the existing instant unchanged on OK', async () => {
const original = new Date('2024-11-03T06:30:00.000Z')
const onValueChange = vi.fn()
const screen = await render(
<DateTimePicker timeZone="America/New_York" value={original} onValueChange={onValueChange}>
<DateTimePickerLabel className="sr-only">Meeting time</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>,
)
await screen.getByRole('button', { name: /Meeting time / }).click()
await expect.element(screen.getByRole('grid')).toBeInTheDocument()
await screen.getByRole('button', { name: /Pick Time:/ }).click()
await expect.element(screen.getByRole('listbox', { name: 'Hour' })).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
expect(onValueChange).not.toHaveBeenCalled()
await screen.getByRole('button', { name: /Meeting time / }).click()
await screen.getByRole('button', { name: /Pick Time:/ }).click()
await screen.getByRole('button', { name: 'OK' }).click()
expect(onValueChange).not.toHaveBeenCalled()
})
})
it('rejects a daylight-saving gap and lets the user repair it before committing', async () => {
const changed = vi.fn()
const screen = await render(
<DateTimePicker
timeZone="America/New_York"
defaultValue={new Date('2025-03-09T06:30:00Z')}
onValueChange={changed}
>
<DateTimePickerLabel className="sr-only">Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>,
)
await screen.getByRole('button', { name: /^Meeting / }).click()
await screen.getByRole('button', { name: /Pick Time:/ }).click()
await screen
.getByRole('listbox', { name: 'Hour' })
.getByRole('option', { name: '2', exact: true })
.click()
await expect
.element(screen.getByRole('status'))
.toHaveTextContent('This date or time is unavailable.')
await expect.element(screen.getByRole('button', { name: 'OK', exact: true })).toBeDisabled()
expect(changed).not.toHaveBeenCalled()
await screen
.getByRole('listbox', { name: 'Hour' })
.getByRole('option', { name: '3', exact: true })
.click()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed.mock.calls[0]?.[0].toISOString()).toBe('2025-03-09T07:30:00.000Z')
})
it.each([
['Asia/Shanghai', '10:23 PM'],
['America/New_York', '10:23 AM'],
])(
'keeps Now in the draft and commits the current instant in %s',
async (timeZone, displayTime) => {
vi.setSystemTime(new Date('2026-09-28T14:23:00Z'))
try {
const changed = vi.fn()
const screen = await render(
<DateTimePicker
timeZone={timeZone}
defaultValue={new Date('2025-01-15T13:30:00Z')}
onValueChange={changed}
>
<DateTimePickerLabel className="sr-only">Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>,
)
await screen.getByRole('button', { name: /^Meeting / }).click()
await screen.getByRole('button', { name: 'Now' }).click()
await expect
.element(screen.getByRole('button', { name: `Pick Time: ${displayTime}` }))
.toBeInTheDocument()
expect(changed).not.toHaveBeenCalled()
await screen.getByRole('button', { name: 'OK' }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith(new Date('2026-09-28T14:23:00Z'))
await expect
.element(screen.getByRole('button', { name: `Meeting Sep 28, 2026, ${displayTime}` }))
.toHaveFocus()
} finally {
vi.useRealTimers()
}
},
)
@@ -0,0 +1,25 @@
import { getPossibleInstants, resolveZonedWallTime } from '../zoned-value'
describe('zoned date-time resolution', () => {
it('rejects a wall time skipped by daylight saving', () => {
expect(getPossibleInstants({ date: '2024-03-10', time: '02:30' }, 'America/New_York')).toEqual(
[],
)
})
it('preserves a previously selected instant during a repeated wall time', () => {
const wall = { date: '2024-11-03', time: '01:30' }
const candidates = getPossibleInstants(wall, 'America/New_York')
expect(candidates).toHaveLength(2)
expect(candidates[0]?.toISOString()).toBe('2024-11-03T05:30:00.000Z')
expect(candidates[1]?.toISOString()).toBe('2024-11-03T06:30:00.000Z')
expect(resolveZonedWallTime(wall, 'America/New_York', candidates[1])).toEqual(candidates[1])
})
})
it('preserves sub-minute precision for an unchanged repeated wall time', () => {
const original = new Date('2024-11-03T06:30:42.123Z')
expect(
resolveZonedWallTime({ date: '2024-11-03', time: '01:30' }, 'America/New_York', original),
).toEqual(original)
})
@@ -0,0 +1,227 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { arTN } from '@daypicker/react/locale/ar-TN'
import { faIR } from '@daypicker/react/locale/fa-IR'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import {
DateTimePicker,
DateTimePickerClear,
DateTimePickerContent,
DateTimePickerLabel,
DateTimePickerTrigger,
} from './index'
const meta = {
title: 'Base/Form/Date Time Picker',
component: DateTimePicker,
args: {
timeZone: 'UTC',
children: null,
},
parameters: {
layout: 'centered',
docs: { story: { autoplay: false } },
a11y: { config: { rules: [{ id: 'color-contrast', enabled: true }] } },
},
tags: ['autodocs'],
} satisfies Meta<typeof DateTimePicker>
export default meta
type Story = StoryObj<typeof meta>
function DateTimePickerDemo({ timeZone = 'UTC' }: { timeZone?: string }) {
const [value, setValue] = React.useState<Date | null>(() => new Date('2025-01-15T13:30:00Z'))
return (
<div className="grid gap-1">
<DateTimePicker timeZone={timeZone} value={value} onValueChange={setValue}>
<DateTimePickerLabel>Meeting time</DateTimePickerLabel>
<div className="flex items-center gap-1">
<DateTimePickerTrigger />
<DateTimePickerClear aria-label="Clear meeting time" />
</div>
<DateTimePickerContent />
</DateTimePicker>
</div>
)
}
export const Default: Story = {
render: () => <DateTimePickerDemo />,
play: async ({ canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
const trigger = within(canvasElement).getByRole('button', {
name: 'Meeting time Jan 15, 2025, 1:30 PM',
})
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
await userEvent.click(trigger)
await userEvent.click(await body.findByRole('button', { name: 'Thursday, January 16th, 2025' }))
await expect(trigger).toHaveAccessibleName('Meeting time Jan 15, 2025, 1:30 PM')
await userEvent.click(body.getByRole('button', { name: /Pick Time:/ }))
const hours = within(await body.findByRole('listbox', { name: 'Hour' }))
await waitFor(() => expect(hours.getByRole('option', { name: '1' })).toHaveFocus())
await userEvent.keyboard('{ArrowDown}')
await userEvent.click(body.getByRole('button', { name: 'OK' }))
await waitFor(() => expect(trigger).toHaveFocus())
await expect(trigger).toHaveAccessibleName('Meeting time Jan 16, 2025, 2:30 PM')
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
},
}
export const RTL: Story = {
parameters: {
docs: {
description: {
story:
'A local direction override applies to both calendar and time views, including the portaled popup. Dates use Arabic formatting with English action labels. Changing views preserves the draft; Escape discards it.',
},
},
},
render: () => (
<DateTimePicker
direction="rtl"
locale={arTN}
timeZone="Asia/Shanghai"
defaultValue={new Date('2025-01-15T13:30:00Z')}
hourCycle={24}
>
<DateTimePickerLabel className="sr-only">Meeting time</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>
),
play: async ({ canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
const trigger = within(canvasElement).getByRole('button', { name: /^Meeting time / })
await userEvent.click(trigger)
await expect(await body.findByRole('dialog', { name: 'Meeting time' })).toHaveAttribute(
'dir',
'rtl',
)
await userEvent.click(body.getByRole('button', { name: /Pick Time:/ }))
const hours = await body.findByRole('listbox', { name: 'Hour' })
const minutes = body.getByRole('listbox', { name: 'Minute' })
await expect(hours.getBoundingClientRect().left).toBeGreaterThan(
minutes.getBoundingClientRect().left,
)
await waitFor(() => expect(within(hours).getByRole('option', { name: '21' })).toHaveFocus())
await userEvent.keyboard('{End}{ArrowDown}')
await expect(within(hours).getByRole('option', { name: '23' })).toHaveFocus()
await userEvent.click(body.getByRole('button', { name: 'Pick Date' }))
await waitFor(() => expect(body.getByRole('grid')).toBeVisible())
await userEvent.click(body.getByRole('button', { name: /Pick Time:/ }))
await waitFor(() =>
expect(
within(body.getByRole('listbox', { name: 'Hour' })).getByRole('option', { name: '23' }),
).toHaveFocus(),
)
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
await userEvent.keyboard('{Enter}')
await userEvent.click(await body.findByRole('button', { name: /Pick Time:/ }))
await waitFor(() =>
expect(
within(body.getByRole('listbox', { name: 'Hour' })).getByRole('option', { name: '21' }),
).toHaveFocus(),
)
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
},
}
export const NewYork: Story = {
parameters: {
docs: {
description: {
story:
'This example displays dates and Now in America/New_York, independently of your browser time zone.',
},
},
},
render: () => <DateTimePickerDemo timeZone="America/New_York" />,
}
export const Calendar = {
render: () => <DateTimePickerDemo />,
name: 'Calendar (open preview)',
tags: ['!autodocs'],
play: async ({ canvasElement, userEvent }) => {
await userEvent.click(within(canvasElement).getByRole('button', { name: /^Meeting time / }))
const dialog = await within(canvasElement.ownerDocument.body).findByRole('dialog', {
name: 'Meeting time',
})
await waitFor(() => expect(dialog).toBeVisible())
},
} satisfies Story
export const ConstrainedHeight: Story = {
parameters: {
docs: {
description: {
story:
'An explicit 210px maxHeight opts into scrolling for the calendar and individual columns while keeping confirmation actions visible. Normal content uses its natural height; this is not the mobile default.',
},
},
},
render: () => (
<DateTimePicker timeZone="UTC" defaultValue={new Date('2025-01-15T13:30:00Z')}>
<DateTimePickerLabel className="sr-only">Meeting time</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent style={{ maxHeight: 210 }} />
</DateTimePicker>
),
}
export const Time: Story = {
...Calendar,
name: 'Time (open preview)',
play: async (context) => {
await Calendar.play(context)
const { canvasElement, userEvent } = context
await userEvent.click(
await within(canvasElement.ownerDocument.body).findByRole('button', { name: /Pick Time:/ }),
)
},
}
export const MonthAndYear: Story = {
...Calendar,
name: 'Month and year (open preview)',
play: async (context) => {
await Calendar.play(context)
const { canvasElement, userEvent } = context
await userEvent.click(
await within(canvasElement.ownerDocument.body).findByRole('button', {
name: /Choose month and year:/,
}),
)
},
}
export const PersianNumbers: Story = {
parameters: {
docs: {
description: {
story:
'Persian numerals across calendar, year and time columns. Native and ASCII digits both locate options. Values remain Gregorian and serialize independently of locale. Action labels use the English defaults; locale does not translate application copy.',
},
},
},
render: () => (
<div dir="rtl" className="grid gap-1">
<DateTimePicker
locale={faIR}
direction="rtl"
timeZone="UTC"
hourCycle={24}
defaultValue={new Date('2025-01-15T10:05:00Z')}
>
<DateTimePickerLabel>Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>
</div>
),
play: async ({ canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(within(canvasElement).getByRole('button', { name: /^Meeting / }))
const dialog = await body.findByRole('dialog', { name: 'Meeting' })
await waitFor(() => expect(within(dialog).getByRole('grid')).toBeVisible())
},
}
@@ -0,0 +1,358 @@
'use client'
import type { CalendarPanelLabels, CalendarPanelProps } from '../date-picker/calendar-panel'
import type {
PickerClearProps,
PickerContentProps,
PickerFieldProps,
PickerLabelProps,
PickerTriggerProps,
PickerValueProps,
} from '../internal/picker-parts'
import type { TimePanelHandle, TimePanelLabels, TimePanelProps } from '../time-picker/time-panel'
import type { ZonedWallTime } from './zoned-value'
import { useControlled } from '@base-ui/utils/useControlled'
import { enUS } from '@daypicker/react/locale/en-US'
import * as React from 'react'
import { Button } from '../../button'
import { CalendarPanel } from '../date-picker/calendar-panel'
import { getInitialMonth, requireDateValue } from '../date-picker/date-value'
import { PickerPanel, PickerPanelHeader } from '../internal/picker-panel'
import {
PickerClear,
PickerContent,
PickerLabel,
PickerRoot,
PickerTrigger,
PickerValue,
usePickerContext,
} from '../internal/picker-parts'
import { TimePanel } from '../time-picker/time-panel'
import { getZonedWallTime, resolveZonedWallTime } from './zoned-value'
type DateTimePickerLabels = CalendarPanelLabels &
TimePanelLabels & { pickTime: string; backToDate: string; now: string }
type DateTimePickerProps = PickerFieldProps &
Pick<CalendarPanelProps, 'locale' | 'minDate' | 'maxDate' | 'isDateUnavailable'> &
Pick<TimePanelProps, 'hourCycle'> & {
/** An instant displayed in timeZone; null represents an empty field. */
value?: Date | null
defaultValue?: Date | null
/** Called on OK, clearing or form reset; popup draft edits do not emit changes. */
onValueChange?: (value: Date | null) => void
timeZone: string
isTimeUnavailable?: (wall: Readonly<ZonedWallTime>) => boolean
labels?: Partial<DateTimePickerLabels>
}
type ContextValue = Omit<
DateTimePickerProps,
'value' | 'defaultValue' | 'onValueChange' | 'children'
> & {
value: Exclude<DateTimePickerProps['value'], undefined>
commit: NonNullable<DateTimePickerProps['onValueChange']>
}
const Context = React.createContext<ContextValue | null>(null)
function useContext() {
const context = React.useContext(Context)
if (!context) throw new Error('DateTimePicker parts require DateTimePicker.')
return context
}
const defaultLabels: DateTimePickerLabels = {
previousMonth: 'Previous month',
nextMonth: 'Next month',
chooseMonthAndYear: 'Choose month and year',
month: 'Month',
year: 'Year',
cancel: 'Cancel',
apply: 'OK',
hour: 'Hour',
minute: 'Minute',
period: 'Period',
am: 'AM',
pm: 'PM',
pickTime: 'Pick Time',
backToDate: 'Pick Date',
now: 'Now',
unavailable: 'This date or time is unavailable.',
}
function DateTimePicker({
value: valueProp,
defaultValue = null,
onValueChange,
children,
...props
}: DateTimePickerProps) {
const [value, setValue] = useControlled({
controlled: valueProp,
default: defaultValue,
name: 'DateTimePicker',
})
if (value && Number.isNaN(value.getTime())) throw new RangeError('Invalid DateTimePicker value')
if (props.minDate) requireDateValue(props.minDate)
if (props.maxDate) requireDateValue(props.maxDate)
if (props.minDate && props.maxDate && props.minDate > props.maxDate)
throw new RangeError('minDate exceeds maxDate')
const wall = value ? getZonedWallTime(value, props.timeZone) : null
const unavailable = Boolean(
wall &&
((props.minDate && wall.date < props.minDate) ||
(props.maxDate && wall.date > props.maxDate) ||
props.isDateUnavailable?.(wall.date) ||
props.isTimeUnavailable?.(wall)),
)
function commit(next: Date | null) {
if (props.disabled || props.readOnly) return
if (next?.getTime() !== value?.getTime()) {
setValue(next)
onValueChange?.(next)
}
}
return (
<Context.Provider value={{ ...props, value, commit }}>
<PickerRoot
{...props}
invalid={props.invalid || unavailable}
validationMessage={
unavailable
? (props.labels?.unavailable ?? defaultLabels.unavailable)
: props.validationMessage
}
placeholder={props.placeholder ?? 'Select date and time'}
displayValue={
value
? new Intl.DateTimeFormat(props.locale?.code ?? 'en-US', {
year: 'numeric',
calendar: 'gregory',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: props.timeZone,
hour12: props.hourCycle !== 24,
}).format(value)
: ''
}
serializedValue={value?.toISOString() ?? ''}
onClear={() => commit(null)}
onReset={() => {
setValue(defaultValue)
if (defaultValue?.getTime() !== value?.getTime()) onValueChange?.(defaultValue)
}}
>
{children}
</PickerRoot>
</Context.Provider>
)
}
type DateTimePickerTriggerProps = PickerTriggerProps
type DateTimePickerContentProps = PickerContentProps
type DateTimePickerClearProps = PickerClearProps
type DateTimePickerLabelProps = PickerLabelProps
type DateTimePickerValueProps = PickerValueProps
const DateTimePickerTrigger = PickerTrigger
const DateTimePickerClear = PickerClear
const DateTimePickerLabel = PickerLabel
const DateTimePickerValue = PickerValue
function DateTimePickerContent(props: DateTimePickerContentProps) {
const { value, timeZone } = useContext()
return (
<PickerContent {...props}>
<Session key={`${value?.getTime() ?? ''}-${timeZone}`} />
</PickerContent>
)
}
function Session() {
const {
value,
commit,
timeZone,
locale = enUS,
minDate,
maxDate,
isDateUnavailable,
isTimeUnavailable,
labels,
hourCycle,
} = useContext()
const { close } = usePickerContext()
const panelRef = React.useRef<TimePanelHandle>(null)
const [draft, setDraft] = React.useState(() => getZonedWallTime(value ?? new Date(), timeZone))
const [displayMonth, setDisplayMonth] = React.useState(() =>
getInitialMonth(draft.date, minDate, maxDate),
)
const [view, setView] = React.useState<'date' | 'time'>('date')
const ref = React.useRef<HTMLDivElement>(null)
const text = { ...defaultLabels, ...labels }
function availableInstant(candidate: typeof draft) {
const instant = resolveZonedWallTime(candidate, timeZone, value)
if (
!instant ||
(minDate && candidate.date < minDate) ||
(maxDate && candidate.date > maxDate) ||
isDateUnavailable?.(candidate.date) ||
isTimeUnavailable?.(candidate)
)
return null
return instant
}
const unavailable = !availableInstant(draft)
const timeText = new Intl.DateTimeFormat(locale.code ?? 'en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: hourCycle !== 24,
timeZone: 'UTC',
}).format(new Date(`2025-01-01T${draft.time}:00Z`))
const [scrollRequest, requestScroll] = React.useReducer((value: number) => value + 1, 0)
function setNow() {
requestScroll()
const next = getZonedWallTime(new Date(), timeZone)
setDraft(next)
setDisplayMonth(getInitialMonth(next.date, minDate, maxDate))
}
const errorId = React.useId()
React.useEffect(() => {
const frame = requestAnimationFrame(() => {
const target =
ref.current?.querySelector<HTMLElement>(
'[role="grid"] button[tabindex="0"], [role="option"][aria-selected="true"]',
) ?? ref.current?.querySelector<HTMLElement>('button:not(:disabled)')
target?.focus({ preventScroll: true })
target?.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'instant' })
})
return () => cancelAnimationFrame(frame)
}, [view])
function changeView(next: 'date' | 'time') {
if (view === 'time') {
const time = panelRef.current?.settle() ?? draft.time
setDraft((previous) => ({ ...previous, time }))
}
ref.current?.closest<HTMLElement>('[role="dialog"]')?.focus({ preventScroll: true })
setView(next)
}
const apply = (
<Button
variant="primary"
className="min-w-16"
size="small"
disabled={unavailable}
aria-describedby={unavailable ? errorId : undefined}
onClick={() => {
const next = { ...draft, time: panelRef.current?.settle() ?? draft.time }
setDraft(next)
const instant = availableInstant(next)
if (instant) {
commit(instant)
close()
}
}}
>
{text.apply}
</Button>
)
return (
<div ref={ref} className="flex min-h-0 flex-col overflow-hidden">
{view === 'date' ? (
<CalendarPanel
value={draft.date}
onSelect={(date) => setDraft({ ...draft, date })}
displayMonth={displayMonth}
onDisplayMonthChange={setDisplayMonth}
locale={locale}
timeZone={timeZone}
minDate={minDate}
maxDate={maxDate}
isDateUnavailable={isDateUnavailable}
labels={text}
footer={
<>
<Button
size="small"
variant="secondary-accent"
className="gap-1 px-1.5"
aria-label={`${text.pickTime}: ${timeText}`}
onClick={() => changeView('time')}
>
<span
className="i-ri-time-line size-3 forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
aria-hidden="true"
/>
{timeText}
</Button>
<div className="flex gap-1">
<Button size="small" variant="ghost-accent" onClick={setNow}>
{text.now}
</Button>
{apply}
</div>
</>
}
/>
) : (
<PickerPanel
header={<PickerPanelHeader>{text.pickTime}</PickerPanelHeader>}
footer={
<>
<Button
size="small"
variant="secondary-accent"
className="gap-1 px-1.5"
onClick={() => changeView('date')}
>
<span
aria-hidden="true"
className="i-ri-calendar-line size-3 forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
/>
{text.backToDate}
</Button>
<div className="flex gap-1">
<Button size="small" variant="ghost-accent" onClick={setNow}>
{text.now}
</Button>
{apply}
</div>
</>
}
>
<TimePanel
ref={panelRef}
scrollRequest={scrollRequest}
value={draft.time}
onValueChange={(update) =>
setDraft((previous) => ({ ...previous, time: update(previous.time) }))
}
labels={text}
locale={locale.code}
hourCycle={hourCycle}
/>
</PickerPanel>
)}
{unavailable && (
<p
id={errorId}
role="status"
className="shrink-0 px-2 system-xs-regular text-text-destructive"
>
{text.unavailable}
</p>
)}
</div>
)
}
export {
DateTimePicker,
DateTimePickerClear,
DateTimePickerContent,
DateTimePickerLabel,
DateTimePickerTrigger,
DateTimePickerValue,
}
export type {
DateTimePickerClearProps,
DateTimePickerContentProps,
DateTimePickerLabelProps,
DateTimePickerProps,
DateTimePickerTriggerProps,
DateTimePickerValueProps,
}
@@ -0,0 +1,53 @@
import { TZDate, tzOffset, tzScan } from '@date-fns/tz'
import { parseDateValue } from '../date-picker/date-value'
import { parseTimeValue } from '../time-picker/time-value'
type ZonedWallTime = { date: string; time: string }
function getZonedWallTime(value: Date, timeZone: string): ZonedWallTime {
const zoned = new TZDate(value.getTime(), timeZone)
const date = `${String(zoned.getFullYear()).padStart(4, '0')}-${String(zoned.getMonth() + 1).padStart(2, '0')}-${String(zoned.getDate()).padStart(2, '0')}`
const time = `${String(zoned.getHours()).padStart(2, '0')}:${String(zoned.getMinutes()).padStart(2, '0')}`
return { date, time }
}
function getPossibleInstants({ date, time }: ZonedWallTime, timeZone: string): Date[] {
const civil = parseDateValue(date)
const clock = parseTimeValue(time)
if (!civil || !clock) return []
const localDate = new Date(0)
localDate.setUTCFullYear(civil.getFullYear(), civil.getMonth(), civil.getDate())
localDate.setUTCHours(clock.hour, clock.minute, 0, 0)
const localEpoch = localDate.getTime()
const start = new Date(localEpoch - 48 * 60 * 60 * 1000)
const end = new Date(localEpoch + 48 * 60 * 60 * 1000)
const offsets = new Set([tzOffset(timeZone, start), tzOffset(timeZone, end)])
for (const transition of tzScan(timeZone, { start, end })) {
offsets.add(transition.offset)
offsets.add(transition.offset - transition.change)
}
return [...offsets]
.map((offset) => new Date(localEpoch - offset * 60 * 1000))
.filter((candidate) => {
const wall = getZonedWallTime(candidate, timeZone)
return wall.date === date && wall.time === time
})
.sort((a, b) => a.getTime() - b.getTime())
}
function resolveZonedWallTime(
wall: ZonedWallTime,
timeZone: string,
preferredInstant?: Date | null,
): Date | null {
if (preferredInstant) {
const original = getZonedWallTime(preferredInstant, timeZone)
if (original.date === wall.date && original.time === wall.time) return preferredInstant
}
return getPossibleInstants(wall, timeZone)[0] ?? null
}
export { getPossibleInstants, getZonedWallTime, resolveZonedWallTime }
export type { ZonedWallTime }
@@ -0,0 +1,313 @@
'use client'
import { useStableCallback } from '@base-ui/utils/useStableCallback'
import { useTimeout } from '@base-ui/utils/useTimeout'
import * as React from 'react'
import { cn } from '../../cn'
type OptionColumnHandle = { settle: () => number }
type OptionColumnProps = {
ref?: React.Ref<OptionColumnHandle>
label: string
normalizeDigits: (text: string) => string
value: number
options: { value: number; label: string }[]
onValueChange: (value: number) => void
onEscape?: () => void
scrollRequest?: number
focusOnMount?: boolean
}
function OptionColumn({
ref: handleRef,
label,
normalizeDigits,
value,
options,
onValueChange,
focusOnMount,
onEscape,
scrollRequest = 0,
}: OptionColumnProps) {
const ref = React.useRef<HTMLDivElement>(null)
const id = React.useId()
const selectedIndex = options.findIndex((option) => option.value === value)
const tabbableIndex = Math.max(0, selectedIndex)
const searchRef = React.useRef({ text: '', time: 0, startIndex: 0 })
React.useEffect(() => {
searchRef.current = { text: '', time: 0, startIndex: 0 }
}, [normalizeDigits])
const gestureRef = React.useRef({ active: false, moved: false, touching: false })
const quietTimer = useTimeout()
const cancelGesture = useStableCallback(() => {
quietTimer.clear()
gestureRef.current.active = false
gestureRef.current.moved = false
})
function scrollToOption(option: HTMLElement, smooth = false) {
const column = ref.current
if (!column) return
column.scrollTo({
top: option.offsetTop,
behavior:
smooth &&
!column.ownerDocument.defaultView?.matchMedia('(prefers-reduced-motion: reduce)').matches
? 'smooth'
: 'instant',
})
}
function nearestOption() {
const column = ref.current
if (!column) return undefined
return Array.from(column.querySelectorAll<HTMLElement>('[role="option"]')).reduce<
HTMLElement | undefined
>(
(nearest, option) =>
!nearest ||
Math.abs(option.offsetTop - column.scrollTop) <
Math.abs(nearest.offsetTop - column.scrollTop)
? option
: nearest,
undefined,
)
}
React.useImperativeHandle(handleRef, () => ({
settle() {
const option =
gestureRef.current.active && gestureRef.current.moved ? nearestOption() : undefined
cancelGesture()
if (option) {
scrollToOption(option)
return Number(option.dataset.optionValue)
}
return value
},
}))
const finishGesture = React.useEffectEvent(() => {
if (!gestureRef.current.active || gestureRef.current.touching) return
const option = gestureRef.current.moved ? nearestOption() : undefined
cancelGesture()
if (!option) return
const next = Number(option.dataset.optionValue)
if (next !== value) onValueChange(next)
if (ref.current?.contains(ref.current.ownerDocument.activeElement))
option.focus({ preventScroll: true })
scrollToOption(option, true)
})
const updateGesture = React.useEffectEvent(() => {
const option = nearestOption()
if (!option) return
const next = Number(option.dataset.optionValue)
if (next !== value) onValueChange(next)
})
React.useEffect(() => {
const column = ref.current
if (!column) return
const supportsScrollEnd = 'onscrollend' in column
function begin() {
quietTimer.clear()
if (!gestureRef.current.active) {
gestureRef.current = {
...gestureRef.current,
active: true,
moved: false,
}
}
quietTimer.start(150, () => {
if (gestureRef.current.touching) return
if (!gestureRef.current.moved) cancelGesture()
else if (!supportsScrollEnd) finishGesture()
})
}
function onWheel(event: WheelEvent) {
if (!event.ctrlKey && event.deltaY !== 0) begin()
}
function onTouchStart() {
gestureRef.current.touching = true
begin()
}
function onTouchEnd() {
gestureRef.current.touching = false
if (!gestureRef.current.moved) cancelGesture()
if (!supportsScrollEnd && gestureRef.current.moved) scheduleFinish()
}
function scheduleFinish() {
quietTimer.clear()
quietTimer.start(150, () => finishGesture())
}
function onScroll() {
if (!gestureRef.current.active) return
gestureRef.current.moved = true
updateGesture()
if (!supportsScrollEnd) scheduleFinish()
}
function onScrollEnd() {
finishGesture()
}
column.addEventListener('wheel', onWheel, { passive: true })
column.addEventListener('touchstart', onTouchStart, { passive: true })
column.addEventListener('touchend', onTouchEnd, { passive: true })
column.addEventListener('touchcancel', onTouchEnd, { passive: true })
column.addEventListener('scroll', onScroll, { passive: true })
if (supportsScrollEnd) column.addEventListener('scrollend', onScrollEnd)
return () => {
cancelGesture()
column.removeEventListener('wheel', onWheel)
column.removeEventListener('touchstart', onTouchStart)
column.removeEventListener('touchend', onTouchEnd)
column.removeEventListener('touchcancel', onTouchEnd)
column.removeEventListener('scroll', onScroll)
column.removeEventListener('scrollend', onScrollEnd)
}
}, [cancelGesture, quietTimer])
// Entry and Now are explicit positioning commands, never gesture-driven selections.
React.useLayoutEffect(() => {
cancelGesture()
const column = ref.current
const selected = column?.querySelector<HTMLElement>('[aria-selected="true"]')
if (selected && column) {
column.scrollTo({
top: selected.offsetTop,
behavior: 'instant',
})
}
}, [scrollRequest, cancelGesture])
React.useEffect(() => {
if (!focusOnMount) return
const frame = requestAnimationFrame(() =>
ref.current
?.querySelector<HTMLElement>('[aria-selected="true"]')
?.focus({ preventScroll: true }),
)
return () => cancelAnimationFrame(frame)
}, [focusOnMount])
function focusValue(next: number, smooth = false) {
cancelGesture()
onValueChange(next)
const option = ref.current?.querySelector<HTMLElement>(`[data-option-value="${next}"]`)
if (!option) return
option.focus({ preventScroll: true })
scrollToOption(option, smooth)
}
return (
<div
role="listbox"
tabIndex={-1}
aria-orientation="vertical"
ref={ref}
aria-label={label}
className="relative flex h-full min-h-0 min-w-0 flex-1 scrollbar-none flex-col items-stretch gap-0.5 overflow-y-auto overscroll-none scroll-auto after:pointer-events-none after:h-[max(0px,calc(100%-1.625rem))] after:shrink-0 after:content-['']"
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
searchRef.current = { text: '', time: 0, startIndex: 0 }
}}
onKeyDown={(event) => {
if (event.key === 'Escape') cancelGesture()
if (event.key === 'Escape' && onEscape) {
event.preventDefault()
event.stopPropagation()
onEscape()
return
}
if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing) return
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key))
searchRef.current = { text: '', time: 0, startIndex: 0 }
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown'].includes(event.key)) {
event.preventDefault()
event.stopPropagation()
const currentOption =
gestureRef.current.active && gestureRef.current.moved
? nearestOption()
: (event.target as HTMLElement)
const currentValue = Number(currentOption?.dataset.optionValue)
const currentIndex = Math.max(
0,
options.findIndex((option) => option.value === currentValue),
)
const step = event.key.startsWith('Page') ? 5 : 1
const delta = event.key.endsWith('Up') ? -step : step
const nextIndex = Math.max(0, Math.min(options.length - 1, currentIndex + delta))
const option = options[nextIndex]
if (option) focusValue(option.value)
return
}
if (event.key === 'Home' || event.key === 'End') {
event.preventDefault()
event.stopPropagation()
const option = event.key === 'Home' ? options[0] : options.at(-1)
if (option) focusValue(option.value)
} else if (
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!event.nativeEvent.isComposing &&
Array.from(event.key).length === 1 &&
event.key !== ' '
) {
event.preventDefault()
event.stopPropagation()
const now = Date.now()
const previous = searchRef.current
let text = now - previous.time < 1000 ? previous.text : ''
let startIndex = text ? previous.startIndex : tabbableIndex + 1
const key = event.key.toLowerCase()
// Repeated letters cycle; repeated digits must still form values such as 11 or 55.
if (
text === key &&
!/^\d$/.test(normalizeDigits(key)) &&
!options.some((option) => option.label.toLowerCase().startsWith(key + key))
) {
text = ''
startIndex = tabbableIndex + 1
}
text += key
searchRef.current = { text, time: now, startIndex }
const normalized = normalizeDigits(text)
const numeric = /^\d+$/.test(normalized)
let match = numeric
? options.find((option) => option.value === Number(normalized))
: undefined
if (!numeric) {
for (let offset = 0; offset < options.length; offset++) {
const option = options[(startIndex + offset) % options.length]!
if (option.label.toLowerCase().startsWith(text)) {
match = option
break
}
}
}
if (match) focusValue(match.value)
else if (!numeric) searchRef.current = { text: '', time: 0, startIndex: 0 }
}
}}
>
{options.map((option, index) => (
<button
key={option.value}
id={`${id}-${option.value}`}
type="button"
role="option"
aria-selected={option.value === value}
tabIndex={index === tabbableIndex ? 0 : -1}
data-option-value={option.value}
onClick={(event) => focusValue(option.value, event.detail > 0)}
className={cn(
'aria-selected:bg-components-button-ghost-bg-hover',
'flex min-h-6 shrink-0 items-center justify-center rounded-md px-1 system-xs-medium text-text-secondary tabular-nums hover:not-aria-selected:bg-state-base-hover-subtle focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-state-accent-solid forced-colors:aria-selected:outline forced-colors:aria-selected:outline-[Highlight]',
)}
>
{option.label}
</button>
))}
</div>
)
}
export { OptionColumn }
export type { OptionColumnHandle }
@@ -0,0 +1,42 @@
import type * as React from 'react'
import { cn } from '../../cn'
type PickerPanelProps = React.ComponentProps<'div'> & {
header?: React.ReactNode
children: React.ReactNode
footer?: React.ReactNode
message?: React.ReactNode
}
function PickerPanel({ header, children, footer, message, className, ...props }: PickerPanelProps) {
return (
<div {...props} className={cn('flex min-h-0 grow flex-col overflow-hidden', className)}>
{header}
{children}
{message && <div className="shrink-0">{message}</div>}
{footer && (
<div className="flex h-10 shrink-0 items-center justify-between gap-1 border-t-[0.5px] border-divider-regular px-2">
{footer}
</div>
)}
</div>
)
}
function PickerPanelHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
{...props}
className={cn(
'flex h-11 shrink-0 items-center border-b-[0.5px] border-divider-regular px-4 system-md-semibold text-text-primary',
className,
)}
/>
)
}
function PickerColumns({ className, ...props }: React.ComponentProps<'div'>) {
return <div {...props} className={cn('flex h-52 min-h-0 grow gap-1 p-2', className)} />
}
export { PickerColumns, PickerPanel, PickerPanelHeader }
@@ -0,0 +1,400 @@
'use client'
import type { IconButtonProps } from '../../icon-button'
import type { PopoverContentProps, PopoverProps, PopoverTriggerProps } from '../../popover'
import { useMergedRefs } from '@base-ui/utils/useMergedRefs'
import * as React from 'react'
import { cn } from '../../cn'
import { DirectionProvider, useDirection } from '../../direction-provider'
import { formLabelClassName } from '../../form-control-shared'
import { IconButton } from '../../icon-button'
import { Popover, PopoverContent, PopoverTrigger } from '../../popover'
type PickerOpenChangeDetails = Omit<
Parameters<NonNullable<PopoverProps['onOpenChange']>>[1],
'preventUnmountOnClose'
>
type PickerFieldProps = Pick<PopoverProps, 'open' | 'defaultOpen'> & {
/** Overrides DirectionProvider for this field and its popup; independent of locale. */
direction?: DirectionProvider.Props['direction']
placeholder?: string
disabled?: boolean
readOnly?: boolean
required?: boolean
requiredLabel?: string
invalid?: boolean
name?: string
form?: string
validationMessage?: string
onOpenChange?: (open: boolean, details: PickerOpenChangeDetails) => void
children: React.ReactNode
}
type PickerContextValue = Omit<PickerFieldProps, 'children'> & {
displayValue: string
serializedValue: string
triggerId: string
valueId: string
labelId: string | undefined
setLabelId: React.Dispatch<React.SetStateAction<string | undefined>>
errorId: string
requiredId: string
triggerRef: React.RefObject<HTMLButtonElement | null>
clear: () => void
close: () => void
}
const PickerContext = React.createContext<PickerContextValue | null>(null)
function usePickerContext() {
const context = React.useContext(PickerContext)
if (!context) throw new Error('Picker parts must be inside their picker root.')
return context
}
function PickerRoot({
displayValue,
serializedValue,
onClear,
onReset,
children,
...props
}: PickerFieldProps & {
displayValue: string
serializedValue: string
onClear: () => void
onReset: () => void
}) {
const inheritedDirection = useDirection()
const direction = props.direction ?? inheritedDirection
const id = React.useId()
const [labelId, setLabelId] = React.useState<string>()
const triggerRef = React.useRef<HTMLButtonElement>(null)
const inputRef = React.useRef<HTMLInputElement>(null)
const actionsRef = React.useRef<NonNullable<PopoverProps['actionsRef']>['current']>(null)
const [validationAttempted, setValidationAttempted] = React.useState(false)
const errorMessage = props.invalid
? (props.validationMessage ?? 'Choose a valid value.')
: validationAttempted && props.required && !serializedValue
? (props.validationMessage ?? 'Choose a value.')
: undefined
const invalid = Boolean(errorMessage)
React.useEffect(() => {
inputRef.current?.setCustomValidity(
props.invalid ? (props.validationMessage ?? 'Choose a valid value.') : '',
)
}, [props.invalid, props.validationMessage])
const onResetEvent = React.useEffectEvent(onReset)
React.useEffect(() => {
const form = inputRef.current?.form
if (!form) return
function reset(event: Event) {
queueMicrotask(() => {
if (event.defaultPrevented) return
setValidationAttempted(false)
onResetEvent()
actionsRef.current?.close()
})
}
form.addEventListener('reset', reset)
return () => form.removeEventListener('reset', reset)
}, [props.form])
return (
<DirectionProvider direction={direction}>
<PickerContext.Provider
value={{
...props,
direction,
invalid,
displayValue,
serializedValue,
triggerId: `${id}-trigger`,
valueId: `${id}-value`,
labelId,
setLabelId,
errorId: `${id}-error`,
requiredId: `${id}-required`,
triggerRef,
clear: () => {
if (props.disabled || props.readOnly) return
triggerRef.current?.focus()
onClear()
actionsRef.current?.close()
},
close: () => actionsRef.current?.close(),
}}
>
{props.required && (
<span id={`${id}-required`} className="sr-only">
{props.requiredLabel ?? 'Required'}
</span>
)}
<input
ref={inputRef}
hidden
tabIndex={-1}
type="text"
autoComplete="off"
name={props.name}
form={props.form}
value={serializedValue}
required={props.required}
disabled={props.disabled}
readOnly={props.readOnly}
onChange={() => {}}
onInvalid={(event) => {
event.preventDefault()
setValidationAttempted(true)
const firstInvalid = inputRef.current?.form
? Array.from(inputRef.current.form.elements).find((element) =>
element.matches('input:invalid, select:invalid, textarea:invalid'),
)
: undefined
if (!firstInvalid || firstInvalid === inputRef.current) triggerRef.current?.focus()
}}
/>
<Popover
open={props.open}
defaultOpen={props.defaultOpen}
onOpenChange={(open, details) => {
if (open && (props.disabled || props.readOnly)) details.cancel()
props.onOpenChange?.(open, details)
}}
actionsRef={actionsRef}
>
{children}
</Popover>
{errorMessage && (
<span id={`${id}-error`} role="alert" className="system-xs-regular text-text-destructive">
{errorMessage}
</span>
)}
</PickerContext.Provider>
</DirectionProvider>
)
}
type PickerLabelProps = Omit<React.ComponentProps<'div'>, 'id'>
function isLabelTextInteraction(event: React.SyntheticEvent<HTMLDivElement>) {
const path = event.nativeEvent.composedPath()
const boundary = path.indexOf(event.currentTarget)
const ElementConstructor = event.currentTarget.ownerDocument.defaultView?.Element
if (boundary < 0 || !ElementConstructor) return false
return !path
.slice(0, boundary)
.some(
(target) =>
target instanceof ElementConstructor &&
target.matches(
'button,a[href],input,select,textarea,summary,[tabindex],[contenteditable]:not([contenteditable="false"]),[role="button"],[role="link"]',
),
)
}
function PickerLabel({ children, className, onClick, onPointerDown, ...props }: PickerLabelProps) {
const { disabled, triggerRef, setLabelId } = usePickerContext()
const id = React.useId()
React.useLayoutEffect(() => {
setLabelId(id)
return () => setLabelId(undefined)
}, [id, setLabelId])
return (
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions -- The label only forwards focus; the native trigger owns keyboard activation.
<div
{...props}
id={id}
data-disabled={disabled || undefined}
className={cn(formLabelClassName, className)}
onPointerDown={(event) => {
onPointerDown?.(event)
if (
!event.defaultPrevented &&
!disabled &&
event.button === 0 &&
isLabelTextInteraction(event)
)
event.preventDefault()
}}
onClick={(event) => {
onClick?.(event)
if (event.defaultPrevented || disabled || !isLabelTextInteraction(event)) return
triggerRef.current?.focus({ focusVisible: true })
}}
>
{children}
</div>
)
}
type PickerTriggerProps = Omit<
PopoverTriggerProps,
'handle' | 'payload' | 'openOnHover' | 'delay' | 'closeDelay' | 'render' | 'nativeButton' | 'ref'
> & {
ref?: React.Ref<HTMLButtonElement>
}
function PickerTrigger({
ref,
children,
className,
disabled,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
'aria-describedby': ariaDescribedBy,
...props
}: PickerTriggerProps) {
const field = usePickerContext()
const mergedRef = useMergedRefs(field.triggerRef, ref)
const labelId = ariaLabelledBy ?? (ariaLabel ? undefined : field.labelId)
return (
<PopoverTrigger
id={field.triggerId}
dir={field.direction}
aria-label={ariaLabel}
aria-labelledby={
labelId
? [labelId, field.serializedValue ? field.valueId : undefined].filter(Boolean).join(' ')
: undefined
}
aria-describedby={
[
ariaDescribedBy,
field.required ? field.requiredId : undefined,
field.invalid ? field.errorId : undefined,
]
.filter(Boolean)
.join(' ') || undefined
}
aria-invalid={field.invalid || undefined}
aria-disabled={field.readOnly || undefined}
{...props}
ref={mergedRef}
disabled={field.disabled || disabled}
className={(state) =>
cn(
'flex h-8 w-63 max-w-full items-center justify-between gap-0.5 rounded-lg bg-components-input-bg-normal ps-3 pe-2 text-start system-sm-regular text-components-input-text-filled',
'hover:bg-state-base-hover-alt focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-accent-solid data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-popup-open:bg-state-base-hover-alt',
!field.serializedValue && 'text-text-tertiary in-data-[theme=dark]:text-text-secondary',
typeof className === 'function' ? className(state) : className,
)
}
>
{children ?? (
<>
<PickerValue />
<span
className="i-ri-calendar-line size-4 shrink-0 text-text-tertiary forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
aria-hidden="true"
/>
</>
)}
</PopoverTrigger>
)
}
type PickerValueProps = Omit<React.ComponentProps<'span'>, 'children' | 'id'> & {
/** Receives the formatted committed value, or the placeholder when empty. */
children?: React.ReactNode | ((displayValue: string) => React.ReactNode)
}
function PickerValue({ children, ...props }: PickerValueProps) {
const field = usePickerContext()
const displayValue = field.serializedValue ? field.displayValue : (field.placeholder ?? '')
return (
<span {...props} id={field.valueId}>
{typeof children === 'function' ? children(displayValue) : (children ?? displayValue)}
</span>
)
}
// Preserve IconButton's accessible-name union when removing picker-owned props.
type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
type PickerClearProps = DistributiveOmit<
IconButtonProps,
'children' | 'size' | 'render' | 'nativeButton' | 'ref'
> & {
ref?: React.Ref<HTMLButtonElement>
}
function PickerClear({ onClick, disabled, ...props }: PickerClearProps) {
const field = usePickerContext()
if (!field.serializedValue || field.readOnly) return null
return (
<IconButton
{...props}
size="lg"
disabled={field.disabled || disabled}
onClick={(event) => {
onClick?.(event)
if (!event.defaultPrevented) field.clear()
}}
>
<span
className="i-ri-close-line size-4 forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
aria-hidden="true"
/>
</IconButton>
)
}
type PickerContentProps = Omit<PopoverContentProps, 'children' | 'initialFocus' | 'render'>
function PickerContent({
children,
ref,
className,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
...props
}: PickerContentProps & { children: React.ReactNode }) {
const { labelId } = usePickerContext()
const direction = useDirection()
const popupRef = React.useRef<HTMLDivElement>(null)
const mergedRef = useMergedRefs(popupRef, ref)
return (
<PopoverContent
placement="bottom-start"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy ?? (ariaLabel ? undefined : labelId)}
dir={direction}
{...props}
ref={mergedRef}
className={(state) =>
cn(
'forced-colors:[&_button:focus-visible]:outline-2 forced-colors:[&_button:focus-visible]:outline-[Highlight] forced-colors:[&_button:focus-visible]:outline-solid',
'flex w-63 max-w-(--available-width) flex-col overflow-hidden border-0 p-0 inset-ring-[0.5px] inset-ring-components-panel-border backdrop-blur-[5px]',
typeof className === 'function' ? className(state) : className,
)
}
initialFocus={() =>
popupRef.current?.querySelector<HTMLElement>(
'[data-picker-initial-focus], [role="grid"] button[tabindex="0"], [role="option"][aria-selected="true"]',
) ??
popupRef.current?.querySelector<HTMLElement>('button:not(:disabled)') ??
true
}
render={(popupProps, state) => <div {...popupProps}>{state.open ? children : null}</div>}
>
{children}
</PopoverContent>
)
}
export {
PickerClear,
PickerContent,
PickerLabel,
PickerRoot,
PickerTrigger,
PickerValue,
usePickerContext,
}
export type {
PickerClearProps,
PickerContentProps,
PickerFieldProps,
PickerLabelProps,
PickerTriggerProps,
PickerValueProps,
}
@@ -0,0 +1,20 @@
import * as React from 'react'
function usePickerNumbers(locale: Intl.LocalesArgument = 'en-US') {
return React.useMemo(() => {
const integer = new Intl.NumberFormat(locale, { useGrouping: false })
const minute = new Intl.NumberFormat(locale, { useGrouping: false, minimumIntegerDigits: 2 })
const digits = new Map(
Array.from({ length: 10 }, (_, digit) => [integer.format(digit), String(digit)]),
)
return {
numberingSystem: integer.resolvedOptions().numberingSystem,
formatInteger: integer.format,
formatMinute: minute.format,
normalizeDigits: (text: string) =>
Array.from(text, (char) => digits.get(char) ?? char).join(''),
}
}, [locale])
}
export { usePickerNumbers }
@@ -0,0 +1,311 @@
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Dialog, DialogContent, DialogTitle } from '../../../dialog'
import { DirectionProvider } from '../../../direction-provider'
import { Field, FieldLabel } from '../../../field'
import {
TimePicker,
TimePickerContent,
TimePickerLabel,
TimePickerTrigger,
TimePickerValue,
} from '../index'
it('allows a valid time to be reached through temporarily unavailable combinations', async () => {
const changed = vi.fn()
const screen = await render(
<TimePicker
defaultValue="09:30"
onValueChange={changed}
isTimeUnavailable={(time) => !['09:30', '10:00'].includes(time)}
>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
await screen.getByRole('button', { name: 'Time 9:30 AM' }).click()
await screen
.getByRole('listbox', { name: 'Hour' })
.getByRole('option', { name: '10', exact: true })
.click()
await expect.element(screen.getByRole('button', { name: 'OK' })).toBeDisabled()
await screen
.getByRole('listbox', { name: 'Minute' })
.getByRole('option', { name: '00', exact: true })
.click()
await screen.getByRole('button', { name: 'OK' }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('10:00')
})
it('starts a fresh draft when controlled open changes without an open request', async () => {
const changed = vi.fn()
const view = (open: boolean, value: string) => (
<TimePicker value={value} open={open} onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>
)
const screen = await render(view(true, '13:30'))
await screen
.getByRole('listbox', { name: 'Hour' })
.getByRole('option', { name: '2', exact: true })
.click()
await screen.rerender(view(false, '13:30'))
await screen.rerender(view(true, '09:45'))
await expect
.element(
screen.getByRole('listbox', { name: 'Hour' }).getByRole('option', { name: '9', exact: true }),
)
.toHaveAttribute('aria-selected', 'true')
await screen.getByRole('button', { name: 'OK' }).click()
expect(changed).not.toHaveBeenCalled()
})
it('honors canceled close requests without discarding the active draft', async () => {
const changed = vi.fn()
const screen = await render(
<TimePicker
defaultValue="13:30"
onValueChange={changed}
onOpenChange={(open, details) => {
if (!open && details.reason === 'escape-key') details.cancel()
}}
>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
await screen.getByRole('button', { name: 'Time 1:30 PM' }).click()
await screen
.getByRole('listbox', { name: 'Hour' })
.getByRole('option', { name: '2', exact: true })
.click()
await userEvent.keyboard('{Escape}')
await expect
.element(screen.getByRole('dialog', { name: 'Time', exact: true }))
.toBeInTheDocument()
await screen.getByRole('button', { name: 'OK' }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('14:30')
})
it('supports 24-hour navigation and numeric minute locating', async () => {
const changed = vi.fn()
const screen = await render(
<TimePicker hourCycle={24} defaultValue="13:30" onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
await screen.getByRole('button', { name: 'Time 13:30' }).click()
await userEvent.keyboard('{End}')
await userEvent.tab()
await userEvent.keyboard('45')
await screen.getByRole('button', { name: 'OK' }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('23:45')
})
it('isolates draft option names and values from an enclosing field', async () => {
const screen = await render(
<form id="time-form">
<Field name="appointment">
<FieldLabel>Appointment</FieldLabel>
<TimePicker name="appointment" defaultValue="13:30">
<TimePickerLabel className="sr-only">Appointment</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>
</Field>
</form>,
)
await screen.getByRole('button', { name: 'Appointment 1:30 PM' }).click()
await screen
.getByRole('listbox', { name: 'Hour' })
.getByRole('option', { name: '2', exact: true })
.click()
expect([
...new FormData(document.querySelector<HTMLFormElement>('#time-form')!).entries(),
]).toEqual([['appointment', '13:30']])
const ids = [...document.querySelectorAll('[role="option"]')].map((element) => element.id)
expect(new Set(ids).size).toBe(ids.length)
await screen.getByRole('button', { name: 'OK' }).click()
expect(
new FormData(document.querySelector<HTMLFormElement>('#time-form')!).get('appointment'),
).toBe('14:30')
})
it('repositions all time columns when Now is clicked again without committing', async () => {
vi.spyOn(Date, 'now').mockReturnValue(new Date('2025-01-15T13:30:00Z').getTime())
const changed = vi.fn()
const screen = await render(
<TimePicker timeZone="UTC" defaultValue="13:30" onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
await screen.getByRole('button', { name: 'Time 1:30 PM' }).click()
const column = screen.getByRole('listbox', { name: 'Minute' }).element()
const selected = screen
.getByRole('listbox', { name: 'Minute' })
.getByRole('option', { name: '30', exact: true })
.element() as HTMLElement
await expect.poll(() => column.scrollTop).toBe(selected.offsetTop)
column.scrollTo({ top: 0, behavior: 'instant' })
await expect.poll(() => column.scrollTop).toBe(0)
expect(changed).not.toHaveBeenCalled()
await screen.getByRole('button', { name: 'Now', exact: true }).click()
await expect.poll(() => column.scrollTop).toBe(selected.offsetTop)
await expect.element(screen.getByRole('button', { name: 'Now', exact: true })).toHaveFocus()
expect(changed).not.toHaveBeenCalled()
vi.restoreAllMocks()
})
it('keeps a quarter-hour field reachable and rounds Now down to its current interval', async () => {
vi.spyOn(Date, 'now').mockReturnValue(new Date('2025-01-15T13:37:00Z').getTime())
const changed = vi.fn()
const screen = await render(
<TimePicker timeZone="UTC" minuteStep={15} defaultValue="09:00" onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
await screen.getByRole('button', { name: 'Time 9:00 AM' }).click()
expect(
screen.getByRole('listbox', { name: 'Minute' }).element().querySelectorAll('[role="option"]'),
).toHaveLength(4)
await screen.getByRole('button', { name: 'Now', exact: true }).click()
expect(changed).not.toHaveBeenCalled()
await expect
.element(screen.getByRole('listbox', { name: 'Minute' }).getByRole('option', { name: '30' }))
.toHaveAttribute('aria-selected', 'true')
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('13:30')
vi.restoreAllMocks()
})
it('discards a draft when Tab leaves the nonmodal picker inside a modal dialog', async () => {
const changed = vi.fn()
const screen = await render(
<Dialog defaultOpen>
<DialogContent>
<DialogTitle>Settings</DialogTitle>
<TimePicker defaultValue="13:30" onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>
<button type="button">Save settings</button>
</DialogContent>
</Dialog>,
)
await screen.getByRole('button', { name: 'Time 1:30 PM' }).click()
await userEvent.keyboard('{ArrowDown}')
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
await expect.element(screen.getByRole('button', { name: 'Now', exact: true })).toHaveFocus()
await userEvent.tab()
await expect.element(screen.getByRole('button', { name: 'OK', exact: true })).toHaveFocus()
await userEvent.tab()
await expect.element(screen.getByRole('button', { name: 'Save settings' })).toHaveFocus()
await expect
.element(screen.getByRole('dialog', { name: 'Time', exact: true }))
.not.toBeInTheDocument()
await expect
.element(screen.getByRole('dialog', { name: 'Settings', exact: true }))
.toBeInTheDocument()
expect(changed).not.toHaveBeenCalled()
})
it('aligns pointer selections and immediately positions keyboard selections at the same top anchor', async () => {
const screen = await render(
<TimePicker defaultValue="13:30">
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
await screen.getByRole('button', { name: 'Time 1:30 PM' }).click()
const hours = screen.getByRole('listbox', { name: 'Hour' })
const column = hours.element()
expect(column.scrollTop).toBe(0)
await userEvent.keyboard('{ArrowDown}')
await expect.element(hours.getByRole('option', { name: '2', exact: true })).toHaveFocus()
expect(column.scrollTop).toBe(26)
// Disable Playwright's retry alignment so the test measures the component's scrolling.
await hours.getByRole('option', { name: '3', exact: true }).click({ scroll: 'none' })
await expect.poll(() => column.scrollTop).toBe(52)
await userEvent.keyboard('12')
const last = hours.getByRole('option', { name: '12', exact: true }).element() as HTMLElement
await expect.element(last).toHaveFocus()
expect(column.scrollTop).toBe(last.offsetTop)
await userEvent.keyboard('{Home}')
expect(column.scrollTop).toBe(0)
await userEvent.keyboard('{PageDown}')
await expect.element(hours.getByRole('option', { name: '6', exact: true })).toHaveFocus()
await userEvent.keyboard('{End}{ArrowDown}')
await expect.element(last).toHaveFocus()
})
it('preserves draft selection after focus leaves and does not select hovered options', async () => {
const changed = vi.fn()
const screen = await render(
<DirectionProvider direction="rtl">
<TimePicker defaultValue="13:30" onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>
</DirectionProvider>,
)
await screen.getByRole('button', { name: 'Time 1:30 PM' }).click()
await expect
.element(screen.getByRole('dialog', { name: 'Time', exact: true }))
.toHaveAttribute('dir', 'rtl')
const hours = screen.getByRole('listbox', { name: 'Hour' })
const minutes = screen.getByRole('listbox', { name: 'Minute' })
expect(hours.element().getBoundingClientRect().left).toBeGreaterThan(
minutes.element().getBoundingClientRect().left,
)
await userEvent.keyboard('{ArrowDown}')
const selectedHour = hours.getByRole('option', { name: '2', exact: true })
await expect.element(selectedHour).toHaveFocus()
await expect.element(selectedHour).toHaveAttribute('aria-selected', 'true')
await userEvent.tab()
await expect.element(minutes.getByRole('option', { name: '30', exact: true })).toHaveFocus()
await userEvent.hover(hours.getByRole('option', { name: '3', exact: true }))
await expect.element(selectedHour).toHaveAttribute('aria-selected', 'true')
await expect
.element(hours.getByRole('option', { name: '3', exact: true }))
.toHaveAttribute('aria-selected', 'false')
expect(changed).not.toHaveBeenCalled()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('14:30')
})
it('announces the complete custom displayed value including its timezone', async () => {
const screen = await render(
<TimePicker defaultValue="13:30">
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger>
<TimePickerValue>
{(displayValue) => (
<>
<span>{displayValue}</span> <span>UTC+08:00</span>
</>
)}
</TimePickerValue>
</TimePickerTrigger>
<TimePickerContent />
</TimePicker>,
)
await expect
.element(screen.getByRole('button', { name: 'Time 1:30 PM UTC+08:00', exact: true }))
.toBeVisible()
})
@@ -0,0 +1,229 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { TimePicker, TimePickerContent, TimePickerLabel, TimePickerTrigger } from '../index'
async function renderPicker() {
const changed = vi.fn()
const screen = await render(
<TimePicker defaultValue="13:30" timeZone="UTC" onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
await screen.getByRole('button', { name: 'Time 1:30 PM' }).click()
const hours = screen.getByRole('listbox', { name: 'Hour' })
return { screen, hours, column: hours.element(), changed }
}
// Intent events are synthetic; scroll geometry, focus, smooth settling and submission use Chromium.
function moveGesture(column: Element, top: number, touch = false) {
column.dispatchEvent(touch ? new Event('touchstart') : new WheelEvent('wheel', { deltaY: top }))
column.scrollTo({ top, behavior: 'instant' })
column.dispatchEvent(new Event('scroll'))
}
it('snaps a wheel gesture to the top and selects a draft without committing', async () => {
const { screen, hours, column, changed } = await renderPicker()
moveGesture(column, 67)
column.dispatchEvent(new Event('scrollend'))
await expect
.element(hours.getByRole('option', { name: '4', exact: true }))
.toHaveAttribute('aria-selected', 'true')
await expect.poll(() => column.scrollTop).toBe(78)
expect(changed).not.toHaveBeenCalled()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('16:30')
})
it('highlights the nearest top row during touch without snapping or moving focus until release', async () => {
const { screen, hours, column, changed } = await renderPicker()
const first = hours.getByRole('option', { name: '1', exact: true })
const selectedBackground = getComputedStyle(first.element()).backgroundColor
moveGesture(column, 67, true)
const fourth = hours.getByRole('option', { name: '4', exact: true })
await expect.element(fourth).toHaveAttribute('aria-selected', 'true')
expect(getComputedStyle(fourth.element()).backgroundColor).toBe(selectedBackground)
await expect.element(first).toHaveAttribute('aria-selected', 'false')
await expect.element(first).toHaveFocus()
expect(column.scrollTop).toBe(67)
column.scrollTo({ top: 92, behavior: 'instant' })
column.dispatchEvent(new Event('scroll'))
const fifth = hours.getByRole('option', { name: '5', exact: true })
await expect.element(fifth).toHaveAttribute('aria-selected', 'true')
expect(column.scrollTop).toBe(92)
expect(changed).not.toHaveBeenCalled()
await expect.element(screen.getByRole('button', { name: 'Time 1:30 PM' })).toBeInTheDocument()
column.dispatchEvent(new Event('touchend'))
column.dispatchEvent(new Event('scrollend'))
await expect.poll(() => column.scrollTop).toBe(104)
await expect.element(fifth).toHaveFocus()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('17:30')
})
it('keeps a clicked target selected throughout positioning after interrupting a gesture', async () => {
const { screen, hours, column, changed } = await renderPicker()
moveGesture(column, 67, true)
await expect
.element(hours.getByRole('option', { name: '4', exact: true }))
.toHaveAttribute('aria-selected', 'true')
const target = hours.getByRole('option', { name: '6', exact: true })
await target.click({ scroll: 'none' })
column.dispatchEvent(new Event('touchend'))
column.dispatchEvent(new Event('scrollend'))
await expect.element(target).toHaveAttribute('aria-selected', 'true')
await expect.poll(() => column.scrollTop).toBe(130)
await expect.element(target).toHaveAttribute('aria-selected', 'true')
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('18:30')
})
it('aligns AM and PM to the same top anchor for keyboard, click and touch selection', async () => {
const { screen, changed } = await renderPicker()
const period = screen.getByRole('listbox', { name: 'Period' })
const column = period.element()
const am = period.getByRole('option', { name: 'AM' })
const pm = period.getByRole('option', { name: 'PM' })
expect(column.scrollTop).toBe(26)
await pm.click({ scroll: 'none' })
await userEvent.keyboard('{ArrowUp}')
await expect.element(am).toHaveFocus()
expect(column.scrollTop).toBe(0)
await pm.click({ scroll: 'none' })
await expect.poll(() => column.scrollTop).toBe(26)
moveGesture(column, 6, true)
await expect.element(am).toHaveAttribute('aria-selected', 'true')
expect(column.scrollTop).toBe(6)
expect(changed).not.toHaveBeenCalled()
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('01:30')
})
it('settles both moving columns synchronously when OK is clicked', async () => {
const { screen, column, changed } = await renderPicker()
const minutes = screen.getByRole('listbox', { name: 'Minute' }).element()
moveGesture(column, 67, true)
moveGesture(minutes, 1050, true)
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('16:40')
})
it('continues keyboard navigation from the moving candidate and ignores its late scrollend', async () => {
const { hours, column, changed } = await renderPicker()
moveGesture(column, 67, true)
await userEvent.keyboard('{ArrowDown}')
column.dispatchEvent(new Event('touchend'))
column.dispatchEvent(new Event('scrollend'))
await expect.element(hours.getByRole('option', { name: '5', exact: true })).toHaveFocus()
await expect
.element(hours.getByRole('option', { name: '5', exact: true }))
.toHaveAttribute('aria-selected', 'true')
expect(column.scrollTop).toBe(104)
expect(changed).not.toHaveBeenCalled()
})
it('lets Now override all moving columns and keeps focus on Now', async () => {
vi.spyOn(Date, 'now').mockReturnValue(new Date('2025-01-15T13:30:00Z').getTime())
try {
const { screen, hours, column, changed } = await renderPicker()
moveGesture(column, 67, true)
await screen.getByRole('button', { name: 'Now', exact: true }).click()
column.dispatchEvent(new Event('touchend'))
column.dispatchEvent(new Event('scrollend'))
await expect
.element(hours.getByRole('option', { name: '1', exact: true }))
.toHaveAttribute('aria-selected', 'true')
expect(column.scrollTop).toBe(0)
await expect.element(screen.getByRole('button', { name: 'Now', exact: true })).toHaveFocus()
expect(changed).not.toHaveBeenCalled()
} finally {
vi.restoreAllMocks()
}
})
it('discards an unfinished gesture on Escape and ignores detached scroll events', async () => {
const { screen, column, changed } = await renderPicker()
moveGesture(column, 67, true)
await userEvent.keyboard('{Escape}')
column.dispatchEvent(new Event('touchend'))
column.dispatchEvent(new Event('scrollend'))
await expect.element(screen.getByRole('button', { name: 'Time 1:30 PM' })).toHaveFocus()
expect(changed).not.toHaveBeenCalled()
})
it('does not interpret an edge wheel or programmatic scrolling as selection', async () => {
const { hours, column } = await renderPicker()
column.dispatchEvent(new WheelEvent('wheel', { deltaY: -100 }))
column.dispatchEvent(new Event('scrollend'))
await expect
.element(hours.getByRole('option', { name: '1', exact: true }))
.toHaveAttribute('aria-selected', 'true')
column.scrollTo({ top: 78, behavior: 'instant' })
column.dispatchEvent(new Event('scroll'))
column.dispatchEvent(new Event('scrollend'))
await expect
.element(hours.getByRole('option', { name: '1', exact: true }))
.toHaveAttribute('aria-selected', 'true')
})
it('does not lose an unfinished selection when only a modifier key is pressed', async () => {
const { screen, column, changed } = await renderPicker()
moveGesture(column, 67, true)
await userEvent.keyboard('{Shift}')
await screen.getByRole('button', { name: 'OK', exact: true }).click()
expect(changed).toHaveBeenCalledExactlyOnceWith('16:30')
})
it('recognizes scrolling when the compositor moves before the passive wheel callback', async () => {
const { hours, column } = await renderPicker()
column.scrollTo({ top: 67, behavior: 'instant' })
column.dispatchEvent(new WheelEvent('wheel', { deltaY: 67 }))
column.dispatchEvent(new Event('scroll'))
column.dispatchEvent(new Event('scrollend'))
await expect
.element(hours.getByRole('option', { name: '4', exact: true }))
.toHaveAttribute('aria-selected', 'true')
await expect.poll(() => column.scrollTop).toBe(78)
})
it('settles through the quiet fallback when scrollend is unavailable', async () => {
let owner: object | null = HTMLElement.prototype
while (owner && !Object.hasOwn(owner, 'onscrollend')) owner = Object.getPrototypeOf(owner)
expect(owner).not.toBeNull()
const descriptor = Object.getOwnPropertyDescriptor(owner!, 'onscrollend')!
Reflect.deleteProperty(owner!, 'onscrollend')
try {
const { hours, column } = await renderPicker()
moveGesture(column, 67, true)
column.dispatchEvent(new Event('touchend'))
await expect
.element(hours.getByRole('option', { name: '4', exact: true }))
.toHaveAttribute('aria-selected', 'true')
await expect.poll(() => column.scrollTop).toBe(78)
} finally {
Object.defineProperty(owner!, 'onscrollend', descriptor)
}
})
it('settles the fallback after a final wheel event produces no additional scroll', async () => {
let owner: object | null = HTMLElement.prototype
while (owner && !Object.hasOwn(owner, 'onscrollend')) owner = Object.getPrototypeOf(owner)
expect(owner).not.toBeNull()
const descriptor = Object.getOwnPropertyDescriptor(owner!, 'onscrollend')!
Reflect.deleteProperty(owner!, 'onscrollend')
try {
const { hours, column } = await renderPicker()
moveGesture(column, 67)
await new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
)
column.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 }))
await expect
.element(hours.getByRole('option', { name: '4' }))
.toHaveAttribute('aria-selected', 'true')
await expect.poll(() => column.scrollTop).toBe(78)
} finally {
Object.defineProperty(owner!, 'onscrollend', descriptor)
}
})
@@ -0,0 +1,187 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import * as React from 'react'
import { expect, waitFor, within } from 'storybook/test'
import { DirectionProvider } from '../../direction-provider'
import {
TimePicker,
TimePickerClear,
TimePickerContent,
TimePickerLabel,
TimePickerTrigger,
TimePickerValue,
} from './index'
const meta = {
title: 'Base/Form/Time Picker',
component: TimePicker,
args: { children: null },
parameters: {
layout: 'centered',
docs: { story: { autoplay: false } },
a11y: { config: { rules: [{ id: 'color-contrast', enabled: true }] } },
},
tags: ['autodocs'],
} satisfies Meta<typeof TimePicker>
export default meta
type Story = StoryObj<typeof meta>
function TimePickerDemo() {
const [value, setValue] = React.useState<string | null>('13:30')
return (
<div className="grid gap-1">
<TimePicker value={value} onValueChange={setValue}>
<TimePickerLabel>Start time</TimePickerLabel>
<div className="flex items-center gap-1">
<TimePickerTrigger />
<TimePickerClear aria-label="Clear start time" />
</div>
<TimePickerContent />
</TimePicker>
</div>
)
}
export const Default: Story = {
render: () => <TimePickerDemo />,
play: async ({ canvasElement, userEvent }) => {
const canvas = within(canvasElement)
const body = within(canvasElement.ownerDocument.body)
const trigger = canvas.getByRole('button', { name: 'Start time 1:30 PM' })
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
trigger.focus()
await userEvent.keyboard('{Enter}')
let hours = within(await body.findByRole('listbox', { name: 'Hour' }))
await waitFor(() => expect(hours.getByRole('option', { name: '1' })).toHaveFocus())
await userEvent.keyboard('{ArrowDown}{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
await expect(trigger).toHaveAccessibleName('Start time 1:30 PM')
await userEvent.keyboard('{Enter}')
hours = within(await body.findByRole('listbox', { name: 'Hour' }))
await waitFor(() => expect(hours.getByRole('option', { name: '1' })).toHaveFocus())
await userEvent.keyboard('{ArrowDown}')
await expect(hours.getByRole('option', { name: '2' })).toHaveFocus()
await expect(hours.getByRole('option', { name: '2' })).toHaveAttribute('aria-selected', 'true')
await expect(trigger).toHaveAccessibleName('Start time 1:30 PM')
await userEvent.click(body.getByRole('button', { name: 'OK' }))
const updatedTrigger = canvas.getByRole('button', { name: 'Start time 2:30 PM' })
await waitFor(() => expect(updatedTrigger).toHaveFocus())
await userEvent.keyboard('{Enter}')
const dialog = await body.findByRole('dialog', { name: 'Start time' })
await waitFor(() => expect(dialog).toBeVisible())
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(updatedTrigger).toHaveFocus())
},
}
export const RTL: Story = {
parameters: {
docs: {
description: {
story:
'RTL reverses column placement, not vertical navigation. Arrow and Page keys stop at the endpoints; Home/End and numeric typing provide quick positioning. Escape discards the draft. Formatting locale remains independent of direction.',
},
},
},
render: () => (
<div dir="rtl">
<DirectionProvider direction="rtl">
<TimePickerDemo />
</DirectionProvider>
</div>
),
play: async ({ canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
const trigger = within(canvasElement).getByRole('button', { name: 'Start time 1:30 PM' })
await userEvent.click(trigger)
await expect(await body.findByRole('dialog', { name: 'Start time' })).toHaveAttribute(
'dir',
'rtl',
)
const hours = body.getByRole('listbox', { name: 'Hour' })
const minutes = body.getByRole('listbox', { name: 'Minute' })
await waitFor(() => expect(within(hours).getByRole('option', { name: '1' })).toHaveFocus())
await expect(hours.getBoundingClientRect().left).toBeGreaterThan(
minutes.getBoundingClientRect().left,
)
await userEvent.tab()
await expect(within(minutes).getByRole('option', { name: '30' })).toHaveFocus()
await userEvent.keyboard('{Home}{ArrowUp}{PageUp}')
await expect(within(minutes).getByRole('option', { name: '00' })).toHaveFocus()
await userEvent.keyboard('{End}{ArrowDown}{PageDown}')
const last = within(minutes).getByRole('option', { name: '59' })
await expect(last).toHaveFocus()
await expect(last).toHaveAttribute('aria-selected', 'true')
await userEvent.tab()
await expect(
within(body.getByRole('listbox', { name: 'Period' })).getByRole('option', { name: 'PM' }),
).toHaveFocus()
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
await expect(trigger).toHaveAccessibleName('Start time 1:30 PM')
await userEvent.keyboard('{Enter}')
await waitFor(() => expect(body.getByRole('dialog', { name: 'Start time' })).toBeVisible())
await expect(
within(body.getByRole('listbox', { name: 'Minute' })).getByRole('option', { name: '30' }),
).toHaveAttribute('aria-selected', 'true')
await userEvent.keyboard('{Escape}')
await waitFor(() => expect(trigger).toHaveFocus())
},
}
export const UnavailableTime: Story = {
parameters: {
docs: {
description: {
story:
'Unavailable combinations can be explored but cannot be confirmed. Change another column to reach a valid time; only OK commits the draft.',
},
},
},
render: () => (
<TimePicker defaultValue="09:00" isTimeUnavailable={(time) => time === '10:00'}>
<TimePickerLabel className="sr-only">Start time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>
),
play: async ({ canvasElement, userEvent }) => {
const body = within(canvasElement.ownerDocument.body)
await userEvent.click(within(canvasElement).getByRole('button', { name: 'Start time 9:00 AM' }))
await userEvent.click(
within(await body.findByRole('listbox', { name: 'Hour' })).getByRole('option', {
name: '10',
}),
)
await expect(body.getByRole('button', { name: 'OK' })).toBeDisabled()
await expect(body.getByRole('status')).toHaveTextContent('This date or time is unavailable.')
},
}
export const Schedule: Story = {
parameters: {
docs: {
description: {
story:
'A 24-hour schedule with quarter-hour steps and a composed value. The time zone determines Now, not the stored HH:mm wall time. Keep meaningful suffixes inside Value so they are included in the trigger name.',
},
},
},
render: () => (
<div className="grid gap-1">
<TimePicker defaultValue="13:30" hourCycle={24} minuteStep={15} timeZone="UTC">
<TimePickerLabel>Update time</TimePickerLabel>
<TimePickerTrigger>
<TimePickerValue>
{(value) => (
<>
{value} <span className="text-text-tertiary">UTC+0</span>
</>
)}
</TimePickerValue>
</TimePickerTrigger>
<TimePickerContent />
</TimePicker>
</div>
),
}
@@ -0,0 +1,259 @@
'use client'
import type {
PickerClearProps,
PickerContentProps,
PickerFieldProps,
PickerLabelProps,
PickerTriggerProps,
PickerValueProps,
} from '../internal/picker-parts'
import type { TimePanelHandle, TimePanelLabels, TimePanelProps } from '../time-picker/time-panel'
import { useControlled } from '@base-ui/utils/useControlled'
import { TZDate } from '@date-fns/tz'
import * as React from 'react'
import { Button } from '../../button'
import { PickerPanel, PickerPanelHeader } from '../internal/picker-panel'
import {
PickerClear,
PickerContent,
PickerLabel,
PickerRoot,
PickerTrigger,
PickerValue,
usePickerContext,
} from '../internal/picker-parts'
import { TimePanel } from '../time-picker/time-panel'
import { formatTimeValue, requireTimeValue } from '../time-picker/time-value'
type TimePickerLabels = TimePanelLabels & {
title: string
now: string
apply: string
unavailable: string
}
type TimePickerProps = PickerFieldProps &
Pick<TimePanelProps, 'hourCycle' | 'minuteStep'> & {
/** A 24-hour HH:mm wall time, independent of a date or time zone. */
value?: string | null
defaultValue?: string | null
/** Called on OK, clearing or form reset; popup draft edits do not emit changes. */
onValueChange?: (value: string | null) => void
isTimeUnavailable?: (value: string) => boolean
locale?: Intl.LocalesArgument
/** IANA zone used for Now and the initial empty draft; it does not convert value. */
timeZone?: string
labels?: Partial<TimePickerLabels>
}
type ContextValue = Omit<
TimePickerProps,
'value' | 'defaultValue' | 'onValueChange' | 'children'
> & {
value: Exclude<TimePickerProps['value'], undefined>
commit: NonNullable<TimePickerProps['onValueChange']>
}
const Context = React.createContext<ContextValue | null>(null)
function useContext() {
const context = React.useContext(Context)
if (!context) throw new Error('TimePicker parts require TimePicker.')
return context
}
const defaultLabels: TimePickerLabels = {
title: 'Pick Time',
hour: 'Hour',
minute: 'Minute',
period: 'Period',
am: 'AM',
pm: 'PM',
now: 'Now',
unavailable: 'This date or time is unavailable.',
apply: 'OK',
}
function TimePicker({
value: valueProp,
defaultValue = null,
onValueChange,
children,
...props
}: TimePickerProps) {
const [value, setValue] = useControlled({
controlled: valueProp,
default: defaultValue,
name: 'TimePicker',
})
requireTimeValue(value)
const unavailable = Boolean(
value &&
(props.isTimeUnavailable?.(value) || Number(value.slice(3)) % (props.minuteStep ?? 1) !== 0),
)
function commit(next: string | null) {
if (props.disabled || props.readOnly) return
if (next !== value) {
setValue(next)
onValueChange?.(next)
}
}
return (
<Context.Provider value={{ ...props, value, commit }}>
<PickerRoot
{...props}
invalid={props.invalid || unavailable}
validationMessage={
unavailable
? (props.labels?.unavailable ?? defaultLabels.unavailable)
: props.validationMessage
}
placeholder={props.placeholder ?? 'Select time'}
displayValue={
value
? new Intl.DateTimeFormat(props.locale ?? 'en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: props.hourCycle !== 24,
timeZone: 'UTC',
}).format(new Date(`2025-01-01T${value}:00Z`))
: ''
}
serializedValue={value ?? ''}
onClear={() => commit(null)}
onReset={() => {
setValue(defaultValue)
if (defaultValue !== value) onValueChange?.(defaultValue)
}}
>
{children}
</PickerRoot>
</Context.Provider>
)
}
type TimePickerTriggerProps = PickerTriggerProps
type TimePickerContentProps = PickerContentProps
type TimePickerClearProps = PickerClearProps
type TimePickerLabelProps = PickerLabelProps
type TimePickerValueProps = PickerValueProps
function TimePickerTrigger({ children, ...props }: TimePickerTriggerProps) {
return (
<PickerTrigger {...props}>
{children ?? (
<>
<PickerValue />
<span
aria-hidden="true"
className="i-ri-time-line size-4 shrink-0 text-text-tertiary forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
/>
</>
)}
</PickerTrigger>
)
}
const TimePickerClear = PickerClear
const TimePickerLabel = PickerLabel
const TimePickerValue = PickerValue
function TimePickerContent(props: TimePickerContentProps) {
const { value, timeZone } = useContext()
return (
<PickerContent {...props}>
<Session key={`${value ?? ''}-${timeZone ?? ''}`} />
</PickerContent>
)
}
function currentTime(timeZone?: string, minuteStep = 1) {
const now = timeZone ? new TZDate(Date.now(), timeZone) : new Date()
return formatTimeValue({
hour: now.getHours(),
minute: Math.floor(now.getMinutes() / minuteStep) * minuteStep,
})
}
function Session() {
const {
value,
commit,
isTimeUnavailable,
labels,
locale,
hourCycle,
timeZone,
minuteStep = 1,
} = useContext()
const { close } = usePickerContext()
const panelRef = React.useRef<TimePanelHandle>(null)
const [draft, setDraft] = React.useState(() => value ?? currentTime(timeZone, minuteStep))
const text = { ...defaultLabels, ...labels }
const unavailable = Boolean(
isTimeUnavailable?.(draft) || Number(draft.slice(3)) % minuteStep !== 0,
)
const [scrollRequest, requestScroll] = React.useReducer((value: number) => value + 1, 0)
const errorId = React.useId()
return (
<PickerPanel
header={<PickerPanelHeader>{text.title}</PickerPanelHeader>}
message={
unavailable && (
<p id={errorId} role="status" className="px-2 system-xs-regular text-text-destructive">
{text.unavailable}
</p>
)
}
footer={
<>
<Button
size="small"
variant="ghost-accent"
onClick={() => {
setDraft(currentTime(timeZone, minuteStep))
requestScroll()
}}
>
{text.now}
</Button>
<Button
variant="primary"
className="min-w-16"
size="small"
disabled={unavailable}
aria-describedby={unavailable ? errorId : undefined}
onClick={() => {
const next = panelRef.current?.settle() ?? draft
setDraft(next)
if (!isTimeUnavailable?.(next) && Number(next.slice(3)) % minuteStep === 0) {
commit(next)
close()
}
}}
>
{text.apply}
</Button>
</>
}
>
<TimePanel
ref={panelRef}
minuteStep={minuteStep}
scrollRequest={scrollRequest}
value={draft}
onValueChange={setDraft}
labels={text}
locale={locale}
hourCycle={hourCycle}
/>
</PickerPanel>
)
}
export {
TimePicker,
TimePickerClear,
TimePickerContent,
TimePickerLabel,
TimePickerTrigger,
TimePickerValue,
}
export type {
TimePickerClearProps,
TimePickerContentProps,
TimePickerLabelProps,
TimePickerProps,
TimePickerTriggerProps,
TimePickerValueProps,
}
@@ -0,0 +1,103 @@
import type { OptionColumnHandle } from '../internal/option-column'
import * as React from 'react'
import { OptionColumn } from '../internal/option-column'
import { PickerColumns } from '../internal/picker-panel'
import { usePickerNumbers } from '../internal/use-picker-numbers'
import { formatTimeValue, parseTimeValue } from './time-value'
type TimePanelLabels = { hour: string; minute: string; period: string; am: string; pm: string }
type TimePanelHandle = { settle: () => string }
type TimePanelProps = {
ref?: React.Ref<TimePanelHandle>
value: string
onValueChange: (update: (value: string) => string) => void
labels: TimePanelLabels
locale?: Intl.LocalesArgument
hourCycle?: 12 | 24
scrollRequest?: number
minuteStep?: 1 | 5 | 10 | 15 | 20 | 30
}
function TimePanel({
ref,
value,
onValueChange,
labels,
locale,
hourCycle = 12,
scrollRequest,
minuteStep = 1,
}: TimePanelProps) {
const numbers = usePickerNumbers(locale)
const current = parseTimeValue(value)
if (!current) throw new RangeError(`Invalid time of day: ${value}`)
const period = current.hour < 12 ? 0 : 1
const displayHour = hourCycle === 24 ? current.hour : current.hour % 12 || 12
const hourRef = React.useRef<OptionColumnHandle>(null)
const minuteRef = React.useRef<OptionColumnHandle>(null)
const periodRef = React.useRef<OptionColumnHandle>(null)
React.useImperativeHandle(ref, () => ({
settle() {
const hour = hourRef.current?.settle() ?? displayHour
const minute = minuteRef.current?.settle() ?? current.minute
const meridiem = periodRef.current?.settle() ?? period
return formatTimeValue({
hour: hourCycle === 24 ? hour : (hour % 12) + meridiem * 12,
minute,
})
},
}))
function update(part: 'hour' | 'minute' | 'period', next: number) {
onValueChange((previous) => {
const time = parseTimeValue(previous)!
if (part === 'minute') time.minute = next
else if (part === 'period') time.hour = (time.hour % 12) + next * 12
else time.hour = hourCycle === 24 ? next : (next % 12) + Math.floor(time.hour / 12) * 12
return formatTimeValue(time)
})
}
return (
<PickerColumns>
<OptionColumn
ref={hourRef}
scrollRequest={scrollRequest}
normalizeDigits={numbers.normalizeDigits}
label={labels.hour}
value={displayHour}
options={Array.from({ length: hourCycle }, (_, index) => {
const hour = hourCycle === 24 ? index : index + 1
return { value: hour, label: numbers.formatInteger(hour) }
})}
onValueChange={(hour) => update('hour', hour)}
/>
<OptionColumn
ref={minuteRef}
scrollRequest={scrollRequest}
normalizeDigits={numbers.normalizeDigits}
label={labels.minute}
value={current.minute}
options={Array.from({ length: 60 / minuteStep }, (_, index) => ({
value: index * minuteStep,
label: numbers.formatMinute(index * minuteStep),
}))}
onValueChange={(minute) => update('minute', minute)}
/>
{hourCycle === 12 && (
<OptionColumn
ref={periodRef}
scrollRequest={scrollRequest}
normalizeDigits={numbers.normalizeDigits}
label={labels.period}
value={period}
options={[
{ value: 0, label: labels.am },
{ value: 1, label: labels.pm },
]}
onValueChange={(next) => update('period', next)}
/>
)}
</PickerColumns>
)
}
export { TimePanel }
export type { TimePanelHandle, TimePanelLabels, TimePanelProps }
@@ -0,0 +1,25 @@
const timePattern = /^(\d{2}):(\d{2})$/
type TimeParts = { hour: number; minute: number }
function parseTimeValue(value: string): TimeParts | null {
const match = timePattern.exec(value)
if (!match) return null
const hour = Number(match[1])
const minute = Number(match[2])
return hour < 24 && minute < 60 ? { hour, minute } : null
}
function formatTimeValue({ hour, minute }: TimeParts): string {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
}
function requireTimeValue(value: string | null): TimeParts | null {
if (value === null) return null
const time = parseTimeValue(value)
if (!time) throw new RangeError(`Invalid time of day: ${value}`)
return time
}
export { formatTimeValue, parseTimeValue, requireTimeValue }
export type { TimeParts }
@@ -0,0 +1,4 @@
'use client'
export { DirectionProvider, useDirection } from '@base-ui/react/direction-provider'
export type { DirectionProviderProps } from '@base-ui/react/direction-provider'
+8 -1
View File
@@ -16,7 +16,14 @@ export default defineConfig({
tsconfigPaths: true,
},
optimizeDeps: {
include: ['vite-plus/test/browser'],
include: [
'vite-plus/test/browser',
'@daypicker/react/locale/en-US',
'@daypicker/react/locale/ar-TN',
'@daypicker/react/locale/fa-IR',
'@base-ui/utils/useStableCallback',
'@base-ui/utils/useTimeout',
],
},
test: {
browser: {