From a47942246ab4789dd9dec866b99751df4bf7a02e Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 24 Aug 2026 14:47:21 +0530 Subject: [PATCH 1/5] feat: RFC calendar rewrite (CalendarPreview) Proposes replacing Calendar, DatePicker, and RangePicker with a single subcomposed root that owns date and popover state explicitly and exposes every surface as a dot-notation part. Co-Authored-By: Claude Opus 5 (1M context) --- docs/rfcs/005-calendar-preview.md | 696 ++++++++++++++++++++++++++++++ 1 file changed, 696 insertions(+) create mode 100644 docs/rfcs/005-calendar-preview.md diff --git a/docs/rfcs/005-calendar-preview.md b/docs/rfcs/005-calendar-preview.md new file mode 100644 index 000000000..1e11ccd4c --- /dev/null +++ b/docs/rfcs/005-calendar-preview.md @@ -0,0 +1,696 @@ +--- +ID: RFC 005 +Created: August 24, 2026 +Status: Draft +RFC PR: TBD +--- + +# Calendar Rewrite: `CalendarPreview` + +This RFC proposes replacing `Calendar`, `DatePicker`, and `RangePicker` with a single subcomposed root, `CalendarPreview`, that owns date state and popover state explicitly and exposes every surface as a dot-notation part. The calendar family is the only part of Apsara that never adopted the composition contract; every recurring bug in it is downstream of that. Because open state is private, the pickers cannot use Base UI's dismissal, which forces 185 lines of bespoke popover machinery, two event-provenance guesses, three lint suppressions, and one reverted feature. + +The rewrite is a breaking change with no compatibility shim. `CalendarPreview` ships alongside the current family and the old exports are removed one release later. Scope covers the full Figma surface — day/range selection, month-year navigation, granularity (day / month / quarter / half-year / year), presets, and time-of-day — because all of it is greenfield with no prior work to reconcile. + +**Design source:** [DLS → Components → Calendar](https://www.figma.com/design/KPQKFJkIxF8OTITIBJfSpm/Components?node-id=4245-7385&m=dev) +**Target package:** `@raystack/apsara` (`packages/raystack/components/calendar-preview/`) +**Verification:** every file/line citation below was checked against commit `0db7b3f3`. + +## 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) + - [1. The composition contract was never adopted](#1-the-composition-contract-was-never-adopted) + - [2. The causal chain: private open state to event guesses](#2-the-causal-chain-private-open-state-to-event-guesses) + - [3. `Date` identity, and one unguarded effect](#3-date-identity-and-one-unguarded-effect) + - [4. A reverted feature that is still advertised](#4-a-reverted-feature-that-is-still-advertised) + - [5. RDP's prop union makes spread-last unsatisfiable](#5-rdps-prop-union-makes-spread-last-unsatisfiable) + - [6. Global `dayjs.extend()` is import-order dependent](#6-global-dayjsextend-is-import-order-dependent) + - [7. Capability loss shipped as intended behaviour](#7-capability-loss-shipped-as-intended-behaviour) + - [8. Consumers and docs have drifted](#8-consumers-and-docs-have-drifted) + - [Scorecard](#scorecard) + - [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) + - [Locking One End of a Range](#locking-one-end-of-a-range) + - [Field Integration](#field-integration) + - [Conventions This Follows](#conventions-this-follows) + - [Internal Architecture](#internal-architecture) + - [File Layout](#file-layout) + - [Context](#context) + - [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) + - [Design Blockers](#design-blockers) + - [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: + +| 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 | + +The barrel (`components/calendar/index.tsx`, 6 lines) exports `Calendar`, `DatePicker`, `RangePicker`, and re-exports react-day-picker's `DateRange` type directly. + +### Current Problems + +#### 1. The composition contract was never adopted + +`.agents/skills/apsara/references/composition.md:7` states the rule: + +> Apsara exports **one name per component** and hangs every sub-part off it as a property. + +The calendar family ships three flat exports with no sub-parts, and configures its internals through four mechanisms that appear nowhere else in the library: + +| Mechanism | Where | What the rest of Apsara does | +|---|---|---| +| `slotProps` object bag | `date-picker.tsx:30-34`, `range-picker.tsx:24-29` | Composable sub-parts (`composition.md:7,29`) | +| `children` as a render function | `date-picker.tsx:61-63`, `range-picker.tsx:48-50` | `render` prop (`composition.md:43`) | +| No `open` / `onOpenChange` — popover state is private inside `use-picker-popover.ts` | `date-picker.tsx:257-263`, `range-picker.tsx:266-272` | `open` + `onOpenChange` (`composition.md:59`) | +| `onErrorChange` callback | `date-picker.tsx:58` | Compose inside `Field` | + +Everything below follows from the third row. + +#### 2. The causal chain: private open state to event guesses + +Because open state never surfaces, the pickers cannot hand dismissal to Base UI. `use-picker-popover.ts:39-46` records why: + +> Why custom instead of Base UI's dismissal: Calendar's `captionLayout='dropdown'` renders Selects inside the popover; their portals look "outside" to a naive dismiss handler. […] `onOpenChange` reads `isOpen` via ref so its identity stays stable — Base UI's store subscriber re-binds on identity change, which caused an updateStoreInstance loop on mount. + +That single carve-out costs 185 lines containing: + +- **Six refs, four of which shadow state or props** (`:56`, `:62`, `:65`, `:71`) purely to keep callback identities stable. +- **Two event-provenance guesses**, both load-bearing (`:141-167`): one swallows `trigger-press` closes because Base UI's `useClick` toggles against the input's `onFocus` (`:158`), one swallows redundant re-opens (`:165`). +- **A handler named `handleMouseDown` registered on `'mouseup'`** (`:84-94`). +- **An uncleaned `setTimeout`** (`:136`). + +None of this is wrong for what it is asked to do. It exists only because the portal carve-out exists, and the carve-out exists only because the component owns dismissal instead of Base UI. + +#### 3. `Date` identity, and one unguarded effect + +Three effects suppress `useExhaustiveDependencies` because `Date` compares by identity: `date-picker.tsx:109`, `range-picker.tsx:105`, `range-picker.tsx:129`. A fourth instance of the same pattern is **not** guarded — `date-picker.tsx:151-155`: + +```tsx +useEffect(() => { + if (popover.isOpen) { + setViewMonth(calendarProps?.defaultMonth ?? selectedDate ?? new Date()); + } +}, [popover.isOpen, selectedDate, calendarProps?.defaultMonth]); +``` + +`calendarProps` is rebuilt on every render (`date-picker.tsx:84`). A consumer writing `slotProps={{ calendar: { defaultMonth: new Date(2025, 0) } }}` inline gets a fresh `Date` identity per render → effect refires → `setViewMonth` → re-render → loop. Its sibling effects were hardened against exactly this; this one was missed. + +#### 4. A reverted feature that is still advertised + +Month/year dropdown navigation cannot be the default inside a picker. `range-picker.tsx:286-290`: + +> No `captionLayout` default — 'dropdown' renders Apsara Selects inside the popover whose unmount loops ("Maximum update depth"). Consumers can opt in via `calendarProps.captionLayout`. + +The fix has never been verified in a browser — `date-picker.runtime.test.tsx:44-49`: + +> Passes in jsdom after the value-default useMemo + stable onOpenChange fixes. Real-browser verification still recommended before re-enabling `captionLayout='dropdown'` as the default. + +That comment cites a `useMemo` in `date-picker.tsx` that no longer exists — the file contains zero `useMemo` calls. The guard has drifted from the mechanism it guards. + +The docs compound this by omission rather than by error: `demo.ts:41-47` shows `captionLayout="dropdown"` on standalone ``, where it genuinely works. Nothing anywhere tells a reader it is unsafe *inside a picker*. + +#### 5. RDP's prop union makes spread-last unsatisfiable + +`.agents/skills/add-new-component/SKILL.md:75` requires "Spread `...props` last so consumers can override defaults." `Calendar` itself complies (`calendar.tsx:264`). The pickers structurally cannot — `range-picker.tsx:296-299`: + +> Must stay after spread: `required` is the discriminator for RDP's prop union, and a widened value would break the narrowing. + +So `mode`, `selected`, `onSelect`, `required`, `month`, and `onMonthChange` are all hard-overridden *after* the consumer spread (`date-picker.tsx:277-285`, `range-picker.tsx:294-306`). Consumers can pass them; they are silently discarded. + +#### 6. Global `dayjs.extend()` is import-order dependent + +Four modules extend plugins independently: + +| File | Plugins | +|---|---| +| `calendar/calendar.tsx:18-19` | `utc`, `timezone` | +| `calendar/date-picker.tsx:19-21` | `customParseFormat`, `isSameOrAfter`, `isSameOrBefore` | +| `data-table/utils/filter-operations.tsx:22-23` | `isSameOrAfter`, `isSameOrBefore` | +| `data-view/utils/filter-operations.tsx:22-23` | `isSameOrAfter`, `isSameOrBefore` | + +`range-picker.tsx` extends **nothing** and relies on `calendar.tsx` having been imported first. `date-picker.tsx` accepts and forwards a `timeZone` prop (`:65`, `:279`) but never extends `utc`/`timezone`; it inherits them only because line 15 imports `./calendar`. Plugin ordering (`timezone` depends on `utc`) is enforced by a comment at `calendar.tsx:17`. + +This is the exact failure class behind the P0 at `CHANGELOG.md:33-35` — a `TypeError` on every keystroke because `isSameOrAfter` was missing — and two tests exist solely to guard it (`date-picker.test.tsx:656-698`). + +#### 7. Capability loss shipped as intended behaviour + +`range-picker.tsx:88-94`: disabling either input gates the entire picker, because the range state machine rewrites both `from` and `to` regardless of which input was clicked. This is asserted as correct at `range-picker.test.tsx:406` and `:421`. "Fix the start, let the user pick the end" is not expressible; the docs tell you to constrain the calendar instead. + +#### 8. Consumers and docs have drifted + +**`FilterChip` is the sole production consumer** (`filter-chip/filter-chip.tsx:183-195`) and carries three problems: + +- Inside `slotProps.input`, the chip sets `classNames: { container: styles.dateField }` and then spreads `...calendarProps?.slotProps?.input` (`:190-193`). A consumer passing any `classNames` object **replaces** the chip's, silently dropping its own container class and breaking the layout. Objects need a deep merge here, not a spread. +- `showCalendarIcon={false}` sits *before* the consumer spread (`:184-185`). That is exactly what `SKILL.md:75` prescribes, and the consequence is that a consumer can re-enable the icon and break the chip. The house rule and the component's needs are in genuine tension; parts resolve it by making the icon a part you either render or don't. +- `filter-chip.module.css:226-232` reaches into `Input`'s hashed class names via `[class*="helper-text"]` and `[class*="input-error-wrapper"]`, which breaks silently if `Input` renames a class. It exists only to suppress error UI the picker shouldn't be rendering. + +**A documented integration was never built.** `CHANGELOG.md:95-96` claims "`DataTable` / `DataView` columns gain a parallel `filterProps.calendar` slot". `data-view.types.tsx:83-85` has only `filterProps?: { select?: BaseSelectProps }`, and `data-view/components/filters.tsx:161` forwards `selectProps` and no calendar props. The calendar filter slot exists only on `DataTable`, which is deprecated. + +**Type and doc drift:** + +- `props.ts:175` and `props.ts:232` type `slotProps.calendar` as the full documented `CalendarProps`, including `mode` (`:44`), `selected` (`:47`), `onSelect` (`:50`), and `footer` (`:124`). The real type is `Omit & CalendarPropsExtended` (`date-picker.tsx:28`), which excludes all four. That `Omit` is also vacuous — `mode` isn't in `PropsBase`, as the source comment at `date-picker.tsx:23-27` admits. +- `index.mdx:44` renders ``, but `RangePickerProps` and `RangePickerSlotProps` are declared without `export` (`range-picker.tsx:31`, `:24`) and appear in neither barrel. **Consumers cannot type a `RangePicker` wrapper.** (`DatePickerProps` *is* exported, which is why `FilterChip` can `Omit` from it.) +- `pickerGroupClassName` exists (`range-picker.tsx:45`) and is undocumented. +- Deprecations are documented inconsistently: `props.ts:262` marks only `DatePicker.calendarProps`, omitting `inputProps` and `popoverProps`; none of `RangePicker`'s three deprecated props appear in `props.ts` at all. +- `index.mdx:56-80` documents 23 slots; §[The `data-slot` Contract](#the-data-slot-contract) maps all of them. + +### Scorecard + +`SKILL.md:501-509` is the eight-item acceptance bar for a new component. The current family fails **two**: + +| Checklist item | Status | +|---|---| +| Component builds without errors | Pass | +| All tests pass | Pass | +| Docs site builds, page generated | Pass | +| `displayName` on all sub-components | Pass | +| `data-slot` on every element + `data-slots.test.tsx` | Pass — 23 slots, well covered | +| Alphabetical export in `index.tsx` | Pass | +| CSS uses `--rs-*` tokens only | **Fail** — see below | +| Interactive `playground` in `demo.ts` | **Fail** — no `playground` export; `index.mdx` imports six demos, none of them a playground | + +CSS detail: three `/* Todo: var does not exist */` markers (`calendar.module.css:6`, `:30`, `:189`), a hardcoded `max-height: 260px` (`:211`), a stray `gap: 0px` (`:282`), and eight `var(--rs-space-10, 40px)` hedged fallbacks (`:73`, `:74`, `:92`, `:93`, `:112`, `:113`, `:169`, `:170`). `SKILL.md:507` admits no hardcoded values. + +Three further house rules — not on the checklist, but the reason this RFC exists — also fail: dot-notation composition (`composition.md:7,29`), spread-`...props`-last in the pickers (`SKILL.md:75`), and docs matching the code. The slot contract is the one genuinely clean part of this family, and the rewrite preserves it. + +## Goals and Non-Goals + +**Goals** + +1. One export, dot-notation parts, matching `composition.md` exactly. +2. Explicit ownership of every piece of state — selection, view month, open, granularity, validity — with `value`/`onValueChange` and `open`/`onOpenChange` on the root. +3. react-day-picker fully isolated: its discriminated union never reaches a consumer, and `...props` spread-last becomes satisfiable at every part. +4. Month/year navigation works by default — the reverted `captionLayout` feature ships. +5. Cover the full Figma surface: day / month / quarter / half-year / year granularity, single and dual month, presets, time-of-day. +6. Zero `slotProps`. Recipes for the common case, parts for everything else. +7. Pass all eight `SKILL.md` checklist items. + +**Non-goals** + +- Preserving the old API. This is a rewrite; breaking changes are accepted. +- 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. They accept the root's props plus a small set of layout switches, and they are *literally* implemented as compositions of the parts above — no private code paths. + +```tsx + + + + + // no popover +``` + +**Rule: recipes take no `slotProps` and no escape hatches.** The moment you need to change what's inside the popover, you drop to parts. This is the whole answer to the props-bag problem — there is no third state where you configure structure through props. + +Precedent for hanging non-part values off the root: `Object.assign` already carries `createHandle` (`dialog/dialog.tsx:22`) and `useFilter`/`useFilteredItems` (`combobox/combobox.tsx:19-20`). + +### Root Props + +```tsx +type CalendarSelection = 'single' | 'range' | 'multiple'; +type CalendarGranularity = 'day' | 'month' | 'quarter' | 'half-year' | 'year'; + +interface CalendarPreviewBaseProps { + /** @defaultValue 'day' */ + granularity?: CalendarGranularity; + /** Granularities the user may switch between. 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'; + + /** Fires when the typed input's validity changes. 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; +} + +export type CalendarPreviewProps = SingleProps | RangeProps | MultipleProps; +``` + +Notes on specific choices: + +- **`onValueChange`, not `onSelect`.** Matches `Select`, `Combobox`, and `Accordion`. Today `onSelect` fires mid-interaction with a half-built range, so the docs have to instruct consumers to gate on `range.to` (`index.mdx:112`). With `commit='immediate'` the new callback still fires on each step, but the value shape is always a complete `DateRangeValue` with explicit `null`s — no "is this partial?" inference at the call site. With `commit='explicit'` it fires once, on `Apply`. +- **`DateRangeValue` is ours**, not RDP's `DateRange`. RDP's type currently leaks through the public barrel (`components/calendar/index.tsx:1`); that stops. +- **`commit`** is what makes the Figma's footer-with-actions layout expressible. Today `footer` is a bare `ReactNode` slot with no way to write back into state (`range-picker.tsx:52`), which is why presets are unimplementable. +- **`isDateUnavailable`** replaces RDP's `disabled` matcher for the common predicate case, so consumers don't need to learn RDP's matcher DSL. RDP matchers stay reachable on `Grid`. +- **`onValidityChange`** replaces `onErrorChange` (`date-picker.tsx:58`). The component still renders no error UI — `Field` does — but the payload is a state object rather than a stringly-typed message, and it reports *why*. +- **`lock`** lives on `RangeProps` only, since it is meaningless for the other two modes. See [Locking One End of a Range](#locking-one-end-of-a-range). + +### Parts + +| Part | Element | Purpose | Key props | +|---|---|---|---| +| `.Trigger` | `div` | Anchors the popover. Never renders a `