Skip to content

Commit a4f735c

Browse files
interacseanclaude
andauthored
feat: DatePicker (@internationalized/date + Base UI) + DataTable date filters (#332)
* docs(date-picker): add design proposal Proposes the DatePicker / DateField / Calendar API for AppShell: react-aria-components + @internationalized/date wrapped in a thin app-shell layer, with passed-through vs masked props, locale and timezone wiring, alternatives analysis, and measured bundle costs. Tracked at tailor-inc/platform-planning#1093. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(date-picker): drop Radix references from proposal Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(date-picker): clarify value layer (representation) vs Intl formatting Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(date-picker): document @internationalized/date packaging (regular dep + re-export) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(date-picker): add DateField/DatePicker/Calendar (@internationalized/date + Base UI) Implements the proposal's §9 lighter-foundation option: DateField, DatePicker, and Calendar built on @internationalized/date (value layer) + Base UI (Popover), with the segmented spinbutton input and APG calendar grid hand-rolled. Same public API and a11y/DOM contract as the react-aria variant; net-new dependency is just @internationalized/date (~11 KB gz) since Base UI is already bundled. - Locale-driven segment ordering, first-day-of-week, localized month/weekday names - Full APG keyboard: arrows, Home/End, PageUp/PageDown, Shift+PageUp/Down, Enter/Space - Roving tabindex; popover auto-focuses the grid and contains Tab (prev → next → grid) - Month-change via nav buttons keeps focus on the button (one-shot focus signal) - Visible focus ring on nav buttons + day cells - AppShell timeZone prop + useResolvedLocale()/useTimeZone(); @internationalized/date re-exports - 25 tests (behaviour + DOM a11y contract), docs, changeset, example page, impl comparison Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(data-table): use DatePicker for date filters + example page Wire the app-shell DatePicker into the DataTable date-filter editor (single and between ranges), replacing the native date input, and add an example page demonstrating DataTable + DataTable.Filters driven by useCollectionVariables and a promise-based async data source (a stub for the GraphQL query). DatePicker robustness fixes surfaced by controlled filter usage: - useDateFieldState keeps internal segment state synced from a controlled value (no thrash/loss of in-progress entry on every keystroke) - typing only emits a complete & valid value; clearing emits null - first digit after a segment is focused replaces rather than accumulates - composeValue guards reject out-of-range segments (no invalid CalendarDate) - per-segment display formatting (never constructs a date from partial input) - DatePicker/DateField gain an `aria-label` prop for compact, label-less inputs Example page: dummy invoice dataset, async queryInvoices(variables) applying filter/order/cursor-pagination, date column with `type: "date"` filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(date-picker): advance on leading-zero entry + align popover to field - Segment auto-advance now also fires when the typed-digit count fills the field's width, so typing "02" completes the day and advances — while "2" then "9" still builds 29 (a leading zero caps the digit count). - Anchor the calendar popover to the field group (not the calendar icon) with align="start", so its left edge aligns to the field's left edge and Base UI collision handling shifts/flips it inward near a viewport edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(data-table): friendlier date filter operators (exact date / after / before / between) Date columns now offer a slimmer, plainer operator set instead of the numeric comparators: - eq → "exact date" - gte → "after" (inclusive) - lte → "before" (inclusive) - between → "between" - dropped gt, lt, and ne (the inclusive after/before cover the intent) Scoped to `date` columns only — number/datetime/time keep the full numeric operator set and labels. Labels added for en + ja; chip labels and both the add-filter and edit-chip operator selects use them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(data-table): locale-format the date value in filter chips Date filter chips now render the value as a locale-appropriate medium date (e.g. "15 Jun 2026" / "Jun 15, 2026") via @internationalized/date's DateFormatter with the resolved AppShell locale, instead of the raw ISO "2026-06-15". Formatted in UTC so the calendar date never shifts across zones. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(date-picker): idiomatic Tailwind focus ring + snapshot tests Align with the add-component skill: - Replace the inline JS focus-ring handlers (el.style.outline / chromeFocusProps) with the same `ring` utility Button/inputs use — focus-visible:ring on the nav buttons + trigger, and :focus:ring (with relative/z-10 so it sits above adjacent cells) on calendar day cells. No more CSS-in-JS. - Add structural snapshot tests for DateField, DatePicker (closed), and Calendar, per the skill's testing convention. The earlier "no visible focus" was a wrong-attribute bug (data-[focus-visible], a react-aria attr our cells never emit), not a failure of `ring`; the compiled utilities are present in dist and identical to Button's. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: document DatePicker-backed date filters + aria-label prop - data-table.md: date filters use the DatePicker input and the friendlier operator set (exact date / after / before / between); note locale-formatted chips and that datetime/time are unchanged. - date-picker.md: add the `aria-label` prop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(example): make Invoice a type so it satisfies the DataTable row constraint The vite-app type-check (run by CI, not by esbuild) failed: an `interface` doesn't satisfy `Record<string, unknown>` — createColumnHelper / useDataTable's row constraint — because interfaces lack the implicit index signature that object-literal `type` aliases have. Switch `interface Invoice` to `type`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(date-picker): allow day 31 before a month, clamp impossible dates on blur Two related date-validity fixes: - Day segment max is 31 until a valid month is entered (was tied to the anchor month, so "31" collapsed to "1" in a 30-day current month — and the snapshot was month-dependent). Snapshots updated to the now-stable max. - On blur, an impossible day for the entered month/year is clamped to that month's real length (e.g. 30/02/2026 → 28; 31/04 → 30). Leap years resolve correctly since the year is complete by blur — 29/02/2024 is kept, 29/02/2026 becomes 28. composeValue still never emits an invalid CalendarDate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(date-picker): let keyboard focus traverse disabled/unavailable days Roving focus could land on a disabled or unavailable calendar day, but the arrow-key handler was only attached to selectable cells — so once focus reached such a day there was no way to navigate off it. Per APG, arrow keys traverse *through* disabled dates; they just can't be selected. Attach onKeyDown to every in-month cell (gated on `focusable`, not `interactive`) while keeping click + Enter/Space selection gated on `interactive`. selectDate already rejects out-of-range/unavailable dates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(date-picker): self-correct impossible day on date completion, not just blur An impossible day (e.g. 29 Feb in a non-leap year) could persist when the field was left in ways that didn't fire the group's blur clamp — including editing a previously-valid leap date's year (29/02/2024 → 2026). Clamp the day to the month's real length inside `commit`, the moment the date is fully specified (a 4-digit year). Partial years are left untouched so the day isn't shrunk mid-typing before the final year's leap-ness is known. The on-blur `clampDate` remains the backstop for the year-still-empty case. This makes the correction blur-independent: typing 29/02/2026 self-corrects to 28 as soon as the year lands, and re-typing the year of a valid leap date re-validates immediately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(example): demonstrate DatePicker validation in a Form Add an "In a form (submit validation)" section to the date-picker demo page: a standard Form + submit Button, with the DatePicker's required/past-date checks run on submit. The DatePicker isn't a Base UI Field control, so the error is surfaced through its own errorMessage/isInvalid props and clears as soon as a valid date is picked. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(date-picker): don't scroll the page when opening the calendar popover Opening the popover moves focus into the grid (and roving nav moves it between cells). Those programmatic `.focus()` calls let the browser scroll the focused cell into view, jumping the page even when the field was already visible. Pass `{ preventScroll: true }` to every programmatic focus in the calendar (popover-open auto-focus, roving-focus effect, Tab containment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(example): clarify locale-dependent week start in date-picker demo The "Week starts Sunday (default)" label was misleading: omitting `firstDayOfWeek` follows the active locale (Sunday for en-US, Monday for en-GB), so on a Monday-first locale it never showed Sunday. Replace with explicit "Forced Sunday" / "Forced Monday" examples plus a "Locale default" one, and a note explaining the behaviour. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(date-picker): give the field a default min-width so it can't collapse The group inherits `w-full min-w-0` from the shared input classes, so in a narrow or flex container it could shrink to nothing. Floor it at 142px — enough for "dd / mm / yyyy" + the trigger icon plus padding. Content can still exceed it, so wider locales (e.g. ja-JP) grow past the floor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(data-table): preserve legacy date-filter operators instead of coercing to eq Date columns narrowed their operator set to eq/gte/lte/between (dropping gt/lt/ne). But a saved view or `useCollectionVariables` config can still hold a date filter on a dropped operator — the chip rendered fine, yet opening the editor preselected `eq` and omitted the original operator, so hitting Apply silently rewrote the filter's semantics (e.g. "after X" → "on X"). `resolveTemporalOperator` now keeps an incoming operator that's outside the standard set as a selectable, preselected option (for that one filter), so opening + re-applying never changes it. Applies to both the temporal and numeric editors; truly unknown operators still fall back to eq. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(date-picker): reconcile proposal with shipped variant; document mobile gap The proposal still recorded react-aria-components as the v1 decision while this PR ships the §9 Base UI + @internationalized/date variant — a self-contradiction. Add a dated Revision note: the foundation swap is proposed for v1 and *pending sign-off*, with the original 2026-06-17 decision kept verbatim for the record. Also surface the mobile/touch-typing limitation (spinbutton segments, no hidden numeric input) and the not-yet-SR-audited caveat in the component docs and the changeset — previously only in the comparison proposal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(date-picker): record the Base UI foundation as the approved v1 decision Foundation swap approved 2026-06-26. Update the proposal's Status and Revision note from "pending sign-off" to a finalised decision: v1 builds on the §9 @internationalized/date + Base UI variant, superseding the original 2026-06-17 react-aria-components decision (kept verbatim below for the record). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(date-picker): correct mobile-typing mechanism (contentEditable, not a hidden input) The comparison doc claimed react-aria enables mobile typing via a hidden `<input inputmode="numeric">`. That's wrong: react-aria's segments are `contentEditable` spans with `inputmode="numeric"` — the soft keyboard surfaces because the focused element is editable. Ours are non-editable spinbutton divs driven by keydown (which soft keyboards don't emit), hence no touch typing. Correct the comparison, component doc, and changeset, and note the gap is addressable by matching react-aria's contentEditable + beforeinput approach. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(date-picker): capture the mobile-typing fast-follow approach in the proposal Add a "Post-v1 fast-follows" section detailing the contentEditable + inputmode + beforeinput approach for touch typing (mirroring react-aria), plus a pointer to the comparison doc's full gap inventory — so the implementation thinking is recorded rather than living only in a PR thread. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(date-picker): split implemented vs proposed props; proper tables for all three DateField/DatePicker/CalendarProps are now honest per-component tables reflecting what this variant actually wires up. Move props that are accepted (for react-aria parity) but not yet acted on into a "Proposed / not yet implemented" table: - granularity beyond "day" + hourCycle (DateTime is the tracked fast-follow) - hideTimeZone and isRequired (accepted but unused) Also: DateField ignores minValue/maxValue/isDateUnavailable (no calendar), so those now live under DatePicker/Calendar; drop the stale "12/24-hour display" localization claim (time granularity isn't supported yet). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(date-picker): rename date-picker-standalone.tsx to date-field.tsx Name the public API file after its base component (DateField) rather than the generic -standalone suffix — and avoid colliding with the internal date-picker.tsx presentation layer. Pure rename + import-path updates; no API or behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(date-picker): split into date-field/ and calendar/ component folders Components with supporting files (external hooks) now live in a folder named after the component, each with {component, presentation, hook(s), test, index}: components/date-field/ DateField + DatePicker, date-input-group (field presentation + popover), use-date-field-state, tests components/calendar/ Calendar, calendar-view (grid presentation), use-calendar-state, tests Calendar is now its own component/file for clarity (was bundled in the public API file). The old shared presentation (date-picker.tsx) is split into each folder's presentation file; DatePicker composes the calendar via ../calendar. Pure reorg — no public API or behaviour change; barrels keep import paths (`@/components/date-field`) working. oxlint a11y overrides re-globbed to the new folders; test snapshots regenerated under the new paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(date-field): backfill current month/year from a partial entry on blur When focus leaves the field, assume the current month/year for a partial date — typing just "2" ⇒ the 2nd of this month/year; "2 Aug" ⇒ 2 Aug this year. The day is the trigger: backfill only ever fills COARSER fields (month, year) from a provided finer one, never guesses the day, so a lone year (or month) is left untouched. Renames the on-blur `clampDate` handler to `commitOnBlur`, which now backfills then clamps in a single commit. Also updates the DataTable filter test helper: clearing a date must now remove the day (the backfill trigger), so `clearDateIn` clears all segments — otherwise the on-blur backfill re-completes the date. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(vite-app): normalize routes.generated.ts ordering Regenerated by the app-shell vite plugin — sorts /dashboard/products into place (the merge left it out of order). No routes added or removed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(date-field): use the resolved timezone for DatePicker's field state DatePicker passed the raw `timeZone` prop to useDateFieldState while the calendar used the resolved zone (prop → AppShell → local). With no `timeZone` prop the field fell back to UTC for its "today"/anchor (and would emit UTC-zoned values for time granularities) while the calendar used the AppShell zone — so the two disagreed. Pass `resolvedTz` to the field too. Addresses a GitHub Copilot review comment on #332. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(appshell): normalize a full-tag locale to its language subtag for UI strings `buildConfigurations` put `options.locale` verbatim into both `locale` (used to key i18n label tables) and `resolvedLocale` (formatting). A consumer passing a full BCP-47 tag like "ja-JP" made `labels["ja-JP"]` miss → English fallback. Normalize `locale` to its language subtag (`toLanguageSubtag`, e.g. "ja-JP" → "ja") while keeping the full tag in `resolvedLocale` for Intl/date formatting. Back-compatible: a subtag ("en"/"ja") — the documented, existing usage — is unchanged; only off-doc full tags are corrected. Addresses a GitHub Copilot review comment on #332. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * perf(date): memoize Intl formatters out of the per-keystroke/per-cell path - calendar: build the cell-label DateFormatter once per locale/timezone instead of a fresh one per rendered cell (~42 per grid render, per frame on held arrows). - date-field: hoist the DateFormatter + two NumberFormats + formatToParts into a memo keyed on locale/granularity/hour-cycle/timezone, so a keystroke (which only changes `fields`) reuses them instead of rebuilding all four. Addresses two GitHub review comments (erickteowarang) on #332. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(data-table): date-filter picker uses the column's visible label `DateFilterPicker`'s aria-label was the raw field id (e.g. "created_at"); thread the column's visible label (`column.label ?? field`) through TemporalFilterEditor so the accessible name matches what sighted users see, including the between "from"/"to" pickers. Addresses a GitHub review comment (github-actions bot, 3/3) on #332. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(date-field): controlled null, aria-required, and localized aria strings Addresses the PR review round on #332: - **Controlled `value={null}`** (High): pass `value` through to useDateFieldState / useControlledState instead of `value ?? undefined`, so `null` (controlled-empty) stays distinct from `undefined` (uncontrolled) — a parent clearing the field with `value={null}` now actually clears it. - **isRequired**: wire it to `aria-required` on the spinbutton segments (ARIA doesn't allow aria-required on the role="group" wrapper), for both DateField and DatePicker. Docs move it from "proposed" to implemented. - **Localized chrome**: the built-in aria-labels (Previous/Next month, Open calendar, Choose date, calendar dialog) now resolve via folder-local `defineI18nLabels` tables (en/ja), matching data-table's i18n pattern. Adds tests for controlled-null clearing (DateField + DatePicker) and aria-required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(date-field): localize the remaining built-in aria strings Sweep for hardcoded English beyond the strings already flagged in review: - Segment accessible names (month/day/year/hour/minute/second/AM-PM) — moved out of the engine's `SEGMENT_LABELS` constant into the i18n table, resolved at the presentation layer (drops the now-unused `Segment.label`). - The empty-segment `aria-valuetext` ("Empty"). - The `DatePopover` "Choose date" aria-label fallback. en values are unchanged (so tests/snapshots are stable); ja added. Tests assert ja resolution for the segment names, popover trigger, empty placeholder, and calendar month-nav labels. Deliberately left: the visual segment placeholders (yyyy/mm/dd — format tokens, a separate design choice) and the AM/PM display (time granularity, unsupported). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 6c5aa48 commit a4f735c

34 files changed

Lines changed: 4372 additions & 91 deletions

.changeset/date-picker.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
"@tailor-platform/app-shell": minor
3+
---
4+
5+
Add DateField, DatePicker, and Calendar components (@internationalized/date + Base UI implementation)
6+
7+
Introduces three accessible date-input components, implemented on `@internationalized/date` (value layer) and Base UI (`Popover`) with hand-rolled segmented-input and calendar-grid behaviour:
8+
9+
- `DateField` — segmented date/time input with label, description, and error message
10+
- `DatePicker` — date field with a popover calendar
11+
- `Calendar` — standalone calendar grid
12+
13+
All three accept `LocalizedString` labels/descriptions and resolve locale + timezone from the AppShell context. The `@internationalized/date` value types (`CalendarDate`, `CalendarDateTime`, `ZonedDateTime`, …) and helpers (`today`, `parseDate`, `getLocalTimeZone`, …) are re-exported from `@tailor-platform/app-shell`.
14+
15+
New AppShell context hooks:
16+
17+
- `useResolvedLocale()` — full BCP-47 locale (e.g. `"en-GB"`) plus the language code
18+
- `useTimeZone()` — the configured IANA timezone, falling back to the user's local timezone
19+
20+
AppShell now accepts an optional `timeZone` prop.
21+
22+
> This is the **`@internationalized/date` + Base UI** variant — the lighter foundation tracked in the design proposal (§9). Net-new dependency is just `@internationalized/date` (~11 KB gz); Base UI is already in the bundle. Public API and accessibility contract are identical to the react-aria variant.
23+
>
24+
> **Known limitation:** the segmented input is built from `role="spinbutton"` elements that aren't `contentEditable`, so on-screen-keyboard typing on touch devices is limited — the calendar popover is the touch-friendly path (desktop keyboard entry works fully). The APG behaviour is unit-tested but not yet screen-reader-audited.

docs/components/data-table.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,14 +492,27 @@ The `filter` property on a column accepts a `FilterConfig` object. When set, the
492492
| `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` |
493493
| `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
494494
| `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
495-
| `date` | Date | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
495+
| `date` | **DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** |
496496
| `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` |
497497
| `enum` | Dropdown | `eq`, `ne`, `in`, `nin` |
498498
| `boolean` | Toggle | `eq`, `ne` |
499499
| `uuid` | Text | `eq`, `ne`, `in`, `nin` |
500500

501501
When the user selects the `between` operator on a `number`, `datetime`, `date`, or `time` column, the filter chip renders a range input with **min** and **max** bounds.
502502

503+
### Date Filters
504+
505+
`date` columns use the app-shell [`DatePicker`](./date-picker.md) as the filter input (single value and `between` ranges) and present a friendlier, slimmer operator set:
506+
507+
| Operator | Label | Meaning |
508+
| --------- | ------------ | -------------------------- |
509+
| `eq` | _exact date_ | matches that calendar date |
510+
| `gte` | _after_ | on or after (inclusive) |
511+
| `lte` | _before_ | on or before (inclusive) |
512+
| `between` | _between_ | inclusive min–max range |
513+
514+
`gt` / `lt` / `ne` are intentionally dropped — the inclusive _after_ / _before_ cover the intent. The filter chip shows the value as a locale-formatted date (e.g. `15 Jun 2026`), and the picker resolves its locale/timezone from the AppShell context. (Only `date` is remapped this way; `datetime` and `time` keep the full numeric operator set and native inputs.)
515+
503516
### String Filter Case Sensitivity
504517

505518
String filters are **case-insensitive by default** — they use the Tailor Platform `regex` operator with an `(?i)` prefix. The filter chip renders a **"Case sensitive"** checkbox that lets users opt into exact-case matching.

docs/components/date-picker.md

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
---
2+
title: DatePicker
3+
description: Accessible date input components (@internationalized/date + Base UI)
4+
---
5+
6+
# DatePicker
7+
8+
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.
9+
10+
> **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. See `docs/proposals/date-picker-impl-comparison.md`.
11+
12+
## Import
13+
14+
```tsx
15+
import {
16+
DateField,
17+
DatePicker,
18+
Calendar,
19+
// Date value helpers (re-exported from @internationalized/date)
20+
today,
21+
parseDate,
22+
getLocalTimeZone,
23+
type CalendarDate,
24+
type DateValue,
25+
} from "@tailor-platform/app-shell";
26+
```
27+
28+
No separate `@internationalized/date` install needed — the value types and helpers are re-exported from `@tailor-platform/app-shell`.
29+
30+
## DateField
31+
32+
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.
33+
34+
```tsx
35+
<DateField label="Invoice date" />
36+
```
37+
38+
### With description and error
39+
40+
```tsx
41+
<DateField
42+
label="Start date"
43+
description="Format follows your locale"
44+
errorMessage="A start date is required"
45+
/>
46+
```
47+
48+
### Controlled
49+
50+
```tsx
51+
const [date, setDate] = useState<CalendarDate | null>(null);
52+
<DateField label="Invoice date" value={date} onChange={setDate} />;
53+
```
54+
55+
## DatePicker
56+
57+
A `DateField` with a calendar popover.
58+
59+
```tsx
60+
<DatePicker label="Ship date" />
61+
```
62+
63+
### Constrained + unavailable dates
64+
65+
```tsx
66+
<DatePicker
67+
label="Delivery date"
68+
minValue={today(getLocalTimeZone())}
69+
isDateUnavailable={(date) => {
70+
const dow = date.toDate(getLocalTimeZone()).getDay();
71+
return dow === 0 || dow === 6; // weekends
72+
}}
73+
/>
74+
```
75+
76+
### Week start
77+
78+
```tsx
79+
<DatePicker label="Date" firstDayOfWeek="mon" />
80+
```
81+
82+
## Calendar
83+
84+
A standalone calendar grid for custom date-selection UIs (e.g. reporting filters).
85+
86+
```tsx
87+
<Calendar aria-label="Select date" onChange={(date) => console.log(date)} />
88+
```
89+
90+
## Localization
91+
92+
Locale and timezone come from AppShell automatically. Override per field with `locale` / `timeZone`:
93+
94+
```tsx
95+
<DatePicker label="Date" locale="ja-JP" />
96+
```
97+
98+
Segment order, first-day-of-week, and month/weekday names all follow the resolved locale.
99+
100+
## Keyboard
101+
102+
- **Segments:** ``/`` increment/decrement, digits type-to-fill (auto-advance), ``/`` move between segments, `Backspace` clears.
103+
- **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.
104+
105+
## Accessibility
106+
107+
- The segmented field is a labelled `role="group"` of `role="spinbutton"` segments with `aria-valuemin`/`max`/`now`/`text`.
108+
- 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`.
109+
- The popover is a labelled `role="dialog"`.
110+
111+
> **Known limitations (this variant).** The segments are `<div role="spinbutton">` 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. See [the implementation comparison](../proposals/date-picker-impl-comparison.md) ("Known gaps vs react-aria") for the full list.
112+
113+
## Props
114+
115+
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).
116+
117+
### DateFieldProps
118+
119+
| Prop | Type | Description |
120+
| ----------------------------------------- | -------------------------------- | ----------------------------------------------------------------------- |
121+
| `label` | `LocalizedString` | Field label |
122+
| `description` | `LocalizedString` | Helper text below the field |
123+
| `errorMessage` | `LocalizedString` | Error text; also sets the invalid state |
124+
| `value` / `defaultValue` | `DateValue \| null` | Controlled / uncontrolled value (`CalendarDate` at date granularity) |
125+
| `onChange` | `(v: DateValue \| null) => void` | Fires on a complete, valid value; `null` when cleared |
126+
| `isDisabled` / `isReadOnly` / `isInvalid` | `boolean` | State flags |
127+
| `isRequired` | `boolean` | Sets `aria-required` on the segments (no visual required indicator yet) |
128+
| `placeholderValue` | `DateValue` | Seeds unset segments (increment start + segment order) |
129+
| `autoFocus` | `boolean` | Focus the first segment on mount |
130+
| `locale` | `string` | BCP-47 locale override (defaults to the AppShell formatting locale) |
131+
| `name` | `string` | Emits a hidden `<input>` with the ISO value for form submission |
132+
| `aria-label` | `string` | Accessible name when there's no visible `label` (e.g. compact filters) |
133+
| `className` | `string` | Root element class |
134+
135+
> `DateField` has no calendar, so `minValue` / `maxValue` / `isDateUnavailable` don't apply to it — they're honoured by `DatePicker` and `Calendar` below.
136+
137+
### DatePickerProps
138+
139+
All `DateFieldProps`, plus:
140+
141+
| Prop | Type | Description |
142+
| ----------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------- |
143+
| `minValue` / `maxValue` | `DateValue` | Earliest / latest selectable date in the calendar |
144+
| `isDateUnavailable` | `(date: DateValue) => boolean` | Mark individual dates unselectable (still keyboard-navigable) |
145+
| `firstDayOfWeek` | `"sun" \| "mon" \| "tue" \| "wed" \| "thu" \| "fri" \| "sat"` | Force the calendar's first column; omit to follow the locale |
146+
| `timeZone` | `string` | IANA timezone for resolving "today"; defaults to AppShell `timeZone` |
147+
148+
### CalendarProps
149+
150+
The standalone calendar grid. It has no segmented input, so its surface is listed in full:
151+
152+
| Prop | Type | Description |
153+
| -------------------------------------- | ------------------------------ | ------------------------------------------------------------- |
154+
| `value` / `defaultValue` | `DateValue \| null` | Controlled / uncontrolled selected date |
155+
| `onChange` | `(v: DateValue) => void` | Fires when a date is selected |
156+
| `minValue` / `maxValue` | `DateValue` | Earliest / latest selectable date |
157+
| `isDateUnavailable` | `(date: DateValue) => boolean` | Mark individual dates unselectable (still keyboard-navigable) |
158+
| `focusedValue` / `defaultFocusedValue` | `DateValue` | Controlled / initial focused (visible) date |
159+
| `onFocusChange` | `(date: CalendarDate) => void` | Fires when the focused date changes (arrows, month paging) |
160+
| `firstDayOfWeek` | `"sun" \| "mon" \| …` | Force the first column; omit to follow the locale |
161+
| `isDisabled` / `isReadOnly` | `boolean` | Disable the grid / prevent selection changes |
162+
| `timeZone` | `string` | IANA timezone for "today"; defaults to AppShell `timeZone` |
163+
| `locale` | `string` | BCP-47 locale override |
164+
| `aria-label` / `aria-labelledby` | `string` | Accessible name for the grid |
165+
| `className` | `string` | Root element class |
166+
167+
### Proposed / not yet implemented
168+
169+
Accepted by the prop types (for parity with the react-aria variant) but **not acted on** in this variant yet:
170+
171+
| Prop | Type | Status |
172+
| -------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
173+
| `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. |
174+
| `hourCycle` | `12 \| 24` | No effect until time granularity lands (12h/24h only matters with an hour segment). |
175+
| `hideTimeZone` | `boolean` | Unused; only relevant to `ZonedDateTime` display (time granularity). |
176+
177+
See the proposal's [Post-v1 fast-follows](../proposals/date-picker.md) for the DateTime plan.
178+
179+
## Related
180+
181+
- [Form](./form.md) — wrap date fields with validation
182+
- [Input](./input.md) — plain text input

0 commit comments

Comments
 (0)