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