From f9f5569a821945941518665ca5bde6a8eb5025ec Mon Sep 17 00:00:00 2001 From: interacsean Date: Wed, 17 Jun 2026 13:51:38 +1000 Subject: [PATCH 01/35] docs(date-picker): add design proposal Proposes the DatePicker / DateField / Calendar API for AppShell: react-aria-components + @internationalized/date wrapped in a thin app-shell layer, with passed-through vs masked props, locale and timezone wiring, alternatives analysis, and measured bundle costs. Tracked at tailor-inc/platform-planning#1093. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/proposals/date-picker.md | 387 ++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 docs/proposals/date-picker.md diff --git a/docs/proposals/date-picker.md b/docs/proposals/date-picker.md new file mode 100644 index 00000000..a0e1bd5c --- /dev/null +++ b/docs/proposals/date-picker.md @@ -0,0 +1,387 @@ +# Proposal: DatePicker / DateField (and future DateRangePicker) + +> Status: **Draft for review — decided: `react-aria-components` for v1; lighter foundation tracked as a future possibility (§9)** +> Scope: v1 ships `DateField`, `DatePicker`, and a standalone `Calendar`. `DateRangePicker` / `RangeCalendar` are designed-for but deferred. + +## 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: + +- **No Radix.** `react-aria-components` pulls in **zero** `@radix-ui/*` packages, and the repo is Radix-free today. This is a _different_ vendor stack from the one we removed, not a reintroduction. (The `aria-hidden` utility in the tree is a standalone dep that both ecosystems happen to share — it is not Radix.) +- **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, which is the direct answer to the Radix-lock-in anxiety. +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 `