Skip to content

feat: CalendarPreview range selection - #897

Open
Shreyag02 wants to merge 2 commits into
feat/calendar-preview-datepickerfrom
feat/calendar-preview-rangepicker
Open

feat: CalendarPreview range selection#897
Shreyag02 wants to merge 2 commits into
feat/calendar-preview-datepickerfrom
feat/calendar-preview-rangepicker

Conversation

@Shreyag02

@Shreyag02 Shreyag02 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 4 of 7 in the RFC 005 stack, on top of #896.

Adds selection='range'. Not an export — the same parts, with field on the inputs.

Warning

Breaking, and no type error will find it. A range now emits only when complete.

Was Is
Emit every step, partial { from?, to? } completion only
to nullable not nullable
range.to gate required idiom retires
First-click event observable gone

The old partial satisfies the new shape whenever to happened to be set — so this is a callback that stops firing, not one that stops compiling.

In selection='range', .Input field, the from/to machine, range styling
Not in selection='multiple' (cut from v1), scale views (PR 5)
Deleted Nothing

Changes

  • selection='range'value / defaultValue are { from: Date; to: Date }, no nulls on either edge.
  • .Input gains field="start" | "end" — two addressable inputs, not a startProps / endProps bag.
  • The half-built range stays internal — on the root context so the grid can draw the track, never emitted.
  • lockreadOnly on one .Input — the endpoint registers itself; the grid refuses to rewrite it.
  • Docs: Basic, Disabled, Disabled dates, Without calendar icon, Read-only start, Custom trigger. No "With Field" — the shipped range picker has none.

The click machine — the shipped one, branch for branch

State A click does
Nothing selected sets from, moves focus to the end field
from only, later day completes, emits, closes
from only, earlier day that day becomes the new from
Complete range restarts — value stays at the previous range until the new one completes

Technical Details

Decision Reason
Machine on the root Completing writes the value and closes the popover — the grid must not touch open state
Auto-close via setOpen A consumer controlling open keeps it open and only sees the request
Arms discriminated on selection Otherwise a single-day consumer's Date | null callback widens to a union. Implementation stays shared, one cast at the seam
readOnly endpoint registers readOnly is the input's prop; the grid is what refuses the write
Range state on the existing context CalendarPreviewContextValue<Value> is already generic. CalendarPreviewDaysProvider is view-scoped and the wrong home

Range styling — endpoints accent-filled and pill-rounded on their outer edges; the days between on one continuous band, not three cell backgrounds. react-day-picker marks every day of a range selected, so the track inherited the single-day white text and rendered grey-on-grey. Caught by rendering it, not by reading the CSS.

Review notes

Item Note
mode="multiple" — consumer search done, zero users See below. Safe to drop
Outside days are inconsistent today Shipped range picker shows none, date picker does. Resolved in PR 2's showOutsideDays: false default rather than reproduced here
Read-only endpoint with no value is unsatisfiable The free endpoint sets, the range never completes, nothing emits. Documented — give it a value

mode="multiple" consumer search

Calendar mode="multiple" is genuinely shipped API — CalendarProps = DayPickerProps & … types it, and mode='single' sits before {...props} in calendar.tsx, so a consumer's mode overrides it and reaches react-day-picker. Searched both orgs (private repos included) for every quote and brace form:

Query Hits
mode="multiple", mode='multiple', mode: "multiple", mode={'multiple'}, mode={"multiple"} 0 in any JS/TS file
Only match anywhere pixxelhq/ff_mission_dashboard — AG Grid's configure_selection(selection_mode="multiple") in Python. Unrelated

Every real Apsara Calendar usage found is explicitly single:

Repo File Mode
pixxelhq/studio EditTaskEndDatePopup.tsx mode='single'
pixxelhq/studio EndDateCell.tsx mode='single'
pixxelhq/studio DateRangePicker.tsx wraps DatePicker, no mode

Nothing to migrate for multiple. One caveat on scope: the search covers the raystack and pixxelhq orgs, not consumers of the published npm package elsewhere.

Noted in passing, for phase 6 rather than for this PR — studio/DateRangePicker.tsx is a full runtime consumer of DatePicker (popoverProps, dateFormat, onSelect, inputProps, calendarProps) and also derives types from it via ComponentProps<typeof Calendar>['disabled']. It's already on the phase-6 migration list on the runtime usage alone, so the type coupling adds no separate work — worth knowing only because those three prop bags are exactly the slotProps shape CalendarPreview drops.

Test Plan

  • Manual testing completed
  • Build and type checking passes
Check Result
New range.test.tsx 13 passed
calendar-preview/ total 398 passed
Full package suite 3105 passed, 1 skipped (skip predates this stack)
biome check / tsc --noEmit clean
build:apsara / docs build both green

Covered — every machine branch including restart; no emit on the first click; one emit with both edges on completion; focus advances to the end field; data-range-start / -middle / -end on the right cells; a controlled range renders without a click; each endpoint shows in its own field; a read-only endpoint is never rewritten by a grid click; auto-close fires through onOpenChange, and a controlled open wins.

Real browser, trusted input — CDP-driven, because jsdom does not restore focus to the trigger after a close:

# Case Result
8 Range focus opens two fields, opens
9 First click emits nothing, stays open
10 Second click emits 10-20, auto-closes and stays closed

This caught a bug the 3104 jsdom tests did not: completing a range closed the popover, the browser handed focus back to the trigger, and the focus handler reopened it — true:trigger-press, false:item-press, true:trigger-focus. closePress now joins escapeKey and triggerPress in the blocked-reason set. Fixing it also surfaced that itemPress is not in the popover's reason union at all, so the close had been carrying a reason Base UI's own type says cannot occur.

Visual — two-month range rendered against the Figma frame: endpoints pill-rounded outward, one connected band between them, today's dot below the number and independent of the range.

SQL Safety (if your PR touches *_repository.go or goqu.*)

Not applicable — TypeScript and CSS only. No Go files, no database access.

PR 4 of 7. `selection='range'` on the root, `field="start" | "end"` on
`.Input`, and the from/to machine that ties them together.

BREAKING CHANGE: a range emits only once it is complete. `onSelect` used
to fire on every step with a partial `{ from?, to? }`, and the docs told
consumers to gate on `range.to`. `onValueChange` now fires with both
edges or not at all, `to` stops being nullable, and the gate-on-range.to
idiom retires. Anyone reading the first-click event loses it. No type
error will find this: the old partial satisfies the new shape whenever
`to` happened to be set, so the failure is a callback that stops firing
rather than one that stops compiling.

The half-built range stays internal. It is on the root context so the
grid can draw the track between endpoints, and it is never emitted. The
restart case leaves the consumer's value at the previous complete range
until the new one completes; Escape or closing drops the draft.

The machine is the shipped one, branch for branch: an empty range takes
the first click as `from` and moves focus to the end field; a later
second click completes and closes; an earlier one becomes the new
`from`; a click on a complete range restarts.

Completing writes to open state, which the grid must not do directly, so
it routes through the root's `setOpen` — a consumer controlling `open`
keeps it open and only sees the request.

`lock` is replaced by `readOnly` on one `.Input`. The endpoint registers
itself, because `readOnly` is the input's prop and the grid is the thing
that has to refuse the write. A read-only endpoint with no value makes
the range unsatisfiable — the free endpoint sets, nothing ever completes
— so it needs a value; the docs say so.

Selection arms are discriminated on `selection`, so a single-day
consumer keeps a `Date | null` callback rather than both arms widening
to a union. The implementation stays shared, with one cast at the seam.

Range styling from the frames: endpoints accent-filled and pill-rounded
on their outer edges, the days between on one continuous band rather
than three cell backgrounds. react-day-picker marks every day of a range
`selected`, so the days on the track needed the single-day white text
undone — caught by rendering it, not by reading the CSS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
apsara Ready Ready Preview Sep 5, 2026 10:59am UTC

@pkg-pr-new

pkg-pr-new Bot commented Sep 5, 2026

Copy link
Copy Markdown

Open in StackBlitz

pnpm add https://pkg.pr.new/@raystack/apsara@897

commit: 18e89f6

@coderabbitai

coderabbitai Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: 9ae47946-e5a5-4924-b139-fda380fc6bb2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Completing a range closes the popover, the browser hands focus back to
the trigger, and the focus handler opened it again — the same shape as
the Escape reopen, but the auto-close reason was not in the blocked set
so the guard let it through. `closePress` joins `escapeKey` and
`triggerPress` there.

Emitting `itemPress` was also wrong: it is not in the popover's reason
union, so the close was carrying a reason Base UI's own type says cannot
occur. `closePress` is in the union and is what this is.

jsdom passed throughout, because it does not restore focus to the
trigger the way a browser does. Found by driving real Chrome over CDP.
The regression test asserts the guard rather than the symptom, since the
symptom is not reproducible in jsdom.

Real browser, trusted input, after the fix:

  10 after 2nd click emit:  10-20
  10 auto-closed:           true
  10 range open log:        true:trigger-press,false:close-press

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant