feat: CalendarPreview root and inline calendar - #895
Conversation
Phase 1 of RFC 005. Delivers the inline day view — a root that owns the
state and eight parts that render it. No popover, no input: `.Trigger`,
`.Content` and `.Input` land in PR 3.
<CalendarPreview>
<CalendarPreview.Days />
</CalendarPreview>
The root renders no DOM of its own. It holds value, view month and scale
through `useControlled`, and hands them to the parts through a part-aware
context hook whose generic value is stored as `unknown` and cast once, at
the hook boundary. A part used outside the root throws a message naming
the part the author actually wrote, not this file.
Two things the RFC singles out:
`.Reset` is a **value** reset, keyed off `defaultDate`. It renders only
when there is something to restore — `defaultDate` is set and the value
differs — and it leaves the visible month alone. `defaultDate` is a
separate prop from `defaultValue` precisely because `useControlled`
ignores `defaultValue` once `value` is passed, so a controlled consumer
would otherwise never see the part at all.
`.Caption`'s dropdown is **our own scroller**: two columns of plain
buttons in a popup we own. No `Select` is mounted anywhere in this
component — asserted, not asserted-to — which is what keeps the
popover-dismissal loop `use-picker-popover.ts` spends 185 lines
suppressing from coming back. Picking from it moves the view; it never
selects a value.
Everything else worth naming:
- Bounds limit **selection**, never navigation. `minDate`/`maxDate` and
`isDateUnavailable` disable cells; the nav buttons and the caption
scroller still move the view wherever the user wants. Bounds compare as
day-keys, so a `minDate` carrying a time of day still makes its own day
selectable — the current family compares instants and silently disables
it
- `dateInfo` and `tooltipMessages` are now functions. The record form
keyed cells by a formatted string and silently missed every day once a
`timeZone` shifted the key. Info still renders above the date number
- `.Grid` is the only file importing react-day-picker. It runs with
`hideNavigation` and `captionLayout='label'`, and `mode`, `selected`,
`onSelect`, `required`, `month`, `onMonthChange` and `timeZone` come
from context rather than props — none is in `CalendarPreviewGridProps`,
so nothing is force-overridden after the consumer's spread and
spread-last holds for the first time in this family
- Cells carry `data-selected`, `data-draft`, `data-unavailable`,
`data-today`, `data-outside` and `data-scale` beside their slot. At day
scale the draft is the roving-focus cell — arrowed to, not yet entered
- `useCalendar()` ships from the barrel beside `useTour` and the other
six, returning value, scale, view month, their setters and the
availability predicate. Nothing more: what it returns is semver-covered
Zero `slotProps`, zero `biome-ignore`, no `forwardRef`, `<Ctx value>`
throughout, every part spreads `...props` last, and the CSS carries no
`Todo: var does not exist`.
`components/calendar/` is untouched.
100% statement, function and line coverage on the new directory, 99.6% of
branches — the one uncovered branch is a defensive guard in the root's
`reset`, unreachable while `.Reset` is the only caller.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
commit: |
The parts landed in the previous commit with placeholder chrome. This matches them to reference A, and finishes the UndoIcon that commit left imported but unmapped. Single month: the caption moves to the left and the reset joins the two nav buttons on the right, drawn as the undo glyph. Source order is the visual order, so nothing reorders in CSS and tab order follows the row. Several months: there is no single header to hold one caption, so each month captions itself. `.Days` drops the header above the grid and binds react-day-picker's MonthCaption slot instead — previous on the first month, next on the last, a spacer holding the absent button's place so every caption centres on its own grid. No reset in this layout; the Calendar Header component in the file has three variants and none of the two-month ones carries it. Also from the frames: weekday headings go to three letters, the caption abbreviates to `Apr 2024`, the scroller lists `Jan`/`Feb`/`Mar`, its chip and selected row take neutral grey rather than accent, and the popover anchors to the caption's start edge over the grid. `showOutsideDays` now defaults to false. Every new frame ends its grid on the last day of the month with the leading cells blank, and no frame shows an outside day. This diverges from today's DatePicker, which is priced in — the rewrite ships no shim. One frame detail is deliberately not encoded: April 1-16 render muted with today on the 17th, which a `minDate` demonstration and a built-in past bound draw identically. Read as a demonstration, so no default bound is applied — a past bound is not expressible as an opt-out and would break date-of-birth and "filter since" fields. Flagged for design. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR 2 of 7 in the RFC 005 stack — RFC 005: Calendar Rewrite
CalendarPreview. Stacked on #894.Delivers the inline calendar: a root that owns the state, and eight parts that render it.
No popover, no input.
.Trigger,.Contentand.Inputare PR 3;selection='range'is PR 4; the scale switcher and the four period views are PR 5. A test asserts the exported part list exactly, so one of them appearing early fails the build rather than shipping as public API by accident.The two things worth reading the diff for
.Resetis a value reset, keyed offdefaultDateIt renders only when there is something to restore —
defaultDateis set and the current value differs from it — and clicking it commits the default day and leaves the visible month exactly where the user left it. It is not a view reset.defaultDateis a separate prop fromdefaultValuefor a specific reason:useControlledignoresdefaultValuethe momentvalueis passed, so a controlled consumer would never see.Resetat all. There is a test for precisely that — reset renders and works under a controlledvalue— alongside the four visibility cases and one assertingonMonthChangenever fires.The caption dropdown is our own scroller, with no
Selectmounted.Caption dropdownopens a two-column month + year scroller built from plain buttons in a popup we own. It is not react-day-picker's dropdown and not ApsaraSelect.That is the whole point. A
Selectportal looking "outside" to a naive dismiss handler is the first of the three reasonsuse-picker-popover.tsexists, and 185 lines of suppression is what re-introducing one costs. The test does not take my word for it:Picking from either column moves the view; it never selects a value (asserted —
onValueChangeis not called).The rest
minDate/maxDate/isDateUnavailabledisable cells; the nav buttons and the scroller still go wherever the user wants. This is the behavioural break the RFC flags, and it is tested from both edges. Bounds compare as day-keys, so aminDatecarrying a time of day still makes its own day selectable — the current family compares instants and silently disables it.dateInfoandtooltipMessagesare functions now. The record form keyed cells by a formatted string and silently missed every day once atimeZoneshifted the key. Info still renders above the date number (asserted on child order, not just presence)..Gridis the only file importing react-day-picker (grep-asserted). It runs withhideNavigationandcaptionLayout='label', andmode,selected,onSelect,required,month,onMonthChangeandtimeZonecome from context — none is inCalendarPreviewGridProps, so nothing is force-overridden after the consumer's spread and spread-last genuinely holds for the first time in this family. RDP'srequiredunion is discriminated on a literal, which abooleancannot narrow, so the two arms are written out rather than cast away; the union reaches no consumer.data-selected,data-draft,data-unavailable,data-today,data-outside,data-scale. At day scale the draft is the roving-focus cell — arrowed to, not yet entered — which is the same field PR 5's scale-switch draft will write to.useCalendar()ships from the barrel besideuseTourand the other six: value, scale, view month, their setters, the availability predicate. Nothing else, because what it returns is semver-covered.Calendar: tooltips, disabled days, controlled month navigation, multiple months, loading state. Loading still disables navigation —.Headerand.Gridare siblings now, so that state lives on their common parent.Daysrather than the root, and two day views in one tree cannot disable each other.Two additions beyond the prop list
yearRangeon the root..Caption dropdowncannot offer a year column without one. It is in the RFC's Root Props; the default is ten years either side oftoday, stretched to coverminDate/maxDateso a picker bounded at 2050 can actually scroll there — a year the user can never reach would be a trap.monthNames,formatDayLabel,formatMonthLabel,formatCaptionLabel,shiftMonths,monthStartadded todate-adapter.ts. Every one is a date-library call, and the adapter is the only module allowed to make them. Nodayjs.extend()anywhere.formatValueis plumbed into context but has no visible consumer until.Triggerand.Inputland in PR 3. Its default is tested directly as a pure function.Rules
Zero
slotProps. Zerobiome-ignore. NoforwardRef— plain function components with ref as a prop.<Ctx value>throughout, never<Ctx.Provider>. Every part takesrender,className,refand adata-slot, spreads...propslast, and has adisplayName— all four asserted per part rather than described. CSS uses--rs-*tokens only, with zeroTodo: var does not exist; the only literals left are 1px hairlines, which the space scale starts above.components/calendar/is untouched.Testing
data-slots.test.tsxasserts the slot set is exactly the documented one — extras and typos fail as loudly as renames — plus every statedata-*on every rendered element.components/calendar-preview/; 99.6% of branches. The single uncovered branch is a defensive guard in the root'sreset, unreachable while.Resetis its only caller.pnpm --filter @raystack/apsara buildclean;pnpm --filter www buildgreen;biome checkclean;tsc --noEmitreports only the 6 errors that predate this stack, none incalendar/orcalendar-preview/.Note
The two screenshots on the request did not reach me as images, so the caption scroller and header layout follow the RFC's description and existing library precedent rather than a pixel target. Happy to adjust the visual details if they differ.
🤖 Generated with Claude Code