diff --git a/.changeset/odd-maps-glow.md b/.changeset/odd-maps-glow.md new file mode 100644 index 00000000..74604e5b --- /dev/null +++ b/.changeset/odd-maps-glow.md @@ -0,0 +1,36 @@ +--- +"@tailor-platform/app-shell": major +--- + +Refactor `DateField` / `DatePicker` to follow the same composition model as `Field`, `Select`, `Combobox`, and `Autocomplete`. + +The date controls are now **control-first**: field chrome moved out of the control props and into `Field.Root` composition. The old `label`, `description`, `errorMessage`, `isInvalid`, `isRequired`, `isDisabled`, `isReadOnly`, `minValue`, `maxValue`, and `isDateUnavailable` props were removed or reshaped. + +Before: + +```tsx + +``` + +After: + +```tsx + + Delivery date + + When should we ship your order? + {error} + +``` + +Standalone usage still works with accessible naming: + +```tsx + +``` diff --git a/docs/components/date-picker.md b/docs/components/date-picker.md index 2dc2598d..c0f71f74 100644 --- a/docs/components/date-picker.md +++ b/docs/components/date-picker.md @@ -5,9 +5,7 @@ description: Accessible date input components (@internationalized/date + Base UI # DatePicker -Three related components for date input — a segmented field, a field with a calendar popover, and a standalone calendar grid. Built on [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/) (the value layer) and Base UI (`Popover`), with the segmented input and calendar grid implemented to the [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/) date-picker/grid patterns. They integrate automatically with AppShell's locale and timezone context. - -> **Implementation note.** This is the `@internationalized/date` + Base UI variant. The public API and accessibility contract are identical to the react-aria variant; only the internals differ. +Three related components for date input — a segmented field, a field with a calendar popover, and a standalone calendar grid. Built on [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/) and Base UI. ## Import @@ -16,6 +14,7 @@ import { DateField, DatePicker, Calendar, + Field, // Date value helpers (re-exported from @internationalized/date) parseDate, getLocalTimeZone, @@ -24,31 +23,42 @@ import { } from "@tailor-platform/app-shell"; ``` -No separate `@internationalized/date` install needed — the value types and helpers are re-exported from `@tailor-platform/app-shell`. +## API shape + +`DateField` and `DatePicker` are **control-first** components. + +- They own date entry, keyboard behavior, constraints, locale/timezone handling, and form value serialization. +- `Field.Root` owns label, description, invalid presentation, and error rendering. + +That matches the rest of the form stack (`Field`, `Select`, `Combobox`, `Autocomplete`). ## DateField -A segmented input that lets users type dates digit-by-digit, with per-segment Up/Down, type-to-fill auto-advance, and full keyboard support. +Standalone usage with an accessible name: ```tsx - + ``` -### With description and error +With `Field.Root` composition: ```tsx - + + Invoice date + + Format follows your locale + ``` -### Controlled +Controlled: ```tsx const [date, setDate] = useState(null); -; + + + Invoice date + +; ``` ## DatePicker @@ -56,31 +66,48 @@ const [date, setDate] = useState(null); A `DateField` with a calendar popover. ```tsx - + ``` -### Constrained + unavailable dates +Constrained + unavailable dates: ```tsx - { - const dow = date.toDate(getLocalTimeZone()).getDay(); - return dow === 0 || dow === 6; // weekends - }} -/> + + Delivery date + { + const dow = date.toDate(getLocalTimeZone()).getDay(); + return dow === 0 || dow === 6; // weekends + }, + }} + /> + ``` -### Week start +Week start: + +```tsx + +``` + +## Validation with Field.Root + +Use the field shell for labels and external errors: ```tsx - + + Delivery date + + {error} + ``` ## Calendar -A standalone calendar grid for custom date-selection UIs (e.g. reporting filters). +A standalone calendar grid for custom date-selection UIs. ```tsx console.log(date)} /> @@ -91,95 +118,50 @@ A standalone calendar grid for custom date-selection UIs (e.g. reporting filters Locale and timezone come from AppShell automatically. Override per field with `locale` / `timeZone`: ```tsx - + ``` -Segment order, first-day-of-week, and month/weekday names all follow the resolved locale. - ## Keyboard -- **Segments:** `↑`/`↓` increment/decrement, digits type-to-fill (auto-advance), `←`/`→` move between segments, `Backspace` clears, `/` commits the current segment and advances (so a single `1` means January, not the start of `1x`). -- **Whole-date shortcuts** (QuickBooks Online-style, case-insensitive): `t` today · `m`/`h` start/end of the entered month (current month when empty) · `y`/`r` start/end of the year · `w`/`k` start/end of the week (locale-aware) · `-` previous day · `=`/`+` next day (both step across month **and** year boundaries; `+` needs no Shift). A 1–2 digit year expands to the 2000s on blur (`26` → `2026`). These work **from a focused date segment** (they set the field value, clamped to `minValue`/`maxValue`) **and while the calendar popover is open** (they move the highlighted day like the arrow keys — press `Enter` to confirm; `minValue`/`maxValue` clamp and unavailable days can't be confirmed). -- **Calendar grid:** arrows move by day/week, `Home`/`End` to week start/end, `PageUp`/`PageDown` by month, `Shift`+`PageUp`/`PageDown` by year, `Enter`/`Space` selects. `Alt`+`↓` opens the calendar from the field (`DatePicker`). - -## Accessibility - -- The segmented field is a labelled `role="group"` of `role="spinbutton"` segments with `aria-valuemin`/`max`/`now`/`text`. -- The calendar is a `role="grid"`; each day is a button with a full-date `aria-label`; disabled/unavailable days are announced via `aria-disabled`. -- The popover is a labelled `role="dialog"`. - -> **Known limitations (this variant).** The segments are `
` that aren't `contentEditable`, so a touch device's on-screen keyboard doesn't open for typing — on mobile, use the calendar popover to pick a date (desktop keyboard entry and the calendar both work fully). The APG patterns are implemented and unit-tested but **not yet screen-reader-audited**, and RTL arrow-key flipping isn't handled. +- **Segments:** `↑`/`↓` increment/decrement, digits type-to-fill (auto-advance), `←`/`→` move between segments, `Backspace` clears, `/` commits the current segment and advances. +- **Whole-date shortcuts:** `t` today · `m`/`h` start/end of the entered month · `y`/`r` start/end of the year · `w`/`k` start/end of the week · `-` previous day · `=`/`+` next day. +- **Calendar grid:** arrows move by day/week, `Home`/`End` to week start/end, `PageUp`/`PageDown` by month, `Shift`+`PageUp`/`PageDown` by year, `Enter`/`Space` selects. `Alt`+`↓` opens the calendar from the field. ## Props -The tables below list props this variant **actually implements** for v1 (date granularity). A few props are part of the type surface — kept identical to the react-aria variant so a later swap is source-compatible — but aren't acted on yet; those are called out under [Proposed / not yet implemented](#proposed--not-yet-implemented). - ### DateFieldProps -| Prop | Type | Description | -| ----------------------------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -| `label` | `LocalizedString` | Field label | -| `description` | `LocalizedString` | Helper text below the field | -| `errorMessage` | `LocalizedString` | Error text; also sets the invalid state | -| `value` / `defaultValue` | `DateValue \| null` | Controlled / uncontrolled value (`CalendarDate` at date granularity) | -| `onChange` | `(v: DateValue \| null) => void` | Fires on a complete, valid value; `null` when cleared | -| `isDisabled` / `isReadOnly` / `isInvalid` | `boolean` | State flags | -| `isRequired` | `boolean` | Sets `aria-required` on the segments (no visual required indicator yet) | -| `placeholderValue` | `DateValue` | Seeds unset segments (increment start + segment order) | -| `autoFocus` | `boolean` | Focus the first segment on mount | -| `locale` | `string` | BCP-47 locale override (defaults to the AppShell formatting locale) | -| `name` | `string` | Emits a hidden `` with the ISO value for form submission | -| `firstDayOfWeek` | `"sun" \| "mon" \| "tue" \| "wed" \| "thu" \| "fri" \| "sat"` | Override the locale's week start for the `w`/`k` keyboard shortcuts; omit to follow the locale | -| `aria-label` | `string` | Accessible name when there's no visible `label` (e.g. compact filters) | -| `className` | `string` | Root element class | - -> `DateField` has no calendar, so `minValue` / `maxValue` / `isDateUnavailable` don't apply to it — they're honoured by `DatePicker` and `Calendar` below. +| Prop | Type | Description | +| -------------------------------- | ------------------------------------------------------------- | --------------------------------------------------- | +| `value` / `defaultValue` | `DateValue \| null` | Controlled / uncontrolled value | +| `onChange` | `(v: DateValue \| null) => void` | Fires when the value changes | +| `onBlur` | `() => void` | Fires when focus leaves the whole segmented control | +| `constraints` | `{ required?, min?, max?, unavailable? }` | Date constraints and required flag | +| `mode` | `"editable" \| "readonly" \| "disabled"` | Control mode | +| `placeholderValue` | `DateValue` | Seeds unset segments | +| `autoFocus` | `boolean` | Focus the first segment on mount | +| `locale` | `string` | BCP-47 locale override | +| `name` | `string` | Emits a form value through the hidden proxy input | +| `id` | `string` | Control id | +| `firstDayOfWeek` | `"sun" \| "mon" \| "tue" \| "wed" \| "thu" \| "fri" \| "sat"` | Override the locale week start used by `w` / `k` shortcuts | +| `aria-label` / `aria-labelledby` | `string` | Accessible name | +| `className` | `string` | Root element class | ### DatePickerProps All `DateFieldProps`, plus: -| Prop | Type | Description | -| ----------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------- | -| `minValue` / `maxValue` | `DateValue` | Earliest / latest selectable date in the calendar | -| `isDateUnavailable` | `(date: DateValue) => boolean` | Mark individual dates unselectable (still keyboard-navigable) | -| `firstDayOfWeek` | `"sun" \| "mon" \| "tue" \| "wed" \| "thu" \| "fri" \| "sat"` | Force the calendar's first column; omit to follow the locale | -| `timeZone` | `string` | IANA timezone for resolving "today"; defaults to AppShell `timeZone` | +| Prop | Type | Description | +| ---------------- | ----------------------- | ----------------------------------- | +| `timeZone` | `string` | IANA timezone for resolving "today" | +| `firstDayOfWeek` | `"sun" \| "mon" \| ...` | Force the calendar's first column | ### CalendarProps -The standalone calendar grid. It has no segmented input, so its surface is listed in full: - -| Prop | Type | Description | -| -------------------------------------- | ------------------------------ | ------------------------------------------------------------- | -| `value` / `defaultValue` | `DateValue \| null` | Controlled / uncontrolled selected date | -| `onChange` | `(v: DateValue) => void` | Fires when a date is selected | -| `minValue` / `maxValue` | `DateValue` | Earliest / latest selectable date | -| `isDateUnavailable` | `(date: DateValue) => boolean` | Mark individual dates unselectable (still keyboard-navigable) | -| `focusedValue` / `defaultFocusedValue` | `DateValue` | Controlled / initial focused (visible) date | -| `onFocusChange` | `(date: CalendarDate) => void` | Fires when the focused date changes (arrows, month paging) | -| `firstDayOfWeek` | `"sun" \| "mon" \| …` | Force the first column; omit to follow the locale | -| `isDisabled` / `isReadOnly` | `boolean` | Disable the grid / prevent selection changes | -| `timeZone` | `string` | IANA timezone for "today"; defaults to AppShell `timeZone` | -| `locale` | `string` | BCP-47 locale override | -| `aria-label` / `aria-labelledby` | `string` | Accessible name for the grid | -| `className` | `string` | Root element class | - -### Proposed / not yet implemented - -Accepted by the prop types (for parity with the react-aria variant) but **not acted on** in this variant yet: - -| Prop | Type | Status | -| -------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `granularity` | `"day" \| "hour" \| "minute" \| "second"` | Only `"day"` is supported (the default). Time granularities — and the `CalendarDateTime` / `ZonedDateTime` values they produce — are the tracked **DateTime fast-follow**; the calendar has no time selection yet. | -| `hourCycle` | `12 \| 24` | No effect until time granularity lands (12h/24h only matters with an hour segment). | -| `hideTimeZone` | `boolean` | Unused; only relevant to `ZonedDateTime` display (time granularity). | - -Only date granularity is supported in v1; DateTime support is planned for a later release. +See the calendar docs in-code: controlled/uncontrolled value, min/max, unavailable dates, focused date, locale, timezone, accessible naming, and className. ## Related -- [Form](./form.md) — wrap date fields with validation -- [Input](./input.md) — plain text input -- [useTimeZone](../api/use-time-zone.md) — access the configured timezone consumed automatically by these components -- [useResolvedLocale](../api/use-resolved-locale.md) — access the locale used for segment order and month names +- [Field](./form.md) — field shell for labels, descriptions, and errors +- [useTimeZone](../api/use-time-zone.md) +- [useResolvedLocale](../api/use-resolved-locale.md) diff --git a/examples/vite-app/src/pages/date-picker/page.tsx b/examples/vite-app/src/pages/date-picker/page.tsx index b421d4e5..7c56d4b9 100644 --- a/examples/vite-app/src/pages/date-picker/page.tsx +++ b/examples/vite-app/src/pages/date-picker/page.tsx @@ -5,6 +5,7 @@ import { DatePicker, Calendar, Form, + Field, Button, useTimeZone, parseDate, @@ -29,8 +30,8 @@ const DatePickerPage = () => { const tomorrow = tz.today().add({ days: 1 }); const threeMonths = tz.today().add({ months: 3 }); - // Validation runs on submit; the DatePicker surfaces the message through its - // own `errorMessage` / `isInvalid` props (it isn't a Base UI Field control). + // Validation runs on submit; the DatePicker composes with Field.Root for the + // label + error presentation, like the rest of the control set. const handleDeliverySubmit = (e: FormEvent) => { e.preventDefault(); if (!deliveryDate) { @@ -72,19 +73,30 @@ const DatePickerPage = () => {

DateField

- setFieldValue(v as CalendarDate | null)} - /> - - - + + Basic + setFieldValue(v as CalendarDate | null)} + /> + + + With description + + Select a date within the next 3 months + + + Disabled + + + + Required + +
{fieldValue && ( @@ -99,30 +111,40 @@ const DatePickerPage = () => {

DatePicker

- setPickerValue(v as CalendarDate | null)} - /> - - { - const day = d.toDate(tz.value).getDay(); - return day === 0 || day === 6; - }} - /> - + + Basic + setPickerValue(v as CalendarDate | null)} + /> + + + Future dates only + + Minimum: tomorrow + + + No weekends + { + const day = d.toDate(tz.value).getDay(); + return day === 0 || day === 6; + }, + }} + /> + Weekday dates only + + + With range + + {`Today → ${threeMonths.toString()}`} +
{pickerValue && ( @@ -137,28 +159,28 @@ const DatePickerPage = () => {

In a form (submit validation)

Standard Form +{" "} - Button. Submitting empty (or - with a past date) triggers validation — the error surfaces through the DatePicker's - own errorMessage /{" "} - isInvalid props, and clears as - soon as a valid date is picked. + Field.Root. Submitting empty (or + with a past date) marks the field invalid through the field shell, and the error + clears as soon as a valid date is picked.

- { - setDeliveryDate(v as CalendarDate | null); - if (v) setDeliveryError(undefined); - }} - errorMessage={deliveryError} - isInvalid={!!deliveryError} - /> + + Delivery date + { + setDeliveryDate(v as CalendarDate | null); + if (v) setDeliveryError(undefined); + }} + /> + When should we ship your order? + {deliveryError} + {confirmedDate && ( @@ -177,9 +199,18 @@ const DatePickerPage = () => { explicitly to force a specific start day regardless of locale.

- - - + + Forced Sunday + + + + Forced Monday + + + + Locale default + +
@@ -189,9 +220,18 @@ const DatePickerPage = () => { Locale (segment order + names)
- - - + + en-US (MM/DD/YYYY) + + + + en-GB (DD/MM/YYYY, Mon-first) + + + + ja-JP (YYYY/MM/DD) + +
diff --git a/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap b/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap index 856ed2c5..73ed52db 100644 --- a/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`snapshots > DateField 1`] = `"
Invoice date
mm
dd
yyyy
"`; +exports[`snapshots > DateField 1`] = `"
mm
dd
yyyy
"`; -exports[`snapshots > DateField — invalid with error 1`] = `"
Date
mm
dd
yyyy

Pick a date

"`; +exports[`snapshots > DateField — inside Field.Root 1`] = `"
mm
dd
yyyy

Pick a date

Required
"`; -exports[`snapshots > DatePicker — closed 1`] = `"
Ship date
mm
dd
yyyy
"`; +exports[`snapshots > DatePicker — closed 1`] = `"
mm
dd
yyyy
"`; diff --git a/packages/core/src/components/date-field/date-field.test.tsx b/packages/core/src/components/date-field/date-field.test.tsx index d5a95433..65ee39e6 100644 --- a/packages/core/src/components/date-field/date-field.test.tsx +++ b/packages/core/src/components/date-field/date-field.test.tsx @@ -1,31 +1,15 @@ -import { useState } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { - CalendarDate, - parseDate, - today, - getLocalTimeZone, - startOfWeek, - endOfWeek, - isSameDay, -} from "@internationalized/date"; +import { CalendarDate, parseDate } from "@internationalized/date"; import { createAppShellWrapper } from "../../../tests/test-utils"; +import { Field } from "../field"; import { DateField, DatePicker } from "./date-field"; -// This suite is the parity contract shared with the react-aria implementation: -// it asserts public behaviour + the DOM accessibility contract (spinbutton -// segments, role="grid" cells with data-* state attributes, role="dialog" -// popover), not implementation details. - afterEach(() => { cleanup(); }); -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** Find
day cells inside a calendar grid (not nav buttons). */ function getCalendarCells() { return screen.getAllByRole("button", { hidden: true }).filter((c) => c.closest('[role="grid"]')); } @@ -36,910 +20,254 @@ function getEnabledCalendarCells() { ); } -// ─── Snapshots ────────────────────────────────────────────────────────────── -// Visual-structure snapshots per the add-component convention. Inputs are -// pinned (fixed `defaultValue`, no live "today" in view) so output is stable. - describe("snapshots", () => { it("DateField", () => { - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toMatchSnapshot(); }); - it("DateField — invalid with error", () => { + it("DateField — inside Field.Root", () => { const { container } = render( - , + + Date + + Pick a date + Required + , ); expect(container.innerHTML).toMatchSnapshot(); }); it("DatePicker — closed", () => { - const { container } = render(); + const { container } = render(); expect(container.innerHTML).toMatchSnapshot(); }); }); -// ─── DateField ───────────────────────────────────────────────────────────────── - describe("DateField", () => { - it("renders with a label", () => { - render(); - expect(screen.getByText("Invoice date")).toBeDefined(); - }); - - it("renders date segments", () => { - render(); - // segments are exposed as spinbuttons for day, month, year - const segments = screen.getAllByRole("spinbutton"); - expect(segments.length).toBeGreaterThan(0); - }); - - it("renders with description", () => { - render(); - expect(screen.getByText("Pick any date")).toBeDefined(); - }); - - it("renders error message when isInvalid", () => { - render(); - expect(screen.getByText("Required")).toBeDefined(); - }); - - it("renders as disabled", () => { - render(); - const groups = screen.getAllByRole("group"); - const disabledGroup = groups.find((g) => g.getAttribute("aria-disabled") === "true"); - expect(disabledGroup).toBeDefined(); - }); - - it("resolves LocalizedString label with language fallback", () => { - render( (locale === "ja" ? "日付" : "Date")} />); - // default locale resolves to english - expect(screen.getByText("Date")).toBeDefined(); - }); - - it("fires onChange once a complete date is typed across the segments", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - // Fill every segment by aria-label (order-independent across locales). - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("06"); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("15"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2025"); - - await waitFor(() => { - expect(onChange).toHaveBeenCalled(); - const last = onChange.mock.calls.at(-1)?.[0]; - expect(last?.toString()).toBe("2025-06-15"); - }); - }); - - it("auto-advances across segments as a full date is typed (no explicit tabbing)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - // Locale here is "en" → MM/DD/YYYY. Typing carries across segments: - // "02" fills+advances month, "15" fills+advances day, "2025" fills year. - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("02152025"); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-02-15"); - }); - }); - - it("accumulates a non-leading-zero entry (2 then 9 → 29, not 9)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("12"); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("29"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2025"); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-12-29"); - }); - }); - - it("accepts day 31 typed before a month (day max isn't tied to the current month)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - // Type the day first — "31" must not collapse to "1" just because the - // current (anchor) month happens to have 30/28 days. - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("31"); - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("12"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2025"); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-12-31"); - }); + it("renders standalone with an aria-label", () => { + render(); + expect(screen.getAllByRole("spinbutton").length).toBeGreaterThan(0); }); - it("clamps an impossible day to the month's length on blur", async () => { + it("integrates with Field.Label and focuses the first segment when the label is clicked", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); render( - <> - - - , + + Date + + , ); - // Enter 30 / 02 / 2026 (Feb 2026 has 28 days) — invalid until blur. - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("30"); - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("02"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2026"); - // Blur the field. - await user.click(screen.getByRole("button", { name: "elsewhere" })); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); - }); + await user.click(screen.getByText("Date")); + expect(document.activeElement?.getAttribute("role")).toBe("spinbutton"); }); - it("keeps 29 Feb in a leap year (no over-clamp)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); + it("wires Field.Description / Field.Error through aria-describedby and invalid state", () => { render( - <> - - - , + + Date + + Pick a date + Required + , ); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("29"); - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("02"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2024"); // leap year - await user.click(screen.getByRole("button", { name: "elsewhere" })); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2024-02-29"); - }); - }); - - // A controlled field round-trips every emit through the parent's `value`, so - // these guard the clamp against external-sync interference (the uncontrolled - // cases above don't exercise that path). - function ControlledField({ onChange }: { onChange: (v: unknown) => void }) { - const [v, setV] = useState(null); - return ( - <> - { - setV(nv as CalendarDate | null); - onChange(nv); - }} - /> - - - ); - } - - it("clamps an impossible day on blur even when controlled (29/02/2026)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("29"); - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("02"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2026"); // not a leap year → Feb has 28 days - await user.click(screen.getByRole("button", { name: "elsewhere" })); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); - }); - }); - - it("re-clamps when editing the year turns a valid leap day invalid (29 Feb 2024 → 2026)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - // First enter a genuinely valid leap-year date. - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("29"); - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("02"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2024"); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2024-02-29"); - }); - - // Now change the year to a non-leap year — 29 Feb no longer exists. - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2026"); - await user.click(screen.getByRole("button", { name: "elsewhere" })); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); - }); + const group = screen.getByRole("group"); + expect(group.getAttribute("aria-describedby")).toBeTruthy(); + expect(group.hasAttribute("data-invalid")).toBe(true); + expect(screen.getByText("Required")).toBeDefined(); }); - it("auto-corrects an impossible day as soon as the year is complete (no blur)", async () => { + it("fires onChange once a complete date is typed across the segments", async () => { const user = userEvent.setup(); const onChange = vi.fn(); - render(); + render(); - // 29 typed before the month (allowed), then Feb 2026 (28 days). The moment - // the 4-digit year lands, the day self-corrects — without leaving the field. - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("29"); await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("02"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2026"); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); - }); - }); - - it("re-corrects on year completion when a leap day turns invalid (no blur)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - + await user.keyboard("06"); await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("29"); - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("02"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2024"); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2024-02-29"); - }); - - // Retype the year to a non-leap year — corrects on completion, no blur. + await user.keyboard("15"); await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2026"); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); - }); - }); - - // On-blur backfill: a provided day (finest unit) lets the coarser fields - // default to the current month/year. The anchor for a bare DateField is - // today("UTC"), so expectations are derived from that same basis. - it("backfills the current month + year when only the day is entered, on blur", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - <> - - - , - ); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("2"); - await user.click(screen.getByRole("button", { name: "elsewhere" })); + await user.keyboard("2025"); - const expected = today("UTC").set({ day: 2 }); await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected.toString()); + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-15"); }); }); - it("backfills the current year when day + month are entered, on blur", async () => { + it("calls onBlur once when focus leaves the whole group", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); + const onBlur = vi.fn(); render( <> - + , ); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("02"); await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("08"); - await user.click(screen.getByRole("button", { name: "elsewhere" })); - - const expected = today("UTC").set({ month: 8, day: 2 }); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected.toString()); - }); - }); - - it("never backfills the day — a lone year entry emits nothing on blur", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - <> - - - , - ); - - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("2025"); await user.click(screen.getByRole("button", { name: "elsewhere" })); - // The day is the trigger for backfill; without it we never guess a value. - expect(onChange.mock.calls.some(([v]) => v != null)).toBe(false); + expect(onBlur).toHaveBeenCalledTimes(1); }); it("clears a controlled DateField when the value is reset to null", () => { const { rerender } = render( {}} />, ); expect(screen.getByRole("spinbutton", { name: "day" }).textContent).toBe("15"); - // Parent clears the field: value={null} is controlled-empty, not uncontrolled. - rerender( {}} />); + rerender( {}} />); expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-valuetext")).toBe( "Empty", ); }); - it("sets aria-required on the segments when isRequired", () => { - render(); - const day = screen.getByRole("spinbutton", { name: "day" }); - expect(day.getAttribute("aria-required")).toBe("true"); - }); -}); - -// ─── Keyboard: whole-date shortcuts (QBO-style) ────────────────────────────── -// These fire from any focused date segment and set the entire date in one go. -// A bare DateField anchors on today("UTC"), so "today"/empty-field expectations -// derive from that same basis. Locale here is "en" (weeks start Sunday). - -describe("DateField keyboard shortcuts", () => { - async function lastEmit(onChange: ReturnType, expected: string) { - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected); - }); - } - - it("'t' jumps to today (case-insensitive)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("T"); // upper-case → same as "t" - - await lastEmit(onChange, today("UTC").toString()); - }); - - it("'m' goes to the start of the entered month", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("m"); - - await lastEmit(onChange, "2025-06-01"); - }); - - it("'m' falls back to the start of the current month when no date is entered", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("m"); - - await lastEmit(onChange, today("UTC").set({ day: 1 }).toString()); - }); - - it("'h' goes to the end of the entered month", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("h"); - - await lastEmit(onChange, "2025-06-30"); - }); - - it("'y' and 'r' jump to the start / end of the entered year", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("y"); - await lastEmit(onChange, "2025-01-01"); - - await user.keyboard("r"); - await lastEmit(onChange, "2025-12-31"); - }); - - it("'w' and 'k' jump to the start / end of the week (locale-aware)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - const start = new CalendarDate(2025, 6, 18); // a Wednesday - render(); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("w"); - await lastEmit(onChange, startOfWeek(start, "en").toString()); - - await user.keyboard("k"); - await lastEmit(onChange, endOfWeek(start, "en").toString()); - }); - - it("'-' decrements a day and rolls back over the month boundary", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - , + it("marks the segments required when constraints.required is set", () => { + render(); + expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-required")).toBe( + "true", ); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("-"); - - await lastEmit(onChange, "2025-02-28"); }); - it("'-' rolls back over the year boundary (1 Jan → 31 Dec of the prior year)", async () => { + it("flags a typed date before constraints.min invalid, but still emits it", async () => { const user = userEvent.setup(); const onChange = vi.fn(); - render( - , - ); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("-"); - - await lastEmit(onChange, "2024-12-31"); - }); - - it("'=' and '+' both increment a day and roll over the year boundary", async () => { - const user = userEvent.setup(); - - const onChangeEq = vi.fn(); - const { unmount } = render( - , - ); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("="); // unshifted plus - await lastEmit(onChangeEq, "2026-01-01"); - unmount(); - - const onChangePlus = vi.fn(); render( , ); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("+"); // shifted plus - await lastEmit(onChangePlus, "2026-01-01"); - }); - it("'-' / '+' step from today when the field is empty", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("+"); - - await lastEmit(onChange, today("UTC").add({ days: 1 }).toString()); - }); - - it("expands a 2-digit year to the 2000s on blur ('26' → 2026)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - <> - - - , - ); - - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("06"); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("15"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("26"); - await user.click(screen.getByRole("button", { name: "elsewhere" })); - - await lastEmit(onChange, "2026-06-15"); - }); - - it("expands a 2-digit year as soon as the year segment is left (still inside the field)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("06"); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("15"); - await user.click(screen.getByRole("spinbutton", { name: "year" })); - await user.keyboard("26"); - // Move focus back to a sibling segment — never leaving the field/group. The - // year should still expand (mirrors tabbing to the calendar icon). await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("06052025"); - await lastEmit(onChange, "2026-06-15"); + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-05"); + }); + expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true); }); - // Regression: the field path must honour DatePicker's firstDayOfWeek for w/k, - // not silently fall back to the locale default (which would disagree with the - // calendar path). en-US defaults to Sunday; firstDayOfWeek="mon" forces Monday. - it("honours firstDayOfWeek for 'w' in the field path (popover closed)", async () => { + it("flags a typed date invalid when constraints.unavailable rejects it", async () => { const user = userEvent.setup(); - const onChange = vi.fn(); render( - d.toString() === "2025-06-12", + }} />, ); - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("w"); // Monday-start week → 16 Jun (not 15 Jun, the Sunday) + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("06122025"); - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-16"); - }); - expect(screen.queryByRole("dialog")).toBeNull(); + expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true); }); - // The standalone DateField (no calendar) also exposes firstDayOfWeek so a - // consumer in a non-default-week-start locale can steer the w/k shortcuts. it("honours firstDayOfWeek for 'w' in a standalone DateField", async () => { const user = userEvent.setup(); const onChange = vi.fn(); render( , ); await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("w"); // Monday-start week → 16 Jun (not 15 Jun, the Sunday) + await user.keyboard("w"); await waitFor(() => { expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-16"); }); }); - - it("flags a shortcut target invalid when it lands outside minValue / maxValue (no clamp)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - , - ); - const group = screen.getByRole("group"); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("y"); // year start = 1 Jan 2025 — before min; emitted, NOT clamped - await lastEmit(onChange, "2025-01-01"); - expect(group.hasAttribute("data-invalid")).toBe(true); - - await user.keyboard("r"); // year end = 31 Dec 2025 — after max; emitted, NOT clamped - await lastEmit(onChange, "2025-12-31"); - expect(group.hasAttribute("data-invalid")).toBe(true); - }); - - it("flags a typed date before minValue invalid, but still emits it", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - const group = screen.getByRole("group"); - - // en order: month / day / year — type 5 Jun 2025 (before the 10 Jun min). - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("06052025"); - await lastEmit(onChange, "2025-06-05"); // value flows through, not clamped - expect(group.hasAttribute("data-invalid")).toBe(true); - // Built-in message (no consumer errorMessage provided). - expect(screen.getByText("Date is outside the allowed range.")).toBeDefined(); - }); - - it("clears the invalid flag once the typed date is back within range", async () => { - const user = userEvent.setup(); - render(); - const group = screen.getByRole("group"); - - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("06052025"); // 5 Jun — before min → invalid - expect(group.hasAttribute("data-invalid")).toBe(true); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("15"); // 15 Jun — within range → valid again - expect(group.hasAttribute("data-invalid")).toBe(false); - }); - - it("flags a typed date invalid when isDateUnavailable rejects it", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - const unavailable = new CalendarDate(2025, 6, 12); - render( - isSameDay(d, unavailable)} - onChange={onChange} - />, - ); - - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("06122025"); // the unavailable 12 Jun 2025 - await lastEmit(onChange, "2025-06-12"); - expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true); - expect(screen.getByText("This date is unavailable.")).toBeDefined(); - }); - - it("'/' commits the current segment and advances to the next ('1/' ⇒ month 01)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render(); - - // en order: month / day / year. Type a single "1" into the month, then "/" - // to declare "that's the whole month" and move on to the day. - await user.click(screen.getByRole("spinbutton", { name: "month" })); - await user.keyboard("1/15"); - await user.keyboard("2025"); - - await lastEmit(onChange, "2025-01-15"); - }); }); -// ─── DatePicker ─────────────────────────────────────────────────────────────── - describe("DatePicker", () => { - it("renders with a label", () => { - render(); - expect(screen.getByText("Ship date")).toBeDefined(); - }); - - it("renders the calendar trigger button", () => { - render(); - const btn = screen.getAllByRole("button").find((b) => !b.closest('[role="grid"]')); - expect(btn).toBeDefined(); + it("renders standalone with an aria-label", () => { + render(); + expect(screen.getByRole("button", { name: "Open calendar" })).toBeDefined(); }); it("opens the popover when the trigger is clicked", async () => { const user = userEvent.setup(); - render(); + render(); - const triggerBtn = screen.getAllByRole("button")[0]; - await user.click(triggerBtn); + await user.click(screen.getByRole("button", { name: "Open calendar" })); await waitFor(() => { expect(screen.getByRole("dialog")).toBeDefined(); }); }); - it("shows a calendar grid in the popover", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getAllByRole("button")[0]); - - await waitFor(() => { - expect(screen.getByRole("grid")).toBeDefined(); - }); - }); - it("fires onChange when a calendar date cell is clicked", async () => { const user = userEvent.setup(); const onChange = vi.fn(); - render(); + render(); - await user.click(screen.getAllByRole("button")[0]); + await user.click(screen.getByRole("button", { name: "Open calendar" })); await waitFor(() => expect(screen.getByRole("grid")).toBeDefined()); const enabled = getEnabledCalendarCells(); - if (enabled.length > 0) { - await user.click(enabled[0]); - await waitFor(() => { - expect(onChange).toHaveBeenCalled(); - expect(onChange.mock.calls[0][0]).not.toBeNull(); - }); - } - }); - - it("renders cells with data-disabled when minValue is set", async () => { - const user = userEvent.setup(); - const tomorrow = today(getLocalTimeZone()).add({ days: 1 }); - render(); - - await user.click(screen.getAllByRole("button")[0]); - await waitFor(() => expect(screen.getByRole("grid")).toBeDefined()); - - const disabled = getCalendarCells().filter((c) => c.hasAttribute("data-disabled")); - expect(disabled.length).toBeGreaterThan(0); - }); - - it("renders cells with data-unavailable when isDateUnavailable returns true", async () => { - const user = userEvent.setup(); - render( true} />); - - await user.click(screen.getAllByRole("button")[0]); - await waitFor(() => expect(screen.getByRole("grid")).toBeDefined()); - - const unavailable = getCalendarCells().filter((c) => c.hasAttribute("data-unavailable")); - expect(unavailable.length).toBeGreaterThan(0); - }); - - it("renders error message when errorMessage is set", () => { - render(); - expect(screen.getByText("Date is required")).toBeDefined(); - }); - - it("clears a controlled DatePicker when the value is reset to null", () => { - const { rerender } = render( - {}} - />, - ); - expect(screen.getByRole("spinbutton", { name: "day" }).textContent).toBe("15"); - - rerender( {}} />); - expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-valuetext")).toBe( - "Empty", - ); - }); - - it("localizes segment names and chrome from the AppShell locale (ja)", () => { - render(, { wrapper: createAppShellWrapper("ja") }); - // Segment accessible name: month → 月. - expect(screen.getByRole("spinbutton", { name: "月" })).toBeDefined(); - // Popover trigger aria-label is localized too. - expect(screen.getByRole("button", { name: "カレンダーを開く" })).toBeDefined(); - // Empty segments announce the localized placeholder. - expect(screen.getByRole("spinbutton", { name: "月" }).getAttribute("aria-valuetext")).toBe( - "未入力", - ); - }); -}); - -// ─── Keyboard: popover focus ───────────────────────────────────────────────── - -describe("DatePicker keyboard", () => { - it("moves focus into the calendar grid when the popover opens", async () => { - const user = userEvent.setup(); - render(); - await user.click(screen.getAllByRole("button")[0]); - await waitFor(() => { - expect(document.activeElement?.closest('[role="grid"]')).not.toBeNull(); - }); - }); - - it("opens the calendar with Alt+↓ from a focused segment", async () => { - const user = userEvent.setup(); - render(); - - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("{Alt>}{ArrowDown}{/Alt}"); + await user.click(enabled[0]); await waitFor(() => { - expect(screen.getByRole("dialog")).toBeDefined(); - }); - }); - - // While the popover is open, focus is in the grid — the shortcuts move the - // highlight (like the arrows), and Enter confirms. This is the calendar path, - // not the segment path. - it("a shortcut moves the calendar highlight while the popover is open", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); - render( - , - ); - - await user.click(screen.getAllByRole("button")[0]); // open (focus → 15 Jun) - await waitFor(() => expect(screen.getByRole("grid")).toBeDefined()); - - await user.keyboard("m"); // month start → 1 Jun - await user.keyboard("{Enter}"); // confirm - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-01"); + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.calls[0][0]).not.toBeNull(); }); }); - it("clamps an open-popover shortcut to minValue (can't highlight before the min)", async () => { + it("flags a field shortcut invalid when it lands before constraints.min (popover closed)", async () => { const user = userEvent.setup(); const onChange = vi.fn(); render( , ); - await user.click(screen.getAllByRole("button")[0]); - await waitFor(() => expect(screen.getByRole("grid")).toBeDefined()); - - await user.keyboard("y"); // year start (1 Jan) is before min → clamps to 10 Jun - await user.keyboard("{Enter}"); + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("y"); await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-06-10"); + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-01-01"); }); + expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true); + expect(screen.queryByRole("dialog")).toBeNull(); }); - it("flags a field shortcut invalid when it lands before minValue (no clamp, popover closed)", async () => { - const user = userEvent.setup(); - const onChange = vi.fn(); + it("inherits invalid state from Field.Root", () => { render( - , + + Date + + Required + , ); - // Focus a segment (not the trigger) — the popover stays closed, so this is - // the field path. "y" targets 1 Jan: emitted as-is and flagged invalid, - // where the open-popover (calendar) path instead clamps the roving focus. - await user.click(screen.getByRole("spinbutton", { name: "day" })); - await user.keyboard("y"); - - await waitFor(() => { - expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-01-01"); - }); expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true); - expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("localizes segment names and chrome from the AppShell locale (ja)", () => { + render(, { wrapper: createAppShellWrapper("ja") }); + expect(screen.getByRole("spinbutton", { name: "月" })).toBeDefined(); + expect(screen.getByRole("button", { name: "カレンダーを開く" })).toBeDefined(); + expect(screen.getByRole("spinbutton", { name: "月" }).getAttribute("aria-valuetext")).toBe( + "未入力", + ); }); }); diff --git a/packages/core/src/components/date-field/date-field.tsx b/packages/core/src/components/date-field/date-field.tsx index 4b81e872..c4daa6a7 100644 --- a/packages/core/src/components/date-field/date-field.tsx +++ b/packages/core/src/components/date-field/date-field.tsx @@ -1,53 +1,56 @@ -import { useCallback, useId, useRef, useState } from "react"; +import * as React from "react"; import type { DateValue } from "@internationalized/date"; +import { useFieldRootContext } from "@base-ui/react/internals/field-root-context"; +import { useRegisterFieldControl } from "@base-ui/react/internals/field-register-control"; +import { useLabelableContext, useLabelableId } from "@base-ui/react/internals/labelable-provider"; import { cn } from "@/lib/utils"; -import { buildLocaleResolver, type LocalizedString } from "@/lib/i18n"; import { useResolvedLocale, useTimeZone } from "@/contexts/appshell-context"; -import { - useDateFieldState, - type DateFieldInvalidReason, - type Granularity, - type HourCycle, -} from "./use-date-field-state"; +import { useDateFieldState, type Granularity, type HourCycle } from "./use-date-field-state"; import { useCalendarState, type FirstDayOfWeek } from "../calendar/use-calendar-state"; import { CalendarView } from "../calendar/calendar-view"; -import { - DateInputGroup, - DatePopover, - DatePickerPopoverTrigger, - DatePickerLabel, - DatePickerDescription, - DatePickerError, -} from "./date-input-group"; +import { DateInputGroup, DatePopover, DatePickerPopoverTrigger } from "./date-input-group"; import { useDateFieldT } from "./i18n"; /** - * Public, closed-API date components — the @internationalized/date + Base UI - * implementation. Same surface as the react-aria variant; only the internals - * differ. Consumers never see Base UI or the date engines. + * Public, control-first date components. + * + * Like Select / Combobox / Autocomplete, these own value entry + interaction, + * and compose with Field.Root for label / description / error presentation. */ -// Built-in validation message key for a field's invalid reason (null = none, so -// the consumer's `errorMessage` — or no message — stands). A lookup rather than -// a nested ternary keeps the lint happy. -function invalidMessageKey( - reason: DateFieldInvalidReason | null | undefined, -): "dateUnavailable" | "dateOutOfRange" | null { - if (reason === "unavailable") return "dateUnavailable"; - if (reason === "range") return "dateOutOfRange"; - return null; +type DateControlMode = "editable" | "readonly" | "disabled"; + +interface DateConstraints { + required?: true; + min?: DateValue; + max?: DateValue; + unavailable?: (date: T) => boolean; +} + +function joinIds(...ids: Array) { + const joined = ids.filter(Boolean).join(" "); + return joined || undefined; } -// ─── Small controlled-state helper ──────────────────────────────────────────── +function assignRef(ref: React.Ref | undefined, value: T) { + if (!ref) return; + if (typeof ref === "function") { + ref(value); + return; + } + ref.current = value; +} + +// Small controlled-state helper. function useControlledState( controlled: V | undefined, defaultValue: V, onChange?: (value: V) => void, ): [V, (value: V) => void] { const isControlled = controlled !== undefined; - const [internal, setInternal] = useState(defaultValue); + const [internal, setInternal] = React.useState(defaultValue); const value = isControlled ? (controlled as V) : internal; - const set = useCallback( + const set = React.useCallback( (next: V) => { if (!isControlled) setInternal(next); onChange?.(next); @@ -57,101 +60,169 @@ function useControlledState( return [value, set]; } -// ─── Shared prop types (names unchanged from react-aria) ────────────────────── - -interface DateFieldMetaProps { - label?: LocalizedString; - description?: LocalizedString; - errorMessage?: LocalizedString; - className?: string; -} - -interface DateBehaviorProps { +interface DateControlProps { value?: T | null; defaultValue?: T | null; onChange?: (value: T | null) => void; + onBlur?: () => void; granularity?: Granularity; - minValue?: DateValue; - maxValue?: DateValue; - isDateUnavailable?: (date: DateValue) => boolean; - isDisabled?: boolean; - isReadOnly?: boolean; - isRequired?: boolean; - isInvalid?: boolean; + constraints?: DateConstraints; + mode?: DateControlMode; autoFocus?: boolean; hourCycle?: HourCycle; - hideTimeZone?: boolean; placeholderValue?: DateValue; - /** - * First day of the week (0 = Sunday … 6 = Saturday); defaults to the locale. - * Only affects the `w`/`k` (start/end of week) keyboard shortcuts here. - */ firstDayOfWeek?: FirstDayOfWeek; name?: string; - /** Accessible name when no visible `label` is provided (e.g. a compact filter input). */ + className?: string; + id?: string; + /** Accessible name when there is no visible label. */ "aria-label"?: string; + /** ID of the element(s) that label the control. */ + "aria-labelledby"?: string; /** BCP-47 locale override; defaults to the AppShell formatting locale. */ locale?: string; } -export type DateFieldProps = DateFieldMetaProps & - DateBehaviorProps; +export type DateFieldProps = DateControlProps; -export type DatePickerProps = DateFieldProps & { +export type DatePickerProps = DateControlProps & { /** IANA timezone; defaults to the AppShell `timeZone`. */ timeZone?: string; }; -// ─── DateField ──────────────────────────────────────────────────────────────── +interface DateFieldA11yOptions { + id?: string; + name?: string; + mode?: DateControlMode; + fieldValue: string; + hasInput: boolean; + localInvalid: boolean; + labelledBy?: string; + ariaLabel?: string; + onBlur?: () => void; + groupRef: React.RefObject; + forwardedRef?: React.Ref; +} + +function useDateFieldA11y({ + id: idProp, + name: nameProp, + mode, + fieldValue, + hasInput, + localInvalid, + labelledBy: labelledByProp, + ariaLabel, + onBlur, + groupRef, + forwardedRef, +}: DateFieldA11yOptions) { + const fieldRoot = useFieldRootContext(); + const { labelId, messageIds } = useLabelableContext(); + const proxyRef = React.useRef(null); + const controlId = useLabelableId({ id: idProp, controlRef: proxyRef }); + + useRegisterFieldControl(proxyRef, controlId, fieldValue, () => proxyRef.current?.value ?? ""); + + const setProxyRef = React.useCallback( + (node: HTMLInputElement | null) => { + proxyRef.current = node; + assignRef(forwardedRef, node); + }, + [forwardedRef], + ); + + const focusFirstSegment = React.useCallback(() => { + const first = groupRef.current?.querySelector('[role="spinbutton"]'); + first?.focus(); + }, [groupRef]); + + const name = fieldRoot?.name ?? nameProp; + const isDisabled = fieldRoot?.disabled || mode === "disabled"; + const isReadOnly = mode === "readonly"; + const externalInvalid = fieldRoot?.state.valid === false; + const derivedInvalid = !!externalInvalid || localInvalid; + const labelledBy = joinIds(labelledByProp, labelId); + const describedBy = joinIds(...messageIds); + const initialValue = + typeof fieldRoot?.validityData.initialValue === "string" + ? fieldRoot.validityData.initialValue + : ""; + + React.useEffect(() => { + fieldRoot?.setFilled(hasInput); + fieldRoot?.setDirty(fieldValue !== initialValue || (hasInput && fieldValue === "")); + + if (!fieldRoot?.shouldValidateOnChange()) return; + queueMicrotask(() => { + fieldRoot.validation.commit(proxyRef.current?.value ?? ""); + }); + }, [fieldRoot, hasInput, fieldValue, initialValue]); + + const handleGroupFocus = React.useCallback(() => { + fieldRoot?.setFocused(true); + }, [fieldRoot]); + + const handleGroupBlur = React.useCallback(() => { + fieldRoot?.setTouched(true); + fieldRoot?.setFocused(false); + onBlur?.(); + + if (fieldRoot?.validationMode !== "onBlur") return; + queueMicrotask(() => { + fieldRoot.validation.commit(proxyRef.current?.value ?? ""); + }); + }, [fieldRoot, onBlur]); + + return { + controlId, + name, + isDisabled, + isReadOnly, + isInvalid: derivedInvalid, + labelledBy, + describedBy, + proxyRef: setProxyRef, + focusFirstSegment, + handleGroupFocus, + handleGroupBlur, + ariaLabel, + }; +} /** * A segmented date/time input field with no popover. * - * @example - * ```tsx - * import { DateField } from "@tailor-platform/app-shell"; - * - * - * - * ``` + * Compose with `Field.Root` for label / description / error presentation, or + * provide `aria-label` / `aria-labelledby` for standalone usage. */ -function DateField({ - label, - description, - errorMessage, - className, - locale: localeProp, - value, - defaultValue, - onChange, - granularity, - hourCycle, - placeholderValue, - minValue, - maxValue, - isDateUnavailable, - isDisabled, - isReadOnly, - isInvalid, - isRequired, - autoFocus, - firstDayOfWeek, - name, - "aria-label": ariaLabel, -}: DateFieldProps) { - const { locale: shellLocale, language } = useResolvedLocale(); +const DateField = React.forwardRef(function DateField( + { + id, + className, + locale: localeProp, + value, + defaultValue, + onChange, + onBlur, + granularity, + constraints, + mode, + hourCycle, + placeholderValue, + autoFocus, + firstDayOfWeek, + name, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + }: DateFieldProps, + ref: React.ForwardedRef, +) { + const { locale: shellLocale } = useResolvedLocale(); const resolvedLocale = localeProp ?? shellLocale; - const resolve = buildLocaleResolver(language); - const t = useDateFieldT(); - - const labelId = useId(); - const descId = useId(); - const errId = useId(); + const groupRef = React.useRef(null); const state = useDateFieldState({ - // Pass `value` through as-is: `null` is a controlled-empty value and must - // stay distinct from `undefined` (uncontrolled), or a parent clearing the - // field with `value={null}` would be treated as uncontrolled and ignored. value, defaultValue, onChange: onChange as (v: DateValue | null) => void, @@ -159,31 +230,44 @@ function DateField({ locale: resolvedLocale, hourCycle, placeholderValue, - // min/max and unavailability flag a typed/shortcut value invalid (not - // clamped) — the field is free-entry with no calendar to gate selection. - minValue, - maxValue, - isDateUnavailable, - // Drives the `w`/`k` (start/end of week) shortcuts; the standalone field has - // no calendar to pair with, so this is the only week-start override. + minValue: constraints?.min, + maxValue: constraints?.max, + isDateUnavailable: constraints?.unavailable as ((date: DateValue) => boolean) | undefined, firstDayOfWeek, - isReadOnly, + isReadOnly: mode === "readonly", }); - const labelText = label ? resolve(label, "") : undefined; - const descText = description ? resolve(description, "") : undefined; - const errorText = errorMessage ? resolve(errorMessage, "") : undefined; - // Consumer `errorMessage` wins; otherwise fall back to the built-in message - // for an out-of-range / unavailable typed value. - const msgKey = invalidMessageKey(state.invalidReason); - const shownError = errorText ?? (msgKey ? t(msgKey) : undefined); - const derivedInvalid = !!errorText || !!isInvalid || state.isInvalid; - - const describedBy = cn(descText && descId, derivedInvalid && shownError && errId) || undefined; + const hasInput = React.useMemo( + () => state.segments.some((segment) => segment.isEditable && !segment.isPlaceholder), + [state.segments], + ); + const bindings = useDateFieldA11y({ + id, + name, + mode, + fieldValue: state.fieldValue?.toString() ?? "", + hasInput, + localInvalid: state.isInvalid, + labelledBy: ariaLabelledby, + ariaLabel, + onBlur, + groupRef, + forwardedRef: ref, + }); return ( -
- {labelText && {labelText}} +
+ ({ applyShortcut={state.applyShortcut} commitOnBlur={state.commitOnBlur} expandShortYear={state.expandShortYear} - isDisabled={isDisabled} - isReadOnly={isReadOnly} - isInvalid={derivedInvalid} - isRequired={isRequired} + isDisabled={bindings.isDisabled} + isReadOnly={bindings.isReadOnly} + isInvalid={bindings.isInvalid} + isRequired={constraints?.required} autoFocus={autoFocus} - labelId={labelText ? labelId : undefined} - ariaLabel={ariaLabel} - describedById={describedBy} + ariaLabelledby={bindings.labelledBy} + ariaLabel={bindings.ariaLabel} + describedById={bindings.describedBy} + groupRef={groupRef} + onGroupFocus={bindings.handleGroupFocus} + onGroupBlur={bindings.handleGroupBlur} /> - {descText && {descText}} - {derivedInvalid && shownError && {shownError}} - {name && }
); -} - -// ─── DatePicker ─────────────────────────────────────────────────────────────── +}) as ( + props: DateFieldProps & { ref?: React.Ref }, +) => React.ReactElement; /** * A date/time input with a popover calendar. * - * Value type is driven by `granularity`: - * - `"day"` (default) → `CalendarDate` - * - `"hour" | "minute" | "second"` → `CalendarDateTime` (or `ZonedDateTime` when a `timeZone` is set) - * - * @example - * ```tsx - * import { DatePicker, today, getLocalTimeZone, type CalendarDate } from "@tailor-platform/app-shell"; - * - * const [date, setDate] = useState(null); - * - * ``` + * Compose with `Field.Root` for label / description / error presentation, or + * provide `aria-label` / `aria-labelledby` for standalone usage. */ -function DatePicker({ - label, - description, - errorMessage, - className, - locale: localeProp, - timeZone: timeZoneProp, - value, - defaultValue, - onChange, - granularity, - hourCycle, - placeholderValue, - minValue, - maxValue, - isDateUnavailable, - isDisabled, - isReadOnly, - isInvalid, - isRequired, - autoFocus, - firstDayOfWeek, - name, - "aria-label": ariaLabel, -}: DatePickerProps) { - const { locale: shellLocale, language } = useResolvedLocale(); +const DatePicker = React.forwardRef(function DatePicker( + { + id, + className, + locale: localeProp, + timeZone: timeZoneProp, + value, + defaultValue, + onChange, + onBlur, + granularity, + constraints, + mode, + hourCycle, + placeholderValue, + autoFocus, + firstDayOfWeek, + name, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledby, + }: DatePickerProps, + ref: React.ForwardedRef, +) { + const { locale: shellLocale } = useResolvedLocale(); const shellTz = useTimeZone(); const resolvedLocale = localeProp ?? shellLocale; const resolvedTz = timeZoneProp ?? shellTz.value; - const resolve = buildLocaleResolver(language); const t = useDateFieldT(); - const labelId = useId(); - const descId = useId(); - const errId = useId(); - - const labelText = label ? resolve(label, "") : undefined; - const descText = description ? resolve(description, "") : undefined; - const errorText = errorMessage ? resolve(errorMessage, "") : undefined; - - const [open, setOpen] = useState(false); - const fieldRef = useRef(null); + const [open, setOpen] = React.useState(false); + const fieldRef = React.useRef(null); const [val, setVal] = useControlledState( - // `null` is controlled-empty; only `undefined` means uncontrolled (see above). value, defaultValue ?? null, onChange as (v: DateValue | null) => void, @@ -280,20 +343,14 @@ function DatePicker({ onChange: setVal, granularity, locale: resolvedLocale, - // Use the resolved timezone (prop → AppShell → local), matching the calendar - // below — otherwise the field falls back to UTC for its "today"/anchor while - // the calendar uses the AppShell zone, and they disagree on defaults. timeZone: resolvedTz, hourCycle, placeholderValue, - // Same bounds the calendar enforces, but on the field they flag a typed/ - // shortcut value invalid (the calendar gates selection; typing can't be). - minValue, - maxValue, - isDateUnavailable, - // Match the calendar's week-start so field + calendar `w`/`k` agree. + minValue: constraints?.min, + maxValue: constraints?.max, + isDateUnavailable: constraints?.unavailable as ((date: DateValue) => boolean) | undefined, firstDayOfWeek, - isReadOnly, + isReadOnly: mode === "readonly", }); const calState = useCalendarState({ @@ -302,35 +359,51 @@ function DatePicker({ setVal(d); setOpen(false); }, - minValue, - maxValue, - isDateUnavailable, - isDisabled, - isReadOnly, + minValue: constraints?.min, + maxValue: constraints?.max, + isDateUnavailable: constraints?.unavailable as ((date: DateValue) => boolean) | undefined, + isDisabled: mode === "disabled", + isReadOnly: mode === "readonly", firstDayOfWeek, locale: resolvedLocale, timeZone: resolvedTz, }); - // Consumer `errorMessage` wins; otherwise the built-in out-of-range / - // unavailable message for a typed or shortcut-entered value. - const msgKey = invalidMessageKey(fieldState.invalidReason); - const shownError = errorText ?? (msgKey ? t(msgKey) : undefined); - const derivedInvalid = !!errorText || !!isInvalid || fieldState.isInvalid; - - const describedBy = cn(descText && descId, derivedInvalid && shownError && errId) || undefined; - const accessibleName = labelText ?? ariaLabel; - const popoverAriaLabel = accessibleName - ? t("chooseDateFor", { name: accessibleName }) - : t("chooseDate"); + const hasInput = React.useMemo( + () => fieldState.segments.some((segment) => segment.isEditable && !segment.isPlaceholder), + [fieldState.segments], + ); + const bindings = useDateFieldA11y({ + id, + name, + mode, + fieldValue: fieldState.fieldValue?.toString() ?? "", + hasInput, + localInvalid: fieldState.isInvalid, + labelledBy: ariaLabelledby, + ariaLabel, + onBlur, + groupRef: fieldRef, + forwardedRef: ref, + }); return ( -
- {labelText && {labelText}} +
+ ({ commitOnBlur={fieldState.commitOnBlur} expandShortYear={fieldState.expandShortYear} onOpenCalendar={() => setOpen(true)} - isDisabled={isDisabled} - isReadOnly={isReadOnly} - isInvalid={derivedInvalid} - isRequired={isRequired} + isDisabled={bindings.isDisabled} + isReadOnly={bindings.isReadOnly} + isInvalid={bindings.isInvalid} + isRequired={constraints?.required} autoFocus={autoFocus} - labelId={labelText ? labelId : undefined} - ariaLabel={ariaLabel} - describedById={describedBy} + ariaLabelledby={bindings.labelledBy} + ariaLabel={bindings.ariaLabel} + describedById={bindings.describedBy} groupRef={fieldRef} - trigger={} + trigger={} + onGroupFocus={bindings.handleGroupFocus} + onGroupBlur={bindings.handleGroupBlur} /> } > - + - {descText && {descText}} - {derivedInvalid && shownError && {shownError}} - {name && }
); -} +}) as ( + props: DatePickerProps & { ref?: React.Ref }, +) => React.ReactElement; export { DateField, DatePicker }; diff --git a/packages/core/src/components/date-field/date-input-group.tsx b/packages/core/src/components/date-field/date-input-group.tsx index 21fa3093..e658e5e2 100644 --- a/packages/core/src/components/date-field/date-input-group.tsx +++ b/packages/core/src/components/date-field/date-input-group.tsx @@ -93,7 +93,8 @@ interface DateInputGroupProps { isInvalid?: boolean; isRequired?: boolean; autoFocus?: boolean; - labelId?: string; + /** ID of the element(s) that label the group. */ + ariaLabelledby?: string; /** Accessible name when there is no visible label (e.g. a compact filter input). */ ariaLabel?: string; describedById?: string; @@ -101,6 +102,10 @@ interface DateInputGroupProps { trigger?: React.ReactNode; /** Ref to the group element — used to anchor the popover to the whole field. */ groupRef?: React.Ref; + /** Called once when focus enters the group from outside. */ + onGroupFocus?: () => void; + /** Called once when focus leaves the group entirely. */ + onGroupBlur?: () => void; } export function DateInputGroup({ @@ -118,12 +123,14 @@ export function DateInputGroup({ isInvalid, isRequired, autoFocus, - labelId, + ariaLabelledby, ariaLabel, describedById, className, trigger, groupRef, + onGroupFocus, + onGroupBlur, }: DateInputGroupProps) { const t = useDateFieldT(); const editableRefs = React.useRef<(HTMLDivElement | null)[]>([]); @@ -248,13 +255,16 @@ export function DateInputGroup({ ref={groupRef} role="group" data-slot="date-picker-group" - aria-labelledby={labelId} - aria-label={labelId ? undefined : ariaLabel} + aria-labelledby={ariaLabelledby} + aria-label={ariaLabelledby ? undefined : ariaLabel} aria-describedby={describedById} aria-disabled={isDisabled || undefined} data-disabled={isDisabled || undefined} data-invalid={isInvalid || undefined} className={cn(groupClasses, className)} + onFocus={(e) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) onGroupFocus?.(); + }} onBlur={(e) => { // Leaving the year segment (to a sibling, the calendar icon, or out of // the field) expands a 1–2 digit year to the 2000s right away — the icon @@ -264,7 +274,10 @@ export function DateInputGroup({ // Focus left the whole group (not just moved between segments) → // backfill the current month/year for a partial entry and clamp an // impossible day. - if (!e.currentTarget.contains(e.relatedTarget as Node | null)) commitOnBlur(); + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + commitOnBlur(); + onGroupBlur?.(); + } }} >