diff --git a/.changeset/odd-maps-glow.md b/.changeset/odd-maps-glow.md
new file mode 100644
index 00000000..59c420e8
--- /dev/null
+++ b/.changeset/odd-maps-glow.md
@@ -0,0 +1,43 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+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. They also interoperate correctly with `Form` / `Field.Root` label wiring, `onFormSubmit` value collection, and submit-time validation for required and out-of-range default values.
+
+Breaking changes:
+
+- `label`, `description`, and `errorMessage` were removed from `DateField` / `DatePicker`; compose them with `Field.Root`, `Field.Label`, `Field.Description`, and `Field.Error` instead.
+- `hideTimeZone` was removed; it was previously accepted by the prop types but had no effect.
+
+`isInvalid` still remains a top-level prop for externally-controlled invalid styling, and the semantic date props (`isRequired`, `isDisabled`, `isReadOnly`, `minValue`, `maxValue`, `isDateUnavailable`) remain top-level and aligned with `Calendar`.
+
+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 20df4f31..2ce1067a 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.
[Live preview in the UI Catalogue →](https://ui.tailor.tech/components/date-picker)
@@ -18,39 +16,62 @@ import {
DateField,
DatePicker,
Calendar,
+ Field,
// Date value helpers (re-exported from @internationalized/date)
parseDate,
getLocalTimeZone,
+ today,
type CalendarDate,
type DateValue,
} 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 standalone composite controls.
+
+- They own date entry, keyboard behavior, constraints, locale/timezone handling, and form value serialization.
+- They expose standard labeling hooks: `id`, `aria-label`, `aria-labelledby`, `aria-describedby`, and `isInvalid`.
+- They also auto-wire into `Field.Root`, so `Field.Label`, `Field.Description`, `Field.Error`, and form validation state work the same way as the other AppShell form controls.
## 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 a visible label + description:
```tsx
+
+ Invoice date
+
+
Format follows your locale
+```
+
+Inside `Field.Root`:
+
+```tsx
+
+ Invoice date
+
+ Format follows your locale
+
```
-### Controlled
+Controlled:
```tsx
const [date, setDate] = useState(null);
- ;
+
+ ;
```
## DatePicker
@@ -58,14 +79,18 @@ const [date, setDate] = useState(null);
A `DateField` with a calendar popover.
```tsx
-
+
```
-### Constrained + unavailable dates
+Constrained + unavailable dates:
```tsx
+
+ Delivery date
+
{
const dow = date.toDate(getLocalTimeZone()).getDay();
@@ -74,15 +99,44 @@ A `DateField` with a calendar popover.
/>
```
-### Week start
+Week start:
```tsx
-
+
+```
+
+## Validation and errors
+
+Use standard HTML + ARIA when rendering the field standalone:
+
+```tsx
+
+ Delivery date
+
+
+{error && {error}
}
+```
+
+Or let `Field.Root` wire the label, description, and error elements:
+
+```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)} />
@@ -93,95 +147,54 @@ 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 |
+| `minValue` / `maxValue` | `DateValue` | Inclusive date range bounds |
+| `isDateUnavailable` | `(date: DateValue) => boolean` | Marks specific dates unavailable |
+| `isDisabled` | `boolean` | Disables interaction and form submission |
+| `isReadOnly` | `boolean` | Allows focus/navigation without editing |
+| `isRequired` | `boolean` | Marks the control required |
+| `isInvalid` | `boolean` | Adds invalid styling / `aria-invalid` to the segmented UI |
+| `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 proxy input |
+| `id` | `string` | Proxy input id (use with external `
`). |
+| `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 |
+| `aria-describedby` | `string` | IDs of description / error elements |
+| `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
+- [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..8789257d 100644
--- a/examples/vite-app/src/pages/date-picker/page.tsx
+++ b/examples/vite-app/src/pages/date-picker/page.tsx
@@ -1,10 +1,11 @@
-import { useState, type FormEvent } from "react";
+import { cloneElement, useState, type ReactElement } from "react";
import {
Layout,
DateField,
DatePicker,
Calendar,
Form,
+ Field,
Button,
useTimeZone,
parseDate,
@@ -14,6 +15,55 @@ import {
} from "@tailor-platform/app-shell";
import { CalendarDays } from "lucide-react";
+type DemoFieldControlProps = {
+ id?: string;
+ "aria-labelledby"?: string;
+ "aria-describedby"?: string;
+ isInvalid?: boolean;
+};
+
+function DemoField({
+ id,
+ label,
+ description,
+ error,
+ children,
+}: {
+ id: string;
+ label: string;
+ description?: string;
+ error?: string;
+ children: ReactElement;
+}) {
+ const describedBy = [description && `${id}-description`, error && `${id}-error`]
+ .filter(Boolean)
+ .join(" ");
+
+ return (
+
+
+ {label}
+
+ {cloneElement(children, {
+ id,
+ "aria-labelledby": `${id}-label`,
+ "aria-describedby": describedBy || undefined,
+ isInvalid: !!error || children.props.isInvalid,
+ })}
+ {description && (
+
+ {description}
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
+
const DatePickerPage = () => {
const tz = useTimeZone();
const [fieldValue, setFieldValue] = useState(null);
@@ -21,32 +71,12 @@ const DatePickerPage = () => {
const [calendarValue, setCalendarValue] = useState(null);
const [weekendValue, setWeekendValue] = useState(null);
- // Form-validation demo state.
const [deliveryDate, setDeliveryDate] = useState(null);
- const [deliveryError, setDeliveryError] = useState(undefined);
const [confirmedDate, setConfirmedDate] = useState(null);
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).
- const handleDeliverySubmit = (e: FormEvent) => {
- e.preventDefault();
- if (!deliveryDate) {
- setConfirmedDate(null);
- setDeliveryError("Please select a delivery date.");
- return;
- }
- if (deliveryDate.compare(tz.today()) < 0) {
- setConfirmedDate(null);
- setDeliveryError("Delivery date can't be in the past.");
- return;
- }
- setDeliveryError(undefined);
- setConfirmedDate(deliveryDate.toString());
- };
-
return (
@@ -72,19 +102,25 @@ const DatePickerPage = () => {
DateField
- setFieldValue(v as CalendarDate | null)}
- />
-
+ setFieldValue(v as CalendarDate | null)}
+ />
+
+
-
-
+ >
+
+
+
+
+
+
+
+
{fieldValue && (
@@ -99,30 +135,38 @@ const DatePickerPage = () => {
DatePicker
- setPickerValue(v as CalendarDate | null)}
- />
-
+ setPickerValue(v as CalendarDate | null)}
+ />
+
+
-
+
+
+ {
- const day = d.toDate(tz.value).getDay();
- return day === 0 || day === 6;
- }}
- />
-
+ {
+ const day = d.toDate(tz.value).getDay();
+ return day === 0 || day === 6;
+ }}
+ />
+
+
+ >
+
+
{pickerValue && (
@@ -137,28 +181,31 @@ 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) blocks submit, shows the field error, and clears as soon as a valid
+ date is picked.
-
{confirmedDate && (
@@ -177,9 +224,15 @@ const DatePickerPage = () => {
explicitly to force a specific start day regardless of locale.
-
-
-
+
+
+
+
+
+
+
+
+
@@ -189,9 +242,15 @@ const DatePickerPage = () => {
Locale (segment order + names)
-
-
-
+
+
+
+
+
+
+
+
+
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..ea542ba0 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`] = `""`;
+exports[`snapshots > DateField 1`] = `""`;
-exports[`snapshots > DateField — invalid with error 1`] = `""`;
+exports[`snapshots > DateField — manually labelled + described 1`] = `"Date Pick a date
Required
"`;
-exports[`snapshots > DatePicker — closed 1`] = `""`;
+exports[`snapshots > DatePicker — closed 1`] = `""`;
diff --git a/packages/core/package.json b/packages/core/package.json
index 85cf7720..fa3561fe 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -50,7 +50,7 @@
"type-check": "tsc --incremental"
},
"dependencies": {
- "@base-ui/react": "^1.6.0",
+ "@base-ui/react": "1.6.0",
"@internationalized/date": "3.12.2",
"@standard-schema/spec": "^1.1.0",
"@tailor-platform/app-shell-vite-plugin": "workspace:*",
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..78aa0ead 100644
--- a/packages/core/src/components/date-field/date-field.test.tsx
+++ b/packages/core/src/components/date-field/date-field.test.tsx
@@ -12,6 +12,8 @@ import {
isSameDay,
} from "@internationalized/date";
import { createAppShellWrapper } from "../../../tests/test-utils";
+import { Field } from "../field";
+import { Form } from "../form";
import { DateField, DatePicker } from "./date-field";
// This suite is the parity contract shared with the react-aria implementation:
@@ -42,65 +44,136 @@ function getEnabledCalendarCells() {
describe("snapshots", () => {
it("DateField", () => {
- const { container } = render( );
+ const { container } = render( );
expect(container.innerHTML).toMatchSnapshot();
});
- it("DateField — invalid with error", () => {
+ it("DateField — manually labelled + described", () => {
const { container } = render(
- ,
+ <>
+
+ Date
+
+
+ Pick a date
+ Required
+ >,
);
expect(container.innerHTML).toMatchSnapshot();
});
it("DatePicker — closed", () => {
- const { container } = render( );
- expect(container.innerHTML).toMatchSnapshot();
+ const { container } = render( );
+ expect(container.innerHTML.replace(/id="base-ui-[^"]+"/g, 'id="base-ui-ID"')).toMatchSnapshot();
});
});
// ─── DateField ─────────────────────────────────────────────────────────────────
describe("DateField", () => {
- it("renders with a label", () => {
- render( );
- expect(screen.getByText("Invoice date")).toBeDefined();
+ it("renders with an aria-label", () => {
+ render( );
+ expect(screen.getByRole("group", { name: "Invoice date" })).toBeDefined();
});
it("renders date segments", () => {
- render( );
+ 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("focuses the first segment when a linked external label is clicked", async () => {
+ const user = userEvent.setup();
+ render(
+ <>
+
+ Date
+
+
+ >,
+ );
+
+ await user.click(screen.getByText("Date"));
+ expect(document.activeElement?.getAttribute("role")).toBe("spinbutton");
+ });
+
+ it("integrates with Field.Label", async () => {
+ const user = userEvent.setup();
+ render(
+
+ Invoice date
+
+ ,
+ );
+
+ await user.click(screen.getByText("Invoice date"));
+ expect(document.activeElement?.getAttribute("role")).toBe("spinbutton");
+ });
+
+ it("supports manual aria-describedby and invalid state", () => {
+ render(
+ <>
+ Date
+
+ Pick a date
+ Required
+ >,
+ );
+
+ const group = screen.getByRole("group");
+ expect(group.getAttribute("aria-describedby")).toBe("date-help date-error");
+ expect(group.hasAttribute("data-invalid")).toBe(true);
+ expect(screen.getByText("Required")).toBeDefined();
+ });
+
+ it("integrates with Field.Root label + description wiring", async () => {
+ const user = userEvent.setup();
+ render(
+
+ Invoice date
+
+ Pick the invoice date
+ ,
+ );
+
+ const group = screen.getByRole("group", { name: "Invoice date" });
+ expect(group.getAttribute("aria-describedby")).toBeTruthy();
+ expect(screen.getByText("Pick the invoice date")).toBeDefined();
+
+ await user.click(screen.getByText("Invoice date"));
+ expect(document.activeElement?.getAttribute("role")).toBe("spinbutton");
});
- it("renders error message when isInvalid", () => {
- render( );
+ it("inherits invalid state from Field.Root", () => {
+ render(
+
+ Invoice date
+
+ Required
+ ,
+ );
+
+ expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
expect(screen.getByText("Required")).toBeDefined();
});
it("renders as disabled", () => {
- render( );
+ 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( );
+ render( );
// Fill every segment by aria-label (order-independent across locales).
await user.click(screen.getByRole("spinbutton", { name: "month" }));
@@ -120,7 +193,7 @@ describe("DateField", () => {
it("auto-advances across segments as a full date is typed (no explicit tabbing)", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
// Locale here is "en" → MM/DD/YYYY. Typing carries across segments:
// "02" fills+advances month, "15" fills+advances day, "2025" fills year.
@@ -135,7 +208,7 @@ describe("DateField", () => {
it("accumulates a non-leading-zero entry (2 then 9 → 29, not 9)", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("12");
@@ -152,7 +225,7 @@ describe("DateField", () => {
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( );
+ render( );
// Type the day first — "31" must not collapse to "1" just because the
// current (anchor) month happens to have 30/28 days.
@@ -173,7 +246,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -198,7 +271,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -224,7 +297,7 @@ describe("DateField", () => {
return (
<>
{
setV(nv as CalendarDate | null);
@@ -283,7 +356,7 @@ describe("DateField", () => {
it("auto-corrects an impossible day as soon as the year is complete (no blur)", 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.
@@ -302,7 +375,7 @@ describe("DateField", () => {
it("re-corrects on year completion when a leap day turns invalid (no blur)", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("29");
@@ -330,7 +403,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -350,7 +423,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -372,7 +445,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -388,7 +461,7 @@ describe("DateField", () => {
it("clears a controlled DateField when the value is reset to null", () => {
const { rerender } = render(
{}}
/>,
@@ -396,14 +469,14 @@ describe("DateField", () => {
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( );
+ render( );
const day = screen.getByRole("spinbutton", { name: "day" });
expect(day.getAttribute("aria-required")).toBe("true");
});
@@ -424,7 +497,7 @@ describe("DateField keyboard shortcuts", () => {
it("'t' jumps to today (case-insensitive)", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("T"); // upper-case → same as "t"
@@ -436,7 +509,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -448,7 +525,7 @@ describe("DateField keyboard shortcuts", () => {
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( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("m");
@@ -460,7 +537,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -473,7 +554,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -488,7 +573,7 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
const start = new CalendarDate(2025, 6, 18); // a Wednesday
- render( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("w");
@@ -502,7 +587,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -515,7 +604,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -530,7 +623,7 @@ describe("DateField keyboard shortcuts", () => {
const onChangeEq = vi.fn();
const { unmount } = render(
,
@@ -543,7 +636,7 @@ describe("DateField keyboard shortcuts", () => {
const onChangePlus = vi.fn();
render(
,
@@ -556,7 +649,7 @@ describe("DateField keyboard shortcuts", () => {
it("'-' / '+' step from today when the field is empty", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("+");
@@ -569,7 +662,7 @@ describe("DateField keyboard shortcuts", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -588,7 +681,7 @@ describe("DateField keyboard shortcuts", () => {
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( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "month" }));
await user.keyboard("06");
@@ -611,7 +704,7 @@ describe("DateField keyboard shortcuts", () => {
const onChange = vi.fn();
render(
{
const onChange = vi.fn();
render(
{
const onChange = vi.fn();
render(
{
it("flags a typed date before minValue invalid, but still emits it", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
- render( );
+ render(
+ ,
+ );
const group = screen.getByRole("group");
// en order: month / day / year — type 5 Jun 2025 (before the 10 Jun min).
@@ -686,13 +781,11 @@ describe("DateField keyboard shortcuts", () => {
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( );
+ render( );
const group = screen.getByRole("group");
await user.click(screen.getByRole("spinbutton", { name: "month" }));
@@ -710,7 +803,7 @@ describe("DateField keyboard shortcuts", () => {
const unavailable = new CalendarDate(2025, 6, 12);
render(
isSameDay(d, unavailable)}
onChange={onChange}
/>,
@@ -720,13 +813,12 @@ describe("DateField keyboard shortcuts", () => {
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( );
+ 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.
@@ -741,20 +833,20 @@ describe("DateField keyboard shortcuts", () => {
// ─── DatePicker ───────────────────────────────────────────────────────────────
describe("DatePicker", () => {
- it("renders with a label", () => {
- render( );
- expect(screen.getByText("Ship date")).toBeDefined();
+ it("renders with an aria-label", () => {
+ render( );
+ expect(screen.getByRole("group", { name: "Ship date" })).toBeDefined();
});
it("renders the calendar trigger button", () => {
- render( );
+ render( );
const btn = screen.getAllByRole("button").find((b) => !b.closest('[role="grid"]'));
expect(btn).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);
@@ -766,7 +858,7 @@ describe("DatePicker", () => {
it("shows a calendar grid in the popover", async () => {
const user = userEvent.setup();
- render( );
+ render( );
await user.click(screen.getAllByRole("button")[0]);
@@ -778,7 +870,7 @@ describe("DatePicker", () => {
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 waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
@@ -796,7 +888,7 @@ describe("DatePicker", () => {
it("renders cells with data-disabled when minValue is set", async () => {
const user = userEvent.setup();
const tomorrow = today(getLocalTimeZone()).add({ days: 1 });
- render( );
+ render( );
await user.click(screen.getAllByRole("button")[0]);
await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
@@ -807,7 +899,7 @@ describe("DatePicker", () => {
it("renders cells with data-unavailable when isDateUnavailable returns true", async () => {
const user = userEvent.setup();
- render( true} />);
+ render( true} />);
await user.click(screen.getAllByRole("button")[0]);
await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
@@ -816,29 +908,61 @@ describe("DatePicker", () => {
expect(unavailable.length).toBeGreaterThan(0);
});
- it("renders error message when errorMessage is set", () => {
- render( );
- expect(screen.getByText("Date is required")).toBeDefined();
+ it("supports manual invalid state", () => {
+ render( );
+ expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
+ });
+
+ it("integrates with Field.Root label + disabled state", async () => {
+ const user = userEvent.setup();
+ render(
+
+ Ship date
+
+ Choose a ship date
+ ,
+ );
+
+ const group = screen.getByRole("group", { name: "Ship date" });
+ expect(group.getAttribute("aria-disabled")).toBe("true");
+ expect(group.getAttribute("aria-describedby")).toBeTruthy();
+
+ await user.click(screen.getByText("Ship date"));
+ expect(document.activeElement?.getAttribute("role")).not.toBe("spinbutton");
+ });
+
+ it("uses the Field.Root label for the popup dialog and calendar", async () => {
+ const user = userEvent.setup();
+ render(
+
+ Ship date
+
+ ,
+ );
+
+ await user.click(screen.getAllByRole("button")[0]);
+ expect(await screen.findByRole("dialog", { name: "Ship date" })).toBeDefined();
+ expect(screen.getByRole("grid", { name: "Ship date" })).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( {}} />);
+ 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") });
+ render( , { wrapper: createAppShellWrapper("ja") });
// Segment accessible name: month → 月.
expect(screen.getByRole("spinbutton", { name: "月" })).toBeDefined();
// Popover trigger aria-label is localized too.
@@ -848,6 +972,109 @@ describe("DatePicker", () => {
"未入力",
);
});
+
+ it("integrates with Field.Label", async () => {
+ const user = userEvent.setup();
+ render(
+
+ Delivery date
+
+ ,
+ );
+
+ await user.click(screen.getByText("Delivery date"));
+ expect(document.activeElement?.getAttribute("role")).toBe("spinbutton");
+ });
+
+ it("registers with Form and includes defaultValue in onFormSubmit", async () => {
+ const user = userEvent.setup();
+ const onFormSubmit = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(onFormSubmit).toHaveBeenCalled();
+ });
+ expect(onFormSubmit.mock.calls[0]?.[0]).toEqual({ deliveryDate: "2025-06-15" });
+ });
+
+ it("supports the controlled Form + Field.Root flow from the example page", async () => {
+ const user = userEvent.setup();
+ const onFormSubmit = vi.fn();
+
+ function ControlledFormDatePicker() {
+ const [deliveryDate, setDeliveryDate] = useState(null);
+ return (
+
+ );
+ }
+
+ render( );
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(onFormSubmit).not.toHaveBeenCalled();
+ expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
+
+ await user.click(screen.getAllByRole("button")[0]);
+ await waitFor(() => expect(screen.getByRole("grid")).toBeDefined());
+ await user.click(getEnabledCalendarCells()[0]);
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ await waitFor(() => {
+ expect(onFormSubmit).toHaveBeenCalled();
+ });
+ expect(onFormSubmit.mock.calls[0]?.[0]).toEqual({ deliveryDate: expect.any(String) });
+ });
+
+ it("blocks Form submit when defaultValue is out of range", async () => {
+ const user = userEvent.setup();
+ const onFormSubmit = vi.fn();
+
+ render(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(onFormSubmit).not.toHaveBeenCalled();
+ expect(screen.getByRole("group").hasAttribute("data-invalid")).toBe(true);
+ });
});
// ─── Keyboard: popover focus ─────────────────────────────────────────────────
@@ -855,7 +1082,7 @@ describe("DatePicker", () => {
describe("DatePicker keyboard", () => {
it("moves focus into the calendar grid when the popover opens", async () => {
const user = userEvent.setup();
- render( );
+ render( );
await user.click(screen.getAllByRole("button")[0]);
await waitFor(() => {
expect(document.activeElement?.closest('[role="grid"]')).not.toBeNull();
@@ -864,7 +1091,7 @@ describe("DatePicker keyboard", () => {
it("opens the calendar with Alt+↓ from a focused segment", async () => {
const user = userEvent.setup();
- render( );
+ render( );
await user.click(screen.getByRole("spinbutton", { name: "day" }));
await user.keyboard("{Alt>}{ArrowDown}{/Alt}");
@@ -874,6 +1101,39 @@ describe("DatePicker keyboard", () => {
});
});
+ it("does not blur when focus moves from the field into the popup", async () => {
+ const user = userEvent.setup();
+ const onBlur = vi.fn();
+ render( );
+
+ await user.click(screen.getAllByRole("button")[0]);
+ await waitFor(() => {
+ expect(document.activeElement?.closest('[role="grid"]')).not.toBeNull();
+ });
+ expect(onBlur).not.toHaveBeenCalled();
+ });
+
+ it("blurs once focus leaves the popup", async () => {
+ const user = userEvent.setup();
+ const onBlur = vi.fn();
+ render(
+ <>
+
+ elsewhere
+ >,
+ );
+
+ await user.click(screen.getAllByRole("button")[0]);
+ await waitFor(() => {
+ expect(document.activeElement?.closest('[role="grid"]')).not.toBeNull();
+ });
+
+ await user.click(screen.getByRole("button", { name: "elsewhere" }));
+ await waitFor(() => {
+ expect(onBlur).toHaveBeenCalledTimes(1);
+ });
+ });
+
// 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.
@@ -881,7 +1141,11 @@ describe("DatePicker keyboard", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getAllByRole("button")[0]); // open (focus → 15 Jun)
@@ -900,7 +1164,7 @@ describe("DatePicker keyboard", () => {
const onChange = vi.fn();
render(
{
const onChange = vi.fn();
render(
) {
+ const joined = ids.filter(Boolean).join(" ");
+ return joined || undefined;
+}
+
+function assignRef(ref: Ref | undefined, value: T | null) {
+ if (!ref) return;
+ if (typeof ref === "function") {
+ ref(value);
+ return;
+ }
+ ref.current = value;
+}
+
+function isTargetWithin(
+ target: EventTarget | null,
+ ref: RefObject,
+): target is Node {
+ return target instanceof Node && ref.current?.contains(target) === true;
+}
+
function useControlledState(
controlled: V | undefined,
defaultValue: V,
@@ -57,19 +85,11 @@ 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;
@@ -80,110 +100,531 @@ interface DateBehaviorProps {
isInvalid?: boolean;
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;
+ /** ID of the element(s) that describe the control. */
+ "aria-describedby"?: 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 DateFieldA11yLabelingOptions {
+ id?: string;
+ name?: string;
+ isDisabled?: boolean;
+ isReadOnly?: boolean;
+ isRequired?: boolean;
+ isInvalid?: boolean;
+ labelledBy?: string;
+ describedBy?: string;
+ ariaLabel?: string;
+ proxyRef: RefObject;
+}
+
+interface DateFieldFieldBridgeOptions {
+ id?: string;
+ name?: string;
+ inputValue: string;
+ hasInput: boolean;
+ localValidationMessage?: string;
+ isDisabled?: boolean;
+ isReadOnly?: boolean;
+ isRequired?: boolean;
+ isInvalid?: boolean;
+ labelledBy?: string;
+ describedBy?: string;
+ ariaLabel?: string;
+ onBlur?: () => void;
+ groupRef: RefObject;
+ forwardedRef?: Ref;
+}
+
+interface DateFieldProxyInputOptions {
+ inputValue: string;
+ localValidationMessage?: string;
+ groupRef: RefObject;
+ forwardedRef?: Ref;
+ validationInputRef: RefObject;
+}
+
+interface DateFieldProxyInputState {
+ inputValue: string;
+ localValidationMessage?: string;
+}
+
+interface DateFieldBridgeState extends DateFieldProxyInputState {
+ hasInput: boolean;
+}
/**
- * A segmented date/time input field with no popover.
+ * Resolve the labeling/accessibility contract for the date widgets.
+ *
+ * The visible control is not a native ` `; it is a `role="group"` with
+ * per-segment `role="spinbutton"` children plus a hidden proxy input. This hook
+ * answers the “what should this control be called and described by?” part of the
+ * problem by merging three sources of metadata into one small object:
*
- * @example
- * ```tsx
- * import { DateField } from "@tailor-platform/app-shell";
+ * - explicit props passed to `DateField` / `DatePicker`
+ * - `Field.Root` / `Field.Label` / `Field.Description` / `Field.Error` context
+ * - the generated control id used by Base UI's labelable system
*
- *
- *
- * ```
+ * Concretely it computes:
+ * - the control id that labels point at
+ * - the effective field `name`
+ * - merged `aria-labelledby` and `aria-describedby` ids
+ * - the externally-derived invalid state coming from `Field.Root`
+ *
+ * Deliberately, this hook does **not** touch DOM nodes, register form fields, or
+ * run validation. It is the narrow “A11y + labeling metadata” layer that the
+ * heavier field/form bridge builds on top of.
*/
-function DateField({
- label,
- description,
- errorMessage,
- className,
- locale: localeProp,
- value,
- defaultValue,
- onChange,
- granularity,
- hourCycle,
- placeholderValue,
- minValue,
- maxValue,
- isDateUnavailable,
+function useDateFieldA11yLabeling({
+ id: idProp,
+ name: nameProp,
isDisabled,
isReadOnly,
+ isRequired,
isInvalid,
+ labelledBy: labelledByProp,
+ describedBy: describedByProp,
+ ariaLabel,
+ proxyRef,
+}: DateFieldA11yLabelingOptions) {
+ const fieldRoot = useFieldRootContext();
+ const { labelId, messageIds } = useLabelableContext();
+ const controlId = useLabelableId({ id: idProp, controlRef: proxyRef });
+ const name = fieldRoot.name ?? nameProp;
+ const externalInvalid = fieldRoot.state.valid === false;
+
+ return {
+ controlId,
+ name,
+ isDisabled,
+ isReadOnly,
+ isRequired,
+ isInvalid: !!externalInvalid || !!isInvalid,
+ labelledBy: joinIds(labelledByProp, labelId),
+ describedBy: joinIds(describedByProp, ...messageIds),
+ ariaLabel,
+ };
+}
+
+/**
+ * Own the hidden proxy input that backs the composite date widget.
+ *
+ * The visible date UI is a segmented `role="group"`, but form systems and the
+ * browser's validity APIs still need a real ` `. This hook centralizes the
+ * DOM-level work for that hidden proxy input:
+ *
+ * - keep the current serialized value and `setCustomValidity()` message in sync
+ * - attach the proxy node to Base UI's validation input ref
+ * - forward the proxy node through the component ref
+ * - provide a helper that focuses the first visible date segment when the proxy
+ * input receives focus from a label or form logic
+ *
+ * Importantly, this hook does not know about `Field.Root`, dirty/touched state,
+ * or form registration. It is only the DOM bridge for the backing input so the
+ * field/form adapter can stay focused on higher-level state integration.
+ */
+function useDateFieldProxyInput({
+ inputValue,
+ localValidationMessage,
+ groupRef,
+ forwardedRef,
+ validationInputRef,
+}: DateFieldProxyInputOptions) {
+ const proxyRef = useRef(null);
+
+ const syncProxyInput = useCallback(
+ (
+ snapshot: DateFieldProxyInputState = {
+ inputValue,
+ localValidationMessage,
+ },
+ node: HTMLInputElement | null = proxyRef.current,
+ ) => {
+ if (!node) return;
+ if (node.value !== snapshot.inputValue) node.value = snapshot.inputValue;
+ node.setCustomValidity(snapshot.localValidationMessage ?? "");
+ },
+ [inputValue, localValidationMessage],
+ );
+
+ const setProxyState = useCallback(
+ (snapshot: DateFieldProxyInputState) => {
+ syncProxyInput(snapshot);
+ },
+ [syncProxyInput],
+ );
+
+ const setProxyRef = useCallback(
+ (node: HTMLInputElement | null) => {
+ proxyRef.current = node;
+ validationInputRef.current = node;
+ syncProxyInput(undefined, node);
+ assignRef(forwardedRef, node);
+ },
+ [forwardedRef, syncProxyInput, validationInputRef],
+ );
+
+ const focusFirstSegment = useCallback(() => {
+ const first = groupRef.current?.querySelector('[role="spinbutton"]');
+ first?.focus();
+ }, [groupRef]);
+
+ const getValue = useCallback(() => proxyRef.current?.value ?? inputValue, [inputValue]);
+
+ return {
+ proxyRef,
+ setProxyRef,
+ setProxyState,
+ syncProxyInput,
+ focusFirstSegment,
+ getValue,
+ };
+}
+
+/**
+ * Bridge the date widgets into Base UI's `Field` / `Form` infrastructure.
+ *
+ * `DateField` and `DatePicker` are composite controls with a hidden proxy input,
+ * so Base UI cannot treat them like a normal text input automatically. This hook
+ * is the adapter that makes them behave like one field from the form system's
+ * perspective.
+ *
+ * Responsibilities:
+ * - register the proxy input as the field control so `Form.onFormSubmit()` and
+ * field-level focus/validation logic can see it
+ * - convert date-specific invalid states (range/unavailable) into Base UI
+ * `FieldValidityData` so submit blocking and `Field.Error` work
+ * - mirror field state transitions (`filled`, `dirty`, `focused`, `touched`)
+ * into `Field.Root`
+ * - clear form errors on user edits and trigger validation on change / blur
+ *
+ * The hook consumes state transitions emitted by `useDateFieldState` via
+ * `handleStateChange()`. That keeps the date engine as the single source of
+ * truth and makes the field bridge react to explicit edit/clear/external-sync
+ * events instead of inferring them later from effects.
+ *
+ * `useDateFieldProxyInput()` handles the DOM-facing hidden-input mechanics;
+ * this hook stays focused on the form-state contract layered on top of it.
+ *
+ * Base UI refreshes the registry entry from layout effects whenever the
+ * registered value / validity changes, so we re-apply our wrapped `validate`
+ * handler after those writes settle instead of on every render.
+ */
+function useDateFieldFieldBridge({
+ id: idProp,
+ name: nameProp,
+ inputValue,
+ hasInput,
+ localValidationMessage,
+ isDisabled,
+ isReadOnly,
isRequired,
- autoFocus,
- firstDayOfWeek,
- name,
- "aria-label": ariaLabel,
-}: DateFieldProps) {
- const { locale: shellLocale, language } = useResolvedLocale();
- const resolvedLocale = localeProp ?? shellLocale;
- const resolve = buildLocaleResolver(language);
+ isInvalid,
+ labelledBy: labelledByProp,
+ describedBy: describedByProp,
+ ariaLabel,
+ onBlur,
+ groupRef,
+ forwardedRef,
+}: DateFieldFieldBridgeOptions) {
+ const fieldRoot = useFieldRootContext();
+ const { formRef, clearErrors } = useFormContext();
const t = useDateFieldT();
+ const proxyInput = useDateFieldProxyInput({
+ inputValue,
+ localValidationMessage,
+ groupRef,
+ forwardedRef,
+ validationInputRef: fieldRoot.validation.inputRef,
+ });
+ const a11y = useDateFieldA11yLabeling({
+ id: idProp,
+ name: nameProp,
+ isDisabled,
+ isReadOnly,
+ isRequired,
+ isInvalid,
+ labelledBy: labelledByProp,
+ describedBy: describedByProp,
+ ariaLabel,
+ proxyRef: proxyInput.proxyRef,
+ });
+
+ const bridgeRef = useRef({
+ fieldRoot,
+ fieldName: nameProp,
+ state: {
+ inputValue,
+ hasInput,
+ localValidationMessage,
+ } as DateFieldBridgeState,
+ nativeValidate: null as (() => void) | null,
+ });
+ bridgeRef.current.fieldRoot = fieldRoot;
+ bridgeRef.current.fieldName = nameProp;
+ bridgeRef.current.state = {
+ inputValue,
+ hasInput,
+ localValidationMessage,
+ };
+
+ const updateRegisteredValidity = useCallback(
+ (nextValidityData: FieldValidityData) => {
+ if (!a11y.controlId) return;
+ const field = formRef.current.fields.get(a11y.controlId);
+ if (!field) return;
+ formRef.current.fields.set(a11y.controlId, {
+ ...field,
+ validityData: nextValidityData,
+ });
+ },
+ [a11y.controlId, formRef],
+ );
+
+ const commitLocalValidation = useCallback(
+ (snapshot: DateFieldBridgeState = bridgeRef.current.state) => {
+ const message = snapshot.localValidationMessage;
+ if (!message) return false;
+
+ const root = bridgeRef.current.fieldRoot;
+ const nextValidityData: FieldValidityData = {
+ value: snapshot.inputValue,
+ state: {
+ ...DEFAULT_VALIDITY_STATE,
+ customError: true,
+ valid: false,
+ },
+ error: message,
+ errors: [message],
+ initialValue: root.validityData.initialValue,
+ };
+
+ root.setValidityData(nextValidityData);
+ updateRegisteredValidity(nextValidityData);
+ return true;
+ },
+ [updateRegisteredValidity],
+ );
+
+ const wrappedValidateRef = useRef<(() => void) | undefined>(undefined);
+ if (!wrappedValidateRef.current) {
+ wrappedValidateRef.current = () => {
+ const snapshot = bridgeRef.current.state;
+ proxyInput.syncProxyInput(snapshot);
+ if (commitLocalValidation(snapshot)) return;
+ bridgeRef.current.nativeValidate?.();
+ };
+ }
+
+ useRegisterFieldControl(
+ proxyInput.proxyRef,
+ a11y.controlId,
+ inputValue,
+ proxyInput.getValue,
+ !a11y.isDisabled,
+ );
+
+ // Base UI registers / refreshes the field entry from layout effects, so patch
+ // the registry after those writes settle whenever that entry's inputs change.
+ useEffect(() => {
+ if (!a11y.controlId) return;
+
+ const field = formRef.current.fields.get(a11y.controlId);
+ if (!field) return;
+ if (field.validate === wrappedValidateRef.current) return;
+
+ bridgeRef.current.nativeValidate = field.validate;
+ formRef.current.fields.set(a11y.controlId, {
+ ...field,
+ validate: wrappedValidateRef.current!,
+ });
+ }, [
+ a11y.controlId,
+ a11y.isDisabled,
+ fieldRoot.state.valid,
+ fieldRoot.validityData,
+ formRef,
+ inputValue,
+ ]);
+
+ const handleStateChange = useCallback(
+ ({
+ source,
+ inputValue: nextInputValue,
+ hasInput: nextHasInput,
+ invalidReason,
+ }: DateFieldStateChange) => {
+ const validationKey = invalidMessageKey(invalidReason);
+ const snapshot = {
+ inputValue: nextInputValue,
+ hasInput: nextHasInput,
+ localValidationMessage: validationKey ? t(validationKey) : undefined,
+ } satisfies DateFieldBridgeState;
+ bridgeRef.current.state = snapshot;
+ proxyInput.setProxyState(snapshot);
+
+ const root = bridgeRef.current.fieldRoot;
+ const initialValue =
+ typeof root.validityData.initialValue === "string" ? root.validityData.initialValue : "";
+
+ root.setFilled(snapshot.hasInput);
+ root.setDirty(
+ snapshot.inputValue !== initialValue || (snapshot.hasInput && snapshot.inputValue === ""),
+ );
+
+ if (source === "external") return;
+
+ const fieldName = root.name ?? bridgeRef.current.fieldName;
+ if (fieldName) clearErrors(fieldName);
+ if (!root.shouldValidateOnChange()) return;
+ if (commitLocalValidation(snapshot)) return;
+ root.validation.commit(snapshot.inputValue);
+ },
+ [clearErrors, commitLocalValidation, proxyInput, t],
+ );
+
+ const handleGroupFocus = useCallback(() => {
+ bridgeRef.current.fieldRoot.setFocused(true);
+ }, []);
- const labelId = useId();
- const descId = useId();
- const errId = useId();
+ const handleGroupBlur = useCallback(() => {
+ const root = bridgeRef.current.fieldRoot;
+ root.setTouched(true);
+ root.setFocused(false);
+ onBlur?.();
+
+ if (root.validationMode !== "onBlur") return;
+ proxyInput.syncProxyInput();
+ if (commitLocalValidation()) return;
+ root.validation.commit(bridgeRef.current.state.inputValue);
+ }, [commitLocalValidation, onBlur, proxyInput]);
+
+ return {
+ ...a11y,
+ isInvalid: a11y.isInvalid || !!localValidationMessage,
+ proxyRef: proxyInput.setProxyRef,
+ focusFirstSegment: proxyInput.focusFirstSegment,
+ handleGroupFocus,
+ handleGroupBlur,
+ handleStateChange,
+ };
+}
+
+/**
+ * A segmented date/time input field with no popover.
+ *
+ * Compose with `Field.Root` for label / description / error presentation, or
+ * provide standalone ARIA wiring yourself.
+ */
+const DateField = forwardRef(function DateField(
+ {
+ id,
+ className,
+ locale: localeProp,
+ value,
+ defaultValue,
+ onChange,
+ onBlur,
+ granularity,
+ minValue,
+ maxValue,
+ isDateUnavailable,
+ isDisabled,
+ isReadOnly,
+ isRequired,
+ isInvalid,
+ hourCycle,
+ placeholderValue,
+ autoFocus,
+ firstDayOfWeek,
+ name,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ }: DateFieldProps,
+ ref: ForwardedRef,
+) {
+ const fieldRoot = useFieldRootContext();
+ const { locale: shellLocale } = useResolvedLocale();
+ const resolvedLocale = localeProp ?? shellLocale;
+ const resolvedDisabled = fieldRoot.disabled || !!isDisabled;
+ const resolvedReadOnly = !!isReadOnly;
+ const groupRef = useRef(null);
+ const t = useDateFieldT();
+ const handleStateChangeRef = useRef<(change: DateFieldStateChange) => void>(() => {});
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,
+ onStateChange: (change) => handleStateChangeRef.current(change),
granularity,
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.
firstDayOfWeek,
- isReadOnly,
+ isReadOnly: resolvedReadOnly,
});
- 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 localValidationMessage = useMemo(() => {
+ const key = invalidMessageKey(state.invalidReason);
+ return key ? t(key) : undefined;
+ }, [state.invalidReason, t]);
+ const bindings = useDateFieldFieldBridge({
+ id,
+ name,
+ inputValue: state.fieldValue?.toString() ?? "",
+ hasInput: state.hasInput,
+ localValidationMessage,
+ isDisabled: resolvedDisabled,
+ isReadOnly: resolvedReadOnly,
+ isRequired,
+ isInvalid,
+ labelledBy: ariaLabelledby,
+ describedBy: ariaDescribedby,
+ ariaLabel,
+ onBlur,
+ groupRef,
+ forwardedRef: ref,
+ });
+ handleStateChangeRef.current = bindings.handleStateChange;
return (
-
- {labelText &&
{labelText} }
+
+ {}}
+ onFocus={bindings.focusFirstSegment}
+ className="astw:pointer-events-none astw:absolute astw:size-px astw:overflow-hidden astw:opacity-0"
+ />
({
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={bindings.isRequired}
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?: Ref },
+) => 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 standalone ARIA wiring yourself.
*/
-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 = forwardRef(function DatePicker(
+ {
+ id,
+ className,
+ locale: localeProp,
+ timeZone: timeZoneProp,
+ value,
+ defaultValue,
+ onChange,
+ onBlur,
+ granularity,
+ minValue,
+ maxValue,
+ isDateUnavailable,
+ isDisabled,
+ isReadOnly,
+ isRequired,
+ isInvalid,
+ hourCycle,
+ placeholderValue,
+ autoFocus,
+ firstDayOfWeek,
+ name,
+ "aria-label": ariaLabel,
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ }: DatePickerProps,
+ ref: ForwardedRef,
+) {
+ const fieldRoot = useFieldRootContext();
+ const { locale: shellLocale } = useResolvedLocale();
const shellTz = useTimeZone();
const resolvedLocale = localeProp ?? shellLocale;
const resolvedTz = timeZoneProp ?? shellTz.value;
- const resolve = buildLocaleResolver(language);
+ const resolvedDisabled = fieldRoot.disabled || !!isDisabled;
+ const resolvedReadOnly = !!isReadOnly;
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 popupRef = useRef(null);
+ const hasFocusWithinRef = useRef(false);
+ const handleStateChangeRef = useRef<(change: DateFieldStateChange) => void>(() => {});
const [val, setVal] = useControlledState(
- // `null` is controlled-empty; only `undefined` means uncontrolled (see above).
value,
defaultValue ?? null,
onChange as (v: DateValue | null) => void,
@@ -278,22 +710,17 @@ function DatePicker({
const fieldState = useDateFieldState({
value: val,
onChange: setVal,
+ onStateChange: (change) => handleStateChangeRef.current(change),
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.
firstDayOfWeek,
- isReadOnly,
+ isReadOnly: resolvedReadOnly,
});
const calState = useCalendarState({
@@ -305,32 +732,91 @@ function DatePicker({
minValue,
maxValue,
isDateUnavailable,
- isDisabled,
- isReadOnly,
+ isDisabled: resolvedDisabled,
+ isReadOnly: resolvedReadOnly,
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 localValidationMessage = useMemo(() => {
+ const key = invalidMessageKey(fieldState.invalidReason);
+ return key ? t(key) : undefined;
+ }, [fieldState.invalidReason, t]);
+ const bindings = useDateFieldFieldBridge({
+ id,
+ name,
+ inputValue: fieldState.fieldValue?.toString() ?? "",
+ hasInput: fieldState.hasInput,
+ localValidationMessage,
+ isDisabled: resolvedDisabled,
+ isReadOnly: resolvedReadOnly,
+ isRequired,
+ isInvalid,
+ labelledBy: ariaLabelledby,
+ describedBy: ariaDescribedby,
+ ariaLabel,
+ onBlur,
+ groupRef: fieldRef,
+ forwardedRef: ref,
+ });
+ handleStateChangeRef.current = bindings.handleStateChange;
+
+ const handleCompositeFocus = useCallback(() => {
+ hasFocusWithinRef.current = true;
+ bindings.handleGroupFocus();
+ }, [bindings]);
+
+ const handleCompositeBlur = useCallback(() => {
+ if (!hasFocusWithinRef.current) return;
+ hasFocusWithinRef.current = false;
+ bindings.handleGroupBlur();
+ }, [bindings]);
+
+ const handleGroupBlur = useCallback(
+ (nextFocused: EventTarget | null) => {
+ if (isTargetWithin(nextFocused, popupRef)) return;
+ handleCompositeBlur();
+ },
+ [handleCompositeBlur],
+ );
+
+ const handlePopupBlur = useCallback(
+ (nextFocused: EventTarget | null) => {
+ if (isTargetWithin(nextFocused, fieldRef) || isTargetWithin(nextFocused, popupRef)) return;
+ handleCompositeBlur();
+ },
+ [handleCompositeBlur],
+ );
- const describedBy = cn(descText && descId, derivedInvalid && shownError && errId) || undefined;
- const accessibleName = labelText ?? ariaLabel;
- const popoverAriaLabel = accessibleName
- ? t("chooseDateFor", { name: accessibleName })
- : t("chooseDate");
+ let popoverAriaLabel: string | undefined;
+ if (bindings.labelledBy == null) {
+ popoverAriaLabel = ariaLabel ? t("chooseDateFor", { name: ariaLabel }) : t("chooseDate");
+ }
return (
-
- {labelText &&
{labelText} }
+
+ {}}
+ onFocus={bindings.focusFirstSegment}
+ className="astw:pointer-events-none astw:absolute astw:size-px astw:overflow-hidden astw:opacity-0"
+ />
({
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={bindings.isRequired}
autoFocus={autoFocus}
- labelId={labelText ? labelId : undefined}
- ariaLabel={ariaLabel}
- describedById={describedBy}
+ ariaLabelledby={bindings.labelledBy}
+ ariaLabel={bindings.ariaLabel}
+ describedById={bindings.describedBy}
groupRef={fieldRef}
- trigger={ }
+ trigger={ }
+ onGroupFocus={handleCompositeFocus}
+ onGroupBlur={handleGroupBlur}
/>
}
>
- {descText && {descText} }
- {derivedInvalid && shownError && {shownError} }
- {name && }
);
-}
+}) as
(
+ props: DatePickerProps & { ref?: Ref },
+) => 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..75da4436 100644
--- a/packages/core/src/components/date-field/date-input-group.tsx
+++ b/packages/core/src/components/date-field/date-input-group.tsx
@@ -1,4 +1,13 @@
-import * as React from "react";
+import {
+ useEffect,
+ useMemo,
+ useRef,
+ type ComponentProps,
+ type KeyboardEvent,
+ type ReactNode,
+ type Ref,
+ type RefObject,
+} from "react";
import { Popover } from "@base-ui/react/popover";
import { CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -8,51 +17,15 @@ import { DATE_SHORTCUT_KEYS, type DateShortcut } from "@/lib/date-shortcuts";
import type { Segment } from "./use-date-field-state";
/**
- * Field presentation for the date components — the segmented spinbutton group,
- * its labels/description/error, and the popover wrapper used by `DatePicker`.
- * Built on Base UI primitives (`Popover`) + plain accessible markup, driven by
+ * Field presentation for the date components — the segmented spinbutton group
+ * and the popover wrapper used by `DatePicker`. Built on Base UI primitives
+ * (`Popover`) + plain accessible markup, driven by
* our own `useDateFieldState` engine. Not exported from the package.
*
* Styling mirrors the rest of the library (`astw:` tokens, dark mode, the same
* popover token set as our other Base UI popovers).
*/
-// ─── Field labels / description / error ───────────────────────────────────────
-
-// A composite spinbutton group can't be labelled by a native ,
-// so the label is a referenced via the group's `aria-labelledby` (the
-// APG date-field pattern).
-export function DatePickerLabel({ className, ...props }: React.ComponentProps<"span">) {
- return (
-
- );
-}
-
-export function DatePickerDescription({ className, ...props }: React.ComponentProps<"p">) {
- return (
-
- );
-}
-
-export function DatePickerError({ className, ...props }: React.ComponentProps<"p">) {
- return (
-
- );
-}
-
// ─── Segmented input group ────────────────────────────────────────────────────
const groupClasses = cn(
@@ -93,14 +66,19 @@ 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;
className?: string;
- trigger?: React.ReactNode;
+ trigger?: ReactNode;
/** Ref to the group element — used to anchor the popover to the whole field. */
- groupRef?: React.Ref;
+ groupRef?: Ref;
+ /** Called once when focus enters the group from outside. */
+ onGroupFocus?: () => void;
+ /** Called once when focus leaves the group entirely. */
+ onGroupBlur?: (nextFocused: EventTarget | null) => void;
}
export function DateInputGroup({
@@ -118,27 +96,29 @@ 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)[]>([]);
+ const editableRefs = useRef<(HTMLDivElement | null)[]>([]);
// Digits typed into the currently-focused segment this session. Reset on
// focus; the first digit (count 0) replaces, and the count decides when the
// segment is "full" and should auto-advance.
- const typedCountRef = React.useRef(0);
+ const typedCountRef = useRef(0);
- React.useEffect(() => {
+ useEffect(() => {
if (autoFocus && !isDisabled) editableRefs.current[0]?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Index editable segments for left/right focus movement.
- const editableIndexById = React.useMemo(() => {
+ const editableIndexById = useMemo(() => {
const map = new Map();
let i = 0;
segments.forEach((s, idx) => {
@@ -153,7 +133,7 @@ export function DateInputGroup({
};
const handleKeyDown = (
- e: React.KeyboardEvent,
+ e: KeyboardEvent,
segment: Segment,
editableIndex: number,
) => {
@@ -248,13 +228,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 +247,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?.(e.relatedTarget);
+ }
}}
>
@@ -344,7 +330,7 @@ const triggerClasses = cn(
export function DatePickerPopoverTrigger({
className,
...props
-}: React.ComponentProps
) {
+}: ComponentProps) {
const t = useDateFieldT();
return (
void;
/** The field group — must contain a `DatePickerPopoverTrigger`. */
- field: React.ReactNode;
- children: React.ReactNode;
+ field: ReactNode;
+ children: ReactNode;
ariaLabel?: string;
+ ariaLabelledby?: string;
+ popupRef?: Ref;
+ onPopupBlur?: (nextFocused: EventTarget | null) => void;
/**
* Element to position the calendar against. Defaults to the trigger; pass the
* field group so the calendar aligns to the field's edge (not the icon),
* overlapping it horizontally and shifting inward near the viewport edge.
*/
- anchor?: React.RefObject;
+ anchor?: RefObject;
}
export function DatePopover({
@@ -383,6 +372,9 @@ export function DatePopover({
field,
children,
ariaLabel,
+ ariaLabelledby,
+ popupRef,
+ onPopupBlur,
anchor,
}: DatePopoverProps) {
const t = useDateFieldT();
@@ -393,14 +385,21 @@ export function DatePopover({
{/* APG date-picker dialog pattern — the popup is a labelled dialog. */}
{
+ if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
+ onPopupBlur?.(e.relatedTarget);
+ }
+ }}
>
{children}
diff --git a/packages/core/src/components/date-field/use-date-field-state.ts b/packages/core/src/components/date-field/use-date-field-state.ts
index 685a5e2b..c1402e18 100644
--- a/packages/core/src/components/date-field/use-date-field-state.ts
+++ b/packages/core/src/components/date-field/use-date-field-state.ts
@@ -71,10 +71,37 @@ export interface Segment {
maxValue?: number;
}
+/**
+ * Why the state machine emitted a change notification.
+ *
+ * - `edit`: the user changed one or more segments
+ * - `clear`: the user explicitly cleared a segment, which may emit `null`
+ * - `external`: the controlled `value` prop changed from outside the widget
+ */
+export type DateFieldStateChangeSource = "edit" | "clear" | "external";
+
+/**
+ * Snapshot emitted to consumers that need more than the composed `DateValue`.
+ *
+ * `useDateFieldState()` is the source of truth for segmented editing. Most
+ * callers only care about `onChange(value)`, but the Field/Form bridge also
+ * needs the serialized input string, whether any segment is currently filled,
+ * and whether the composed value is locally invalid. This payload exposes that
+ * derived state at the same moment the internal segment state changes.
+ */
+export interface DateFieldStateChange {
+ source: DateFieldStateChangeSource;
+ fieldValue: DateValue | null;
+ inputValue: string;
+ hasInput: boolean;
+ invalidReason: DateFieldInvalidReason | null;
+}
+
export interface DateFieldStateOptions {
value?: DateValue | null;
defaultValue?: DateValue | null;
onChange?: (value: DateValue | null) => void;
+ onStateChange?: (change: DateFieldStateChange) => void;
granularity?: Granularity;
locale: string;
/** Used only to construct `ZonedDateTime` values for time granularities. */
@@ -191,11 +218,39 @@ function getInvalidReason(
return null;
}
+/**
+ * Stateful engine for segmented date/time entry.
+ *
+ * This hook owns the editable segment model used by both `DateField` and
+ * `DatePicker`. It is intentionally lower-level than a normal input hook:
+ * instead of tracking one text string, it tracks logical date segments
+ * (`year`, `month`, `day`, optional time parts) and derives everything else
+ * from that state.
+ *
+ * Responsibilities:
+ * - keep per-segment editing state stable across partial/incomplete input
+ * - compose a public `DateValue` once the entered segments form a complete date
+ * - preserve controlled/uncontrolled behaviour without clobbering in-progress edits
+ * - implement keyboard editing semantics such as increment/decrement,
+ * auto-advance, shortcuts, and blur-time normalization
+ * - surface local invalid reasons (`range` / `unavailable`) without coercing the value
+ *
+ * Outputs are split in two layers:
+ * - `onChange(value)` for the public component contract
+ * - `onStateChange(change)` for internal consumers that need richer derived
+ * state, such as the hidden proxy-input bridge used by `Field` / `Form`
+ *
+ * In practice, this hook is the date-widget equivalent of an input state
+ * machine: callers render the segments it describes, invoke the returned
+ * mutation helpers in response to keyboard interactions, and react to the
+ * emitted value/state snapshots.
+ */
export function useDateFieldState(options: DateFieldStateOptions) {
const {
value: controlledValue,
defaultValue,
onChange,
+ onStateChange,
granularity = "day",
locale,
timeZone,
@@ -305,19 +360,37 @@ export function useDateFieldState(options: DateFieldStateOptions) {
[hasTime, is12, controlledValue, defaultValue, placeholderValue, timeZone],
);
+ const buildStateChange = useCallback(
+ (nextFields: Fields, source: DateFieldStateChangeSource): DateFieldStateChange => {
+ const fieldValue = composeValue(nextFields);
+ const hasInput = editableTypes.some((type) => nextFields[type] != null);
+ const invalidReason = getInvalidReason(fieldValue, minValue, maxValue, isDateUnavailable);
+ return {
+ source,
+ fieldValue,
+ inputValue: fieldValue?.toString() ?? "",
+ hasInput,
+ invalidReason,
+ };
+ },
+ [composeValue, editableTypes, isDateUnavailable, maxValue, minValue],
+ );
+
const commit = useCallback(
(next: Fields, intent: "edit" | "clear" = "edit") => {
// Self-correct an impossible day as soon as the date is complete, so an
// unreachable date (e.g. 29 Feb in a non-leap year) never persists — no
// matter how the field is left. Skipped while clearing a segment.
const f = intent === "edit" ? clampCompleteDay(next) : next;
+ const change = buildStateChange(f, intent === "clear" ? "clear" : "edit");
setInternalFields(f);
- const composed = composeValue(f);
+ onStateChange?.(change);
+
// While editing, only emit a *complete & valid* value — never `null` for a
// half-typed/out-of-range intermediate (that would thrash a controlled
// value and lose the in-progress entry). Clearing explicitly emits `null`.
let emit: DateValue | null | undefined;
- if (composed != null) emit = composed;
+ if (change.fieldValue != null) emit = change.fieldValue;
else if (intent === "clear") emit = null;
else emit = undefined;
if (emit === undefined) return;
@@ -331,7 +404,7 @@ export function useDateFieldState(options: DateFieldStateOptions) {
onChange?.(emit);
}
},
- [composeValue, onChange],
+ [buildStateChange, onChange, onStateChange],
);
// Sync internal segments when a *controlled* value changes externally (i.e.
@@ -344,10 +417,12 @@ export function useDateFieldState(options: DateFieldStateOptions) {
const same =
(cv == null && le == null) || (cv != null && le != null && cv.compare(le as never) === 0);
if (!same) {
+ const nextFields = fieldsFromValue(cv);
lastEmitted.current = cv;
- setInternalFields(fieldsFromValue(cv));
+ setInternalFields(nextFields);
+ onStateChange?.(buildStateChange(nextFields, "external"));
}
- }, [controlledValue, isControlled]);
+ }, [buildStateChange, controlledValue, isControlled, onStateChange]);
// ── Mutations ───────────────────────────────────────────────────────────────
const cycle = useCallback(
@@ -571,16 +646,14 @@ export function useDateFieldState(options: DateFieldStateOptions) {
});
}, [segmentFormat, fields, editableTypes, getLimits]);
- const fieldValue = composeValue(fields);
- // Free-entry validation: the value still emits (it's what the user typed), but
- // an out-of-range or unavailable date is surfaced as invalid, not clamped.
- const invalidReason = getInvalidReason(fieldValue, minValue, maxValue, isDateUnavailable);
+ const currentChange = buildStateChange(fields, "edit");
return {
segments,
- fieldValue,
- isInvalid: invalidReason != null,
- invalidReason,
+ fieldValue: currentChange.fieldValue,
+ hasInput: currentChange.hasInput,
+ isInvalid: currentChange.invalidReason != null,
+ invalidReason: currentChange.invalidReason,
cycle,
setDigit,
setDayPeriod,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2286598c..0ba8fb2f 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -234,7 +234,7 @@ importers:
packages/core:
dependencies:
'@base-ui/react':
- specifier: ^1.6.0
+ specifier: 1.6.0
version: 1.6.0(@types/react@19.2.13)(date-fns@4.4.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@internationalized/date':
specifier: 3.12.2