diff --git a/docs/rfcs/005-calendar-preview.md b/docs/rfcs/005-calendar-preview.md new file mode 100644 index 000000000..049397a7b --- /dev/null +++ b/docs/rfcs/005-calendar-preview.md @@ -0,0 +1,518 @@ +--- +ID: RFC 005 +Created: August 24, 2026 +Status: Draft +RFC PR: https://github.com/raystack/apsara/pull/890 +--- + +# Calendar Rewrite: `CalendarPreview` + +Replace `Calendar`, `DatePicker`, and `RangePicker` with one subcomposed root, `CalendarPreview`, that owns date state and popover state explicitly and exposes every surface as a dot-notation part. + +Every recurring bug in this family traces to one fact: **popover open state is private**, so the pickers cannot hand dismissal to Base UI. That costs 185 lines of bespoke popover machinery, three suppression branches inside one callback, and a month/year navigation feature that is off by default but still demoed. + +Breaking change, no compatibility shim. `CalendarPreview` ships alongside the current family; the old exports are removed one release later. Scope: single and range selection, month-year navigation, granularity (day / month / quarter / half-year / year), presets, and time-of-day. + +**Target package:** `@raystack/apsara` (`packages/raystack/components/calendar-preview/`) + +## Table of Contents + +- [Calendar Rewrite: `CalendarPreview`](#calendar-rewrite-calendarpreview) + - [Table of Contents](#table-of-contents) + - [Background](#background) + - [Current Architecture](#current-architecture) + - [Current Problems](#current-problems) + - [Goals and Non-Goals](#goals-and-non-goals) + - [Proposal](#proposal) + - [API at a Glance](#api-at-a-glance) + - [Recipes](#recipes) + - [Root Props](#root-props) + - [Parts](#parts) + - [State Ownership](#state-ownership) + - [Field Integration](#field-integration) + - [Conventions This Follows](#conventions-this-follows) + - [Internal Architecture](#internal-architecture) + - [File Layout](#file-layout) + - [The Date Adapter](#the-date-adapter) + - [The react-day-picker Boundary](#the-react-day-picker-boundary) + - [Dependencies](#dependencies) + - [The `data-slot` Contract](#the-data-slot-contract) + - [Breaking Changes](#breaking-changes) + - [Migration Map](#migration-map) + - [Repo-Internal Follow-ups](#repo-internal-follow-ups) + - [Implementation Plan](#implementation-plan) + - [Testing](#testing) + - [Open Items](#open-items) + - [Alternatives](#alternatives) + - [Helpful Links](#helpful-links) + +## Background + +### Current Architecture + +`packages/raystack/components/calendar/` ships three flat exports across 1,071 lines of TypeScript and 345 of CSS. The barrel exports `Calendar`, `DatePicker`, `RangePicker`, and re-exports react-day-picker's `DateRange` type directly. + +| File | Lines | Role | +|---|---|---| +| `calendar.tsx` | 269 | Wraps react-day-picker's `DayPicker`; 5 component overrides, 20 `classNames` keys | +| `date-picker.tsx` | 293 | Single-date picker with a typable input | +| `range-picker.tsx` | 324 | Two-input range picker | +| `use-picker-popover.ts` | 185 | Bespoke open/close, outside-click, and dropdown carve-out | +| `calendar.module.css` | 345 | Shared styles | + +### Current Problems + +| # | Problem | Evidence in the repo | Rewrite's answer | +|---|---|---|---| +| 1 | No dot-notation composition | Three flat exports, zero sub-parts, against `composition.md`'s "one name per component". `slotProps`, `children`-as-function, and `onErrorChange` appear in no other component. | One root, every surface a part | +| 2 | **Open state is private** | Neither picker takes `open` / `onOpenChange`; both pass `popover.isOpen` into `Popover` and expose nothing. `Menu`, `Select`, `Sidebar`, and `Tour` roots all expose it. | `open` / `defaultOpen` / `onOpenChange` on the root | +| 3 | 185 lines of popover machinery | `use-picker-popover.ts` — inventory below | File deleted; Base UI owns dismissal | +| 4 | `Date` identity churn | Three `biome-ignore`s for `useExhaustiveDependencies` — two reading *compare on timestamp, not Date identity*, one *engage/disengage are stable*. A fourth effect with the same problem is unguarded (below). | `dayKey()` / `epoch()` internally; zero suppressions | +| 5 | Month/year nav off by default | `range-picker.tsx`: `'dropdown'` mounts Apsara `Select`s whose unmount loops ("Maximum update depth"). `date-picker.runtime.test.tsx` still asks for real-browser verification and credits a `useMemo` that no longer exists — `date-picker.tsx` has zero. The "With Dropdowns" demo shows it on standalone `` with nothing marking it unsafe inside a picker. | `.Nav` is ours; RDP never mounts a `Select` | +| 6 | Spread-last unsatisfiable | `SKILL.md` requires `...props` last; `Calendar` complies, the pickers cannot — eight keys are pinned after the consumer spread. Detail below. | RDP's union never reaches a part | +| 7 | `dayjs.extend()` is import-order dependent | Four modules extend independently. `range-picker.tsx` extends nothing and rides on `calendar.tsx` importing first; `date-picker.tsx` forwards `timeZone` without extending `utc`/`timezone`; ordering is enforced by a comment. This is the failure class behind the 0.49.0 P0 — a `TypeError` on every keystroke — and two tests guard it. | One `date-adapter.ts` owns every `extend()` | +| 8 | Partial range disable impossible | Disabling either input gates the whole picker, because the state machine rewrites both `from` and `to` regardless of which input was clicked. Two tests assert the gate; the source comment and CHANGELOG 0.49.0 both say to constrain the calendar instead. | `lock='from' \| 'to'` on `RangeProps` | +| 9 | `FilterChip` absorbs the cost | Sole production consumer, hit three ways. Detail below. | Compose parts; delete `toDateValue()`, the merge, and the dead CSS | +| 10 | Published types are wrong | `props.ts` types both `slotProps.calendar` as the full docs `CalendarProps`, including `mode`, `selected`, `onSelect`, `footer` — none of which the real slot type accepts. `RangePickerProps` and `RangePickerSlotProps` are unexported and absent from the barrel, so **consumers cannot type a `RangePicker` wrapper**. `pickerGroupClassName` is undocumented. Of six deprecated props, exactly one carries an `@deprecated` marker. | Regenerated in phase 5 against the real types | +| 11 | A documented integration was never built | CHANGELOG 0.49.0 claims `DataTable` / `DataView` columns gained `filterProps.calendar`. `data-view.types.tsx` has only `{ select?: BaseSelectProps }` — the slot exists solely on `DataTable`, which is `@deprecated`. | Added to `DataView` in phase 4 | +| 12 | Fails 2 of 8 `SKILL.md` checklist items | CSS carries three `Todo: var does not exist` markers, a hardcoded `max-height: 260px`, and eight `var(--rs-space-10, 40px)` fallbacks. No interactive `playground` in `demo.ts` — 54 other components have one. | Phase 5 exit criteria | + +Three further house rules fail off-checklist: dot-notation composition, spread-last in the pickers, and docs matching code. The `data-slot` contract is the one genuinely clean part of this family, and the rewrite preserves it. + +**What the 185 lines contain.** Six refs — two DOM handles, two internal flags, and two mirrors of state and props kept purely so callback identities stay stable. Three suppression branches inside a single `onOpenChange`: one swallows the dropdown's own open-change, one swallows `trigger-press` closes because Base UI's `useClick` toggles against the input's `onFocus`, one swallows redundant re-opens. Then a handler named `handleMouseDown` registered on `'mouseup'`, an uncleaned `setTimeout`, and a `setIsOpen` returned from the hook that neither picker ever calls. + +**Why spread-last is unsatisfiable.** `required` discriminates react-day-picker's prop union, so a widened value breaks the narrowing — which forces `required={true}` to sit after the consumer spread, and seven more keys with it: `timeZone`, `onDropdownOpen`, `mode`, `month`, `selected`, `onSelect`, `onMonthChange`. Three of those (`month`, `onMonthChange`, `timeZone`) are reachable through `slotProps.calendar`, because they live in RDP's `PropsBase`, so a consumer can pass them and watch them vanish. The slot type — `Omit & CalendarPropsExtended` — already excludes the rest. + +**How `FilterChip` absorbs it.** Three ways. The shallow `slotProps.input` merge lets a consumer-supplied `classNames` object **replace** the chip's own, dropping its container class and breaking layout. `showCalendarIcon={false}` sits before the consumer spread exactly as `SKILL.md` prescribes, so a consumer can re-enable the icon and break the chip. And two `[class*="…"]` rules reach at what they assume are `Input`'s hashed class names for `helper-text` and `input-error-wrapper` — strings that appear nowhere else in the repo, and `Input` renders no such element, so both rules are dead code suppressing nothing. + +**The causal chain.** `use-picker-popover.ts`'s header comment names its own reasons: `captionLayout='dropdown'` renders `Select`s whose portals look "outside" to a naive dismiss handler, and `isOpen` is read through a ref because Base UI's store subscriber re-binds on `onOpenChange` identity change and looped on mount. Both dissolve once `.Nav` renders the `Select`s outside the grid and `useControlled` supplies a stable setter. None of the 185 lines is wrong for what it is asked to do — it exists only because the component owns dismissal instead of Base UI. + +**The unguarded effect** is `setViewMonth` in `date-picker.tsx`: + +```tsx +useEffect(() => { + if (popover.isOpen) { + setViewMonth(calendarProps?.defaultMonth ?? selectedDate ?? new Date()); + } +}, [popover.isOpen, selectedDate, calendarProps?.defaultMonth]); +``` + +`calendarProps` is a fresh object literal every render — `{ ...legacyCalendarProps, ...slotProps?.calendar }` — so an inline `slotProps={{ calendar: { defaultMonth: new Date(2025, 0) } }}` yields a fresh `Date` identity per render → effect refires → `setViewMonth` → re-render → loop. + +## Goals and Non-Goals + +**Goals.** One export with dot-notation parts, matching `composition.md`. Explicit ownership of every piece of state — selection, view month, open, granularity, validity. react-day-picker fully isolated, so its union never reaches a consumer and spread-last becomes satisfiable at every part. Month/year navigation on by default. Full feature set: day / month / quarter / half-year / year granularity, single and dual month, presets, time-of-day. Zero `slotProps`. All eight `SKILL.md` checklist items pass. + +**Non-goals.** Preserving the old API — this is a rewrite. Locale/i18n expansion beyond what RDP already gives us (tracked as follow-up). Replacing the date library with Temporal (see [The Date Adapter](#the-date-adapter) for the seam that makes it possible later). + +## Proposal + +### API at a Glance + +```tsx +import { CalendarPreview } from '@raystack/apsara'; + +// Zero-config recipe for the common case + + +// Same component, fully composed, when you need control + + + + + + + Last 7 days + + + + + + + + + +``` + +Full part tree: + +``` + state owner +├── anchor (render-friendly) +│ ├── single text field +│ └── paired start/end fields +└── portaled popover surface + ├── + │ └── + ├── Day | Month | Quarter | Half-year | Year + ├── caption + chevrons + month/year selects + ├── the day grid (replaces `Calendar`) + ├── month / quarter / half-year / year grid + ├── time-of-day + └── + ├── + └── +``` + +### Recipes + +Pre-composed compositions hung off the same object, implemented as compositions of the parts below — no private code paths. + +```tsx + + + + + // no popover +``` + +**Recipes take no `slotProps` and no escape hatches.** The moment you need to change what is inside the popover, you drop to parts — there is no third state where you configure structure through props. Precedent for hanging non-part values off a root: `Dialog` carries `createHandle`; `Combobox` carries `useFilter` and `useFilteredItems`. + +### Root Props + +```tsx +type CalendarSelection = 'single' | 'range' | 'multiple'; +type CalendarGranularity = 'day' | 'month' | 'quarter' | 'half-year' | 'year'; + +interface CalendarPreviewBaseProps { + /** @defaultValue 'day' */ + granularity?: CalendarGranularity; + /** Switchable granularities. Renders `GranularityTabs` when >1. */ + granularities?: CalendarGranularity[]; + + // popover state (was entirely private) + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean, details?: { reason?: string }) => void; + + // view state, independent of selection + month?: Date; + defaultMonth?: Date; + onMonthChange?: (month: Date) => void; + + minDate?: Date; + maxDate?: Date; + isDateUnavailable?: (date: Date) => boolean; + + /** @defaultValue 'DD MMM YYYY' */ + format?: string; + timeZone?: string; + /** @defaultValue 0 */ + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + + /** + * `'immediate'` fires `onValueChange` on every interaction. + * `'explicit'` buffers until `Apply` (requires a `Footer`). + * @defaultValue 'immediate' + */ + commit?: 'immediate' | 'explicit'; + + /** Compose in `Field` for UI. */ + onValidityChange?: (validity: { + valid: boolean; + reason?: 'unparseable' | 'out-of-bounds' | 'unavailable'; + }) => void; + + disabled?: boolean; + readOnly?: boolean; + children?: ReactNode; +} + +interface SingleProps extends CalendarPreviewBaseProps { + selection?: 'single'; + value?: Date | null; + defaultValue?: Date | null; + onValueChange?: (value: Date | null) => void; +} + +interface RangeProps extends CalendarPreviewBaseProps { + selection: 'range'; + value?: DateRangeValue | null; // { from: Date | null; to: Date | null } + defaultValue?: DateRangeValue | null; + onValueChange?: (value: DateRangeValue | null) => void; + /** Makes one endpoint read-only in both the input and the grid. */ + lock?: 'from' | 'to'; +} + +interface MultipleProps extends CalendarPreviewBaseProps { + selection: 'multiple'; + value?: Date[]; + defaultValue?: Date[]; + onValueChange?: (value: Date[]) => void; + maxSelected?: number; +} +``` + +| Choice | Replaces | Why | +|---|---|---| +| `onValueChange` | `onSelect` | Matches `Select`, `Combobox`, `Accordion`. Still fires on each step under `commit='immediate'`, but the value is always a complete `DateRangeValue` with explicit `null`s — today the docs must tell consumers to gate on `range.to`. | +| `DateRangeValue` | RDP's `DateRange` | Ours, not react-day-picker's, which currently leaks through the barrel. | +| `commit` | — | Makes a footer-with-actions layout expressible. Today `footer` is a bare `ReactNode` with no way to write back into state, which is why presets are unimplementable. | +| `isDateUnavailable` | RDP's `disabled` matcher | Covers the common predicate without learning RDP's matcher DSL. RDP matchers stay reachable on `.Grid`. | +| `onValidityChange` | `onErrorChange` | Still renders no error UI — `Field` does — but reports a state object with a reason, not a stringly-typed message. | +| `lock` | whole-picker disable | `RangeProps` only. Makes one endpoint read-only in input and grid, closing the partial-disable gate. | + +### Parts + +| Part | Parent | Element | Purpose | Key props | +|---|---|---|---|---| +| `.Trigger` | root | `div` | Anchors the popover. Never renders a `