mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(ui): redesign date and time pickers (#43090)
This commit is contained in:
210 files changed
+6997
-6758
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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'
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user