From 67ba80a390f1cebd0ff040c6f5df862e16ca478c Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 15:24:52 +0530 Subject: [PATCH 1/2] feat: CalendarPreview range selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../docs/components/calendar-preview/demo.ts | 100 ++++++++ .../components/calendar-preview/index.mdx | 34 +++ .../calendar-preview/__tests__/range.test.tsx | 221 ++++++++++++++++++ .../calendar-preview-context.tsx | 41 ++++ .../calendar-preview-grid.tsx | 35 ++- .../calendar-preview-input.tsx | 78 +++++-- .../calendar-preview-root.tsx | 162 ++++++++++++- .../calendar-preview.module.css | 56 +++++ .../components/calendar-preview/index.tsx | 3 + packages/raystack/index.tsx | 3 + 10 files changed, 697 insertions(+), 36 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/range.test.tsx diff --git a/apps/www/src/content/docs/components/calendar-preview/demo.ts b/apps/www/src/content/docs/components/calendar-preview/demo.ts index 00389ae18..df204e56e 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -309,3 +309,103 @@ export const pickerDemo = { } ] }; + +export const rangeDemo = { + type: 'code', + tabs: [ + { + name: 'Basic', + code: ` + + + + + + + + + + ` + }, + { + name: 'Disabled', + code: ` + + + + + + + + + + ` + }, + { + name: 'Disabled dates', + code: ` date.getDay() === 0 || date.getDay() === 6} + > + + + + + + + + + + ` + }, + { + name: 'Without calendar icon', + code: ` + + + + + + + + + + ` + }, + { + name: 'Read-only start', + code: ` + + + + + + + + + + ` + }, + { + name: 'Custom trigger', + code: ` + }> + 10 Apr – 20 Apr + + + + + ` + } + ] +}; diff --git a/apps/www/src/content/docs/components/calendar-preview/index.mdx b/apps/www/src/content/docs/components/calendar-preview/index.mdx index cce168abf..c14e8a371 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -12,6 +12,7 @@ import { gridDemo, dateInfoDemo, pickerDemo, + rangeDemo, } from "./demo.ts"; @@ -223,6 +224,39 @@ The popover opens when the input takes focus. Enter, blur and an outside click a +### Range selection + +`selection="range"` turns clicks into endpoints. Give each `.Input` a `field`: + +```tsx + + + + + + + + + +``` + +**`onValueChange` fires on a complete range or not at all.** `to` is not nullable, so there is no partial `{ from?, to? }` to gate on. The half-built range stays internal — the grid styles the track from it, but nothing is emitted until the second endpoint lands. + +The click machine: + +| State | A click does | +|---|---| +| Nothing selected | sets `from`, moves focus to the end field | +| `from` only, later day | completes the range, emits, closes the popover | +| `from` only, earlier day | that day becomes the new `from` | +| Complete range | restarts — the new day is `from`, and the value stays at the previous range until the new one completes | + +Completing asks the popover to close through `onOpenChange`, so a consumer holding `open` open is not fought. + +Instead of a `lock` prop, mark one endpoint's `.Input` as `readOnly` — the grid will not rewrite it. **A read-only endpoint with no value makes the range unsatisfiable:** the free endpoint sets, the range never completes, and nothing emits. Give a read-only endpoint a value. + + + ## Accessibility - Arrow keys move between days; the focused cell carries `data-draft` until it is committed diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx new file mode 100644 index 000000000..292e38fd4 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -0,0 +1,221 @@ +import { fireEvent, render } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; + +const TODAY = new Date(2026, 7, 15); +const AUGUST = new Date(2026, 7, 1); + +function renderRange(props = {}, children?: React.ReactNode) { + return render( + + {children ?? } + + ); +} + +function day(container: HTMLElement, text: string): HTMLElement { + const match = getAllSlots(container, 'calendar-preview-day').find( + cell => + getSlot(cell, 'calendar-preview-day-number')?.textContent === text && + !cell.hasAttribute('data-outside') + ); + if (!match) throw new Error(`no cell for ${text}`); + return match; +} + +describe('CalendarPreview range machine', () => { + it('does not emit on the first click', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ onValueChange }); + fireEvent.click(day(container, '10')); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('emits once, with both edges, when the range completes', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ onValueChange }); + fireEvent.click(day(container, '10')); + fireEvent.click(day(container, '20')); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toEqual({ + from: new Date(2026, 7, 10), + to: new Date(2026, 7, 20) + }); + }); + + it('treats an earlier second click as a new start, still emitting nothing', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ onValueChange }); + fireEvent.click(day(container, '20')); + fireEvent.click(day(container, '10')); + expect(onValueChange).not.toHaveBeenCalled(); + /* The earlier day became the new start, so a later click completes. */ + fireEvent.click(day(container, '15')); + expect(onValueChange.mock.calls[0][0]).toEqual({ + from: new Date(2026, 7, 10), + to: new Date(2026, 7, 15) + }); + }); + + it('restarts from a click on a complete range, and emits nothing until it completes again', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ onValueChange }); + fireEvent.click(day(container, '10')); + fireEvent.click(day(container, '20')); + expect(onValueChange).toHaveBeenCalledTimes(1); + + fireEvent.click(day(container, '5')); + expect(onValueChange).toHaveBeenCalledTimes(1); + + fireEvent.click(day(container, '8')); + expect(onValueChange).toHaveBeenCalledTimes(2); + expect(onValueChange.mock.calls[1][0]).toEqual({ + from: new Date(2026, 7, 5), + to: new Date(2026, 7, 8) + }); + }); + + it('marks the endpoints and the days between them', () => { + const { container } = renderRange(); + fireEvent.click(day(container, '10')); + fireEvent.click(day(container, '13')); + + expect(day(container, '10')).toHaveAttribute('data-range-start'); + expect(day(container, '13')).toHaveAttribute('data-range-end'); + for (const between of ['11', '12']) { + expect(day(container, between)).toHaveAttribute('data-range-middle'); + } + expect(day(container, '9')).not.toHaveAttribute('data-range-middle'); + }); + + it('renders a controlled range without a click', () => { + const { container } = renderRange({ + value: { from: new Date(2026, 7, 10), to: new Date(2026, 7, 12) } + }); + expect(day(container, '10')).toHaveAttribute('data-range-start'); + expect(day(container, '12')).toHaveAttribute('data-range-end'); + }); +}); + +describe('CalendarPreview range inputs', () => { + const picker = ( + <> + + + + + + + + + ); + + const inputs = (container: HTMLElement) => + getAllSlots(container, 'calendar-preview-input') as HTMLInputElement[]; + + it('gives each endpoint its own field and placeholder', () => { + const { container } = renderRange({}, picker); + const [start, end] = inputs(container); + expect(start).toHaveAttribute('data-field', 'start'); + expect(end).toHaveAttribute('data-field', 'end'); + expect(start).toHaveAttribute('placeholder', 'Select start date'); + expect(end).toHaveAttribute('placeholder', 'Select end date'); + }); + + it('advances the active endpoint to the end after the first click', () => { + const { container } = renderRange({}, picker); + const [start, end] = inputs(container); + expect(start).toHaveAttribute('data-active', 'true'); + expect(end).not.toHaveAttribute('data-active'); + + fireEvent.focus(start); + fireEvent.click(day(document.body, '10')); + + expect(end).toHaveAttribute('data-active', 'true'); + expect(start).not.toHaveAttribute('data-active'); + }); + + it('shows each endpoint in its own field', () => { + const { container } = renderRange({}, picker); + fireEvent.focus(inputs(container)[0]); + fireEvent.click(day(document.body, '10')); + fireEvent.click(day(document.body, '20')); + const [start, end] = inputs(container); + expect(start.value).toBe('10/08/2026'); + expect(end.value).toBe('20/08/2026'); + }); + + /* `lock` is gone: a read-only endpoint is one read-only `.Input`. */ + it('never lets a grid click rewrite a read-only endpoint', () => { + const onValueChange = vi.fn(); + const { container } = renderRange( + { + onValueChange, + value: { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) } + }, + <> + + + + + + + + + ); + fireEvent.focus(inputs(container)[1]); + /* A click that would restart the range has to rewrite `from`, which is + read-only, so nothing moves. */ + fireEvent.click(day(document.body, '5')); + expect(onValueChange).not.toHaveBeenCalled(); + }); +}); + +describe('CalendarPreview range auto-close', () => { + const picker = ( + <> + + + + + + + + + ); + + const isOpen = () => + getSlot(document.body, 'calendar-preview-content') !== null; + + it('closes through onOpenChange when the range completes', () => { + const onOpenChange = vi.fn(); + const { container } = renderRange({ onOpenChange }, picker); + fireEvent.focus( + getAllSlots(container, 'calendar-preview-input')[0] as HTMLElement + ); + expect(isOpen()).toBe(true); + + fireEvent.click(day(document.body, '10')); + expect(isOpen()).toBe(true); + + fireEvent.click(day(document.body, '20')); + expect(isOpen()).toBe(false); + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything()); + }); + + /* Completing a range asks to close; a consumer holding `open` open wins. */ + it('does not fight a controlled open', () => { + const onOpenChange = vi.fn(); + renderRange({ open: true, onOpenChange }, picker); + fireEvent.click(day(document.body, '10')); + fireEvent.click(day(document.body, '20')); + expect(isOpen()).toBe(true); + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything()); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 6f33c50ce..e41d26e95 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -14,6 +14,24 @@ export type CalendarPreviewChangeReason = export type CalendarPreviewOpenChangeDetails = Popover.Root.ChangeEventDetails; +/** Which endpoint a range `.Input` addresses. */ +export type CalendarPreviewField = 'start' | 'end'; + +/** + * A completed range. Neither edge is nullable: a range that is still being + * built is a draft, and drafts are never emitted. + */ +export interface CalendarPreviewDateRange { + from: Date; + to: Date; +} + +/** A range mid-build. `to` is absent until the second click lands. */ +export interface CalendarPreviewDraftRange { + from: Date; + to?: Date; +} + export interface CalendarPreviewChangeDetails { /** What caused the change. */ reason: CalendarPreviewChangeReason; @@ -70,6 +88,29 @@ export interface CalendarPreviewContextValue { disabled: boolean; readOnly: boolean; formatValue: (value: Date | ScaleValue, scale: Scale) => string; + + selection: 'single' | 'range'; + /** + * Commits a clicked day. Single scale commits it directly; range runs the + * from/to machine, which lives here because completing a range both writes + * the value and closes the popover. + */ + selectDay: (date: Date) => void; + /** + * The range as the grid should draw it — the draft while one is being built, + * the committed value otherwise. Never emitted; the track between endpoints + * is styled from it. + */ + draft: CalendarPreviewDraftRange | null; + /** The endpoint the next click fills. `.Input` reads it to show focus. */ + activeField: CalendarPreviewField; + setActiveField: (field: CalendarPreviewField) => void; + /** + * Which endpoints a `.Input` has declared read-only, so a grid click cannot + * rewrite one. Registered by the inputs, because `readOnly` is their prop. + */ + fieldReadOnly: Record; + setFieldReadOnly: (field: CalendarPreviewField, readOnly: boolean) => void; } const CalendarPreviewContext = diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 47d35c777..1b2c0ea33 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -111,15 +111,16 @@ export function CalendarPreviewGrid({ }: CalendarPreviewGridProps) { const { value, - setValue, + selection, + selectDay, + draft, month, setMonth, isDateUnavailable, today, timeZone, clearable, - disabled, - readOnly + disabled } = useCalendarPreviewContext('CalendarPreview.Grid'); const days = useCalendarPreviewDaysContext(); const setBusy = days?.setBusy; @@ -158,9 +159,11 @@ export function CalendarPreviewGrid({ [components, months] ); - const handleSelect = (selected: Date | undefined, triggerDate: Date) => { - if (readOnly || disabled) return; - setValue(selected ?? null, selected ? 'select' : 'clear', triggerDate); + /* Every click goes to the root, which owns both the single commit and the + from/to machine — completing a range has to close the popover, and that + must travel through the root's open state rather than from in here. */ + const handleSelect = (_selected: unknown, triggerDate: Date) => { + selectDay(triggerDate); }; /* `mode`, `required`, `selected` and `onSelect` stay on the elements below: @@ -191,12 +194,20 @@ export function CalendarPreviewGrid({ return ( - {clearable ? ( + {selection === 'range' ? ( + + ) : clearable ? ( ) : ( @@ -204,7 +215,7 @@ export function CalendarPreviewGrid({ {...base} mode='single' required - selected={value ?? undefined} + selected={(value as Date | null) ?? undefined} onSelect={handleSelect} /> )} @@ -330,6 +341,9 @@ export function CalendarPreviewDay({ 'data-slot': 'calendar-preview-day', 'data-scale': scale, 'data-selected': modifiers.selected || undefined, + 'data-range-start': modifiers.range_start || undefined, + 'data-range-middle': modifiers.range_middle || undefined, + 'data-range-end': modifiers.range_end || undefined, 'data-draft': (modifiers.focused && !modifiers.selected) || undefined, 'data-unavailable': modifiers.disabled || undefined, 'data-today': modifiers.today || undefined, @@ -419,6 +433,9 @@ const GRID_CLASS_NAMES: DayPickerProps['classNames'] = { disabled: styles.disabled, selected: styles.selected, hidden: styles.hidden, + range_start: styles['range-start'], + range_middle: styles['range-middle'], + range_end: styles['range-end'], week_number: styles['week-number'], week_number_header: styles['week-number-header'] }; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 6550ff021..cc5c40eb6 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -1,8 +1,9 @@ import { cx } from 'class-variance-authority'; -import { type ComponentProps, useRef, useState } from 'react'; +import { type ComponentProps, useEffect, useRef, useState } from 'react'; import { CalendarIcon } from '~/icons'; import { Input } from '../input'; import styles from './calendar-preview.module.css'; +import type { CalendarPreviewField } from './calendar-preview-context'; import { useCalendarPreviewContext } from './calendar-preview-context'; import { dayKey, parseKey } from './date-adapter'; import { parseScaleInput } from './lib/parse'; @@ -16,6 +17,11 @@ export interface CalendarPreviewInputProps extends Omit, 'value' | 'defaultValue'> { /** Called when the typed text starts or stops being a usable date. */ onValidityChange?: (validity: CalendarPreviewInputValidity) => void; + /** + * Which endpoint this field addresses, at `selection='range'`. Two inputs, + * each addressable — rather than one bag of props per endpoint. + */ + field?: CalendarPreviewField; } const VALID: CalendarPreviewInputValidity = { valid: true }; @@ -28,11 +34,13 @@ const VALID: CalendarPreviewInputValidity = { valid: true }; * popover, which blurs and therefore commits too. */ export function CalendarPreviewInput({ - placeholder = 'Select date', + field = 'start', + placeholder, trailingIcon = , onValidityChange, onKeyDown, onBlur, + onFocus, className, readOnly: readOnlyProp, ...props @@ -49,11 +57,27 @@ export function CalendarPreviewInput({ clearable, today, disabled, - readOnly + readOnly, + selection, + selectDay, + draft, + activeField, + setActiveField, + setFieldReadOnly } = useCalendarPreviewContext('CalendarPreview.Input'); + const isRange = selection === 'range'; + + /* The grid has to know which endpoint refuses a write, and `readOnly` is + this input's prop, so it registers rather than the root guessing. */ + useEffect(() => { + if (!isRange) return; + setFieldReadOnly(field, Boolean(readOnlyProp)); + return () => setFieldReadOnly(field, false); + }, [isRange, field, readOnlyProp, setFieldReadOnly]); + /* Null means "show the committed value"; a string is the user's draft. */ - const [draft, setDraft] = useState(null); + const [text, setText] = useState(null); const lastReported = useRef(VALID); const report = (next: CalendarPreviewInputValidity) => { @@ -88,38 +112,58 @@ export function CalendarPreviewInput({ }; const commit = () => { - if (draft === null) return; - const text = draft.trim(); - if (text === '') { + if (text === null) return; + const trimmed = text.trim(); + if (trimmed === '') { if (clearable && value) setValue(null, 'clear', today); - setDraft(null); + setText(null); report(VALID); return; } - const resolved = resolve(text); - if (resolved instanceof Date) { - setValue(resolved, 'input', resolved); - setDraft(null); - report(VALID); - } + const resolved = resolve(trimmed); + if (!(resolved instanceof Date)) return; + /* A typed endpoint goes through the same machine a clicked one does, so + the two cannot disagree about what completes a range. */ + if (isRange) selectDay(resolved); + else setValue(resolved, 'input', resolved); + setText(null); + report(VALID); }; const inert = disabled || readOnly || readOnlyProp; + const endpoint = isRange + ? ((field === 'start' ? draft?.from : draft?.to) ?? null) + : (value as Date | null); + const committedText = endpoint ? formatValue(endpoint, scale) : ''; + const resolvedPlaceholder = + placeholder ?? + (isRange + ? field === 'start' + ? 'Select start date' + : 'Select end date' + : 'Select date'); + return ( { + onFocus?.(event); + if (isRange) setActiveField(field); + }} trailingIcon={trailingIcon} disabled={disabled} readOnly={readOnly || readOnlyProp} aria-invalid={lastReported.current.valid ? undefined : true} - value={draft ?? (value ? formatValue(value, scale) : '')} + value={text ?? committedText} onValueChange={text => { if (inert) return; - setDraft(text); + setText(text); if (text.trim() === '') { report(VALID); return; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 67af6a640..6ec24c2d8 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -1,15 +1,19 @@ 'use client'; import { mergeProps, Popover, useRender } from '@base-ui/react'; +import { createChangeEventDetails } from '@base-ui/react/internals/createBaseUIEventDetails'; import { REASONS } from '@base-ui/react/internals/reasons'; import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; import styles from './calendar-preview.module.css'; import { type CalendarPreviewChangeDetails, type CalendarPreviewChangeReason, type CalendarPreviewContextValue, + type CalendarPreviewDateRange, + type CalendarPreviewDraftRange, + type CalendarPreviewField, type CalendarPreviewOpenChangeDetails, CalendarPreviewProvider } from './calendar-preview-context'; @@ -25,20 +29,59 @@ import { periodOf, type Scale, type ScaleValue } from './lib/scale'; const DEFAULT_YEAR_SPAN = 10; +function isRange(value: unknown): value is CalendarPreviewDateRange { + return value != null && typeof value === 'object' && 'from' in value; +} + +/* The day the view should open on, whichever selection shape the value is. */ +function monthAnchor(value: CalendarPreviewValue): Date | undefined { + if (!value) return undefined; + return isRange(value) ? value.from : value; +} + /* `defaultValue` is omitted because `HTMLAttributes` already declares it as a form value, which is not what it means here. */ -export interface CalendarPreviewProps - extends Omit, 'defaultValue'> { +type CalendarPreviewValue = Date | CalendarPreviewDateRange | null; + +/* Selection arms are discriminated on `selection`, so a single-day consumer + keeps a `Date | null` callback and a range consumer gets a range that has + both edges. One shared `value` type would widen both. */ +interface CalendarPreviewSingleProps { + selection?: 'single'; /** The selected day (controlled). */ value?: Date | null; /** The initially selected day (uncontrolled). */ defaultValue?: Date | null; - /** Called when a day is committed or cleared. */ onValueChange?: ( value: Date | null, details: CalendarPreviewChangeDetails ) => void; +} + +interface CalendarPreviewRangeProps { + selection: 'range'; + /** The selected range (controlled). Both edges, or nothing. */ + value?: CalendarPreviewDateRange | null; + /** The initial range (uncontrolled). */ + defaultValue?: CalendarPreviewDateRange | null; + /** + * Fires on a **complete** range or not at all. The half-built state stays + * internal, so there is no partial `{ from?, to? }` to gate on. + */ + onValueChange?: ( + value: CalendarPreviewDateRange | null, + details: CalendarPreviewChangeDetails + ) => void; +} +export type CalendarPreviewProps = ( + | CalendarPreviewSingleProps + | CalendarPreviewRangeProps +) & + CalendarPreviewSharedProps; + +interface CalendarPreviewSharedProps + extends Omit, 'defaultValue' | 'onChange'> { /** Whether the popover is open (controlled). Ignored by an inline calendar. */ open?: boolean; /** @defaultValue false */ @@ -125,6 +168,7 @@ export function defaultFormatValue( } export function CalendarPreviewRoot({ + selection = 'single', value: valueProp, defaultValue = null, onValueChange, @@ -153,7 +197,16 @@ export function CalendarPreviewRoot({ }: CalendarPreviewProps) { const today = useMemo(() => todayProp ?? new Date(), [todayProp]); - const [value, setValueUnwrapped] = useControlled({ + /* The public props are discriminated on `selection`; the implementation is + shared and works in the widened value. This is the one seam between them. */ + const emit = onValueChange as + | (( + value: CalendarPreviewValue, + details: CalendarPreviewChangeDetails + ) => void) + | undefined; + + const [value, setValueUnwrapped] = useControlled({ controlled: valueProp, default: defaultValue, name: 'CalendarPreview', @@ -162,7 +215,7 @@ export function CalendarPreviewRoot({ const [month, setMonthUnwrapped] = useControlled({ controlled: monthProp, - default: defaultMonth ?? defaultValue ?? today, + default: defaultMonth ?? monthAnchor(defaultValue) ?? today, name: 'CalendarPreview', state: 'month' }); @@ -186,18 +239,18 @@ export function CalendarPreviewRoot({ const setValue = useCallback( ( - next: Date | null, + next: CalendarPreviewValue, reason: CalendarPreviewChangeReason, occasion: Date ) => { setValueUnwrapped(next); - onValueChange?.(next, { + emit?.(next, { reason, period: periodOf(occasion, scale), toDate: () => occasion }); }, - [setValueUnwrapped, onValueChange, scale] + [setValueUnwrapped, emit, scale] ); const [open, setOpenUnwrapped] = useControlled({ @@ -239,6 +292,82 @@ export function CalendarPreviewRoot({ [setScaleUnwrapped] ); + const [draft, setDraft] = useState(null); + const [activeField, setActiveField] = useState('start'); + const [fieldReadOnly, setFieldReadOnlyState] = useState< + Record + >({ start: false, end: false }); + + const setFieldReadOnly = useCallback( + (field: CalendarPreviewField, next: boolean) => { + setFieldReadOnlyState(current => + current[field] === next ? current : { ...current, [field]: next } + ); + }, + [] + ); + + /* + * The from/to machine, unchanged from the shipped picker: + * no from -> set from, advance to the end input + * from, day earlier -> that day becomes the new from + * from, day later -> completes, emits, closes + * from and to -> restart from the new day + * + * It lives on the root because completing a range both writes the value and + * closes the popover, and closing has to go through `setOpen` so a consumer + * controlling `open` is not fought. + */ + const selectDay = useCallback( + (date: Date) => { + if (readOnly || disabled) return; + + if (selection === 'single') { + const isSame = + value instanceof Date && + dayKey(value, timeZone) === dayKey(date, timeZone); + if (isSame && clearable) setValue(null, 'clear', date); + else setValue(date, 'select', date); + return; + } + + const from = draft?.from; + if (!from || draft?.to) { + if (fieldReadOnly.start) return; + setDraft({ from: date }); + setActiveField('end'); + return; + } + + if (dayKey(date, timeZone) < dayKey(from, timeZone)) { + if (fieldReadOnly.start) return; + setDraft({ from: date }); + return; + } + + if (fieldReadOnly.end) return; + setDraft(null); + setActiveField('start'); + setValue({ from, to: date }, 'select', date); + setOpen( + false, + createChangeEventDetails(REASONS.itemPress, undefined, undefined) + ); + }, + [ + selection, + value, + draft, + fieldReadOnly, + clearable, + timeZone, + readOnly, + disabled, + setValue, + setOpen + ] + ); + const reset = useCallback(() => { if (!defaultDate) return; setValue(defaultDate, 'select', defaultDate); @@ -267,10 +396,17 @@ export function CalendarPreviewRoot({ return { from: Math.min(...years), to: Math.max(...years) }; }, [yearRangeProp, today, minDate, maxDate]); - const context = useMemo>( + const context = useMemo>( () => ({ value, setValue, + selection, + selectDay, + draft: draft ?? (isRange(value) ? value : null), + activeField, + setActiveField, + fieldReadOnly, + setFieldReadOnly, open, setOpen, shouldIgnoreFocusOpen, @@ -294,6 +430,12 @@ export function CalendarPreviewRoot({ [ value, setValue, + selection, + selectDay, + draft, + activeField, + fieldReadOnly, + setFieldReadOnly, open, setOpen, shouldIgnoreFocusOpen, diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index c5e2716be..502159f54 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -463,3 +463,59 @@ .input { width: 100%; } + +/* The endpoints are pill-rounded on their outer edges and the days between sit + on one continuous band. The track is drawn on the cell rather than the day + button so neighbouring cells meet with no seam. */ +.range-middle { + background: var(--rs-color-background-neutral-secondary); + border-radius: 0; +} + +/* react-day-picker marks every day of the range `selected`, and the single-day + rule paints that white for the accent pill. The days on the track are on + grey, so they keep the ordinary text colour. */ +.range-middle .day-button { + background: transparent; + color: var(--rs-color-foreground-base-primary); +} + +.range-start, +.range-end { + background: var(--rs-color-background-neutral-secondary); +} + +/* A half-open range has one endpoint and no band to join, so it keeps the + plain selected pill instead of a flat edge. */ +.range-start:not(.range-end) { + border-start-start-radius: var(--rs-radius-5); + border-end-start-radius: var(--rs-radius-5); + border-start-end-radius: 0; + border-end-end-radius: 0; +} + +.range-end:not(.range-start) { + border-start-end-radius: var(--rs-radius-5); + border-end-end-radius: var(--rs-radius-5); + border-start-start-radius: 0; + border-end-start-radius: 0; +} + +.range-start .day-button, +.range-end .day-button { + background: var(--rs-color-background-accent-emphasis); + color: var(--rs-color-foreground-base-emphasis); + border-radius: var(--rs-radius-5); +} + +.range-start .day-button[data-today]::after, +.range-end .day-button[data-today]::after { + background-color: var(--rs-color-foreground-base-emphasis); +} + +/* Two fields side by side, sharing the trigger's width. */ +.range-fields { + display: flex; + align-items: center; + gap: var(--rs-space-3); +} diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index ac50dab4f..9b83afa74 100644 --- a/packages/raystack/components/calendar-preview/index.tsx +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -4,6 +4,9 @@ export type { CalendarPreviewContentProps } from './calendar-preview-content'; export type { CalendarPreviewChangeDetails, CalendarPreviewChangeReason, + CalendarPreviewDateRange, + CalendarPreviewDraftRange, + CalendarPreviewField, CalendarPreviewOpenChangeDetails } from './calendar-preview-context'; export type { CalendarPreviewDaysProps } from './calendar-preview-days'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 41e68661a..aff716c65 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -25,8 +25,11 @@ export { type CalendarPreviewCaptionProps, type CalendarPreviewChangeDetails, type CalendarPreviewChangeReason, + type CalendarPreviewDateRange, type CalendarPreviewDayProps, type CalendarPreviewDaysProps, + type CalendarPreviewDraftRange, + type CalendarPreviewField, type CalendarPreviewFooterProps, type CalendarPreviewGridProps, type CalendarPreviewHeaderProps, From 18e89f64e079a798655026af10abe5faa38ad6b7 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 16:26:29 +0530 Subject: [PATCH 2/2] fix: stop the range auto-close reopening itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../calendar-preview/__tests__/range.test.tsx | 24 +++++++++++++++++++ .../calendar-preview-root.tsx | 14 ++++++----- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 292e38fd4..745f145ff 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -209,6 +209,30 @@ describe('CalendarPreview range auto-close', () => { expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything()); }); + /* Completing a range hands focus back to the trigger, and an unguarded + focus handler reopens the popover on the way out. jsdom does not restore + focus the way a browser does, so this asserts the guard rather than the + symptom: the close must be the last thing that happens. */ + it('does not reopen on the focus that follows an auto-close', () => { + const onOpenChange = vi.fn(); + const { container } = renderRange({ onOpenChange }, picker); + const [start] = getAllSlots( + container, + 'calendar-preview-input' + ) as HTMLElement[]; + fireEvent.focus(start); + + fireEvent.click(day(document.body, '10')); + fireEvent.click(day(document.body, '20')); + expect(isOpen()).toBe(false); + + /* The browser returns focus to the trigger here. */ + fireEvent.focus(start); + expect(isOpen()).toBe(false); + const calls = onOpenChange.mock.calls; + expect(calls[calls.length - 1][0]).toBe(false); + }); + /* Completing a range asks to close; a consumer holding `open` open wins. */ it('does not fight a controlled open', () => { const onOpenChange = vi.fn(); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 6ec24c2d8..bfd572f4d 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -260,10 +260,11 @@ export function CalendarPreviewRoot({ state: 'open' }); - /* Escape and a press on the trigger both leave focus on the trigger, so the - focus event that follows would immediately undo the close. Recording the - reason lets `.Trigger` swallow exactly that one focus — the same rule - floating-ui's own `useFocus` applies. */ + /* Escape, a press on the trigger, and completing a range all leave focus on + the trigger, so the focus event that follows would immediately undo the + close. Recording the reason lets `.Trigger` swallow exactly that one focus + — the rule floating-ui's own `useFocus` applies, plus `closePress`, which + is ours because auto-closing on completion is. */ const focusOpenBlocked = useRef(false); const setOpen = useCallback( @@ -271,7 +272,8 @@ export function CalendarPreviewRoot({ if ( !next && (details.reason === REASONS.escapeKey || - details.reason === REASONS.triggerPress) + details.reason === REASONS.triggerPress || + details.reason === REASONS.closePress) ) { focusOpenBlocked.current = true; } @@ -351,7 +353,7 @@ export function CalendarPreviewRoot({ setValue({ from, to: date }, 'select', date); setOpen( false, - createChangeEventDetails(REASONS.itemPress, undefined, undefined) + createChangeEventDetails(REASONS.closePress, undefined, undefined) ); }, [