From 67ba80a390f1cebd0ff040c6f5df862e16ca478c Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 15:24:52 +0530 Subject: [PATCH 1/7] 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/7] 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) ); }, [ From 4495bd802c4f29ba8e980443ab31ef00e54117a9 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 03:59:57 +0530 Subject: [PATCH 3/7] fix(calendar-preview): make the parts that read the value range-safe Widening the value to a range left the parts that consume it still assuming a Date, which TypeScript could not catch: the context is generic with a `Date | null` default and the provider casts through `unknown`, so each part believed it held a day and threw at runtime instead. - `.Reset` called `dayKey` on the range object and threw on render. It is the default composition, `.Days` to `.Header` to `.Reset`, so a range calendar with a `defaultDate` did not render at all. A single-day default cannot describe a range, so the part now stands down at range selection; a `null` default still shows, because clearing means the same thing either way. - A childless `.Trigger` handed the range to `formatValue`, reaching `parseKey(undefined)`. It now labels itself with both endpoints. - Typing into the end field restarted the range from that day, silently rewriting the start and emitting nothing. Typed dates go through a new `setEndpoint`, which writes the field that was typed into: a click means "the next endpoint", but typing into a field means that field. - `reset()` wrote a bare Date into a range value, reaching a range consumer's callback with the wrong shape. It stands down at range selection too. - `useCalendar().setValue(null)` passed the range as the `occasion`, which is always one day, and threw in `dayKey`. A range now reports the day it starts on, and `UseCalendarReturn.value` admits it can hold a range rather than claiming `Date | null` while already carrying one. Eight cases added to range.test.tsx, each verified against the broken code first: both render crashes, both edit directions, the crossed endpoint, the read-only endpoint, and the null-default reset. --- .../__tests__/calendar-preview.test.tsx | 4 +- .../calendar-preview/__tests__/range.test.tsx | 122 ++++++++++++++++++ .../calendar-preview-context.tsx | 2 + .../calendar-preview-input.tsx | 8 +- .../calendar-preview-reset.tsx | 6 +- .../calendar-preview-root.tsx | 34 ++++- .../calendar-preview-trigger.tsx | 17 ++- .../calendar-preview/use-calendar.tsx | 22 +++- 8 files changed, 196 insertions(+), 19 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx index 022e7f7b0..6be52116d 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -1454,7 +1454,9 @@ describe('useCalendar', () => { useCalendar(); return (
- {value ? value.getDate() : 'none'} + + {value instanceof Date ? value.getDate() : 'none'} + {month.getMonth()} {scale} diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 745f145ff..452a2ac6d 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -243,3 +243,125 @@ describe('CalendarPreview range auto-close', () => { expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything()); }); }); + +describe('CalendarPreview range parts that read the value', () => { + const RANGE = { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) }; + + const typeAndCommit = (input: HTMLInputElement, text: string) => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: text } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }; + + const inputs = (container: HTMLElement) => + getAllSlots(container, 'calendar-preview-input') as HTMLInputElement[]; + + const picker = ( + <> + + + + + + + + + ); + + /* `.Days` renders `.Header` renders `.Reset`, so this is the default + composition — it threw on `dayKey(range)` before the shape guard. */ + it('renders the default composition with a range value and a defaultDate', () => { + expect(() => + renderRange({ defaultValue: RANGE, defaultDate: RANGE.from }) + ).not.toThrow(); + }); + + it('hides .Reset at range selection, where a single-day default cannot restore', () => { + const { container } = renderRange({ + defaultValue: RANGE, + defaultDate: RANGE.from + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeNull(); + }); + + /* Clearing is shape-agnostic, so a `null` default keeps working. */ + it('keeps .Reset for a null defaultDate, and clears the range', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ + defaultValue: RANGE, + defaultDate: null, + onValueChange + }); + const reset = getSlot(container, 'calendar-preview-reset'); + expect(reset).not.toBeNull(); + fireEvent.click(reset as HTMLElement); + expect(onValueChange).toHaveBeenCalledWith(null, expect.anything()); + }); + + it('labels a childless .Trigger with both endpoints', () => { + const { container } = renderRange( + { defaultValue: RANGE }, + + ); + const trigger = getSlot(container, 'calendar-preview-trigger'); + expect(trigger?.textContent).toContain('10/08/2026'); + expect(trigger?.textContent).toContain('20/08/2026'); + }); + + it('edits the end without disturbing the start', () => { + const onValueChange = vi.fn(); + const { container } = renderRange( + { defaultValue: RANGE, onValueChange }, + picker + ); + const [start, end] = inputs(container); + typeAndCommit(end, '25/08/2026'); + expect(start.value).toBe('10/08/2026'); + expect(end.value).toBe('25/08/2026'); + expect(onValueChange).toHaveBeenCalledWith( + { from: RANGE.from, to: new Date(2026, 7, 25) }, + expect.objectContaining({ reason: 'input' }) + ); + }); + + it('edits the start without disturbing the end', () => { + const onValueChange = vi.fn(); + const { container } = renderRange( + { defaultValue: RANGE, onValueChange }, + picker + ); + const [start, end] = inputs(container); + typeAndCommit(start, '05/08/2026'); + expect(start.value).toBe('05/08/2026'); + expect(end.value).toBe('20/08/2026'); + expect(onValueChange).toHaveBeenCalledWith( + { from: new Date(2026, 7, 5), to: RANGE.to }, + expect.objectContaining({ reason: 'input' }) + ); + }); + + it('restarts, and emits nothing, when a typed end crosses the start', () => { + const onValueChange = vi.fn(); + const { container } = renderRange( + { defaultValue: RANGE, onValueChange }, + picker + ); + const [, end] = inputs(container); + typeAndCommit(end, '01/08/2026'); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('refuses a typed endpoint the consumer marked read-only', () => { + const onValueChange = vi.fn(); + const { container } = renderRange( + { defaultValue: RANGE, onValueChange }, + + + + + ); + const [start] = inputs(container); + typeAndCommit(start, '05/08/2026'); + expect(onValueChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 88e1a6a7e..1951bd43f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -104,6 +104,8 @@ export interface CalendarPreviewContextValue { * the value and closes the popover. */ selectDay: (date: Date) => void; + /** Writes one named endpoint, for a typed `.Input`. */ + setEndpoint: (field: CalendarPreviewField, 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 diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 42942b160..799fa7098 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -81,7 +81,7 @@ export function CalendarPreviewInput({ disabled, readOnly, selection, - selectDay, + setEndpoint, draft, activeField, setActiveField, @@ -160,9 +160,9 @@ export function CalendarPreviewInput({ } 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); + /* Addressed to this field, not to "the next endpoint" — a click means the + latter, but typing into the end of a settled range means the former. */ + if (isRange) setEndpoint(field, resolved); else setValue(resolved, 'input', resolved); setText(null); report(VALID); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx index a994fbb67..4ce9d4896 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -28,13 +28,17 @@ export function CalendarPreviewReset({ onClick, ...props }: CalendarPreviewResetProps) { - const { value, defaultDate, reset, disabled, readOnly, timeZone } = + const { value, defaultDate, reset, disabled, readOnly, timeZone, selection } = useCalendarPreviewContext('CalendarPreview.Reset'); /* No `defaultDate` means the part has no job at all, which is a different thing from having nothing to restore right now — `null` is a default. */ if (defaultDate === undefined) return null; + /* A single-day default cannot describe a range, and comparing the two shapes + below would format the range as a date and throw. `null` still clears. */ + if (selection === 'range' && defaultDate !== null) return null; + const restored = defaultDate === null ? value == null diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index a877ef7ad..2d7bcfb79 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -48,7 +48,7 @@ function monthAnchor( /* `defaultValue` is omitted because `HTMLAttributes` already declares it as a form value, which is not what it means here. */ -type CalendarPreviewValue = Date | CalendarPreviewDateRange | null; +export 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 @@ -413,6 +413,31 @@ export function CalendarPreviewRoot({ ] ); + /* A click means "the next endpoint"; typing into a field means that field, + so a typed date cannot go through `selectDay`. Falls back to the committed + value when there is no draft, so editing one edge keeps the other. */ + const setEndpoint = useCallback( + (field: CalendarPreviewField, date: Date) => { + if (readOnly || disabled || fieldReadOnly[field]) return; + + const base = draft ?? (isRange(value) ? value : null); + const from = field === 'start' ? date : base?.from; + const to = field === 'end' ? date : base?.to; + + /* An ordered pair completes. Anything else — one edge still missing, or + a typed day that crossed its partner — restarts from that day. */ + if (from && to && dayKey(from, timeZone) <= dayKey(to, timeZone)) { + setDraft(null); + setActiveField('start'); + setValue({ from, to }, 'input', date); + return; + } + setDraft({ from: date }); + setActiveField('end'); + }, + [value, draft, fieldReadOnly, timeZone, readOnly, disabled, setValue] + ); + /* `'reset'`, not `'select'`: restoring the default is not a pick, and a consumer that logs or validates on selection needs to tell them apart. */ const reset = useCallback(() => { @@ -426,8 +451,11 @@ export function CalendarPreviewRoot({ setValue(null, 'clear', monthAnchor(value) ?? today); return; } + /* No range-shaped default exists, so restoring one would hand a range + consumer a bare `Date`. `.Reset` hides itself for the same reason. */ + if (selection === 'range') return; setValue(defaultDate, 'reset', defaultDate); - }, [defaultDate, value, setValue, today]); + }, [defaultDate, value, setValue, today, selection]); /* Day-keys, not instants: a `minDate` carrying a time of day still leaves its own day selectable, which the current family gets wrong. */ @@ -458,6 +486,7 @@ export function CalendarPreviewRoot({ setValue, selection, selectDay, + setEndpoint, draft: draft ?? (isRange(value) ? value : null), activeField, setActiveField, @@ -488,6 +517,7 @@ export function CalendarPreviewRoot({ setValue, selection, selectDay, + setEndpoint, draft, activeField, fieldReadOnly, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index d1ea944e6..3ffad3661 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -5,6 +5,7 @@ import { cx } from 'class-variance-authority'; import { type ComponentProps, type FocusEvent, useRef } from 'react'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; +import type { CalendarPreviewValue } from './calendar-preview-root'; export interface CalendarPreviewTriggerProps extends useRender.ComponentProps<'div'> { @@ -45,7 +46,9 @@ export function CalendarPreviewTrigger({ shouldIgnoreFocusOpen, disabled, readOnly - } = useCalendarPreviewContext('CalendarPreview.Trigger'); + } = useCalendarPreviewContext( + 'CalendarPreview.Trigger' + ); /* Tracks the pointer, not the open state: Base UI owns whether the popover is open, and this only says whether a press is mid-flight. */ @@ -87,10 +90,16 @@ export function CalendarPreviewTrigger({ ) } as ComponentProps; + /* `formatValue` takes a single day, so a range formats as its two ends. */ + const label = + value instanceof Date + ? formatValue(value, scale) + : value + ? `${formatValue(value.from, scale)} – ${formatValue(value.to, scale)}` + : placeholder; + return ( - - {children ?? (value ? formatValue(value, scale) : placeholder)} - + {children ?? label} ); } diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx index d45b24943..be8fbc4cf 100644 --- a/packages/raystack/components/calendar-preview/use-calendar.tsx +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -1,12 +1,15 @@ 'use client'; import { useCalendarPreviewContext } from './calendar-preview-context'; +import type { CalendarPreviewValue } from './calendar-preview-root'; import type { CalendarPreviewScale } from './lib/scale'; export interface UseCalendarReturn { - value: Date | null; - /** Commit a day, or clear with `null`. Emits `onValueChange`. */ - setValue: (value: Date | null) => void; + /* Holds a range at `selection='range'`; it was typed `Date | null` while + already carrying one. */ + value: CalendarPreviewValue; + /** Commit a day or a range, or clear with `null`. Emits `onValueChange`. */ + setValue: (value: CalendarPreviewValue) => void; /* Read-only until the scale switcher lands in phase 5. Exposing a setter now would be a public API we cannot take back if the switcher reshapes it; adding one later is additive. */ @@ -23,18 +26,23 @@ export interface UseCalendarReturn { */ export function useCalendar(): UseCalendarReturn { const { value, setValue, scale, month, setMonth, isDateUnavailable } = - useCalendarPreviewContext('useCalendar'); + useCalendarPreviewContext('useCalendar'); return { value, /* A null commit is a clear, and the day acted on is the day being cleared. Reporting `'select'` with `new Date()` broke the context's documented promise that `toDate()` is the day acted on — it handed back - today, which is a day nobody touched. */ + today, which is a day nobody touched. `occasion` is one day either way, + so a range reports the day it starts on. */ setValue: next => next === null - ? setValue(null, 'clear', value ?? new Date()) - : setValue(next, 'select', next), + ? setValue( + null, + 'clear', + (value instanceof Date ? value : value?.from) ?? new Date() + ) + : setValue(next, 'select', next instanceof Date ? next : next.from), scale, month, setMonth, From 0015558ea312a69ab21eae7ec99f8c85b7c1516b Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 04:17:18 +0530 Subject: [PATCH 4/7] feat(calendar-preview): reject a typed endpoint that crosses its partner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three existing reasons read one date on its own, so they could not see the one constraint a range adds: an end typed before its start parsed cleanly, reported valid, emitted nothing, and silently moved the start to that day. The person got no error, no value, and a rearranged form. `out-of-order` joins the reason union. It reads the counterpart from the context's draft, which resolves to the live draft while a range is half-built and to the committed value otherwise, so it covers both a settled range and one still being entered. Two endpoints on the same day stay a valid range. Typing is deliberately stricter than clicking. A click means "the next endpoint", so an earlier day restarts the range, which is documented and unchanged — there is a test pinning it. Typing names the field it lands in, and silently moving it elsewhere is never what was meant. This reason also gets a real default message, unlike the other three. Their default stays vague because only the consumer knows the field's bounds, but that reasoning does not hold here: this one needs no knowledge of the bounds, only of which endpoint was typed. The docs paragraph claiming a flat default for every reason is corrected rather than left contradicted. `errorMessages` overrides it like any other. Docs get the reason row, the click-versus-type paragraph, and an Invalid input tab on the range example showing the built-in wording beside an override. The docs site copy of the reason union is hand-written in two places, so both move. --- .../docs/components/calendar-preview/demo.ts | 68 +++++++++++ .../components/calendar-preview/index.mdx | 19 ++- .../docs/components/calendar-preview/props.ts | 9 +- .../calendar-preview/__tests__/range.test.tsx | 112 ++++++++++++++++++ .../calendar-preview-input.tsx | 26 +++- 5 files changed, 228 insertions(+), 6 deletions(-) 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 4a2684f9d..91ac344ac 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -505,6 +505,74 @@ export const rangeDemo = { ` }, + { + name: 'Invalid input', + code: ` +function CalendarPreviewRangeInvalidExample() { + const [defaultError, setDefaultError] = React.useState(); + const [customError, setCustomError] = React.useState(); + + const range = { + selection: 'range', + defaultMonth: new Date(2024, 3, 1), + defaultValue: { from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) } + }; + + return ( + + + + + + setDefaultError(message)} + /> + setDefaultError(message)} + /> + + + + + + + + + + + + + setCustomError(message)} + /> + setCustomError(message)} + /> + + + + + + + + + ); +}` + }, { name: 'Custom trigger', code: ` setError(message)} +/> +``` + 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. @@ -321,10 +334,13 @@ it is `undefined` while valid, which is exactly what [Field](/docs/components/fi ``` -The default is a flat **"Invalid input"** for every reason. It stays deliberately vague because only +The default is a flat **"Invalid input"** for most reasons. It stays deliberately vague because only you know the field's bounds — the component cannot say *which* dates would be accepted without inventing wording it has no basis for. +`out-of-order` is the exception, and gets a real default: it needs no knowledge of your bounds, only +of which endpoint was typed. + Override it with `errorMessages`, per reason. Anything left out keeps the default, so wording one reason does not mean restating the rest: @@ -345,6 +361,7 @@ The reason is also on the payload if you would rather branch on it yourself: | `unparseable` | The text is not a date the input could read at all | | `out-of-bounds` | A real date, outside `minDate` / `maxDate` | | `unavailable` | A real date in range that `isDateUnavailable` rejected | +| `out-of-order` | Range only — the endpoint crossed its partner | It fires only when validity *changes*, not on every keystroke, so it is safe to drive state with. diff --git a/apps/www/src/content/docs/components/calendar-preview/props.ts b/apps/www/src/content/docs/components/calendar-preview/props.ts index 2d6adcba0..01e70b025 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -242,17 +242,20 @@ export interface CalendarPreviewInputProps { */ onValidityChange?: (validity: { valid: boolean; - reason?: 'unparseable' | 'out-of-bounds' | 'unavailable'; + reason?: 'unparseable' | 'out-of-bounds' | 'unavailable' | 'out-of-order'; message?: string; }) => void; /** * Replaces the message for one or more reasons; anything left out keeps the * default. - * @default "Invalid input" for every reason + * @default "Invalid input", except out-of-order, which words itself */ errorMessages?: Partial< - Record<'unparseable' | 'out-of-bounds' | 'unavailable', string> + Record< + 'unparseable' | 'out-of-bounds' | 'unavailable' | 'out-of-order', + string + > >; /** Read and navigable, but not typeable. */ diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 452a2ac6d..df24b7e84 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -365,3 +365,115 @@ describe('CalendarPreview range parts that read the value', () => { expect(onValueChange).not.toHaveBeenCalled(); }); }); + +describe('CalendarPreview range order validation', () => { + const RANGE = { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) }; + + const inputs = (container: HTMLElement) => + getAllSlots(container, 'calendar-preview-input') as HTMLInputElement[]; + + const typeAndCommit = (input: HTMLInputElement, text: string) => { + fireEvent.focus(input); + fireEvent.change(input, { target: { value: text } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }; + + const picker = (onValidityChange?: (v: unknown) => void) => ( + + + + + ); + + it('rejects an end typed before the start, and emits nothing', () => { + const onValidityChange = vi.fn(); + const onValueChange = vi.fn(); + const { container } = renderRange( + { defaultValue: RANGE, onValueChange }, + picker(onValidityChange) + ); + const [start, end] = inputs(container); + typeAndCommit(end, '01/08/2026'); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'out-of-order', + message: 'End date cannot be before the start date' + }); + expect(onValueChange).not.toHaveBeenCalled(); + expect(start.value).toBe('10/08/2026'); + expect(end).toHaveAttribute('data-invalid'); + }); + + it('rejects a start typed after the end', () => { + const onValidityChange = vi.fn(); + const { container } = renderRange( + { defaultValue: RANGE }, + picker(onValidityChange) + ); + const [start] = inputs(container); + typeAndCommit(start, '25/08/2026'); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'out-of-order', + message: 'Start date cannot be after the end date' + }); + expect(start).toHaveAttribute('data-invalid'); + }); + + it('allows the two endpoints to be the same day', () => { + const onValueChange = vi.fn(); + const { container } = renderRange( + { defaultValue: RANGE, onValueChange }, + picker() + ); + const [, end] = inputs(container); + typeAndCommit(end, '10/08/2026'); + expect(onValueChange).toHaveBeenCalledWith( + { from: RANGE.from, to: RANGE.from }, + expect.objectContaining({ reason: 'input' }) + ); + }); + + it('checks a half-built range against its own start', () => { + const onValidityChange = vi.fn(); + const { container } = renderRange({}, picker(onValidityChange)); + const [start, end] = inputs(container); + typeAndCommit(start, '10/08/2026'); + typeAndCommit(end, '05/08/2026'); + expect(onValidityChange).toHaveBeenLastCalledWith( + expect.objectContaining({ reason: 'out-of-order' }) + ); + }); + + it('takes an errorMessages override for the new reason', () => { + const onValidityChange = vi.fn(); + const { container } = renderRange( + { defaultValue: RANGE }, + + + + + ); + const [, end] = inputs(container); + typeAndCommit(end, '01/08/2026'); + expect(onValidityChange).toHaveBeenLastCalledWith( + expect.objectContaining({ message: 'Pick a day after the start' }) + ); + }); + + /* The grid keeps its restart rule — only typing is strict. */ + it('still lets a grid click restart the range from an earlier day', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ defaultValue: RANGE, onValueChange }); + fireEvent.click(day(container, '5')); + expect(onValueChange).not.toHaveBeenCalled(); + expect(day(container, '5')).toHaveAttribute('data-selected'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 799fa7098..d31f94241 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -11,7 +11,8 @@ import { parseScaleInput } from './lib/parse'; export type CalendarPreviewInputInvalidReason = | 'unparseable' | 'out-of-bounds' - | 'unavailable'; + | 'unavailable' + | 'out-of-order'; export type CalendarPreviewInputValidity = { valid: boolean; @@ -45,6 +46,13 @@ export interface CalendarPreviewInputProps const DEFAULT_INVALID_MESSAGE = 'Invalid input'; +/* The one reason the component can word itself: it needs no knowledge of the + field's bounds, only of which endpoint was typed. */ +const DEFAULT_OUT_OF_ORDER: Record = { + start: 'Start date cannot be after the end date', + end: 'End date cannot be before the start date' +}; + const VALID: CalendarPreviewInputValidity = { valid: true }; /** @@ -113,7 +121,9 @@ export function CalendarPreviewInput({ ...validity, message: (validity.reason && errorMessages?.[validity.reason]) ?? - DEFAULT_INVALID_MESSAGE + (validity.reason === 'out-of-order' + ? DEFAULT_OUT_OF_ORDER[field] + : DEFAULT_INVALID_MESSAGE) }; const report = (candidate: CalendarPreviewInputValidity) => { @@ -146,6 +156,18 @@ export function CalendarPreviewInput({ return { valid: false, reason: 'out-of-bounds' }; } if (isDateUnavailable(date)) return { valid: false, reason: 'unavailable' }; + /* An endpoint also has to sit on the right side of its partner, which the + checks above cannot see — they read one date on its own. A grid click + restarts the range instead, on purpose: a click is the next endpoint, + but typing names the field it lands in. Equal days are a valid range. */ + const partner = field === 'start' ? draft?.to : draft?.from; + if (isRange && partner) { + const typed = dayKey(date, timeZone); + const against = dayKey(partner, timeZone); + if (field === 'start' ? typed > against : typed < against) { + return { valid: false, reason: 'out-of-order' }; + } + } return date; }; From ae1c90f0cd46058f44a00e6112b034f9ba544f71 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 04:50:53 +0530 Subject: [PATCH 5/7] feat(calendar-preview): let .Reset restore a range MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `defaultDate` sat in the shared props as `Date | null`, so it could not describe a range and `.Reset` had nothing to restore at range selection. It moves into the two selection arms instead, taking a day for single and a range for range, which is how `value`, `defaultValue` and `onValueChange` already work. No new prop: the same one now follows `selection`. `reset()` restores whichever shape it was given, reporting the range's start as the `occasion`, which is one day whatever the value is. It no longer stands down at range selection, and the shape guard that kept it from writing a bare Date into a range value is gone because the type now prevents that outright. `.Reset` compares by shape as well as by day. A day and a range are never the same default, and both edges have to match before the button counts as restored — a shared start with a different end still has something to restore. The Reset docs claimed the button disappears once there is nothing left to restore, which contradicted both the implementation and the part's own description a few sections above. It stays mounted and goes disabled, for the reasons the component already documents: unmounting the focused element strands a keyboard user, and dropping a child from the header shifts both nav buttons sideways every time the value crosses the default. Three demo tabs: a range reset under Reset, a single-day one on the picker, and one on the range example. All render a single month, because `.Days` only draws a `.Header` below two and `.Reset` lives inside it. --- .../docs/components/calendar-preview/demo.ts | 45 +++++++++++++++++++ .../components/calendar-preview/index.mdx | 11 ++++- .../docs/components/calendar-preview/props.ts | 5 ++- .../calendar-preview/__tests__/range.test.tsx | 35 +++++++++++++-- .../calendar-preview-context.tsx | 2 +- .../calendar-preview-reset.tsx | 21 ++++++--- .../calendar-preview-root.tsx | 36 ++++++++------- 7 files changed, 126 insertions(+), 29 deletions(-) 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 91ac344ac..d7a2fe5ff 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -145,6 +145,17 @@ export const resetDemo = { ` }, + { + name: 'Range', + code: ` + + ` + }, { name: 'Clear the selection', code: ` ` }, + { + name: 'Reset', + code: ` + + + + + + + ` + }, { name: 'Invalid input', code: ` @@ -505,6 +531,25 @@ export const rangeDemo = { ` }, + { + name: 'Reset', + code: ` + + + + + + + + + + ` + }, { name: 'Invalid input', code: ` 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 5061405cb..2199cff04 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -213,7 +213,16 @@ Each part renders a default; children replace it. ### Reset -`.Reset` restores `defaultDate` and **leaves the visible month alone** — it is a value reset, not a view reset. It renders only when `defaultDate` is set *and* the current value differs from it, so the button disappears once there is nothing to restore. +`.Reset` restores `defaultDate` and **leaves the visible month alone** — it is a value reset, not a view reset. It renders whenever `defaultDate` is set, and goes disabled once there is nothing left to restore rather than unmounting: removing the focused element would strand a keyboard user, and dropping a child from the header would shift both nav buttons sideways every time the value crossed the default. + +`defaultDate` follows the selection. At `selection="range"` it takes a range, and both edges have to match before the button counts as restored: + +```tsx + +``` `defaultDate` is a separate prop from `defaultValue` because `defaultValue` is ignored once `value` is passed. Keying the reset off its own prop is what makes it work for a controlled calendar. diff --git a/apps/www/src/content/docs/components/calendar-preview/props.ts b/apps/www/src/content/docs/components/calendar-preview/props.ts index 01e70b025..57fdcdda5 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -57,9 +57,10 @@ export interface CalendarPreviewProps { /** * The day `.Reset` restores. Read even when `value` is controlled, which * `defaultValue` is not. `null` is a default of nothing selected, so - * `.Reset` clears; omitting the prop renders no button at all. + * `.Reset` clears; omitting the prop renders no button at all. Takes a + * range at `selection="range"`. */ - defaultDate?: Date | null; + defaultDate?: Date | { from: Date; to: Date } | null; /** * The zone the grid reads days in. Forwarded to the grid; the component does diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index df24b7e84..7255d864f 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -276,12 +276,41 @@ describe('CalendarPreview range parts that read the value', () => { ).not.toThrow(); }); - it('hides .Reset at range selection, where a single-day default cannot restore', () => { + it('restores a range defaultDate, and disables itself once restored', () => { + const onValueChange = vi.fn(); + const RESTORED = { from: new Date(2026, 7, 3), to: new Date(2026, 7, 7) }; + const { container } = renderRange({ + defaultValue: RANGE, + defaultDate: RESTORED, + onValueChange + }); + const reset = getSlot(container, 'calendar-preview-reset') as HTMLElement; + expect(reset).not.toBeNull(); + expect(reset).not.toBeDisabled(); + fireEvent.click(reset); + expect(onValueChange).toHaveBeenCalledWith( + RESTORED, + expect.objectContaining({ reason: 'reset' }) + ); + }); + + it('starts restored when the value already equals the range default', () => { + const { container } = renderRange({ + defaultValue: RANGE, + defaultDate: RANGE + }); + const reset = getSlot(container, 'calendar-preview-reset') as HTMLElement; + expect(reset).toBeDisabled(); + expect(reset).toHaveAttribute('data-restored'); + }); + + /* Both edges have to match — a shared start is not a restored range. */ + it('is not restored when only one edge matches the default', () => { const { container } = renderRange({ defaultValue: RANGE, - defaultDate: RANGE.from + defaultDate: { from: RANGE.from, to: new Date(2026, 7, 25) } }); - expect(getSlot(container, 'calendar-preview-reset')).toBeNull(); + expect(getSlot(container, 'calendar-preview-reset')).not.toBeDisabled(); }); /* Clearing is shape-agnostic, so a `null` default keeps working. */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 1951bd43f..4e6684607 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -73,7 +73,7 @@ export interface CalendarPreviewContextValue { */ shouldIgnoreFocusOpen: () => boolean; /** Read even when `value` is controlled. */ - defaultDate: Date | null | undefined; + defaultDate: Date | CalendarPreviewDateRange | null | undefined; /** A value reset — it never moves the view. */ reset: () => void; month: Date; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx index 4ce9d4896..7c79a1364 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -6,12 +6,14 @@ import { UndoIcon } from '~/icons'; import { IconButton } from '../icon-button'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; +import type { CalendarPreviewValue } from './calendar-preview-root'; import { dayKey } from './date-adapter'; export type CalendarPreviewResetProps = ComponentProps; /** - * Restores `defaultDate`, or clears the selection when it is `null`. A value + * Restores `defaultDate` — a day, or a range at range selection — or clears + * the selection when it is `null`. A value * reset, not a view reset — it leaves the * visible month alone. Keyed off `defaultDate` rather than `defaultValue` so * it still shows under a controlled `value`. @@ -28,22 +30,27 @@ export function CalendarPreviewReset({ onClick, ...props }: CalendarPreviewResetProps) { - const { value, defaultDate, reset, disabled, readOnly, timeZone, selection } = - useCalendarPreviewContext('CalendarPreview.Reset'); + const { value, defaultDate, reset, disabled, readOnly, timeZone } = + useCalendarPreviewContext('CalendarPreview.Reset'); /* No `defaultDate` means the part has no job at all, which is a different thing from having nothing to restore right now — `null` is a default. */ if (defaultDate === undefined) return null; - /* A single-day default cannot describe a range, and comparing the two shapes - below would format the range as a date and throw. `null` still clears. */ - if (selection === 'range' && defaultDate !== null) return null; + const sameDay = (a: Date, b: Date) => + dayKey(a, timeZone) === dayKey(b, timeZone); + /* Compared by shape as well as by day: a range and a day are never the same + default, and only both edges matching counts as restored. */ const restored = defaultDate === null ? value == null : value != null && - dayKey(value, timeZone) === dayKey(defaultDate, timeZone); + (defaultDate instanceof Date + ? value instanceof Date && sameDay(value, defaultDate) + : !(value instanceof Date) && + sameDay(value.from, defaultDate.from) && + sameDay(value.to, defaultDate.to)); return ( void; + /** + * The day `.Reset` restores. Read even when `value` is controlled, which + * `defaultValue` is not — otherwise a controlled consumer never sees + * `.Reset`. + * + * `null` is a default of *nothing selected*, so `.Reset` clears. Omitting + * the prop is different: the part then has no job and does not render. + */ + defaultDate?: Date | null; } interface CalendarPreviewRangeProps { @@ -79,6 +88,15 @@ interface CalendarPreviewRangeProps { value: CalendarPreviewDateRange | null, details: CalendarPreviewChangeDetails ) => void; + /** + * The range `.Reset` restores. Read even when `value` is controlled, which + * `defaultValue` is not — otherwise a controlled consumer never sees + * `.Reset`. + * + * `null` is a default of *nothing selected*, so `.Reset` clears. Omitting + * the prop is different: the part then has no job and does not render. + */ + defaultDate?: CalendarPreviewDateRange | null; } export type CalendarPreviewProps = ( @@ -121,16 +139,6 @@ interface CalendarPreviewSharedProps /** Reject individual days. Applied on top of `minDate` / `maxDate`. */ isDateUnavailable?: (date: Date) => boolean; - /** - * The day `.Reset` restores. Read even when `value` is controlled, which - * `defaultValue` is not — otherwise a controlled consumer never sees - * `.Reset`. - * - * `null` is a default of *nothing selected*, so `.Reset` clears. Omitting - * the prop is different: the part then has no job and does not render. - */ - defaultDate?: Date | null; - /** * Renders a value for display. * @defaultValue `DD/MM/YYYY` at day scale @@ -451,11 +459,9 @@ export function CalendarPreviewRoot({ setValue(null, 'clear', monthAnchor(value) ?? today); return; } - /* No range-shaped default exists, so restoring one would hand a range - consumer a bare `Date`. `.Reset` hides itself for the same reason. */ - if (selection === 'range') return; - setValue(defaultDate, 'reset', defaultDate); - }, [defaultDate, value, setValue, today, selection]); + /* `occasion` is one day, so a restored range reports the day it starts on. */ + setValue(defaultDate, 'reset', monthAnchor(defaultDate) ?? today); + }, [defaultDate, value, setValue, today]); /* Day-keys, not instants: a `minDate` carrying a time of day still leaves its own day selectable, which the current family gets wrong. */ From 3db76c2d7e956709d7ec8b29207cce1bb1bc20f4 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 04:54:44 +0530 Subject: [PATCH 6/7] docs(calendar-preview): trim the comments added alongside the range work Net fifteen lines out, all of them from comments added in this branch rather than from the ones the picker and the range machine already carried. Splitting `defaultDate` across the two selection arms copied an eight-line block verbatim, where only the word day or range differs. Both are four lines now. The rest were repeats or restatements. The reason a click and a typed date mean different things was written out three times, on `setEndpoint` where it belongs and again at both call sites; `reset()` explained twice in adjacent lines that `occasion` is a single day. A comment saying `monthAnchor` takes `undefined` sat above a signature that says so, one describing the draft fallback sat above the expression that does it, and one in `useCalendar` described the shape the value used to be typed as, which belongs in history rather than in the source. What stays is what the code cannot say on its own: why typing rejects an endpoint the grid would have accepted, why `out-of-order` words itself when the other reasons deliberately do not, why equal days are a range, and why both edges have to match before `.Reset` counts as restored. --- .../calendar-preview-input.tsx | 11 +++----- .../calendar-preview-reset.tsx | 3 +- .../calendar-preview-root.tsx | 28 ++++++------------- .../calendar-preview/use-calendar.tsx | 3 +- 4 files changed, 15 insertions(+), 30 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index d31f94241..ee4d338b5 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -47,7 +47,7 @@ export interface CalendarPreviewInputProps const DEFAULT_INVALID_MESSAGE = 'Invalid input'; /* The one reason the component can word itself: it needs no knowledge of the - field's bounds, only of which endpoint was typed. */ + field's bounds. */ const DEFAULT_OUT_OF_ORDER: Record = { start: 'Start date cannot be after the end date', end: 'End date cannot be before the start date' @@ -156,10 +156,9 @@ export function CalendarPreviewInput({ return { valid: false, reason: 'out-of-bounds' }; } if (isDateUnavailable(date)) return { valid: false, reason: 'unavailable' }; - /* An endpoint also has to sit on the right side of its partner, which the - checks above cannot see — they read one date on its own. A grid click - restarts the range instead, on purpose: a click is the next endpoint, - but typing names the field it lands in. Equal days are a valid range. */ + /* The checks above read one date on its own and cannot see the partner. A + grid click restarts instead of rejecting, on purpose. Equal days are a + valid range. */ const partner = field === 'start' ? draft?.to : draft?.from; if (isRange && partner) { const typed = dayKey(date, timeZone); @@ -182,8 +181,6 @@ export function CalendarPreviewInput({ } const resolved = resolve(trimmed); if (!(resolved instanceof Date)) return; - /* Addressed to this field, not to "the next endpoint" — a click means the - latter, but typing into the end of a settled range means the former. */ if (isRange) setEndpoint(field, resolved); else setValue(resolved, 'input', resolved); setText(null); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx index 7c79a1364..6a3e0d221 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -40,8 +40,7 @@ export function CalendarPreviewReset({ const sameDay = (a: Date, b: Date) => dayKey(a, timeZone) === dayKey(b, timeZone); - /* Compared by shape as well as by day: a range and a day are never the same - default, and only both edges matching counts as restored. */ + /* Both edges have to match: a shared start is not a restored range. */ const restored = defaultDate === null ? value == null diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 886c173b3..533a91be1 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -37,8 +37,7 @@ 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. - Takes `undefined` so an unset `value` prop can be read straight through. */ +/* The day the view should open on, whichever selection shape the value is. */ function monthAnchor( value: CalendarPreviewValue | undefined ): Date | undefined { @@ -64,12 +63,9 @@ interface CalendarPreviewSingleProps { details: CalendarPreviewChangeDetails ) => void; /** - * The day `.Reset` restores. Read even when `value` is controlled, which - * `defaultValue` is not — otherwise a controlled consumer never sees - * `.Reset`. - * - * `null` is a default of *nothing selected*, so `.Reset` clears. Omitting - * the prop is different: the part then has no job and does not render. + * The day `.Reset` restores, read even when `value` is controlled — which + * `defaultValue` is not. `null` is a default of *nothing selected*, so + * `.Reset` clears; omitting it renders no button at all. */ defaultDate?: Date | null; } @@ -89,12 +85,9 @@ interface CalendarPreviewRangeProps { details: CalendarPreviewChangeDetails ) => void; /** - * The range `.Reset` restores. Read even when `value` is controlled, which - * `defaultValue` is not — otherwise a controlled consumer never sees - * `.Reset`. - * - * `null` is a default of *nothing selected*, so `.Reset` clears. Omitting - * the prop is different: the part then has no job and does not render. + * The range `.Reset` restores, read even when `value` is controlled — which + * `defaultValue` is not. `null` is a default of *nothing selected*, so + * `.Reset` clears; omitting it renders no button at all. */ defaultDate?: CalendarPreviewDateRange | null; } @@ -422,8 +415,7 @@ export function CalendarPreviewRoot({ ); /* A click means "the next endpoint"; typing into a field means that field, - so a typed date cannot go through `selectDay`. Falls back to the committed - value when there is no draft, so editing one edge keeps the other. */ + so a typed date cannot go through `selectDay`. */ const setEndpoint = useCallback( (field: CalendarPreviewField, date: Date) => { if (readOnly || disabled || fieldReadOnly[field]) return; @@ -454,12 +446,10 @@ export function CalendarPreviewRoot({ claim a day was restored when none was. */ if (defaultDate === null) { if (value == null) return; - /* A range reports the day it started on — `occasion` is a single day, - and the start is the endpoint the view was anchored to. */ setValue(null, 'clear', monthAnchor(value) ?? today); return; } - /* `occasion` is one day, so a restored range reports the day it starts on. */ + /* `occasion` is one day, so a range reports the day it starts on. */ setValue(defaultDate, 'reset', monthAnchor(defaultDate) ?? today); }, [defaultDate, value, setValue, today]); diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx index be8fbc4cf..c9b5ea89f 100644 --- a/packages/raystack/components/calendar-preview/use-calendar.tsx +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -5,8 +5,7 @@ import type { CalendarPreviewValue } from './calendar-preview-root'; import type { CalendarPreviewScale } from './lib/scale'; export interface UseCalendarReturn { - /* Holds a range at `selection='range'`; it was typed `Date | null` while - already carrying one. */ + /* Holds a range at `selection='range'`. */ value: CalendarPreviewValue; /** Commit a day or a range, or clear with `null`. Emits `onValueChange`. */ setValue: (value: CalendarPreviewValue) => void; From 98bebab70d8f3c9e99f7b6e8943ac48f8b262c53 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 05:02:18 +0530 Subject: [PATCH 7/7] fix(calendar-preview): re-export the remaining types from the root barrel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component barrel exported these; the root one had never had them copied across, so a consumer could pass `onValidityChange` or `onOpenChange` a handler but could not name the type of its argument. Six move up: the input's props, validity and invalid-reason types, the trigger and content props, and the open-change details. The two barrels now match exactly, so the gap closes rather than shrinking by whichever names came up. Types only. Nothing about runtime, the docs site or the rendered prop tables changes — those are generated from the docs' own props.ts, not from here. --- packages/raystack/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 98e22d553..c747b1d79 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -25,6 +25,7 @@ export { type CalendarPreviewCaptionProps, type CalendarPreviewChangeDetails, type CalendarPreviewChangeReason, + type CalendarPreviewContentProps, type CalendarPreviewDateRange, type CalendarPreviewDayProps, type CalendarPreviewDaysProps, @@ -33,11 +34,16 @@ export { type CalendarPreviewFooterProps, type CalendarPreviewGridProps, type CalendarPreviewHeaderProps, + type CalendarPreviewInputInvalidReason, + type CalendarPreviewInputProps, + type CalendarPreviewInputValidity, type CalendarPreviewNavProps, + type CalendarPreviewOpenChangeDetails, type CalendarPreviewProps, type CalendarPreviewResetProps, type CalendarPreviewScale, type CalendarPreviewScaleValue, + type CalendarPreviewTriggerProps, type CalendarPreviewWeekdayProps, type UseCalendarReturn, useCalendar