diff --git a/.changeset/odd-maps-glow.md b/.changeset/odd-maps-glow.md
new file mode 100644
index 00000000..98967c23
--- /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.
+
+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 because it was unused.
+
+`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..91961c13 100644
--- a/examples/vite-app/src/pages/date-picker/page.tsx
+++ b/examples/vite-app/src/pages/date-picker/page.tsx
@@ -1,10 +1,10 @@
-import { useState, type FormEvent } from "react";
+import { useState, type FormEvent, type ReactElement } from "react";
import {
Layout,
+ Field,
DateField,
DatePicker,
Calendar,
- Form,
Button,
useTimeZone,
parseDate,
@@ -14,6 +14,29 @@ import {
} from "@tailor-platform/app-shell";
import { CalendarDays } from "lucide-react";
+function DemoField({
+ id,
+ label,
+ description,
+ error,
+ children,
+}: {
+ id: string;
+ label: string;
+ description?: string;
+ error?: string;
+ children: ReactElement;
+}) {
+ return (
+
+ {label}
+ {children}
+ {description && {description} }
+ {error && {error} }
+
+ );
+}
+
const DatePickerPage = () => {
const tz = useTimeZone();
const [fieldValue, setFieldValue] = useState(null);
@@ -29,8 +52,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 example uses AppShell's `Field` wiring so
+ // the date controls behave like the other form inputs in the library.
const handleDeliverySubmit = (e: FormEvent) => {
e.preventDefault();
if (!deliveryDate) {
@@ -72,19 +95,25 @@ const DatePickerPage = () => {
DateField
- setFieldValue(v as CalendarDate | null)}
- />
-
+ setFieldValue(v as CalendarDate | null)}
+ />
+
+
-
-
+ >
+
+
+
+
+
+
+
+
{fieldValue && (
@@ -99,30 +128,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 && (
@@ -136,31 +173,32 @@ 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.
+ Standard form submit with AppShell{" "}
+ Field wiring. Submitting empty
+ (or with a past date) marks the date picker invalid, and the error clears as soon as a
+ valid date is picked.
-
+
{confirmedDate && (
✓ Delivery scheduled for {confirmedDate}
@@ -177,9 +215,15 @@ const DatePickerPage = () => {
explicitly to force a specific start day regardless of locale.
-
-
-
+
+
+
+
+
+
+
+
+
@@ -189,9 +233,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..dd087381 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/src/components/date-field/date-field.test.tsx b/packages/core/src/components/date-field/date-field.test.tsx
index d5a95433..088d12a1 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,7 @@ import {
isSameDay,
} 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:
@@ -42,65 +43,123 @@ 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("renders error message when isInvalid", () => {
- render( );
+ 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("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 +179,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 +194,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 +211,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 +232,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -198,7 +257,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -224,7 +283,7 @@ describe("DateField", () => {
return (
<>
{
setV(nv as CalendarDate | null);
@@ -283,7 +342,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 +361,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 +389,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -350,7 +409,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -372,7 +431,7 @@ describe("DateField", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -388,7 +447,7 @@ describe("DateField", () => {
it("clears a controlled DateField when the value is reset to null", () => {
const { rerender } = render(
{}}
/>,
@@ -396,14 +455,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 +483,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 +495,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -448,7 +511,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 +523,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -473,7 +540,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -488,7 +559,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 +573,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -515,7 +590,11 @@ describe("DateField keyboard shortcuts", () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(
- ,
+ ,
);
await user.click(screen.getByRole("spinbutton", { name: "day" }));
@@ -530,7 +609,7 @@ describe("DateField keyboard shortcuts", () => {
const onChangeEq = vi.fn();
const { unmount } = render(
,
@@ -543,7 +622,7 @@ describe("DateField keyboard shortcuts", () => {
const onChangePlus = vi.fn();
render(
,
@@ -556,7 +635,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 +648,7 @@ describe("DateField keyboard shortcuts", () => {
const onChange = vi.fn();
render(
<>
-
+
elsewhere
>,
);
@@ -588,7 +667,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 +690,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 +767,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 +789,7 @@ describe("DateField keyboard shortcuts", () => {
const unavailable = new CalendarDate(2025, 6, 12);
render(
isSameDay(d, unavailable)}
onChange={onChange}
/>,
@@ -720,13 +799,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 +819,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 +844,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 +856,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 +874,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 +885,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 +894,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.
@@ -855,7 +965,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 +974,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 +984,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 +1024,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 +1047,7 @@ describe("DatePicker keyboard", () => {
const onChange = vi.fn();
render(
{
const onChange = vi.fn();
render(
`.
+ *
+ * Because of that, they also render a hidden **proxy input**.
+ *
+ * What the proxy input is:
+ * - a real ` ` that mirrors the composed date value as a string
+ * - visually hidden and not used for direct text entry
+ * - the native form/validation anchor that the composite widget can delegate to
+ *
+ * Why it exists:
+ * - native form submission expects a real form control with `name` / `value`
+ * - browser validity APIs such as `setCustomValidity()` only exist on native
+ * form controls
+ * - `` and Base UI's `Field` / `Form` infrastructure need a
+ * concrete control element to point at / register
+ * - native validation bubbles should anchor near the date widget rather than at
+ * some unrelated off-screen element
+ *
+ * When rendered inside `Field.Root`, the proxy input is also bridged into Base
+ * UI's field/form wiring so `Field.Label`, `Field.Description`, `Field.Error`,
+ * and form error routing work the same way they do for the other AppShell form
+ * controls.
*/
-// 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,
+ reason: "range" | "unavailable" | null | undefined,
): "dateUnavailable" | "dateOutOfRange" | null {
if (reason === "unavailable") return "dateUnavailable";
if (reason === "range") return "dateOutOfRange";
return null;
}
-// ─── Small controlled-state helper ────────────────────────────────────────────
+function assignRef(ref: Ref | undefined, value: T | null) {
+ if (!ref) return;
+ if (typeof ref === "function") {
+ ref(value);
+ return;
+ }
+ ref.current = value;
+}
+
+/**
+ * Produces the ref callback used by the hidden proxy input.
+ *
+ * The proxy input has two distinct consumers:
+ * - Base UI field/form internals, which need the DOM node to register the
+ * control, run validation, and focus it from `Field.Label`
+ * - the component's forwarded `ref`, so consumers can still receive the input
+ * element exposed by `DateField` / `DatePicker`
+ *
+ * It also applies the current custom validity message to the DOM node via
+ * `setCustomValidity()`. That imperative step must happen on the actual
+ * ` ` element; it cannot be expressed declaratively in JSX props.
+ *
+ * In short: this hook is the small piece that turns the hidden input from
+ * "just some DOM node" into the date widget's native form/validation bridge.
+ */
+function useProxyInputRef(
+ fieldRef: Ref | undefined,
+ forwardedRef: Ref | undefined,
+ customValidity: string,
+) {
+ return useCallback(
+ (node: HTMLInputElement | null) => {
+ if (node) node.setCustomValidity(customValidity);
+ assignRef(fieldRef, node);
+ assignRef(forwardedRef, node);
+ },
+ [customValidity, fieldRef, forwardedRef],
+ );
+}
+
function useControlledState(
controlled: V | undefined,
defaultValue: V,
@@ -57,101 +127,297 @@ function useControlledState(
return [value, set];
}
-// ─── Shared prop types (names unchanged from react-aria) ──────────────────────
+function isSameDateValue(a: DateValue | null | undefined, b: DateValue | null | undefined) {
+ if (a == null || b == null) return a == null && b == null;
+ return a.compare(b as never) === 0;
+}
-interface DateFieldMetaProps {
- label?: LocalizedString;
- description?: LocalizedString;
- errorMessage?: LocalizedString;
- className?: string;
+function isTargetWithin(
+ target: EventTarget | null,
+ ref: RefObject,
+): target is Node {
+ return target instanceof Node && ref.current?.contains(target) === true;
}
-interface DateBehaviorProps {
+interface DateFieldBridgeOptions {
+ id?: string;
+ name?: string;
+ value: DateValue | null;
+ hasValue: boolean;
+ isDisabled?: boolean;
+ isInvalid?: boolean;
+ customValidity: string;
+ "aria-labelledby"?: string;
+ "aria-describedby"?: string;
+ ref?: Ref;
+}
+
+/**
+ * Adapts the standalone date widgets to Base UI's `Field` / `Form` contract.
+ *
+ * Why this exists:
+ * - `DateField` / `DatePicker` are hand-rolled composite widgets made of a
+ * labelled `role="group"` plus per-segment `role="spinbutton"` elements.
+ * - Base UI's form ecosystem (`Field.Root`, `Field.Label`, `Field.Description`,
+ * `Field.Error`, `Form`) is built around a *registered control* that exposes
+ * an id, name, ref, value, validation lifecycle, and field state updates.
+ * - The date widgets already render a hidden proxy ` ` for native form
+ * submission and validity bubbles, but without this bridge that input is just
+ * a DOM detail — Base UI wouldn't know to associate labels/descriptions with
+ * it, treat it as the field's control, or drive dirty/touched/focused state.
+ *
+ * What this hook does:
+ * - resolves the effective control `id` / `name`
+ * - derives `aria-labelledby` / `aria-describedby` from Base UI's labelable
+ * context so `Field.Label` / `Field.Description` / `Field.Error` work
+ * - registers the hidden proxy input as the field control via
+ * `useRegisterFieldControl`
+ * - mirrors Base UI field state onto the visual date group (`disabled`,
+ * `invalid`) so styling and accessibility stay in sync
+ * - updates form state (`filled`, `dirty`, `focused`, `touched`) and triggers
+ * validation / server-error clearing on change and blur
+ *
+ * Standalone safety:
+ * Base UI's internals fall back to inert default contexts outside `Field.Root`
+ * / `Form`, so this hook becomes a no-op bridge and the date widgets continue
+ * to work as plain standalone controls.
+ *
+ * We keep all Base UI internals usage here so the dependency surface is narrow:
+ * if Base UI changes these internals in the future, this is the one place to
+ * adjust rather than spreading the coupling throughout both date components.
+ */
+function useDateFieldFieldBridge({
+ id: idProp,
+ name: nameProp,
+ value,
+ hasValue,
+ isDisabled,
+ isInvalid,
+ customValidity,
+ "aria-labelledby": ariaLabelledbyProp,
+ "aria-describedby": ariaDescribedbyProp,
+ ref,
+}: DateFieldBridgeOptions) {
+ const { clearErrors, errors } = useFormContext();
+ const {
+ name: fieldName,
+ disabled: fieldDisabled,
+ invalid: fieldInvalid,
+ state: fieldState,
+ validityData,
+ setTouched,
+ setDirty,
+ setFilled,
+ setFocused,
+ validationMode,
+ validation,
+ shouldValidateOnChange,
+ } = useFieldRootContext();
+ const { labelId, messageIds } = useLabelableContext();
+
+ const needsRegisteredId = idProp != null || fieldName != null || labelId != null;
+ const generatedId = useLabelableId({ id: idProp });
+ const id = needsRegisteredId ? generatedId : idProp;
+ const ariaLabelledby = useAriaLabelledBy(
+ ariaLabelledbyProp,
+ labelId,
+ validation.inputRef,
+ true,
+ id,
+ );
+ const describedById = [ariaDescribedbyProp, ...messageIds].filter(Boolean).join(" ") || undefined;
+
+ const resolvedDisabled = fieldDisabled || isDisabled;
+ const resolvedName = fieldName ?? nameProp;
+ const hasFormError =
+ !!resolvedName && Object.hasOwn(errors, resolvedName) && errors[resolvedName] !== undefined;
+ const resolvedInvalid =
+ !!isInvalid || fieldInvalid === true || fieldState.valid === false || hasFormError;
+
+ const setProxyRef = useProxyInputRef(validation.inputRef, ref, customValidity);
+ const getFormValue = useCallback(() => value?.toString() ?? "", [value]);
+ useRegisterFieldControl(validation.inputRef, id, value, getFormValue, id != null);
+
+ const latestValueRef = useRef(value);
+ latestValueRef.current = value;
+
+ const didMountRef = useRef(false);
+ const pendingBlurValidationRef = useRef(false);
+
+ useEffect(() => {
+ const initialValue = validityData.initialValue as DateValue | null;
+ const isDirty =
+ value != null && initialValue != null
+ ? !isSameDateValue(value, initialValue)
+ : hasValue || initialValue != null;
+
+ setFilled(hasValue);
+ setDirty(isDirty);
+
+ if (!didMountRef.current) {
+ didMountRef.current = true;
+ return;
+ }
+
+ if (resolvedName) clearErrors(resolvedName);
+
+ if (pendingBlurValidationRef.current) {
+ pendingBlurValidationRef.current = false;
+ validation.commit(latestValueRef.current);
+ return;
+ }
+
+ if (shouldValidateOnChange() && (value != null || !hasValue)) {
+ validation.commit(latestValueRef.current);
+ }
+ }, [
+ clearErrors,
+ hasValue,
+ resolvedName,
+ setDirty,
+ setFilled,
+ shouldValidateOnChange,
+ validation,
+ validityData.initialValue,
+ value,
+ ]);
+
+ const handleGroupFocus = useCallback(() => {
+ setFocused(true);
+ }, [setFocused]);
+
+ const handleGroupBlur = useCallback(() => {
+ setTouched(true);
+ setFocused(false);
+
+ if (validationMode === "onBlur") {
+ pendingBlurValidationRef.current = true;
+ queueMicrotask(() => {
+ if (!pendingBlurValidationRef.current) return;
+ pendingBlurValidationRef.current = false;
+ validation.commit(latestValueRef.current);
+ });
+ }
+ }, [setFocused, setTouched, validation, validationMode]);
+
+ return {
+ id,
+ name: resolvedName,
+ isDisabled: resolvedDisabled,
+ isInvalid: resolvedInvalid,
+ ariaLabelledby,
+ describedById,
+ setProxyRef,
+ onGroupFocus: handleGroupFocus,
+ onGroupBlur: handleGroupBlur,
+ };
+}
+
+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;
+ isDateUnavailable?: (date: T) => boolean;
isDisabled?: boolean;
isReadOnly?: boolean;
isRequired?: boolean;
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 ────────────────────────────────────────────────────────────────
+function toDateUnavailablePredicate(
+ predicate?: (date: T) => boolean,
+): ((date: DateValue) => boolean) | undefined {
+ if (!predicate) return undefined;
+ return (date: DateValue) => predicate(date as T);
+}
+
+function hasSegmentValue(segments: Segment[]) {
+ return segments.some((segment) => segment.type !== "literal" && !segment.isPlaceholder);
+}
/**
* A segmented date/time input field with no popover.
*
+ * Provide an accessible name with `aria-label` or `aria-labelledby`. When used
+ * inside `Field.Root`, `Field.Label` / `Field.Description` / `Field.Error`
+ * wiring is automatic.
+ *
* @example
* ```tsx
- * import { DateField } from "@tailor-platform/app-shell";
+ * import { DateField, Field } from "@tailor-platform/app-shell";
+ *
+ * ;
*
- *
- *
+ *
+ * Invoice date
+ *
+ * ;
* ```
*/
-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 = 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 { locale: shellLocale } = useResolvedLocale();
const resolvedLocale = localeProp ?? shellLocale;
- const resolve = buildLocaleResolver(language);
+ const groupRef = useRef(null);
const t = useDateFieldT();
-
- const labelId = useId();
- const descId = useId();
- const errId = useId();
+ const dateUnavailable = useMemo(
+ () => toDateUnavailablePredicate(isDateUnavailable),
+ [isDateUnavailable],
+ );
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 +425,59 @@ 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.
+ isDateUnavailable: dateUnavailable,
firstDayOfWeek,
isReadOnly,
});
- 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 localValidationMessage = useMemo(() => {
+ const key = invalidMessageKey(state.invalidReason);
+ return key ? t(key) : "";
+ }, [state.invalidReason, t]);
- const describedBy = cn(descText && descId, derivedInvalid && shownError && errId) || undefined;
+ const bridge = useDateFieldFieldBridge({
+ id,
+ name,
+ value: state.fieldValue,
+ hasValue: hasSegmentValue(state.segments),
+ isDisabled,
+ isInvalid: !!isInvalid || !!localValidationMessage,
+ customValidity: localValidationMessage,
+ ref,
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ });
+
+ const focusFirstSegment = useCallback(() => {
+ const first = groupRef.current?.querySelector('[role="spinbutton"]');
+ first?.focus();
+ }, []);
return (
-
- {labelText &&
{labelText} }
+
+ {/*
+ Hidden proxy input:
+ - carries the serialized form value
+ - receives native/custom validity
+ - is the target for external labels / Base UI Field wiring
+ - forwards focus into the first visible date segment
+ */}
+ {}}
+ onFocus={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}
+ isDisabled={bridge.isDisabled}
isReadOnly={isReadOnly}
- isInvalid={derivedInvalid}
+ isInvalid={bridge.isInvalid}
isRequired={isRequired}
autoFocus={autoFocus}
- labelId={labelText ? labelId : undefined}
+ ariaLabelledby={bridge.ariaLabelledby}
ariaLabel={ariaLabel}
- describedById={describedBy}
+ describedById={bridge.describedById}
+ groupRef={groupRef}
+ onGroupFocus={bridge.onGroupFocus}
+ onGroupBlur={() => {
+ bridge.onGroupBlur();
+ onBlur?.();
+ }}
/>
- {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)
+ * Provide an accessible name with `aria-label` or `aria-labelledby`. When used
+ * inside `Field.Root`, `Field.Label` / `Field.Description` / `Field.Error`
+ * wiring is automatic.
*
* @example
* ```tsx
- * import { DatePicker, today, getLocalTimeZone, type CalendarDate } from "@tailor-platform/app-shell";
+ * import { DatePicker, Field, getLocalTimeZone, today } from "@tailor-platform/app-shell";
*
- * const [date, setDate] = useState(null);
- *
+ * ;
+ *
+ *
+ * Ship date
+ *
+ * ;
* ```
*/
-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 { 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 dateUnavailable = useMemo(
+ () => toDateUnavailablePredicate(isDateUnavailable),
+ [isDateUnavailable],
+ );
const [open, setOpen] = useState(false);
const fieldRef = useRef(null);
+ const popupRef = useRef(null);
+ const hasFocusWithinRef = useRef(false);
const [val, setVal] = useControlledState(
- // `null` is controlled-empty; only `undefined` means uncontrolled (see above).
value,
defaultValue ?? null,
onChange as (v: DateValue | null) => void,
@@ -280,31 +581,89 @@ 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.
+ isDateUnavailable: dateUnavailable,
firstDayOfWeek,
isReadOnly,
});
+ const localValidationMessage = useMemo(() => {
+ const key = invalidMessageKey(fieldState.invalidReason);
+ return key ? t(key) : "";
+ }, [fieldState.invalidReason, t]);
+
+ const bridge = useDateFieldFieldBridge({
+ id,
+ name,
+ value: fieldState.fieldValue,
+ hasValue: hasSegmentValue(fieldState.segments),
+ isDisabled,
+ isInvalid: !!isInvalid || !!localValidationMessage,
+ customValidity: localValidationMessage,
+ ref,
+ "aria-labelledby": ariaLabelledby,
+ "aria-describedby": ariaDescribedby,
+ });
+
+ const focusFirstSegment = useCallback(() => {
+ const first = fieldRef.current?.querySelector('[role="spinbutton"]');
+ first?.focus();
+ }, []);
+
+ const handleCompositeFocus = useCallback(() => {
+ hasFocusWithinRef.current = true;
+ bridge.onGroupFocus();
+ }, [bridge]);
+
+ const handleCompositeBlur = useCallback(() => {
+ if (!hasFocusWithinRef.current) return;
+ hasFocusWithinRef.current = false;
+ bridge.onGroupBlur();
+ onBlur?.();
+ }, [bridge, onBlur]);
+
+ 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 handleOpenChange = useCallback(
+ (nextOpen: boolean) => {
+ setOpen(nextOpen);
+ if (nextOpen) return;
+ queueMicrotask(() => {
+ if (isTargetWithin(document.activeElement, fieldRef)) return;
+ if (isTargetWithin(document.activeElement, popupRef)) return;
+ handleCompositeBlur();
+ });
+ },
+ [handleCompositeBlur],
+ );
+
const calState = useCalendarState({
value: val,
onChange: (d) => {
setVal(d);
- setOpen(false);
+ handleOpenChange(false);
},
minValue,
maxValue,
- isDateUnavailable,
+ isDateUnavailable: dateUnavailable,
isDisabled,
isReadOnly,
firstDayOfWeek,
@@ -312,25 +671,41 @@ function DatePicker({
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");
+ let popoverAriaLabel: string | undefined;
+ if (!bridge.ariaLabelledby) {
+ popoverAriaLabel = ariaLabel ? t("chooseDateFor", { name: ariaLabel }) : t("chooseDate");
+ }
return (
-
- {labelText &&
{labelText} }
+
+ {/*
+ Hidden proxy input:
+ - carries the serialized form value
+ - receives native/custom validity
+ - is the target for external labels / Base UI Field wiring
+ - forwards focus into the first visible date segment
+ */}
+ {}}
+ onFocus={focusFirstSegment}
+ className="astw:pointer-events-none astw:absolute astw:size-px astw:overflow-hidden astw:opacity-0"
+ />
({
applyShortcut={fieldState.applyShortcut}
commitOnBlur={fieldState.commitOnBlur}
expandShortYear={fieldState.expandShortYear}
- onOpenCalendar={() => setOpen(true)}
- isDisabled={isDisabled}
+ onOpenCalendar={() => handleOpenChange(true)}
+ isDisabled={bridge.isDisabled}
isReadOnly={isReadOnly}
- isInvalid={derivedInvalid}
+ isInvalid={bridge.isInvalid}
isRequired={isRequired}
autoFocus={autoFocus}
- labelId={labelText ? labelId : undefined}
+ ariaLabelledby={bridge.ariaLabelledby}
ariaLabel={ariaLabel}
- describedById={describedBy}
+ describedById={bridge.describedById}
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}