diff --git a/.changeset/date-picker.md b/.changeset/date-picker.md new file mode 100644 index 00000000..f6c990ea --- /dev/null +++ b/.changeset/date-picker.md @@ -0,0 +1,24 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add DateField, DatePicker, and Calendar components (@internationalized/date + Base UI implementation) + +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: + +- `DateField` — segmented date/time input with label, description, and error message +- `DatePicker` — date field with a popover calendar +- `Calendar` — standalone calendar grid + +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`. + +New AppShell context hooks: + +- `useResolvedLocale()` — full BCP-47 locale (e.g. `"en-GB"`) plus the language code +- `useTimeZone()` — the configured IANA timezone, falling back to the user's local timezone + +AppShell now accepts an optional `timeZone` prop. + +> 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. +> +> **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. diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 1c0d0c9f..5b9c8140 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -492,7 +492,7 @@ The `filter` property on a column accepts a `FilterConfig` object. When set, the | `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | | `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `date` | Date | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `date` | **DatePicker** | `eq` (_exact date_), `gte` (_after_), `lte` (_before_), **`between`** | | `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | | `enum` | Dropdown | `eq`, `ne`, `in`, `nin` | | `boolean` | Toggle | `eq`, `ne` | @@ -500,6 +500,19 @@ The `filter` property on a column accepts a `FilterConfig` object. When set, the 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. +### Date Filters + +`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: + +| Operator | Label | Meaning | +| --------- | ------------ | -------------------------- | +| `eq` | _exact date_ | matches that calendar date | +| `gte` | _after_ | on or after (inclusive) | +| `lte` | _before_ | on or before (inclusive) | +| `between` | _between_ | inclusive min–max range | + +`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.) + ### String Filter Case Sensitivity 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. diff --git a/docs/components/date-picker.md b/docs/components/date-picker.md new file mode 100644 index 00000000..8f0a9dec --- /dev/null +++ b/docs/components/date-picker.md @@ -0,0 +1,182 @@ +--- +title: DatePicker +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. See `docs/proposals/date-picker-impl-comparison.md`. + +## Import + +```tsx +import { + DateField, + DatePicker, + Calendar, + // Date value helpers (re-exported from @internationalized/date) + today, + parseDate, + getLocalTimeZone, + 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`. + +## 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. + +```tsx + +``` + +### With description and error + +```tsx + +``` + +### Controlled + +```tsx +const [date, setDate] = useState(null); +; +``` + +## DatePicker + +A `DateField` with a calendar popover. + +```tsx + +``` + +### Constrained + unavailable dates + +```tsx + { + const dow = date.toDate(getLocalTimeZone()).getDay(); + return dow === 0 || dow === 6; // weekends + }} +/> +``` + +### Week start + +```tsx + +``` + +## Calendar + +A standalone calendar grid for custom date-selection UIs (e.g. reporting filters). + +```tsx + console.log(date)} /> +``` + +## Localization + +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. +- **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. + +## 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. See [the implementation comparison](../proposals/date-picker-impl-comparison.md) ("Known gaps vs react-aria") for the full list. + +## 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 | +| `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. + +### 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` | + +### 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). | + +See the proposal's [Post-v1 fast-follows](../proposals/date-picker.md) for the DateTime plan. + +## Related + +- [Form](./form.md) — wrap date fields with validation +- [Input](./input.md) — plain text input diff --git a/docs/proposals/date-picker-impl-comparison.md b/docs/proposals/date-picker-impl-comparison.md new file mode 100644 index 00000000..4e2e89f5 --- /dev/null +++ b/docs/proposals/date-picker-impl-comparison.md @@ -0,0 +1,63 @@ +# DatePicker — implementation comparison (measured) + +Two complete, API-identical implementations of `DateField` / `DatePicker` / `Calendar` were built to validate the design proposal's library-choice analysis (`docs/proposals/date-picker.md`, §1.1 and §9) against real code: + +| Branch | Behaviour/a11y foundation | +| --------------------------------- | ----------------------------------------------------- | +| `feat/date-picker-pp-1093` | **react-aria-components** (Adobe) | +| `feat/date-picker-baseui-pp-1093` | **`@internationalized/date` + Base UI** (this branch) | + +Both expose the **same public API** (`DateFieldProps` / `DatePickerProps` / `CalendarProps`), the same `LocalizedString` label/description/error props, the same AppShell `useResolvedLocale()` / `useTimeZone()` wiring, and the same `@internationalized/date` re-exports. A consumer cannot tell them apart from the import surface — exactly the "foundation-agnostic API" the proposal relies on for a low-regret swap. + +## What "Base UI + raw @internationalized/date" actually means + +Base UI has **no date primitives**, so this branch hand-rolls the behaviour react-aria would otherwise supply, on top of two new state engines: + +- **`use-date-field-state.ts`** — segmented spinbutton field: locale-driven segment ordering via `DateFormatter.formatToParts`, per-segment increment/decrement with context-aware limits (e.g. day max from `endOfMonth`), numeric type-to-fill with auto-advance, and value-type discrimination by `granularity` (`CalendarDate` / `CalendarDateTime` / `ZonedDateTime`). No `Date` ever escapes. +- **`use-calendar-state.ts`** — calendar grid: locale + first-day-of-week week computation (`getWeeksInMonth` / `startOfWeek`), selection, roving focus, and full APG keyboard navigation (arrows, Home/End, PageUp/PageDown, Shift+PageUp/Down). + +Base UI provides only the **`Popover`** (positioning, dismiss, portal) and the field's label/description/error structure idioms. + +## Measured bundle cost (esbuild, minified + gzipped, react/react-dom external) + +| Date slice | gzipped | Net-new for an app-shell consumer | +| ------------------------------------------------- | ----------: | ------------------------------------------------ | +| `@internationalized/date` only | **11.2 KB** | both branches pay this | +| Base UI `Popover` + `@internationalized/date` | 46.3 KB | Base UI already in bundle → marginal ≈ **11 KB** | +| react-aria date slice + `@internationalized/date` | 73.7 KB | whole second foundation → **+73.7 KB** | + +The decisive point: **Base UI is already in the app-shell bundle** (Dialog, Select, Combobox, Popover all use it), so the marginal dependency cost of the date components on this branch is essentially just `@internationalized/date` (~11 KB gz). The react-aria branch adds an entire second headless foundation (~74 KB gz) that overlaps Base UI. This confirms the proposal's projected ~11 KB vs ~73 KB (§1.1). + +## Cost we take on: code we now own + +| Implementation | Source LOC | Hand-rolled behaviour logic | +| ----------------------------------- | ---------: | ---------------------------- | +| react-aria variant | ~565 | 0 (rented from Adobe) | +| `@internationalized/date` + Base UI | ~1,526 | ~670 (the two state engines) | + +~2.7× the source, ~670 LOC of date-interaction behaviour to maintain ourselves — the "highest build effort" the proposal flagged, now quantified. + +## Parity verified + +- **Tests:** the same 23-test suite (public behaviour + the DOM a11y contract — spinbutton segments, `role="grid"` cells with `data-selected`/`data-disabled`/`data-unavailable`/`data-outside-month`, `role="dialog"` popover, roving-focus keyboard nav) passes against both implementations unchanged. +- **Localization (verified in-browser):** segment order follows the locale — `en-US` → `mm/dd/yyyy`, `en-GB` → `dd/mm/yyyy`, `ja-JP` → `yyyy/mm/dd`; first-day-of-week follows the locale (Monday-first for `en-GB`); month/weekday names and the cell `aria-label` are `DateFormatter`-localized. +- **Value correctness:** `onChange` emits `@internationalized/date` values (never `Date`); time is preserved when picking a day in a date+time picker. + +## Accessibility coverage (matched, verified in-browser) + +- **Segmented field:** labelled `role="group"` of `role="spinbutton"` segments carrying `aria-valuemin`/`max`/`now`/`text`, `aria-label`, `aria-invalid`, `aria-disabled`, `aria-readonly`; per-segment `↑`/`↓`, digit type-to-fill with auto-advance, `←`/`→`/`Home`/`End` between segments, `Backspace` to clear. Errors render as `role="alert"` and link via `aria-describedby`. +- **Calendar grid:** `role="grid"` with localized `columnheader`s (full-day `aria-label`s), full-date cell `aria-label`s, roving `tabindex`, and APG keyboard — `←`/`→` day, `↑`/`↓` week, `Home`/`End` week ends, `PageUp`/`PageDown` month, `Shift`+`PageUp`/`PageDown` year, `Enter`/`Space` select. Focus follows across month boundaries; the month heading is an `aria-live` region. +- **Popover/dialog:** opening the picker moves focus into the grid (today/selected cell) so arrows work immediately; selecting or `Escape` closes it and returns focus to the trigger (Base UI). The trigger gets `aria-haspopup="dialog"`/`aria-expanded` from Base UI. + +## Known gaps vs react-aria (the price of owning it) + +- **Mobile/touch text entry:** react-aria's segments are `contentEditable` spans with `inputmode="numeric"`, so a touch device surfaces the on-screen keyboard and types into them. Ours are **non-editable** (`contentEditable={false}`) `role="spinbutton"` divs driven by `keydown` — which soft keyboards don't emit — so touch typing doesn't work (desktop keyboard + the calendar both work fully; the calendar is the touch path). The biggest remaining gap, but **addressable** by matching react-aria: make the segments `contentEditable` with `inputmode="numeric"` and route `beforeinput` (a focused, contained fast-follow — needs real-device QA). +- **RTL arrow direction:** `←`/`→` are LTR-fixed; in RTL locales react-aria flips them. Needs a direction check in the grid key handler. +- **Calendar systems:** robust for Gregorian; the 13 non-Gregorian calendars react-aria supports automatically are not wired up (the value layer can represent them; the field/grid interaction would need extension). +- **Localized chrome strings:** segment placeholders (`yyyy`/`mm`/`dd`) and the prev/next-month button labels are English; react-aria localizes these from its bundled CLDR strings. +- **Dedicated navigation live-region:** we rely on roving focus + cell `aria-label` (the APG-standard announcement) plus an `aria-live` month heading; react-aria adds an extra visually-hidden live region for navigation/selection announcements. +- **Breadth of SR/browser testing:** react-aria ships vendor-maintained cross-browser + screen-reader coverage; ours is the APG pattern implemented and unit-tested, not yet SR-audited. + +## Takeaway + +The Base-UI-native build is real and shippable: identical API, identical a11y/DOM contract, full localization for the common case, at ~1/7th the dependency weight — at the cost of ~670 LOC of behaviour we maintain and a few advanced-i18n gaps. This is precisely the trade the proposal framed as the §9 "future possibility," now de-risked with working code on both sides. diff --git a/docs/proposals/date-picker.md b/docs/proposals/date-picker.md new file mode 100644 index 00000000..d95e3106 --- /dev/null +++ b/docs/proposals/date-picker.md @@ -0,0 +1,449 @@ +# Proposal: DatePicker / DateField (and future DateRangePicker) + +> Status: **Decided — v1 builds on the §9 lighter foundation (`@internationalized/date` + Base UI), approved 2026-06-26 (superseding the original `react-aria-components` decision). See the Revision note.** +> Scope: v1 ships `DateField`, `DatePicker`, and a standalone `Calendar`. `DateRangePicker` / `RangeCalendar` are designed-for but deferred. + +## Revision — 2026-06-26 (foundation swap approved) + +The original v1 foundation decision (`react-aria-components`, 2026-06-17, §1 below) has been **superseded**: v1 now builds on the **§9 lighter foundation** — `@internationalized/date` + Base UI — measured at **~11 KB gz vs ~74 KB** for react-aria, with the same public API and value layer (see [`date-picker-impl-comparison.md`](date-picker-impl-comparison.md)). The swap was **approved on 2026-06-26**. + +- **Decision (v1):** ship the `@internationalized/date` + Base UI variant. Public API and value layer are unchanged, so this is invisible to consumers. +- **Trade-off accepted:** we own ~670 LOC of APG behaviour instead of Adobe-audited primitives. Known gaps tracked for follow-up: **mobile/touch typing** (segments are `role="spinbutton"` with no hidden numeric ``, so on-screen-keyboard entry is limited — the calendar is the touch path), **RTL arrow-key flipping**, and the pattern is unit-tested but **not yet screen-reader-audited**. + +## Post-v1 fast-follows (Base UI variant) + +Owning the segmented input + grid means a handful of behaviours react-aria gave us for free are now our own follow-up work. The full inventory is in [`date-picker-impl-comparison.md`](date-picker-impl-comparison.md) ("Known gaps vs react-aria"); the highest-value item is written up here so the approach isn't lost. + +### Mobile / touch text entry (biggest gap) + +**Problem.** Our segments are `role="spinbutton"` `
`s with `contentEditable={false}`, and input is read only from `keydown`. On-screen keyboards don't emit usable `keydown`, and with no editable element focused the keyboard doesn't surface at all — so on touch devices you can't _type_ a date (the calendar popover is the only touch path). Desktop keyboard + calendar work fully. + +**Approach — mirror react-aria.** react-aria makes each segment a `contentEditable` span with `inputmode="numeric"`; that's exactly what surfaces the numeric soft keyboard and lets it type into the element (verified: react-aria's month segment is ``). To match it in `DateInputGroup`: + +1. Render each editable segment with `contentEditable` (when not disabled/readonly), `inputMode="numeric"` (omit for the AM/PM segment), plus `enterKeyHint="next"`, `autoCorrect="off"`, `autoCapitalize="off"`, `spellCheck={false}`. Keep the transparent caret so the field still renders our locale-formatted text, not an editing caret. +2. Add a **native** `beforeinput` listener that **always `preventDefault`s** — the segment text stays React-controlled, so the browser must never mutate the `contentEditable` DOM — then translates the event into the existing engine: + - `insertText` → per character: digit → `setDigit(...)` (same typed-count / auto-advance path as `keydown`); `a`/`p` on the day-period segment → `setDayPeriod`. + - `deleteContentBackward` / `deleteContentForward` → `clearSegment`. + - anything else → ignored (already prevented), which also swallows stray characters so the segment can't accumulate junk. +3. Keep the current `keydown` path for physical keyboards. The two are mutually exclusive: a handled `keydown` calls `preventDefault`, which suppresses the follow-on `beforeinput`; soft keyboards emit no actionable `keydown`, so `beforeinput` is the only path that fires there. + +**Why native `beforeinput`, not React's `onBeforeInput`:** React's synthetic `onBeforeInput` is unreliable for cancelling the native default on `contentEditable`; a native listener (attached via ref/effect) makes `preventDefault` reliably stop DOM mutation across browsers. + +**Scope:** touches only `DateInputGroup` (segment attributes + the input handler). No public-API or value-layer change. Segment DOM snapshots update (the new `contentEditable`/`inputmode` attributes). + +**Verification:** the desktop `keydown` path stays covered by the existing unit tests; the `beforeinput` path can be exercised by dispatching a synthetic `InputEvent("beforeinput", { inputType, data })`. The soft keyboard _appearing on focus_ is platform behaviour driven purely by the `contentEditable` + `inputmode` attributes, so it can't be unit-tested — it needs **real-device QA** (physical phone or BrowserStack) before sign-off. + +### Other gaps + +RTL arrow-key flipping, non-Gregorian calendar systems, localized placeholder/nav-button strings, a dedicated navigation live-region, and a full screen-reader audit — all inventoried in the comparison doc's "Known gaps vs react-aria" section. + +--- + +Everything below is the **original 2026-06-17 decision and analysis**, kept verbatim for the record (now superseded by the notes above). + +## 1. Summary & decision + +> **Decision (v1):** build on **`react-aria-components`** (Adobe) for the behaviour/a11y layer, with **`@internationalized/date`** as the value layer, wrapped in a thin app-shell presentation layer that matches every other component in the library. We accept the ~75 KB cost (§7) in exchange for audited APG accessibility at the lowest build risk. A lighter foundation is kept as a deliberate **future possibility (§9)**. + +This choice is low-regret: the public API (§3) and the value layer are foundation-agnostic, so a later swap to a lighter foundation touches only the internal wrapper — not consumers, not stored values. The alternatives measured before settling here are kept in §1.1 for the record. + +Verified facts behind this decision: + +- **Single bundled stack.** Modern `react-aria-components` v1 ships as 3 pre-bundled artifacts (`react-aria-components`, `react-aria`, `react-stately`), not the 30+ split `@react-aria/*` packages of the v0 era. Net-new footprint ≈ 10 packages, 3 substantive. +- **Base UI has no date primitives** and none on the near roadmap, so "wait for Base UI" is not an option. Base UI remains our primitive provider for everything else; react-aria is scoped strictly to the date family. + +The trade-off we accept: a second headless stack exists in the bundle, **bounded to date components** and invisible to consumers (they never import from `react-aria-components`). + +## 1.1 Library choice — alternatives re-evaluated + +The 75 KB figure prompted a re-test of the hypothesis: _is there a slimmer option that doesn't add a primitive foundation competing with Base UI?_ The deepest form of that concern is not bundle size — it's that **`react-aria-components` is itself a general primitive suite** (Popover, Dialog, Select, Menu, ComboBox, Table…) that directly overlaps Base UI. Measured with identical methodology (esbuild, minified + gzipped, `react`/`react-dom` externalised, tree-shaken to the date slice): + +| Option | Date slice (gz) | Value layer | Adds a competing primitive foundation? | A11y | Build effort | +| --------------------------------------- | --------------: | ------------------------------- | ----------------------------------------------------------- | ---------------------------------------- | -------------------------------- | +| react-aria-components | 73.4 KB | `@internationalized/date` ✅ | **Yes** — full primitive suite overlaps Base UI | Best-in-class, APG-audited, SR-tested | Lowest | +| Ark UI / Zag.js | 42.7 KB | `@internationalized/date` ✅ | **Yes** — Ark is also a full headless suite (lighter) | Good — Zag implements APG | Low | +| Zag machine only + Base UI | 39.4 KB | `@internationalized/date` ✅ | Partial — own popper/dismiss/live-region (dual positioning) | Good | Medium (awkward wiring) | +| **`@internationalized/date` + Base UI** | **10.8 KB** | _is_ the value layer ✅ | **No** — value/math lib only | **We own it** | **Highest** | +| react-day-picker v10 + Base UI | 20.4 KB | ❌ `date-fns`, **`Date`-based** | No — calendar widget only | Calendar grid only; build input + dialog | High (+ loses value correctness) | + +What the data changes: + +- **The hypothesis is partly confirmed.** Slimmer, better-aligned options exist. react-aria is the _heaviest_ candidate and, with Ark available, no longer the obvious pick. +- **Ark UI/Zag strictly dominates react-aria** _if_ a second behaviour layer is acceptable: ~40% lighter, the **same** value layer (`@internationalized/date` → identical calendar systems, `ZonedDateTime`, value-type correctness), comparable APG a11y. react-aria's only remaining edges are maturity/ecosystem (Adobe, larger adoption) and an exceptionally polished segmented input. +- **`@internationalized/date` + Base UI is the only option that honours "no competing foundation"** while keeping value correctness — ~7× slimmer than react-aria. The cost is real: we build and _own_ the segmented input + calendar grid + APG dialog a11y (date pickers are the #1 place teams ship broken a11y). De-riskable by shipping the simpler APG _text-input + calendar-dialog_ pattern first (Base UI `Dialog` already handles focus trap/return/dismiss), with the segmented input as a fast-follow. +- **Drop react-day-picker:** `Date`-based, abandons the value-type thesis, _and_ still calendar-only (you build the input + dialog anyway). Worst of both. +- **Drop raw Zag-machine-on-Base-UI:** not meaningfully lighter than Ark (39 vs 43 KB) and forces two positioning systems. + +**Outcome:** for v1 we chose **react-aria-components** (audited a11y, least build risk, largest ecosystem) and accept the bundle cost. Ark UI/Zag and the Base-UI-native build are retained as a **future direction** — see §9. + +## 2. Why wrap — what the wrapper actually buys us + +The interface-slimming is the _least_ of it. Ranked by value: + +1. **Styling ownership (the main reason).** react-aria ships **zero CSS**. Without a wrapper, "use the DatePicker" means assembling ~12 headless sub-components and re-authoring all the `astw:` / theme-token / dark-mode / animation classes at every call site. The wrapper is where the visual identity lives — **once**. This is the same reason `Select` is wrapped today. +2. **Abstraction seam / vendor insulation (the strategic one).** Wrapping makes react-aria an _implementation detail_ behind our own stable API. If react-aria reshapes its composition, or we later move the popover to Base UI, or migrate off react-aria entirely, it's a one-package change — not a consumer-wide migration. This is precisely what makes "we took on a second stack" reversible rather than a long-term lock-in. +3. **Library consistency.** Consumers get ``, shaped like every other app-shell component, instead of a foreign 12-part Adobe composition. The second stack never enters the consumer's mental model. +4. **Footgun masking (correctness).** Bake in safe defaults: site timezone instead of `getLocalTimeZone()`, full BCP-47 locale, no `Date` round-trip, `granularity` → value-type discrimination. Hide the knobs that let people do the wrong thing. +5. **Interface slimming itself.** ~40 props + 12 sub-components → ~12 flat props. Real, but a _consequence_ of 1–4, not the goal. + +**Counter-discipline:** don't re-design what's already good. Keep `value` / `onChange` / `minValue` / `maxValue` / `granularity` / `isDateUnavailable` named exactly as react-aria has them. We slim and add safety; we do not invent a new vocabulary (that would create a translation tax against upstream docs and churn). + +## 3. Proposed public API + +Three exports in v1, plus two deferred: + +```tsx + // segmented typed input only, no popover → DateValue + // input + popover + calendar → DateValue + // standalone inline calendar (reporting filters, no popover) + +// deferred, designed-for: + // → { start: DateValue; end: DateValue } + +``` + +Single-date and range are **separate components, not a `mode` prop** — they have different value types (`T` vs `{ start: T; end: T }`), and a `mode` prop forces every callsite to discriminate. + +### 3.1 Value type is set by `granularity` + +```tsx + // → CalendarDate (no time, no tz) + // → CalendarDateTime (local wall-time) + + // → ZonedDateTime (tz-aware) +``` + +`onChange` returns the `@internationalized/date` type — **never a JS `Date`**. Returning `Date` discards the calendar system and corrupts timezone reasoning, which is the whole point of the stack. For callers that genuinely need a `Date`, the value carries `.toDate(timeZone)`; ISO round-tripping to the backend lives in a codec layer (see §7). + +### 3.2 Props we pass through (the 90% surface — names unchanged from react-aria) + +| Prop | Type | Notes | +| ------------------------------------------ | ----------------------------------------- | ------------------------------------------------------- | +| `value` / `defaultValue` / `onChange` | `DateValue` (typed by `granularity`) | Controlled or uncontrolled | +| `granularity` | `"day" \| "hour" \| "minute" \| "second"` | Default `"day"`. Drives the value type | +| `minValue` / `maxValue` | `DateValue` | Same type as `value` | +| `isDateUnavailable` | `(date) => boolean` | Single predicate — holidays, weekends, lead-time | +| `isDisabled` / `isReadOnly` / `isRequired` | `boolean` | | +| `isInvalid` | `boolean` | | +| `autoFocus` | `boolean` | | +| `hourCycle` | `12 \| 24` | Overrides locale default | +| `hideTimeZone` | `boolean` | Cosmetic, `ZonedDateTime` only | +| `placeholderValue` | `DateValue` | Seeds the segment placeholder (e.g. default year) | +| `firstDayOfWeek` | `"sun" \| "mon" \| …` | Forwarded to the inner `Calendar`; defaults from locale | +| `name` | `string` | Native form / form-library integration | + +### 3.3 Props we add (app-shell conveniences) + +| Prop | Type | Why | +| -------------- | ----------------- | -------------------------------------------------------------------------------------------------- | +| `label` | `LocalizedString` | Renders `
+ ); +} + +interface CalendarCellProps { + day: CalendarDay; + label: string; + onSelect: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + registerRef: (el: HTMLButtonElement | null) => void; + onFocus: () => void; +} + +function CalendarCell({ + day, + label, + onSelect, + onKeyDown, + registerRef, + onFocus, +}: CalendarCellProps) { + // Selectable: a real, in-range, available day. Gates click + Enter/Space. + const interactive = !day.isOutsideMonth && !day.isDisabled && !day.isUnavailable; + // Reachable by roving keyboard focus: any in-month day, including disabled and + // unavailable ones. Per APG, arrow keys traverse *through* disabled dates — they + // just can't be selected — so the keydown handler must be attached to them too, + // otherwise focus lands on a disabled day with no way to navigate away. + const focusable = !day.isOutsideMonth; + // `` inside `role="grid"` is implicitly a gridcell — no explicit role needed. + return ( + + + + ); +} + +export { calendarCellVariants }; diff --git a/packages/core/src/components/calendar/calendar.test.tsx b/packages/core/src/components/calendar/calendar.test.tsx new file mode 100644 index 00000000..047d46e5 --- /dev/null +++ b/packages/core/src/components/calendar/calendar.test.tsx @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CalendarDate, parseDate, isSameDay } from "@internationalized/date"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { Calendar } from "./calendar"; + +// Behaviour + DOM a11y contract for the standalone Calendar grid (roving focus, +// APG keyboard nav, selection). The field/popover coverage lives in +// ../date-field/date-field.test.tsx. + +afterEach(() => { + cleanup(); +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Find
day cells inside a calendar grid (not nav buttons). */ +function getCalendarCells() { + return screen.getAllByRole("button", { hidden: true }).filter((c) => c.closest('[role="grid"]')); +} + +function getEnabledCalendarCells() { + return getCalendarCells().filter( + (c) => !c.hasAttribute("data-disabled") && !c.hasAttribute("data-outside-month"), + ); +} + +// ─── Snapshot ───────────────────────────────────────────────────────────────── +// Pinned input (fixed `defaultValue`, no live "today") so output is stable. + +describe("snapshots", () => { + it("Calendar — pre-selected", () => { + const { container } = render( + , + ); + expect(container.innerHTML).toMatchSnapshot(); + }); +}); + +// ─── Calendar ───────────────────────────────────────────────────────────────── + +describe("Calendar", () => { + it("renders a calendar grid", () => { + render(); + expect(screen.getByRole("grid")).toBeDefined(); + }); + + it("renders navigation buttons", () => { + render(); + const navButtons = screen.getAllByRole("button").filter((b) => !b.closest('[role="grid"]')); + expect(navButtons.length).toBeGreaterThanOrEqual(2); + }); + + it("localizes the month-nav aria-labels from the AppShell locale (ja)", () => { + render(, { wrapper: createAppShellWrapper("ja") }); + expect(screen.getByRole("button", { name: "前の月" })).toBeDefined(); + expect(screen.getByRole("button", { name: "次の月" })).toBeDefined(); + }); + + it("fires onChange when a date cell is clicked", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + const enabled = getEnabledCalendarCells(); + if (enabled.length > 0) { + await user.click(enabled[0]); + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + }); + } + }); + + it("renders selected cell with data-selected attribute", () => { + const defaultValue = parseDate("2025-06-15") as CalendarDate; + render(); + + const selected = getCalendarCells().find((c) => c.hasAttribute("data-selected")); + expect(selected).toBeDefined(); + }); + + it("renders no disabled cells when minValue is not set", () => { + render(); + const currentMonthCells = getCalendarCells().filter( + (c) => !c.hasAttribute("data-outside-month"), + ); + expect(currentMonthCells.length).toBeGreaterThan(0); + }); + + it("uses a roving tabindex anchored on the focused date", () => { + render( + , + ); + const tabbable = getCalendarCells().filter((c) => c.getAttribute("tabindex") === "0"); + expect(tabbable).toHaveLength(1); + expect(tabbable[0].textContent).toBe("15"); + }); + + it("carries a focus-ring utility class on day cells", () => { + render( + , + ); + const cell = getCalendarCells().find((c) => c.getAttribute("tabindex") === "0")!; + // Focus styling is the same `ring` utility used by Button/inputs. + expect(cell.className).toMatch(/focus:ring/); + }); + + it("keeps focus on the month nav button when changing months", async () => { + const user = userEvent.setup(); + render(); + const nextBtn = screen + .getAllByRole("button") + .find((b) => b.getAttribute("aria-label") === "Next month")!; + // Simulate the grid having been focused first (e.g. via keyboard nav). + const cell = getCalendarCells().find((c) => c.getAttribute("tabindex") === "0")!; + cell.focus(); + await user.click(nextBtn); + // Focus must remain on the nav button, not jump back into the grid. + expect(document.activeElement).toBe(nextBtn); + expect(document.activeElement?.closest('[role="grid"]')).toBeNull(); + }); + + it("moves the roving focus with arrow keys (→ next day, ↓ next week)", async () => { + const user = userEvent.setup(); + render( + , + ); + const start = getCalendarCells().find((c) => c.getAttribute("tabindex") === "0")!; + start.focus(); + await user.keyboard("{ArrowRight}"); + expect(getCalendarCells().find((c) => c.getAttribute("tabindex") === "0")?.textContent).toBe( + "16", + ); + await user.keyboard("{ArrowDown}"); + expect(getCalendarCells().find((c) => c.getAttribute("tabindex") === "0")?.textContent).toBe( + "23", + ); + }); + + it("lets arrow keys traverse through unavailable dates without getting stuck", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + // Mark the 16th unavailable — the day we'll land on mid-navigation. + const unavailable = parseDate("2025-06-16") as CalendarDate; + render( + isSameDay(d, unavailable)} + onChange={onChange} + />, + ); + const tabbable = () => getCalendarCells().find((c) => c.getAttribute("tabindex") === "0")!; + + tabbable().focus(); + await user.keyboard("{ArrowRight}"); // → 16 (unavailable, but focusable) + const onSixteenth = tabbable(); + expect(onSixteenth.textContent).toBe("16"); + expect(onSixteenth.hasAttribute("data-unavailable")).toBe(true); + + // Selection is blocked on the unavailable day... + await user.keyboard("{Enter}"); + expect(onChange).not.toHaveBeenCalled(); + + // ...but navigation continues right off it (this is the bug being fixed). + await user.keyboard("{ArrowRight}"); + expect(tabbable().textContent).toBe("17"); + }); +}); diff --git a/packages/core/src/components/calendar/calendar.tsx b/packages/core/src/components/calendar/calendar.tsx new file mode 100644 index 00000000..985d64f7 --- /dev/null +++ b/packages/core/src/components/calendar/calendar.tsx @@ -0,0 +1,95 @@ +import type { CalendarDate, DateValue } from "@internationalized/date"; +import { useResolvedLocale, useTimeZone } from "@/contexts/appshell-context"; +import { useCalendarState, type FirstDayOfWeek } from "./use-calendar-state"; +import { CalendarView } from "./calendar-view"; + +/** + * Public, closed-API standalone calendar — the @internationalized/date + Base UI + * implementation. Same surface as the react-aria variant; only the internals + * differ. Consumers never see Base UI or the calendar engine. + */ + +export interface CalendarProps { + value?: T | null; + defaultValue?: T | null; + onChange?: (value: T) => void; + minValue?: DateValue; + maxValue?: DateValue; + isDateUnavailable?: (date: DateValue) => boolean; + isDisabled?: boolean; + isReadOnly?: boolean; + focusedValue?: DateValue; + defaultFocusedValue?: DateValue; + onFocusChange?: (date: CalendarDate) => void; + firstDayOfWeek?: FirstDayOfWeek; + "aria-label"?: string; + "aria-labelledby"?: string; + timeZone?: string; + className?: string; + /** BCP-47 locale override; defaults to the AppShell formatting locale. */ + locale?: string; +} + +/** + * A standalone inline calendar — no popover, suitable for reporting filters + * or date selection within a larger layout. + * + * @example + * ```tsx + * import { Calendar } from "@tailor-platform/app-shell"; + * + * + * ``` + */ +function Calendar({ + value, + defaultValue, + onChange, + minValue, + maxValue, + isDateUnavailable, + isDisabled, + isReadOnly, + focusedValue, + defaultFocusedValue, + onFocusChange, + firstDayOfWeek, + timeZone: timeZoneProp, + locale: localeProp, + className, + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, +}: CalendarProps) { + const { locale: shellLocale } = useResolvedLocale(); + const shellTz = useTimeZone(); + const resolvedLocale = localeProp ?? shellLocale; + const resolvedTz = timeZoneProp ?? shellTz; + + const state = useCalendarState({ + value: value ?? undefined, + defaultValue, + onChange: onChange as (v: DateValue) => void, + minValue, + maxValue, + isDateUnavailable, + isDisabled, + isReadOnly, + focusedValue, + defaultFocusedValue, + onFocusChange, + firstDayOfWeek, + locale: resolvedLocale, + timeZone: resolvedTz, + }); + + return ( + + ); +} + +export { Calendar }; diff --git a/packages/core/src/components/calendar/i18n.ts b/packages/core/src/components/calendar/i18n.ts new file mode 100644 index 00000000..417c1053 --- /dev/null +++ b/packages/core/src/components/calendar/i18n.ts @@ -0,0 +1,15 @@ +import { defineI18nLabels } from "@/hooks/i18n"; + +/** Built-in aria-label strings for the calendar grid chrome (month nav). */ +export const calendarLabels = defineI18nLabels({ + en: { + previousMonth: "Previous month", + nextMonth: "Next month", + }, + ja: { + previousMonth: "前の月", + nextMonth: "次の月", + }, +}); + +export const useCalendarT = calendarLabels.useT; diff --git a/packages/core/src/components/calendar/index.ts b/packages/core/src/components/calendar/index.ts new file mode 100644 index 00000000..55ed1ae1 --- /dev/null +++ b/packages/core/src/components/calendar/index.ts @@ -0,0 +1 @@ +export { Calendar, type CalendarProps } from "./calendar"; diff --git a/packages/core/src/components/calendar/use-calendar-state.ts b/packages/core/src/components/calendar/use-calendar-state.ts new file mode 100644 index 00000000..31ea9f87 --- /dev/null +++ b/packages/core/src/components/calendar/use-calendar-state.ts @@ -0,0 +1,311 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { + CalendarDate, + CalendarDateTime, + DateFormatter, + ZonedDateTime, + endOfMonth, + getWeeksInMonth, + isSameDay, + isSameMonth, + startOfMonth, + startOfWeek, + toCalendarDate, + today, + type DateValue, +} from "@internationalized/date"; + +/** + * Hand-rolled calendar-grid state — the logic react-aria's `useCalendarState` + * would otherwise provide. We own: month/week computation (locale + first-day- + * of-week aware), selection, roving focus, and full APG keyboard navigation + * (arrows, Home/End, PageUp/Down, Shift+PageUp/Down). + */ + +export type FirstDayOfWeek = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat"; + +export interface CalendarStateOptions { + value?: DateValue | null; + defaultValue?: DateValue | null; + onChange?: (value: DateValue) => void; + focusedValue?: DateValue; + defaultFocusedValue?: DateValue; + onFocusChange?: (date: CalendarDate) => void; + minValue?: DateValue; + maxValue?: DateValue; + isDateUnavailable?: (date: DateValue) => boolean; + isDisabled?: boolean; + isReadOnly?: boolean; + firstDayOfWeek?: FirstDayOfWeek; + locale: string; + timeZone: string; +} + +export interface CalendarDay { + date: CalendarDate; + isOutsideMonth: boolean; + isSelected: boolean; + isDisabled: boolean; + isUnavailable: boolean; + isToday: boolean; + isFocused: boolean; +} + +function toCal(v: DateValue | null | undefined): CalendarDate | null { + if (!v) return null; + return toCalendarDate(v as never); +} + +export function useCalendarState(options: CalendarStateOptions) { + const { + value: controlledValue, + defaultValue, + onChange, + focusedValue, + defaultFocusedValue, + onFocusChange, + minValue, + maxValue, + isDateUnavailable, + isDisabled, + isReadOnly, + firstDayOfWeek, + locale, + timeZone, + } = options; + + const isValueControlled = controlledValue !== undefined; + const [internalValue, setInternalValue] = useState(defaultValue ?? null); + const value = isValueControlled ? controlledValue : internalValue; + const selectedDate = toCal(value); + + const todayDate = useMemo(() => today(timeZone), [timeZone]); + + const isFocusControlled = focusedValue !== undefined; + const [internalFocused, setInternalFocused] = useState( + () => toCal(defaultFocusedValue) ?? selectedDate ?? todayDate, + ); + const focusedDate = isFocusControlled ? toCal(focusedValue)! : internalFocused; + + // Tracks whether the grid currently holds focus (used by Tab containment). + const isFocusedRef = useRef(false); + // One-shot signal: when a `focusedDate` change should pull DOM focus onto the + // new day cell. Set by keyboard grid navigation only — NOT by the prev/next + // month buttons, so clicking those keeps focus on the button. + const moveFocusRef = useRef(false); + + const min = useMemo(() => toCal(minValue), [minValue]); + const max = useMemo(() => toCal(maxValue), [maxValue]); + + const isInvalidRange = useCallback( + (date: CalendarDate) => + (min != null && date.compare(min) < 0) || (max != null && date.compare(max) > 0), + [min, max], + ); + + const setFocusedDate = useCallback( + (date: CalendarDate) => { + // Clamp focus to the allowed range. + let next = date; + if (min != null && next.compare(min) < 0) next = min; + if (max != null && next.compare(max) > 0) next = max; + if (!isFocusControlled) setInternalFocused(next); + onFocusChange?.(next); + }, + [min, max, isFocusControlled, onFocusChange], + ); + + const selectDate = useCallback( + (date: CalendarDate) => { + if (isDisabled || isReadOnly) return; + if (isInvalidRange(date) || isDateUnavailable?.(date)) return; + // Preserve the time portion when the existing value carries time. + const next: DateValue = withDatePart(value, date); + if (!isValueControlled) setInternalValue(next); + onChange?.(next); + setFocusedDate(date); + }, + [ + isDisabled, + isReadOnly, + isInvalidRange, + isDateUnavailable, + value, + isValueControlled, + onChange, + setFocusedDate, + ], + ); + + const visibleMonth = startOfMonth(focusedDate); + + // ── Weeks ───────────────────────────────────────────────────────────────── + const weeks = useMemo(() => { + const weeksInMonth = getWeeksInMonth(visibleMonth, locale, firstDayOfWeek); + const gridStart = startOfWeek(visibleMonth, locale, firstDayOfWeek); + const result: CalendarDay[][] = []; + for (let w = 0; w < weeksInMonth; w++) { + const days: CalendarDay[] = []; + for (let d = 0; d < 7; d++) { + const date = gridStart.add({ days: w * 7 + d }); + const outside = !isSameMonth(date, visibleMonth); + days.push({ + date, + isOutsideMonth: outside, + isSelected: selectedDate != null && isSameDay(date, selectedDate), + isDisabled: !!isDisabled || isInvalidRange(date), + isUnavailable: !outside && !!isDateUnavailable?.(date), + isToday: isSameDay(date, todayDate), + isFocused: isSameDay(date, focusedDate), + }); + } + result.push(days); + } + return result; + }, [ + visibleMonth, + locale, + firstDayOfWeek, + selectedDate, + isDisabled, + isInvalidRange, + isDateUnavailable, + todayDate, + focusedDate, + ]); + + // ── Localised chrome ──────────────────────────────────────────────────────── + const weekDays = useMemo(() => { + const shortFmt = new DateFormatter(locale, { weekday: "short", timeZone }); + const longFmt = new DateFormatter(locale, { weekday: "long", timeZone }); + const gridStart = startOfWeek(visibleMonth, locale, firstDayOfWeek); + return Array.from({ length: 7 }, (_, i) => { + const d = gridStart.add({ days: i }).toDate(timeZone); + return { short: shortFmt.format(d), long: longFmt.format(d) }; + }); + }, [locale, timeZone, visibleMonth, firstDayOfWeek]); + + const title = useMemo( + () => + new DateFormatter(locale, { month: "long", year: "numeric", timeZone }).format( + visibleMonth.toDate(timeZone), + ), + [locale, timeZone, visibleMonth], + ); + + // One formatter per locale/timezone, reused for every cell — the grid renders + // ~42 cells and re-renders on each arrow keypress, so building a fresh + // DateFormatter per cell (per frame while a key is held) was needless churn. + const cellLabelFmt = useMemo( + () => + new DateFormatter(locale, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + timeZone, + }), + [locale, timeZone], + ); + const cellLabel = useCallback( + (date: CalendarDate) => cellLabelFmt.format(date.toDate(timeZone)), + [cellLabelFmt, timeZone], + ); + + // ── Month paging ──────────────────────────────────────────────────────────── + const previousMonth = useCallback( + () => setFocusedDate(visibleMonth.subtract({ months: 1 })), + [visibleMonth, setFocusedDate], + ); + const nextMonth = useCallback( + () => setFocusedDate(visibleMonth.add({ months: 1 })), + [visibleMonth, setFocusedDate], + ); + + const prevDisabled = useMemo( + () => + !!isDisabled || + (min != null && endOfMonth(visibleMonth.subtract({ months: 1 })).compare(min) < 0), + [isDisabled, min, visibleMonth], + ); + const nextDisabled = useMemo( + () => + !!isDisabled || + (max != null && startOfMonth(visibleMonth.add({ months: 1 })).compare(max) > 0), + [isDisabled, max, visibleMonth], + ); + + // ── Keyboard navigation ────────────────────────────────────────────────────── + const onCellKeyDown = useCallback( + (e: React.KeyboardEvent, date: CalendarDate) => { + let next: CalendarDate | null = null; + switch (e.key) { + case "ArrowLeft": + next = date.subtract({ days: 1 }); + break; + case "ArrowRight": + next = date.add({ days: 1 }); + break; + case "ArrowUp": + next = date.subtract({ weeks: 1 }); + break; + case "ArrowDown": + next = date.add({ weeks: 1 }); + break; + case "Home": + next = startOfWeek(date, locale, firstDayOfWeek); + break; + case "End": + next = startOfWeek(date, locale, firstDayOfWeek).add({ days: 6 }); + break; + case "PageUp": + next = date.subtract(e.shiftKey ? { years: 1 } : { months: 1 }); + break; + case "PageDown": + next = date.add(e.shiftKey ? { years: 1 } : { months: 1 }); + break; + case "Enter": + case " ": + e.preventDefault(); + selectDate(date); + return; + default: + return; + } + e.preventDefault(); + isFocusedRef.current = true; + moveFocusRef.current = true; + setFocusedDate(next); + }, + [locale, firstDayOfWeek, selectDate, setFocusedDate], + ); + + return { + weeks, + weekDays, + title, + cellLabel, + focusedDate, + isFocusedRef, + moveFocusRef, + setFocusedDate, + selectDate, + previousMonth, + nextMonth, + prevDisabled, + nextDisabled, + onCellKeyDown, + }; +} + +/** + * Returns a value whose date portion is `date`, preserving the time/zone of + * `base` when it carries one (so picking a day in a date+time picker keeps the + * time the user typed). + */ +function withDatePart(base: DateValue | null | undefined, date: CalendarDate): DateValue { + const parts = { year: date.year, month: date.month, day: date.day }; + if (base instanceof ZonedDateTime) return base.set(parts); + if (base instanceof CalendarDateTime) return base.set(parts); + return date; +} diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 63aa6371..9732593d 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -50,6 +50,11 @@ export const dataTableLabels = defineI18nLabels({ filterOperator_between: "between", filterOperator_in: "in", filterOperator_nin: "not in", + // Date-specific operator labels (date filters drop gt/lt/ne and treat the + // boundary as inclusive). + filterDateOperator_eq: "exact date", + filterDateOperator_gte: "after", + filterDateOperator_lte: "before", filterBetweenFrom: "From", filterBetweenTo: "To", filterBetweenMin: "Min", @@ -102,6 +107,9 @@ export const dataTableLabels = defineI18nLabels({ filterOperator_between: "の範囲内", filterOperator_in: "次のいずれか", filterOperator_nin: "次のいずれでもない", + filterDateOperator_eq: "指定日", + filterDateOperator_gte: "以降", + filterDateOperator_lte: "以前", filterBetweenFrom: "開始", filterBetweenTo: "終了", filterBetweenMin: "最小", diff --git a/packages/core/src/components/data-table/toolbar.test.tsx b/packages/core/src/components/data-table/toolbar.test.tsx index 4c25fb91..9ac83509 100644 --- a/packages/core/src/components/data-table/toolbar.test.tsx +++ b/packages/core/src/components/data-table/toolbar.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, it, expect, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { createAppShellWrapper } from "../../../tests/test-utils"; import { DataTable } from "./data-table"; @@ -127,6 +127,42 @@ const booleanColumn: Column = { filter: { type: "boolean", field: "enabled" }, }; +// --------------------------------------------------------------------------- +// Date filter (DatePicker) helpers +// --------------------------------------------------------------------------- +// `date` filters render the app-shell DatePicker — a labelled group of +// `spinbutton` segments — instead of a native date input. These helpers drive +// it via the segments, re-querying by index so they survive controlled +// re-renders. + +const datePickerGroup = (index: number) => screen.getAllByRole("group")[index]; + +async function typeDateInto( + user: ReturnType, + groupIndex: number, + iso: string, +) { + const [year, month, day] = iso.split("-"); + const seg = (name: string) => + within(datePickerGroup(groupIndex)).getByRole("spinbutton", { name }); + await user.click(seg("month")); + await user.keyboard(month); + await user.click(seg("day")); + await user.keyboard(day); + await user.click(seg("year")); + await user.keyboard(year); +} + +async function clearDateIn(user: ReturnType, groupIndex: number) { + // Clear every segment. Leaving the day set would trigger the field's on-blur + // backfill (assume current month/year), so a genuine "cleared" state must + // remove the day too — the day is the trigger for that backfill. + for (const name of ["day", "month", "year"]) { + await user.click(within(datePickerGroup(groupIndex)).getByRole("spinbutton", { name })); + await user.keyboard("{Delete}"); + } +} + // --------------------------------------------------------------------------- // DataTable.Filters — rendering // --------------------------------------------------------------------------- @@ -471,50 +507,116 @@ describe("DateFilterEditor", () => { wrapper, }); - await user.click(screen.getByRole("button", { name: /Created At equals 2025-01-01/ })); + await user.click(screen.getByRole("button", { name: /Created At exact date/ })); expect(await screen.findByRole("button", { name: "Apply" })).toBeDefined(); }); - it("Apply button calls addFilter with the selected date", async () => { + it("renders a DatePicker (spinbutton segments) for the date filter", async () => { const user = userEvent.setup(); const control = makeControl({ filters: [{ field: "createdAt", operator: "eq", value: "2025-01-01" }], }); - const { container } = render(, { - wrapper, + render(, { wrapper }); + + await user.click(screen.getByRole("button", { name: /Created At exact date/ })); + + // The editor uses the app-shell DatePicker: a labelled group of spinbuttons. + expect( + await within(datePickerGroup(0)).findByRole("spinbutton", { name: "day" }), + ).toBeDefined(); + }); + + it("Apply button calls addFilter with the selected date", async () => { + const user = userEvent.setup(); + const control = makeControl({ + filters: [{ field: "createdAt", operator: "eq", value: "2025-01-01" }], }); + render(, { wrapper }); - await user.click(screen.getByRole("button", { name: /Created At equals 2025-01-01/ })); + await user.click(screen.getByRole("button", { name: /Created At exact date/ })); + await screen.findByRole("button", { name: "Apply" }); - // Use the data-slot selector since date inputs have no simple ARIA role - const dateInput = await screen.findByDisplayValue("2025-01-01"); - await user.clear(dateInput); - await user.type(dateInput, "2026-06-15"); + await typeDateInto(user, 0, "2026-06-15"); await user.click(screen.getByRole("button", { name: "Apply" })); expect(control.addFilter).toHaveBeenCalledWith("createdAt", "eq", "2026-06-15"); - // Verify empty value triggers removeFilter instead - void container; // suppress unused-var }); - it("Apply button calls removeFilter when date is cleared", async () => { + it("Apply button calls removeFilter when the date is cleared", async () => { const user = userEvent.setup(); const control = makeControl({ filters: [{ field: "createdAt", operator: "eq", value: "2025-01-01" }], }); - render(, { - wrapper, - }); + render(, { wrapper }); - await user.click(screen.getByRole("button", { name: /Created At equals 2025-01-01/ })); + await user.click(screen.getByRole("button", { name: /Created At exact date/ })); + await screen.findByRole("button", { name: "Apply" }); - const dateInput = await screen.findByDisplayValue("2025-01-01"); - await user.clear(dateInput); + await clearDateIn(user, 0); await user.click(screen.getByRole("button", { name: "Apply" })); expect(control.removeFilter).toHaveBeenCalledWith("createdAt"); }); + + it("labels date operators as exact date / after / before", () => { + const control = makeControl({ + filters: [ + { field: "createdAt", operator: "eq", value: "2025-01-01" }, + { field: "createdAt", operator: "gte", value: "2025-02-01" }, + { field: "createdAt", operator: "lte", value: "2025-03-01" }, + ], + }); + // Render each via its own chip; assert the friendly date labels appear and + // the numeric labels do not. + const { rerender } = render( + , + { wrapper }, + ); + // Operator label is friendly, and the value is locale-formatted (not raw ISO). + expect(screen.getByText(/Created At exact date Jan 1, 2025/)).toBeDefined(); + expect(screen.queryByText(/2025-01-01/)).toBeNull(); + + rerender( + , + ); + expect(screen.getByText(/Created At after Feb 1, 2025/)).toBeDefined(); + expect(screen.queryByText(/greater than/)).toBeNull(); + + rerender( + , + ); + expect(screen.getByText(/Created At before Mar 1, 2025/)).toBeDefined(); + }); + + it("preserves a legacy operator (e.g. gt) on Apply instead of coercing to eq", async () => { + const user = userEvent.setup(); + // A saved view / useCollectionVariables config can hold a date filter on the + // now-dropped `gt` operator. Opening + re-applying it must NOT silently flip + // it to `eq` (which would turn "after X" into "on X"). + const control = makeControl({ + filters: [{ field: "createdAt", operator: "gt", value: "2025-01-01" }], + }); + render(, { wrapper }); + + // The chip still renders the legacy operator's (generic) label. + await user.click(screen.getByRole("button", { name: /Created At greater than/ })); + await screen.findByRole("button", { name: "Apply" }); + + // Apply without touching the operator → the operator is preserved. + await user.click(screen.getByRole("button", { name: "Apply" })); + expect(control.addFilter).toHaveBeenCalledWith("createdAt", "gt", "2025-01-01"); + expect(control.addFilter).not.toHaveBeenCalledWith("createdAt", "eq", expect.anything()); + }); }); // --------------------------------------------------------------------------- @@ -807,7 +909,7 @@ describe("NumericFilterEditor (between)", () => { // --------------------------------------------------------------------------- describe("TemporalFilterEditor (between)", () => { - it("shows two date inputs when filter operator is between", async () => { + it("shows two date pickers when filter operator is between", async () => { const user = userEvent.setup(); const control = makeControl({ filters: [ @@ -818,18 +920,16 @@ describe("TemporalFilterEditor (between)", () => { }, ], }); - render(, { - wrapper, - }); + render(, { wrapper }); await user.click( screen.getByRole("button", { - name: /Created At between 2025-01-01 - 2025-12-31/, + name: /Created At between/, }), ); - const inputs = await screen.findAllByDisplayValue(/2025/); - expect(inputs.length).toBe(2); + await screen.findByRole("button", { name: "Apply" }); + expect(screen.getAllByRole("group")).toHaveLength(2); }); it("Apply button calls addFilter with min/max range for date between", async () => { @@ -843,21 +943,17 @@ describe("TemporalFilterEditor (between)", () => { }, ], }); - render(, { - wrapper, - }); + render(, { wrapper }); await user.click( screen.getByRole("button", { - name: /Created At between 2025-01-01 - 2025-12-31/, + name: /Created At between/, }), ); + await screen.findByRole("button", { name: "Apply" }); - const inputs = await screen.findAllByDisplayValue(/2025/); - await user.clear(inputs[0]); - await user.type(inputs[0], "2026-03-01"); - await user.clear(inputs[1]); - await user.type(inputs[1], "2026-06-30"); + await typeDateInto(user, 0, "2026-03-01"); + await typeDateInto(user, 1, "2026-06-30"); await user.click(screen.getByRole("button", { name: "Apply" })); expect(control.addFilter).toHaveBeenCalledWith("createdAt", "between", { @@ -866,7 +962,7 @@ describe("TemporalFilterEditor (between)", () => { }); }); - it("Apply button calls removeFilter when both temporal inputs are empty", async () => { + it("Apply button calls removeFilter when both date pickers are cleared", async () => { const user = userEvent.setup(); const control = makeControl({ filters: [ @@ -877,19 +973,17 @@ describe("TemporalFilterEditor (between)", () => { }, ], }); - render(, { - wrapper, - }); + render(, { wrapper }); await user.click( screen.getByRole("button", { - name: /Created At between 2025-01-01 - 2025-12-31/, + name: /Created At between/, }), ); + await screen.findByRole("button", { name: "Apply" }); - const inputs = await screen.findAllByDisplayValue(/2025/); - await user.clear(inputs[0]); - await user.clear(inputs[1]); + await clearDateIn(user, 0); + await clearDateIn(user, 1); await user.click(screen.getByRole("button", { name: "Apply" })); expect(control.removeFilter).toHaveBeenCalledWith("createdAt"); diff --git a/packages/core/src/components/data-table/toolbar.tsx b/packages/core/src/components/data-table/toolbar.tsx index a27ff1f5..711902cb 100644 --- a/packages/core/src/components/data-table/toolbar.tsx +++ b/packages/core/src/components/data-table/toolbar.tsx @@ -7,6 +7,9 @@ import { useCollectionControlOptional } from "@/contexts/collection-control-cont import { Button } from "@/components/button"; import { Input } from "@/components/input"; import { Select } from "@/components/select-standalone"; +import { DatePicker } from "@/components/date-field"; +import { parseDate, DateFormatter } from "@internationalized/date"; +import { useResolvedLocale } from "@/contexts/appshell-context"; import { useDataTableContext } from "./data-table-context"; import { useDataTableT } from "./i18n"; import type { CollectionControl, Filter, FilterConfig, FilterOperator } from "@/types/collection"; @@ -52,6 +55,42 @@ const DEFAULT_OPERATOR: Record = { const NUMERIC_TEMPORAL_OPERATORS = ["eq", "ne", "gt", "gte", "lt", "lte", "between"] as const; type NumericTemporalOperator = (typeof NUMERIC_TEMPORAL_OPERATORS)[number]; +// Date filters use a slimmer, friendlier operator set: an exact date, inclusive +// "after"/"before" (gte/lte), and a between range. No gt/lt/ne. +const DATE_OPERATORS = ["eq", "gte", "lte", "between"] as const; + +/** Operators offered for a temporal editor, narrowed for `date` columns. */ +function temporalOperatorsFor(type: FilterConfig["type"]): readonly NumericTemporalOperator[] { + return type === "date" ? DATE_OPERATORS : NUMERIC_TEMPORAL_OPERATORS; +} + +/** + * Operator options + initial selection for a numeric/temporal editor. + * + * A saved view or `useCollectionVariables` config can hold a filter whose + * operator is no longer in the standard set — most importantly a `date` filter + * on the now-dropped `gt`/`lt`/`ne` (the pre-narrowing operator set). In that + * case we keep the incoming operator as a selectable, preselected option rather + * than resetting to `eq`, so opening the editor and hitting Apply never silently + * rewrites the filter's operator (e.g. "after X" → "on X"). A truly unknown + * operator falls back to `eq`. + */ +function resolveTemporalOperator( + standard: readonly NumericTemporalOperator[], + current: FilterOperator, +): { items: readonly NumericTemporalOperator[]; initial: NumericTemporalOperator } { + if (standard.includes(current as NumericTemporalOperator)) { + return { items: standard, initial: current as NumericTemporalOperator }; + } + if ((NUMERIC_TEMPORAL_OPERATORS as readonly string[]).includes(current)) { + return { + items: [...standard, current as NumericTemporalOperator], + initial: current as NumericTemporalOperator, + }; + } + return { items: standard, initial: "eq" }; +} + /** String operators available in the operator selector. */ const STRING_OPERATORS = ["eq", "ne", "contains", "notContains", "hasPrefix", "hasSuffix"] as const; type StringOperator = (typeof STRING_OPERATORS)[number]; @@ -166,6 +205,30 @@ function BetweenInputGroup({ ); } +/** + * Date filter input backed by the app-shell `DatePicker`. Bridges the filter's + * string value (`"YYYY-MM-DD"`) and the `CalendarDate` the picker works with. + */ +function DateFilterPicker({ + ariaLabel, + value, + onChange, +}: { + ariaLabel: string; + value: string; + onChange: (value: string) => void; +}) { + const calValue = /^\d{4}-\d{2}-\d{2}$/.test(value) ? parseDate(value) : null; + return ( + onChange(v ? v.toString() : "")} + className="astw:w-full" + /> + ); +} + function AddFilterPopover({ availableColumns, control, @@ -320,8 +383,26 @@ function AddFilterPopover({ } if (isTemporalFilterType(config.type)) { + const isDate = config.type === "date"; + const fieldLabel = selectedColumn.label ?? config.field; if (operator === "between") { const [min, max] = Array.isArray(value) ? value : ["", ""]; + if (isDate) { + return ( +
+ setValue([v, max])} + /> + setValue([min, v])} + /> +
+ ); + } return ( ); } + if (isDate) { + return ( + setValue(v)} + /> + ); + } return ( ({ value: op, - label: getOperatorLabel(op, t), + label: getOperatorLabel(op, t, selectedColumn?.filter.type), })} className="astw:h-8 astw:text-sm" /> @@ -486,6 +576,7 @@ function FilterChip({ control: CollectionControl; }) { const t = useDataTableT(); + const { locale } = useResolvedLocale(); const config = column.filter; const label = column.label ?? config.field; @@ -503,7 +594,7 @@ function FilterChip({ control.removeFilter(config.field); }, [control, config.field]); - const chipLabel = getChipDisplayLabel(label, filter, config, t); + const chipLabel = getChipDisplayLabel(label, filter, config, t, locale); return (
@@ -568,6 +659,7 @@ function FilterPopoverContent({ onClose: () => void; }) { const config = column.filter; + const label = column.label ?? config.field; switch (config.type) { case "enum": @@ -592,7 +684,13 @@ function FilterPopoverContent({ case "date": case "time": return ( - + ); } } @@ -867,11 +965,11 @@ function NumericFilterEditor({ onClose: () => void; }) { const t = useDataTableT(); - const [localOp, setLocalOp] = useState( - NUMERIC_TEMPORAL_OPERATORS.includes(filter.operator as NumericTemporalOperator) - ? (filter.operator as NumericTemporalOperator) - : "eq", + const { items: operatorItems, initial: initialOp } = resolveTemporalOperator( + temporalOperatorsFor(config.type), + filter.operator, ); + const [localOp, setLocalOp] = useState(initialOp); const [localValue, setLocalValue] = useState(() => { if (filter.operator === "between" && typeof filter.value === "object" && filter.value != null) { const range = filter.value as { min?: unknown; max?: unknown }; @@ -932,12 +1030,12 @@ function NumericFilterEditor({ className="astw:flex astw:flex-col astw:gap-2 astw:p-2" > { + setLocalValue(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCommit(); + } + }} + className="astw:h-8 astw:text-sm" + /> + ); + } + return (
{ - setLocalValue(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === "Enter") { - handleCommit(); - } - }} - className="astw:h-8 astw:text-sm" - /> - )} + {valueInput} @@ -1099,9 +1223,10 @@ function getAddFilterOperators(type: FilterConfig["type"]): FilterOperator[] { switch (type) { case "string": return [...STRING_OPERATORS]; + case "date": + return [...DATE_OPERATORS]; case "number": case "datetime": - case "date": case "time": return [...NUMERIC_TEMPORAL_OPERATORS]; case "enum": @@ -1235,7 +1360,17 @@ function getTemporalInputProps(type: "datetime" | "date" | "time") { } } -function getOperatorLabel(operator: FilterOperator, t: ReturnType): string { +function getOperatorLabel( + operator: FilterOperator, + t: ReturnType, + type?: FilterConfig["type"], +): string { + // Date columns relabel the comparison operators (exact date / after / before). + if (type === "date") { + if (operator === "eq") return t("filterDateOperator_eq"); + if (operator === "gte") return t("filterDateOperator_gte"); + if (operator === "lte") return t("filterDateOperator_lte"); + } switch (operator) { case "eq": return t("filterOperator_eq"); @@ -1272,10 +1407,28 @@ function getOperatorLabel(operator: FilterOperator, t: ReturnType, + locale: string, ): string { if (config.type === "enum" && Array.isArray(filter.value)) { const labels = filter.value @@ -1296,6 +1449,18 @@ function formatFilterValue( return [min, max].filter(Boolean).join(" - "); } + if (config.type === "date") { + if (filter.operator === "between") { + const range = filter.value as { min?: unknown; max?: unknown } | null; + if (!range || typeof range !== "object") return ""; + const min = range.min != null ? formatDateValue(String(range.min), locale) : ""; + const max = range.max != null ? formatDateValue(String(range.max), locale) : ""; + return [min, max].filter(Boolean).join(" - "); + } + if (filter.value == null || filter.value === "") return ""; + return formatDateValue(String(filter.value), locale); + } + if (isTemporalFilterType(config.type) && filter.operator === "between") { const range = filter.value as { min?: unknown; max?: unknown } | null; if (!range || typeof range !== "object") return ""; @@ -1317,11 +1482,12 @@ function getChipDisplayLabel( filter: Filter, config: FilterConfig, t: ReturnType, + locale: string, ): string { - const valueLabel = formatFilterValue(filter, config, t); + const valueLabel = formatFilterValue(filter, config, t, locale); if (!valueLabel) return columnLabel; - const operatorLabel = getOperatorLabel(filter.operator, t); + const operatorLabel = getOperatorLabel(filter.operator, t, config.type); const ciSuffix = filter.caseSensitive ? " (Aa)" : ""; if (config.type === "enum") { diff --git a/packages/core/src/components/date-field/date-field.test.tsx b/packages/core/src/components/date-field/date-field.test.tsx new file mode 100644 index 00000000..95b8fac9 --- /dev/null +++ b/packages/core/src/components/date-field/date-field.test.tsx @@ -0,0 +1,527 @@ +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { CalendarDate, parseDate, today, getLocalTimeZone } from "@internationalized/date"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { DateField, DatePicker } from "./date-field"; + +// This suite is the parity contract shared with the react-aria implementation: +// it asserts public behaviour + the DOM accessibility contract (spinbutton +// segments, role="grid" cells with data-* state attributes, role="dialog" +// popover), not implementation details. + +afterEach(() => { + cleanup(); +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Find
day cells inside a calendar grid (not nav buttons). */ +function getCalendarCells() { + return screen.getAllByRole("button", { hidden: true }).filter((c) => c.closest('[role="grid"]')); +} + +function getEnabledCalendarCells() { + return getCalendarCells().filter( + (c) => !c.hasAttribute("data-disabled") && !c.hasAttribute("data-outside-month"), + ); +} + +// ─── Snapshots ────────────────────────────────────────────────────────────── +// Visual-structure snapshots per the add-component convention. Inputs are +// pinned (fixed `defaultValue`, no live "today" in view) so output is stable. + +describe("snapshots", () => { + it("DateField", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("DateField — invalid with error", () => { + const { container } = render( + , + ); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("DatePicker — closed", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); +}); + +// ─── DateField ───────────────────────────────────────────────────────────────── + +describe("DateField", () => { + it("renders with a label", () => { + render(); + expect(screen.getByText("Invoice date")).toBeDefined(); + }); + + it("renders date segments", () => { + render(); + // segments are exposed as spinbuttons for day, month, year + const segments = screen.getAllByRole("spinbutton"); + expect(segments.length).toBeGreaterThan(0); + }); + + it("renders with description", () => { + render(); + expect(screen.getByText("Pick any date")).toBeDefined(); + }); + + it("renders error message when isInvalid", () => { + render(); + expect(screen.getByText("Required")).toBeDefined(); + }); + + it("renders as disabled", () => { + render(); + const groups = screen.getAllByRole("group"); + const disabledGroup = groups.find((g) => g.getAttribute("aria-disabled") === "true"); + expect(disabledGroup).toBeDefined(); + }); + + it("resolves LocalizedString label with language fallback", () => { + render( (locale === "ja" ? "日付" : "Date")} />); + // default locale resolves to english + expect(screen.getByText("Date")).toBeDefined(); + }); + + it("fires onChange once a complete date is typed across the segments", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + // Fill every segment by aria-label (order-independent across locales). + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("06"); + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("15"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2025"); + + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + const last = onChange.mock.calls.at(-1)?.[0]; + expect(last?.toString()).toBe("2025-06-15"); + }); + }); + + it("auto-advances across segments as a full date is typed (no explicit tabbing)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + // Locale here is "en" → MM/DD/YYYY. Typing carries across segments: + // "02" fills+advances month, "15" fills+advances day, "2025" fills year. + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("02152025"); + + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-02-15"); + }); + }); + + it("accumulates a non-leading-zero entry (2 then 9 → 29, not 9)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("12"); + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("29"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2025"); + + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-12-29"); + }); + }); + + it("accepts day 31 typed before a month (day max isn't tied to the current month)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + // Type the day first — "31" must not collapse to "1" just because the + // current (anchor) month happens to have 30/28 days. + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("31"); + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("12"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2025"); + + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-12-31"); + }); + }); + + it("clamps an impossible day to the month's length on blur", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <> + + + , + ); + + // Enter 30 / 02 / 2026 (Feb 2026 has 28 days) — invalid until blur. + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("30"); + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("02"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2026"); + // Blur the field. + await user.click(screen.getByRole("button", { name: "elsewhere" })); + + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); + }); + }); + + it("keeps 29 Feb in a leap year (no over-clamp)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <> + + + , + ); + + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("29"); + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("02"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2024"); // leap year + await user.click(screen.getByRole("button", { name: "elsewhere" })); + + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2024-02-29"); + }); + }); + + // A controlled field round-trips every emit through the parent's `value`, so + // these guard the clamp against external-sync interference (the uncontrolled + // cases above don't exercise that path). + function ControlledField({ onChange }: { onChange: (v: unknown) => void }) { + const [v, setV] = useState(null); + return ( + <> + { + setV(nv as CalendarDate | null); + onChange(nv); + }} + /> + + + ); + } + + it("clamps an impossible day on blur even when controlled (29/02/2026)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("29"); + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("02"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2026"); // not a leap year → Feb has 28 days + await user.click(screen.getByRole("button", { name: "elsewhere" })); + + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); + }); + }); + + it("re-clamps when editing the year turns a valid leap day invalid (29 Feb 2024 → 2026)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + // First enter a genuinely valid leap-year date. + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("29"); + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("02"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2024"); + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2024-02-29"); + }); + + // Now change the year to a non-leap year — 29 Feb no longer exists. + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2026"); + await user.click(screen.getByRole("button", { name: "elsewhere" })); + + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); + }); + }); + + 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(); + + // 29 typed before the month (allowed), then Feb 2026 (28 days). The moment + // the 4-digit year lands, the day self-corrects — without leaving the field. + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("29"); + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("02"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2026"); + + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); + }); + }); + + it("re-corrects on year completion when a leap day turns invalid (no blur)", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("29"); + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("02"); + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2024"); + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2024-02-29"); + }); + + // Retype the year to a non-leap year — corrects on completion, no blur. + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2026"); + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2026-02-28"); + }); + }); + + // On-blur backfill: a provided day (finest unit) lets the coarser fields + // default to the current month/year. The anchor for a bare DateField is + // today("UTC"), so expectations are derived from that same basis. + it("backfills the current month + year when only the day is entered, on blur", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <> + + + , + ); + + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("2"); + await user.click(screen.getByRole("button", { name: "elsewhere" })); + + const expected = today("UTC").set({ day: 2 }); + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected.toString()); + }); + }); + + it("backfills the current year when day + month are entered, on blur", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <> + + + , + ); + + await user.click(screen.getByRole("spinbutton", { name: "day" })); + await user.keyboard("02"); + await user.click(screen.getByRole("spinbutton", { name: "month" })); + await user.keyboard("08"); + await user.click(screen.getByRole("button", { name: "elsewhere" })); + + const expected = today("UTC").set({ month: 8, day: 2 }); + await waitFor(() => { + expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe(expected.toString()); + }); + }); + + it("never backfills the day — a lone year entry emits nothing on blur", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + <> + + + , + ); + + await user.click(screen.getByRole("spinbutton", { name: "year" })); + await user.keyboard("2025"); + await user.click(screen.getByRole("button", { name: "elsewhere" })); + + // The day is the trigger for backfill; without it we never guess a value. + expect(onChange.mock.calls.some(([v]) => v != null)).toBe(false); + }); + + it("clears a controlled DateField when the value is reset to null", () => { + const { rerender } = render( + {}} + />, + ); + expect(screen.getByRole("spinbutton", { name: "day" }).textContent).toBe("15"); + + // Parent clears the field: value={null} is controlled-empty, not uncontrolled. + rerender( {}} />); + expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-valuetext")).toBe( + "Empty", + ); + }); + + it("sets aria-required on the segments when isRequired", () => { + render(); + const day = screen.getByRole("spinbutton", { name: "day" }); + expect(day.getAttribute("aria-required")).toBe("true"); + }); +}); + +// ─── DatePicker ─────────────────────────────────────────────────────────────── + +describe("DatePicker", () => { + it("renders with a label", () => { + render(); + expect(screen.getByText("Ship date")).toBeDefined(); + }); + + it("renders the calendar trigger button", () => { + render(); + const btn = screen.getAllByRole("button").find((b) => !b.closest('[role="grid"]')); + expect(btn).toBeDefined(); + }); + + it("opens the popover when the trigger is clicked", async () => { + const user = userEvent.setup(); + render(); + + const triggerBtn = screen.getAllByRole("button")[0]; + await user.click(triggerBtn); + + await waitFor(() => { + expect(screen.getByRole("dialog")).toBeDefined(); + }); + }); + + it("shows a calendar grid in the popover", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getAllByRole("button")[0]); + + await waitFor(() => { + expect(screen.getByRole("grid")).toBeDefined(); + }); + }); + + it("fires onChange when a calendar date cell is clicked", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render(); + + await user.click(screen.getAllByRole("button")[0]); + await waitFor(() => expect(screen.getByRole("grid")).toBeDefined()); + + const enabled = getEnabledCalendarCells(); + if (enabled.length > 0) { + await user.click(enabled[0]); + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.calls[0][0]).not.toBeNull(); + }); + } + }); + + it("renders cells with data-disabled when minValue is set", async () => { + const user = userEvent.setup(); + const tomorrow = today(getLocalTimeZone()).add({ days: 1 }); + render(); + + await user.click(screen.getAllByRole("button")[0]); + await waitFor(() => expect(screen.getByRole("grid")).toBeDefined()); + + const disabled = getCalendarCells().filter((c) => c.hasAttribute("data-disabled")); + expect(disabled.length).toBeGreaterThan(0); + }); + + it("renders cells with data-unavailable when isDateUnavailable returns true", async () => { + const user = userEvent.setup(); + render( true} />); + + await user.click(screen.getAllByRole("button")[0]); + await waitFor(() => expect(screen.getByRole("grid")).toBeDefined()); + + const unavailable = getCalendarCells().filter((c) => c.hasAttribute("data-unavailable")); + expect(unavailable.length).toBeGreaterThan(0); + }); + + it("renders error message when errorMessage is set", () => { + render(); + expect(screen.getByText("Date is required")).toBeDefined(); + }); + + it("clears a controlled DatePicker when the value is reset to null", () => { + const { rerender } = render( + {}} + />, + ); + expect(screen.getByRole("spinbutton", { name: "day" }).textContent).toBe("15"); + + rerender( {}} />); + expect(screen.getByRole("spinbutton", { name: "day" }).getAttribute("aria-valuetext")).toBe( + "Empty", + ); + }); + + it("localizes segment names and chrome from the AppShell locale (ja)", () => { + render(, { wrapper: createAppShellWrapper("ja") }); + // Segment accessible name: month → 月. + expect(screen.getByRole("spinbutton", { name: "月" })).toBeDefined(); + // Popover trigger aria-label is localized too. + expect(screen.getByRole("button", { name: "カレンダーを開く" })).toBeDefined(); + // Empty segments announce the localized placeholder. + expect(screen.getByRole("spinbutton", { name: "月" }).getAttribute("aria-valuetext")).toBe( + "未入力", + ); + }); +}); + +// ─── Keyboard: popover focus ───────────────────────────────────────────────── + +describe("DatePicker keyboard", () => { + it("moves focus into the calendar grid when the popover opens", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getAllByRole("button")[0]); + await waitFor(() => { + expect(document.activeElement?.closest('[role="grid"]')).not.toBeNull(); + }); + }); +}); diff --git a/packages/core/src/components/date-field/date-field.tsx b/packages/core/src/components/date-field/date-field.tsx new file mode 100644 index 00000000..82f118d8 --- /dev/null +++ b/packages/core/src/components/date-field/date-field.tsx @@ -0,0 +1,318 @@ +import { useCallback, useId, useRef, useState } from "react"; +import type { DateValue } from "@internationalized/date"; +import { cn } from "@/lib/utils"; +import { buildLocaleResolver, type LocalizedString } from "@/lib/i18n"; +import { useResolvedLocale, useTimeZone } from "@/contexts/appshell-context"; +import { useDateFieldState, type Granularity, type HourCycle } from "./use-date-field-state"; +import { useCalendarState, type FirstDayOfWeek } from "../calendar/use-calendar-state"; +import { CalendarView } from "../calendar/calendar-view"; +import { + DateInputGroup, + DatePopover, + DatePickerPopoverTrigger, + DatePickerLabel, + DatePickerDescription, + DatePickerError, +} from "./date-input-group"; +import { useDateFieldT } from "./i18n"; + +/** + * Public, closed-API date components — the @internationalized/date + Base UI + * implementation. Same surface as the react-aria variant; only the internals + * differ. Consumers never see Base UI or the date engines. + */ + +// ─── Small controlled-state helper ──────────────────────────────────────────── +function useControlledState( + controlled: V | undefined, + defaultValue: V, + onChange?: (value: V) => void, +): [V, (value: V) => void] { + const isControlled = controlled !== undefined; + const [internal, setInternal] = useState(defaultValue); + const value = isControlled ? (controlled as V) : internal; + const set = useCallback( + (next: V) => { + if (!isControlled) setInternal(next); + onChange?.(next); + }, + [isControlled, onChange], + ); + return [value, set]; +} + +// ─── Shared prop types (names unchanged from react-aria) ────────────────────── + +interface DateFieldMetaProps { + label?: LocalizedString; + description?: LocalizedString; + errorMessage?: LocalizedString; + className?: string; +} + +interface DateBehaviorProps { + value?: T | null; + defaultValue?: T | null; + onChange?: (value: T | null) => void; + granularity?: Granularity; + minValue?: DateValue; + maxValue?: DateValue; + isDateUnavailable?: (date: DateValue) => boolean; + isDisabled?: boolean; + isReadOnly?: boolean; + isRequired?: boolean; + isInvalid?: boolean; + autoFocus?: boolean; + hourCycle?: HourCycle; + hideTimeZone?: boolean; + placeholderValue?: DateValue; + name?: string; + /** Accessible name when no visible `label` is provided (e.g. a compact filter input). */ + "aria-label"?: string; + /** BCP-47 locale override; defaults to the AppShell formatting locale. */ + locale?: string; +} + +export type DateFieldProps = DateFieldMetaProps & + DateBehaviorProps; + +export type DatePickerProps = DateFieldProps & { + firstDayOfWeek?: FirstDayOfWeek; + /** IANA timezone; defaults to the AppShell `timeZone`. */ + timeZone?: string; +}; + +// ─── DateField ──────────────────────────────────────────────────────────────── + +/** + * A segmented date/time input field with no popover. + * + * @example + * ```tsx + * import { DateField } from "@tailor-platform/app-shell"; + * + * + * + * ``` + */ +function DateField({ + label, + description, + errorMessage, + className, + locale: localeProp, + value, + defaultValue, + onChange, + granularity, + hourCycle, + placeholderValue, + isDisabled, + isReadOnly, + isInvalid, + isRequired, + autoFocus, + name, + "aria-label": ariaLabel, +}: DateFieldProps) { + const { locale: shellLocale, language } = useResolvedLocale(); + const resolvedLocale = localeProp ?? shellLocale; + const resolve = buildLocaleResolver(language); + + 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 derivedInvalid = !!errorText || !!isInvalid; + + 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, + granularity, + locale: resolvedLocale, + hourCycle, + placeholderValue, + isReadOnly, + }); + + const describedBy = cn(descText && descId, derivedInvalid && errorText && errId) || undefined; + + return ( +
+ {labelText && {labelText}} + + {descText && {descText}} + {derivedInvalid && errorText && {errorText}} + {name && } +
+ ); +} + +// ─── DatePicker ─────────────────────────────────────────────────────────────── + +/** + * A date/time input with a popover calendar. + * + * Value type is driven by `granularity`: + * - `"day"` (default) → `CalendarDate` + * - `"hour" | "minute" | "second"` → `CalendarDateTime` (or `ZonedDateTime` when a `timeZone` is set) + * + * @example + * ```tsx + * import { DatePicker, today, getLocalTimeZone, type CalendarDate } from "@tailor-platform/app-shell"; + * + * const [date, setDate] = useState(null); + * + * ``` + */ +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 shellTz = useTimeZone(); + const resolvedLocale = localeProp ?? shellLocale; + const resolvedTz = timeZoneProp ?? shellTz; + 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 derivedInvalid = !!errorText || !!isInvalid; + + const [open, setOpen] = useState(false); + const fieldRef = useRef(null); + const [val, setVal] = useControlledState( + // `null` is controlled-empty; only `undefined` means uncontrolled (see above). + value, + defaultValue ?? null, + onChange as (v: DateValue | null) => void, + ); + + const fieldState = useDateFieldState({ + value: val, + 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, + isReadOnly, + }); + + const calState = useCalendarState({ + value: val, + onChange: (d) => { + setVal(d); + setOpen(false); + }, + minValue, + maxValue, + isDateUnavailable, + isDisabled, + isReadOnly, + firstDayOfWeek, + locale: resolvedLocale, + timeZone: resolvedTz, + }); + + const describedBy = cn(descText && descId, derivedInvalid && errorText && errId) || undefined; + const accessibleName = labelText ?? ariaLabel; + const popoverAriaLabel = accessibleName + ? t("chooseDateFor", { name: accessibleName }) + : t("chooseDate"); + + return ( +
+ {labelText && {labelText}} + } + /> + } + > + + + {descText && {descText}} + {derivedInvalid && errorText && {errorText}} + {name && } +
+ ); +} + +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 new file mode 100644 index 00000000..9357fd48 --- /dev/null +++ b/packages/core/src/components/date-field/date-input-group.tsx @@ -0,0 +1,365 @@ +import * as React from "react"; +import { Popover } from "@base-ui/react/popover"; +import { CalendarIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { inputBaseClasses } from "@/lib/input-classes"; +import { useDateFieldT } from "./i18n"; +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 + * 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