From 29ae7fbe05cd07f77b079e0970eb53be2db3047d Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 16:52:17 +0530 Subject: [PATCH 01/52] feat: CalendarPreview scale-aware selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 5 of 7. `scales` and `trailingValue` on the root, and eight parts: `.Picker`, `.Label`, `.Scales`, `.Scale`, `.Separator`, `.Panel`, and the four period views. This is the surface that forced the value contract. A `Date` cannot say whether it means "August 2026" or "1 August 2026", so beyond day scale the value is a `ScaleValue` — `{ date: 'YYYY-MM-DD', scale }` — and the scale travels with it rather than with a prop. Every date computation goes through `lib/scale.ts`: `periodOf`, `anchorOf`, `convertScale` and `isAvailable`. Nothing here does period maths, and no component imports date-fns. Availability tests the date a period would PRODUCE, not the period, so the same period answers differently at each end of a pair. With a bound of 15 July 2026, Q3 2026 is disabled for a start field (emits 1 July) and available for an end field (emits 30 September). That is the RFC's table, and it is the fixture. A scale switch moves the view and sets a draft; it emits nothing. A cell click or Enter commits. Escape drops the draft AND restores the scale the value carries — without that the input still reads "Q3 2026" for a day value, which the test caught. `.Days` becomes a sibling view that gates on the day scale, the way the four period views do, so `.Panel` can mount all five and a consumer can mount `.Quarters` alone. That is a behaviour change for `.Days` and is why the day-only default matters: at `scales='day'` the scale is always 'day', so an inline calendar is unaffected. The period lists are one scrolling column with year headings inside it, and open scrolled to the active year — a twenty-year list otherwise opens on 2016, which the tests found first. Open Item 1, the `scales` discriminator: TypeScript cannot test an array's contents, so the arms discriminate on the SHAPE of `scales`. Omitted or the literal 'day' keeps `Date`; any other scale, or any array, moves to `ScaleValue`. The wart is that `scales={['day']}` takes the scale-aware arm where `scales='day'` does not. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/calendar-preview.test.tsx | 12 +- .../__tests__/scale-selection.test.tsx | 291 ++++++++++++++++++ .../calendar-preview-context.tsx | 18 ++ .../calendar-preview-days.tsx | 4 + .../calendar-preview-input.tsx | 51 ++- .../calendar-preview-label.tsx | 33 ++ .../calendar-preview-panel.tsx | 53 ++++ .../calendar-preview-periods.tsx | 230 ++++++++++++++ .../calendar-preview-picker.tsx | 56 ++++ .../calendar-preview-root.tsx | 146 ++++++++- .../calendar-preview-scales.tsx | 110 +++++++ .../calendar-preview-separator.tsx | 28 ++ .../calendar-preview.module.css | 87 ++++++ .../calendar-preview/calendar-preview.tsx | 24 ++ 14 files changed, 1119 insertions(+), 24 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-label.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-panel.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-periods.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-picker.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-scales.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-separator.tsx 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 57d2942d4..9654cc392 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -916,6 +916,7 @@ describe('CalendarPreview public surface', () => { [ 'Caption', 'Content', + 'HalfYears', 'Day', 'Days', 'Footer', @@ -924,9 +925,18 @@ describe('CalendarPreview public surface', () => { 'NextMonth', 'PrevMonth', 'Input', + 'Label', + 'Months', + 'Panel', + 'Picker', + 'Quarters', 'Reset', + 'Scale', + 'Scales', + 'Separator', 'Trigger', - 'Weekday' + 'Weekday', + 'Years' ].sort() ); }); diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx new file mode 100644 index 000000000..0e6c79a68 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -0,0 +1,291 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import type { Scale } from '../lib/scale'; + +const TODAY = new Date(2026, 7, 15); +const ALL: Scale[] = ['day', 'month', 'quarter', 'halfYear', 'year']; + +function renderPicker(props = {}) { + return render( + + + + ); +} + +/* The list runs across every year in `yearRange`, so a label alone is + ambiguous — "Aug" exists once per year. */ +const period = (container: HTMLElement, label: string, year = 2026) => { + const group = getAllSlots(container, 'calendar-preview-period-group').find( + node => + getSlot(node, 'calendar-preview-period-year')?.textContent === + String(year) + ); + if (!group) throw new Error(`no year group ${year}`); + const match = getAllSlots(group, 'calendar-preview-period').find( + cell => cell.textContent === label + ); + if (!match) throw new Error(`no period cell ${label} in ${year}`); + return match; +}; + +const switchTo = (container: HTMLElement, scale: Scale) => { + const chip = getAllSlots(container, 'calendar-preview-scale').find( + node => node.getAttribute('data-scale') === scale + ); + fireEvent.click(chip as HTMLElement); +}; + +describe('CalendarPreview scale switching', () => { + it('emits nothing on a scale switch — it only drafts', () => { + const onValueChange = vi.fn(); + const { container } = renderPicker({ onValueChange }); + switchTo(container, 'quarter'); + expect(onValueChange).not.toHaveBeenCalled(); + switchTo(container, 'year'); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('emits once a period is picked', () => { + const onValueChange = vi.fn(); + const { container } = renderPicker({ onValueChange }); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q3')); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-07-01', + scale: 'quarter' + }); + }); + + it('reports the scale it moved to', () => { + const onScaleChange = vi.fn(); + const { container } = renderPicker({ onScaleChange }); + switchTo(container, 'month'); + expect(onScaleChange).toHaveBeenCalledWith('month'); + }); +}); + +describe('CalendarPreview trailingValue', () => { + /* The value itself changes, not the formatting — a start field emits the + period's first day and an end field its last. */ + it.each([ + ['month', 'Aug', '2026-08-01', '2026-08-31'], + ['quarter', 'Q3', '2026-07-01', '2026-09-30'], + ['halfYear', 'H2', '2026-07-01', '2026-12-31'], + ['year', '2026', '2026-01-01', '2026-12-31'] + ] as const)('flips the emitted edge for %s', (scale, label, lead, trail) => { + for (const [trailing, expected] of [ + [false, lead], + [true, trail] + ] as const) { + const onValueChange = vi.fn(); + const { container, unmount } = renderPicker({ + onValueChange, + trailingValue: trailing + }); + switchTo(container, scale); + fireEvent.click(period(container, label)); + expect(onValueChange.mock.calls[0][0]).toEqual({ date: expected, scale }); + unmount(); + } + }); + + it('is month-end correct in a leap February', () => { + const onValueChange = vi.fn(); + const { container } = renderPicker({ + onValueChange, + trailingValue: true, + today: new Date(2028, 1, 10), + yearRange: { from: 2028, to: 2028 } + }); + switchTo(container, 'month'); + fireEvent.click(period(container, 'Feb', 2028)); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2028-02-29', + scale: 'month' + }); + }); +}); + +/* The RFC's table: an end field bounded at 15 July 2026 disables H1 2026, + which would emit 30 June, while allowing July and Q3, which emit later. The + same periods are all available to a start field. */ +describe('CalendarPreview availability differs by field', () => { + const bounded = { minDate: new Date(2026, 6, 15), today: TODAY }; + + /* The same period, opposite answers: Q3 2026 starts 1 July — before the + bound — but ends 30 September, after it. Only the produced date separates + them, which is the whole reason availability takes `trailing`. */ + it.each([ + ['quarter', 'Q3'], + ['month', 'Jul'] + ] as const)('disables %s for a start field and allows it for an end field', (scale, label) => { + const start = renderPicker({ ...bounded, trailingValue: false }); + switchTo(start.container, scale); + expect(period(start.container, label)).toBeDisabled(); + start.unmount(); + + const end = renderPicker({ ...bounded, trailingValue: true }); + switchTo(end.container, scale); + expect(period(end.container, label)).not.toBeDisabled(); + }); + + it('disables H1 2026 for an end field, which would emit 30 June', () => { + const { container } = renderPicker({ ...bounded, trailingValue: true }); + switchTo(container, 'halfYear'); + expect(period(container, 'H1')).toBeDisabled(); + expect(period(container, 'H2')).not.toBeDisabled(); + }); + + it('allows July and Q3 for an end field, because they emit after the bound', () => { + const { container } = renderPicker({ ...bounded, trailingValue: true }); + switchTo(container, 'month'); + expect(period(container, 'Jul')).not.toBeDisabled(); + switchTo(container, 'quarter'); + expect(period(container, 'Q3')).not.toBeDisabled(); + }); + + it('shows out-of-bounds periods rather than hiding them', () => { + const { container } = renderPicker({ + maxDate: new Date(2026, 7, 31), + today: TODAY + }); + switchTo(container, 'month'); + expect(period(container, 'Dec')).toBeInTheDocument(); + expect(period(container, 'Dec')).toBeDisabled(); + }); +}); + +describe('CalendarPreview.Scales', () => { + it('renders nothing when only one scale is offered', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-scales')).toBeNull(); + }); + + it('renders one chip per offered scale', () => { + const { container } = renderPicker(); + expect(getAllSlots(container, 'calendar-preview-scale')).toHaveLength(5); + }); +}); + +describe('CalendarPreview period views mount alone', () => { + it.each([ + ['quarter', CalendarPreview.Quarters, 'calendar-preview-quarters'], + ['month', CalendarPreview.Months, 'calendar-preview-months'], + ['halfYear', CalendarPreview.HalfYears, 'calendar-preview-half-years'], + ['year', CalendarPreview.Years, 'calendar-preview-years'] + ] as const)('%s renders with no other view in the tree', (scale, View, slot) => { + const { container } = render( + + + + ); + expect(getSlot(container, slot)).toBeInTheDocument(); + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + }); + + it('gates on the active scale, so the others stay unmounted', () => { + const { container } = renderPicker({ defaultScale: 'quarter' }); + expect(getSlot(container, 'calendar-preview-quarters')).toBeInTheDocument(); + expect(getSlot(container, 'calendar-preview-months')).toBeNull(); + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + }); +}); + +/* DataView cells and FilterChip labels render the annotation with no calendar + anywhere in the tree. */ +describe('CalendarPreview.Trigger annotation', () => { + it.each([ + ['day', '2026-07-02', '02/07/2026'], + ['month', '2026-06-01', 'Jun 2026'], + ['quarter', '2026-07-01', 'Q3 2026'], + ['halfYear', '2026-01-01', 'H1 2026'], + ['year', '2025-01-01', '2025'] + ] as const)('formats %s with no popover open', (scale, date, expected) => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent( + expected + ); + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); + + it('shows the empty state when there is no value', () => { + render( + + + + ); + expect(screen.getByText('Add start date')).toBeInTheDocument(); + }); +}); + +describe('CalendarPreview.Input at scale', () => { + const input = (container: HTMLElement) => + getSlot(container, 'calendar-preview-input') as HTMLInputElement; + + it('advertises the formats it accepts', () => { + const { container } = renderPicker(); + expect(input(container)).toHaveAttribute( + 'placeholder', + 'Try: May 2027, Q4, 20/05/2027' + ); + }); + + it('moves the scale to match what was typed', () => { + const onValueChange = vi.fn(); + const onScaleChange = vi.fn(); + const { container } = renderPicker({ onValueChange, onScaleChange }); + fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-10-01', + scale: 'quarter' + }); + }); + + it('refuses a scale this root does not offer', () => { + const { container } = render( + + + + ); + fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); + expect(getSlot(container, 'calendar-preview-input')).toHaveAttribute( + 'aria-invalid' + ); + }); + + it('drops the draft on Escape and falls back to the value', () => { + const { container } = renderPicker({ + value: { date: '2026-08-20', scale: 'day' } + }); + expect(input(container).value).toBe('20/08/2026'); + + switchTo(container, 'quarter'); + expect(input(container).value).toBe('Q3 2026'); + + fireEvent.keyDown( + getSlot(container, 'calendar-preview-picker') as HTMLElement, + { + key: 'Escape' + } + ); + expect(input(container).value).toBe('20/08/2026'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index e41d26e95..4f54a89ab 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -89,6 +89,24 @@ export interface CalendarPreviewContextValue { readOnly: boolean; formatValue: (value: Date | ScaleValue, scale: Scale) => string; + /** Every scale the switcher offers. One entry hides `.Scales`. */ + scales: readonly Scale[]; + /** Whether a period emits its last day rather than its first. */ + trailingValue: boolean; + /** + * The pending value after a scale switch or a keystroke. Never emitted — a + * cell click or Enter commits it, Escape drops it. + */ + scaleDraft: ScaleValue | null; + /** Moves the view and sets the draft. Emits nothing. */ + switchScale: (scale: Scale) => void; + /** Commits a period at `scale`, honouring `trailingValue`. */ + selectPeriod: (date: Date | string, scale: Scale) => void; + /** Drops the draft; the input falls back to `value`. */ + dropDraft: () => void; + /** Whether the period containing `date` can be selected at `scale`. */ + isPeriodAvailable: (date: Date | string, scale: Scale) => boolean; + selection: 'single' | 'range'; /** * Commits a clicked day. Single scale commits it directly; range runs the diff --git a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx index 8d8664ccf..0fd0bbae3 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -66,6 +66,10 @@ export function CalendarPreviewDays({ ) }); + /* A sibling of the period views, gating the same way, so `.Panel` can mount + all five and only the active one renders. */ + if (scale !== 'day') return null; + return ( {element} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index cc5c40eb6..32514c0b9 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -7,6 +7,7 @@ import type { CalendarPreviewField } from './calendar-preview-context'; import { useCalendarPreviewContext } from './calendar-preview-context'; import { dayKey, parseKey } from './date-adapter'; import { parseScaleInput } from './lib/parse'; +import type { Scale } from './lib/scale'; export type CalendarPreviewInputValidity = { valid: boolean; @@ -58,6 +59,10 @@ export function CalendarPreviewInput({ today, disabled, readOnly, + scales, + scaleDraft, + selectPeriod, + isPeriodAvailable, selection, selectDay, draft, @@ -91,15 +96,23 @@ export function CalendarPreviewInput({ onValidityChange?.(next); }; - const resolve = (text: string): CalendarPreviewInputValidity | Date => { + /* Only the scales this root offers: typing "Q4" into a day-only field is not + a quarter, it is a typo. */ + const resolve = ( + text: string + ): CalendarPreviewInputValidity | { date: Date; scale: Scale } => { const parsed = parseScaleInput(text); - /* Coarser scales parse today but have nowhere to go until the scale - switcher lands, so they read as unparseable rather than committing a day - the user did not type. */ - if (!parsed || parsed.scale !== 'day') { + if (!parsed || !scales.includes(parsed.scale)) { return { valid: false, reason: 'unparseable' }; } const date = parseKey(parsed.date); + + if (parsed.scale !== 'day') { + return isPeriodAvailable(date, parsed.scale) + ? { date, scale: parsed.scale } + : { valid: false, reason: 'out-of-bounds' }; + } + const key = dayKey(date, timeZone); if ( (minDate && key < dayKey(minDate, timeZone)) || @@ -108,7 +121,7 @@ export function CalendarPreviewInput({ return { valid: false, reason: 'out-of-bounds' }; } if (isDateUnavailable(date)) return { valid: false, reason: 'unavailable' }; - return date; + return { date, scale: 'day' }; }; const commit = () => { @@ -121,11 +134,13 @@ export function CalendarPreviewInput({ return; } const resolved = resolve(trimmed); - if (!(resolved instanceof Date)) return; + if ('valid' in resolved) 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); + if (isRange) selectDay(resolved.date); + else if (resolved.scale !== 'day') + selectPeriod(resolved.date, resolved.scale); + else setValue(resolved.date, 'input', resolved.date); setText(null); report(VALID); }; @@ -134,15 +149,19 @@ export function CalendarPreviewInput({ const endpoint = isRange ? ((field === 'start' ? draft?.from : draft?.to) ?? null) - : (value as Date | null); + : (scaleDraft ?? (value as Date | null)); const committedText = endpoint ? formatValue(endpoint, scale) : ''; + /* A multi-scale field has to advertise what it accepts; a day-only one does + not, and the old placeholder still reads correctly there. */ const resolvedPlaceholder = placeholder ?? - (isRange - ? field === 'start' - ? 'Select start date' - : 'Select end date' - : 'Select date'); + (scales.length > 1 + ? 'Try: May 2027, Q4, 20/05/2027' + : isRange + ? field === 'start' + ? 'Select start date' + : 'Select end date' + : 'Select date'); return ( { onKeyDown?.(event); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-label.tsx b/packages/raystack/components/calendar-preview/calendar-preview-label.tsx new file mode 100644 index 000000000..f0182e893 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-label.tsx @@ -0,0 +1,33 @@ +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; + +export type CalendarPreviewLabelProps = useRender.ComponentProps<'span'>; + +export function CalendarPreviewLabel({ + className, + children, + render, + ref, + ...props +}: CalendarPreviewLabelProps) { + const { scale } = useCalendarPreviewContext('CalendarPreview.Label'); + + return useRender({ + defaultTagName: 'span', + ref, + render, + props: mergeProps<'span'>( + { + className: cx(styles.label, className), + 'data-slot': 'calendar-preview-label', + 'data-scale': scale, + children: children ?? 'Date' + } as useRender.ComponentProps<'span'>, + props + ) + }); +} + +CalendarPreviewLabel.displayName = 'CalendarPreview.Label'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx new file mode 100644 index 000000000..73d9c4238 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx @@ -0,0 +1,53 @@ +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { CalendarPreviewDays } from './calendar-preview-days'; +import { + CalendarPreviewHalfYears, + CalendarPreviewMonths, + CalendarPreviewQuarters, + CalendarPreviewYears +} from './calendar-preview-periods'; + +export type CalendarPreviewPanelProps = useRender.ComponentProps<'div'>; + +/** + * The view container. Mounts all five views when childless; each one gates on + * the active scale itself, so a consumer can mount `.Quarters` alone with no + * day grid in the tree. + */ +export function CalendarPreviewPanel({ + className, + children, + render, + ref, + ...props +}: CalendarPreviewPanelProps) { + const { scale } = useCalendarPreviewContext('CalendarPreview.Panel'); + + return useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.panel, className), + 'data-slot': 'calendar-preview-panel', + 'data-scale': scale, + children: children ?? ( + <> + + + + + + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); +} + +CalendarPreviewPanel.displayName = 'CalendarPreview.Panel'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx new file mode 100644 index 000000000..0baa34a65 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -0,0 +1,230 @@ +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { useEffect, useMemo, useRef } from 'react'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey, monthShortNames, monthStart, yearOf } from './date-adapter'; +import { anchorOf, periodOf, type Scale } from './lib/scale'; + +export type CalendarPreviewPeriodViewProps = useRender.ComponentProps<'div'>; + +interface Cell { + key: string; + label: string; + /** The day this cell stands for, before `trailingValue` is applied. */ + date: Date; +} + +const MONTHS = monthShortNames(); + +function cellsFor(scale: Scale, year: number): Cell[] { + if (scale === 'month') { + return MONTHS.map((label, index) => ({ + key: `${year}-${index}`, + label, + date: monthStart(year, index) + })); + } + if (scale === 'quarter') { + return [0, 1, 2, 3].map(q => ({ + key: `${year}-q${q}`, + label: `Q${q + 1}`, + date: monthStart(year, q * 3) + })); + } + if (scale === 'halfYear') { + return [0, 1].map(h => ({ + key: `${year}-h${h}`, + label: `H${h + 1}`, + date: monthStart(year, h * 6) + })); + } + return [{ key: `${year}`, label: String(year), date: monthStart(year, 0) }]; +} + +/** + * One scale's period list. + * + * Every year is a heading inside a single scrolling column rather than a page + * of its own, so the whole list scrolls past the bounds — periods outside them + * render disabled rather than being cut off. + */ +function PeriodView({ + scale: viewScale, + columns, + slot, + className, + children, + render, + ref, + ...props +}: CalendarPreviewPeriodViewProps & { + scale: Scale; + columns: number; + slot: string; +}) { + const { + scale, + scaleDraft, + value, + yearRange, + selectPeriod, + isPeriodAvailable, + trailingValue, + today, + timeZone, + disabled, + readOnly + } = useCalendarPreviewContext('CalendarPreview.Periods'); + + const years = useMemo(() => { + const list: number[] = []; + for (let y = yearRange.from; y <= yearRange.to; y += 1) list.push(y); + return list; + }, [yearRange]); + + /* Compared as day-keys so a re-rendered Date never counts as a change. The + draft wins: it is what the user is looking at after a scale switch. */ + const activeYear = yearOf( + scaleDraft?.date ?? + (value && !(value instanceof Date) && 'date' in value + ? (value as { date: string }).date + : dayKey(today, timeZone)) + ); + + const selectedKey = + scaleDraft?.date ?? + (value && !(value instanceof Date) && 'date' in value + ? (value as { date: string }).date + : null); + + /* A twenty-year list otherwise opens on its first year. Optional-called + because jsdom does not implement scrollIntoView. */ + const activeRef = useRef(null); + useEffect(() => { + activeRef.current?.scrollIntoView?.({ block: 'start' }); + }, []); + + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.periods, className), + 'data-slot': slot, + 'data-scale': viewScale, + children: children ?? ( + <> + {years.map(year => ( +
+
+ {year} +
+
+ {cellsFor(viewScale, year).map(cell => { + const produced = anchorOf( + periodOf(cell.date, viewScale), + trailingValue + ); + const unavailable = !isPeriodAvailable( + cell.date, + viewScale + ); + return ( + + ); + })} +
+
+ ))} + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); + + /* Sibling views all mount; each gates on the active scale, so `.Quarters` + can stand alone with no day grid in the tree. */ + return scale === viewScale ? element : null; +} + +export function CalendarPreviewMonths(props: CalendarPreviewPeriodViewProps) { + return ( + + ); +} +CalendarPreviewMonths.displayName = 'CalendarPreview.Months'; + +export function CalendarPreviewQuarters(props: CalendarPreviewPeriodViewProps) { + return ( + + ); +} +CalendarPreviewQuarters.displayName = 'CalendarPreview.Quarters'; + +export function CalendarPreviewHalfYears( + props: CalendarPreviewPeriodViewProps +) { + return ( + + ); +} +CalendarPreviewHalfYears.displayName = 'CalendarPreview.HalfYears'; + +export function CalendarPreviewYears(props: CalendarPreviewPeriodViewProps) { + return ( + + ); +} +CalendarPreviewYears.displayName = 'CalendarPreview.Years'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-picker.tsx b/packages/raystack/components/calendar-preview/calendar-preview-picker.tsx new file mode 100644 index 000000000..0c1b930ae --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-picker.tsx @@ -0,0 +1,56 @@ +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { CalendarPreviewInput } from './calendar-preview-input'; +import { CalendarPreviewLabel } from './calendar-preview-label'; +import { CalendarPreviewPanel } from './calendar-preview-panel'; +import { CalendarPreviewScales } from './calendar-preview-scales'; +import { CalendarPreviewSeparator } from './calendar-preview-separator'; + +export type CalendarPreviewPickerProps = useRender.ComponentProps<'div'>; + +/** + * The popup body: label, input, scale switcher and the view for the active + * scale. The input sits above the switcher, which is where the frames put it. + */ +export function CalendarPreviewPicker({ + className, + children, + render, + ref, + ...props +}: CalendarPreviewPickerProps) { + const { scale, dropDraft } = useCalendarPreviewContext( + 'CalendarPreview.Picker' + ); + + return useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.picker, className), + 'data-slot': 'calendar-preview-picker', + 'data-scale': scale, + /* Escape drops the draft on its way to Base UI, which closes on it. */ + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === 'Escape') dropDraft(); + }, + children: children ?? ( + <> + + + + + + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); +} + +CalendarPreviewPicker.displayName = 'CalendarPreview.Picker'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index bfd572f4d..e6ff57075 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -25,7 +25,16 @@ import { parseKey, yearOf } from './date-adapter'; -import { periodOf, type Scale, type ScaleValue } from './lib/scale'; +import { + anchorOf, + convertScale, + isAvailable, + isScale, + periodOf, + SCALES, + type Scale, + type ScaleValue +} from './lib/scale'; const DEFAULT_YEAR_SPAN = 10; @@ -36,18 +45,25 @@ function isRange(value: unknown): value is CalendarPreviewDateRange { /* 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; + if (isRange(value)) return value.from; + return value instanceof Date ? value : parseKey(value.date); } /* `defaultValue` is omitted because `HTMLAttributes` already declares it as a form value, which is not what it means here. */ -type CalendarPreviewValue = Date | CalendarPreviewDateRange | null; +type CalendarPreviewValue = Date | CalendarPreviewDateRange | ScaleValue | null; + +function isScaleValue(value: CalendarPreviewValue): value is ScaleValue { + return value != null && !(value instanceof Date) && 'date' in value; +} /* 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'; + /** @defaultValue 'day' */ + scales?: 'day'; /** The selected day (controlled). */ value?: Date | null; /** The initially selected day (uncontrolled). */ @@ -60,6 +76,7 @@ interface CalendarPreviewSingleProps { interface CalendarPreviewRangeProps { selection: 'range'; + scales?: 'day'; /** The selected range (controlled). Both edges, or nothing. */ value?: CalendarPreviewDateRange | null; /** The initial range (uncontrolled). */ @@ -74,14 +91,48 @@ interface CalendarPreviewRangeProps { ) => void; } +/* + * Open Item 1 in the RFC: expressing "day-only keeps `Date`" so that + * `['day','month']` still narrows. TypeScript cannot test an array's contents, + * so the discriminator is the SHAPE of `scales` rather than its members — + * omitted or the literal `'day'` keeps `Date`; any other scale, or any array, + * moves to `ScaleValue`. The wart is that `scales={['day']}` takes the + * scale-aware arm where `scales='day'` does not. + */ +interface CalendarPreviewScaleAwareProps { + /* Ranges across scales are not a thing this ships — a start/end pair is two + independent roots, each with its own `scales` and `trailingValue`. */ + selection?: 'single'; + scales: Exclude | Scale[]; + /** The selected period. `date` is timeless `'YYYY-MM-DD'`. */ + value?: ScaleValue | null; + defaultValue?: ScaleValue | null; + onValueChange?: ( + value: ScaleValue | null, + details: CalendarPreviewChangeDetails + ) => void; +} + export type CalendarPreviewProps = ( | CalendarPreviewSingleProps | CalendarPreviewRangeProps + | CalendarPreviewScaleAwareProps ) & CalendarPreviewSharedProps; interface CalendarPreviewSharedProps extends Omit, 'defaultValue' | 'onChange'> { + /** The scale the picker opens on. @defaultValue the first of `scales` */ + defaultScale?: Scale; + scale?: Scale; + onScaleChange?: (scale: Scale) => void; + /** + * Whether a period emits its last day rather than its first — an end field + * wants 31 July from "July 2026", a start field wants the 1st. It changes + * the value, not the formatting. + * @defaultValue false + */ + trailingValue?: boolean; /** Whether the popover is open (controlled). Ignored by an inline calendar. */ open?: boolean; /** @defaultValue false */ @@ -169,6 +220,11 @@ export function defaultFormatValue( export function CalendarPreviewRoot({ selection = 'single', + scales: scalesProp = 'day', + scale: scaleProp, + defaultScale, + onScaleChange, + trailingValue = false, value: valueProp, defaultValue = null, onValueChange, @@ -222,13 +278,22 @@ export function CalendarPreviewRoot({ /* Uncontrolled until the scale switcher lands in PR 5. The state lives here now so the parts and `useCalendar()` read it from one place either way. */ + const scales = useMemo(() => { + const list = (Array.isArray(scalesProp) ? scalesProp : [scalesProp]).filter( + isScale + ); + return list.length > 0 ? SCALES.filter(s => list.includes(s)) : ['day']; + }, [scalesProp]); + const [scale, setScaleUnwrapped] = useControlled({ - controlled: undefined, - default: 'day', + controlled: scaleProp, + default: defaultScale ?? scales[0], name: 'CalendarPreview', state: 'scale' }); + const [scaleDraft, setScaleDraft] = useState(null); + const setMonth = useCallback( (next: Date) => { setMonthUnwrapped(next); @@ -290,8 +355,11 @@ export function CalendarPreviewRoot({ }, []); const setScale = useCallback( - (next: Scale) => setScaleUnwrapped(next), - [setScaleUnwrapped] + (next: Scale) => { + setScaleUnwrapped(next); + onScaleChange?.(next); + }, + [setScaleUnwrapped, onScaleChange] ); const [draft, setDraft] = useState(null); @@ -370,6 +438,56 @@ export function CalendarPreviewRoot({ ] ); + /* The value as a ScaleValue, whichever shape the consumer holds. */ + const scaleValue = useMemo(() => { + if (scaleDraft) return scaleDraft; + if (value instanceof Date) return { date: dayKey(value, timeZone), scale }; + if (isScaleValue(value)) return value; + return null; + }, [scaleDraft, value, scale, timeZone]); + + /* A scale switch moves the view and drafts; it never emits. The draft is + what the user is looking at, so the input and the views read it. */ + const switchScale = useCallback( + (next: Scale) => { + const anchor = scaleValue ?? { + date: dayKey(today, timeZone), + scale + }; + setScaleDraft(convertScale(anchor, next, trailingValue)); + setMonth(parseKey(convertScale(anchor, next, false).date)); + setScale(next); + }, + [scaleValue, today, timeZone, scale, trailingValue, setMonth, setScale] + ); + + const selectPeriod = useCallback( + (date: Date | string, next: Scale) => { + if (readOnly || disabled) return; + const key = anchorOf(periodOf(date, next), trailingValue); + setScaleDraft(null); + setValue({ date: key, scale: next } as never, 'select', parseKey(key)); + setOpen( + false, + createChangeEventDetails(REASONS.closePress, undefined, undefined) + ); + }, + [trailingValue, readOnly, disabled, setValue, setOpen] + ); + + /* Restoring the input means restoring the scale too: a day value rendered at + the drafted quarter scale would still read "Q3 2026". */ + const dropDraft = useCallback(() => { + setScaleDraft(null); + setScaleUnwrapped(isScaleValue(value) ? value.scale : scales[0]); + }, [value, scales, setScaleUnwrapped]); + + const isPeriodAvailable = useCallback( + (date: Date | string, next: Scale) => + isAvailable(date, next, trailingValue, minDate, maxDate), + [trailingValue, minDate, maxDate] + ); + const reset = useCallback(() => { if (!defaultDate) return; setValue(defaultDate, 'select', defaultDate); @@ -402,6 +520,13 @@ export function CalendarPreviewRoot({ () => ({ value, setValue, + scales, + trailingValue, + scaleDraft, + switchScale, + selectPeriod, + dropDraft, + isPeriodAvailable, selection, selectDay, draft: draft ?? (isRange(value) ? value : null), @@ -432,6 +557,13 @@ export function CalendarPreviewRoot({ [ value, setValue, + scales, + trailingValue, + scaleDraft, + switchScale, + selectPeriod, + dropDraft, + isPeriodAvailable, selection, selectDay, draft, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx new file mode 100644 index 000000000..b864a7e7b --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx @@ -0,0 +1,110 @@ +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { Tabs } from '../tabs'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import type { Scale } from './lib/scale'; + +const LABELS: Record = { + day: 'Day', + month: 'Month', + quarter: 'Quarter', + halfYear: 'Half-year', + year: 'Year' +}; + +export type CalendarPreviewScalesProps = useRender.ComponentProps<'div'>; + +/** + * The scale switcher. Renders nothing when only one scale is offered, which is + * what keeps a plain day calendar from growing a one-tab row. + */ +export function CalendarPreviewScales({ + className, + children, + render, + ref, + ...props +}: CalendarPreviewScalesProps) { + const { scales, scale, switchScale, disabled } = useCalendarPreviewContext( + 'CalendarPreview.Scales' + ); + + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.scales, className), + 'data-slot': 'calendar-preview-scales', + children: children ?? ( + switchScale(next as Scale)} + > + + {scales.map(one => ( + + {LABELS[one]} + + ))} + + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); + + return scales.length > 1 ? element : null; +} + +CalendarPreviewScales.displayName = 'CalendarPreview.Scales'; + +export interface CalendarPreviewScaleProps + extends useRender.ComponentProps<'button'> { + value: Scale; +} + +/** One scale. Only needed to relabel or reorder what `.Scales` renders. */ +export function CalendarPreviewScale({ + value, + className, + children, + render, + ref, + ...props +}: CalendarPreviewScaleProps) { + const { scale, switchScale, disabled } = useCalendarPreviewContext( + 'CalendarPreview.Scale' + ); + + return useRender({ + defaultTagName: 'button', + ref, + render, + props: mergeProps<'button'>( + { + type: 'button', + className: cx(styles.scale, className), + 'data-slot': 'calendar-preview-scale', + 'data-scale': value, + 'data-active': scale === value || undefined, + disabled, + onClick: () => switchScale(value), + children: children ?? LABELS[value] + } as useRender.ComponentProps<'button'>, + props + ) + }); +} + +CalendarPreviewScale.displayName = 'CalendarPreview.Scale'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-separator.tsx b/packages/raystack/components/calendar-preview/calendar-preview-separator.tsx new file mode 100644 index 000000000..d14a18fe9 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-separator.tsx @@ -0,0 +1,28 @@ +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import styles from './calendar-preview.module.css'; + +export type CalendarPreviewSeparatorProps = useRender.ComponentProps<'div'>; + +export function CalendarPreviewSeparator({ + className, + render, + ref, + ...props +}: CalendarPreviewSeparatorProps) { + return useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.separator, className), + 'data-slot': 'calendar-preview-separator', + role: 'separator' + } as useRender.ComponentProps<'div'>, + props + ) + }); +} + +CalendarPreviewSeparator.displayName = 'CalendarPreview.Separator'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 502159f54..3270b6b2f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -519,3 +519,90 @@ align-items: center; gap: var(--rs-space-3); } + +.picker { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + padding: var(--rs-space-3); + width: max-content; +} + +.label { + color: var(--rs-color-foreground-base-secondary); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.separator { + height: 1px; + background: var(--rs-color-border-base-primary); +} + +.scales { + display: flex; +} + +/* The day view hugs; every period list is a fixed box that scrolls as one, so + the year headings scroll with their cells rather than pinning. */ +.panel[data-scale="day"] { + display: block; +} + +.periods { + display: flex; + flex-direction: column; + gap: var(--rs-space-4); + height: calc(var(--rs-space-10) * 8); + overflow-y: auto; +} + +.period-group { + display: flex; + flex-direction: column; + gap: var(--rs-space-2); +} + +.period-year { + color: var(--rs-color-foreground-base-secondary); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.period-cells { + display: grid; + grid-template-columns: repeat(var(--rs-period-columns), 1fr); + gap: var(--rs-space-2); +} + +.period { + padding: var(--rs-space-2) var(--rs-space-3); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + background: transparent; + color: var(--rs-color-foreground-base-primary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + cursor: pointer; +} + +.period:hover:not(:disabled) { + background: var(--rs-color-background-base-primary-hover); +} + +.period:focus-visible { + outline: var(--rs-focus-ring); + outline-offset: var(--rs-focus-ring-offset-inset); +} + +.period[data-selected] { + background: var(--rs-color-background-neutral-secondary); +} + +.period[data-unavailable] { + color: var(--rs-color-foreground-base-tertiary); + cursor: not-allowed; +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx index 6717b31df..6397b175a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx @@ -15,14 +15,38 @@ import { CalendarPreviewPrevMonth } from './calendar-preview-header'; import { CalendarPreviewInput } from './calendar-preview-input'; +import { CalendarPreviewLabel } from './calendar-preview-label'; +import { CalendarPreviewPanel } from './calendar-preview-panel'; +import { + CalendarPreviewHalfYears, + CalendarPreviewMonths, + CalendarPreviewQuarters, + CalendarPreviewYears +} from './calendar-preview-periods'; +import { CalendarPreviewPicker } from './calendar-preview-picker'; import { CalendarPreviewReset } from './calendar-preview-reset'; import { CalendarPreviewRoot } from './calendar-preview-root'; +import { + CalendarPreviewScale, + CalendarPreviewScales +} from './calendar-preview-scales'; +import { CalendarPreviewSeparator } from './calendar-preview-separator'; import { CalendarPreviewTrigger } from './calendar-preview-trigger'; export const CalendarPreview = Object.assign(CalendarPreviewRoot, { Trigger: CalendarPreviewTrigger, Content: CalendarPreviewContent, Input: CalendarPreviewInput, + Picker: CalendarPreviewPicker, + Label: CalendarPreviewLabel, + Scales: CalendarPreviewScales, + Scale: CalendarPreviewScale, + Separator: CalendarPreviewSeparator, + Panel: CalendarPreviewPanel, + Months: CalendarPreviewMonths, + Quarters: CalendarPreviewQuarters, + HalfYears: CalendarPreviewHalfYears, + Years: CalendarPreviewYears, Days: CalendarPreviewDays, Header: CalendarPreviewHeader, PrevMonth: CalendarPreviewPrevMonth, From bc56566986fca15f98bf9b0dac03cbe5bce5568b Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 17:13:05 +0530 Subject: [PATCH 02/52] refactor!: rename CalendarPreview.Picker to .Body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Open Item 2 in the RFC, settled. `.Picker` overloaded the old `DatePicker` vocabulary for what is just the popup body, and `.Field` would have collided with Apsara's `Field`. Renames the part, its props type, its display name and its `data-slot`. The slot moves from `calendar-preview-picker` to `calendar-preview-body`, which is semver-covered surface — it has never shipped, so this costs nobody, but it is the last chance to make it free. While here: the eight parts added in the previous commit were registered on the root but their props types were never exported. They are now, from both barrels, so a consumer can type a wrapper around any of them. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/calendar-preview.test.tsx | 8 ++-- .../__tests__/scale-selection.test.tsx | 40 +++++++++---------- ...w-picker.tsx => calendar-preview-body.tsx} | 14 +++---- .../calendar-preview.module.css | 2 +- .../calendar-preview/calendar-preview.tsx | 4 +- .../components/calendar-preview/index.tsx | 9 +++++ packages/raystack/index.tsx | 1 + 7 files changed, 44 insertions(+), 34 deletions(-) rename packages/raystack/components/calendar-preview/{calendar-preview-picker.tsx => calendar-preview-body.tsx} (81%) 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 9654cc392..415edd040 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -914,21 +914,21 @@ describe('CalendarPreview public surface', () => { it('exports exactly the parts this phase builds', () => { expect(partNames.sort()).toEqual( [ + 'Body', 'Caption', 'Content', - 'HalfYears', 'Day', 'Days', 'Footer', 'Grid', + 'HalfYears', 'Header', - 'NextMonth', - 'PrevMonth', 'Input', 'Label', 'Months', + 'NextMonth', 'Panel', - 'Picker', + 'PrevMonth', 'Quarters', 'Reset', 'Scale', diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index 0e6c79a68..ad043a1a3 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -7,10 +7,10 @@ import type { Scale } from '../lib/scale'; const TODAY = new Date(2026, 7, 15); const ALL: Scale[] = ['day', 'month', 'quarter', 'halfYear', 'year']; -function renderPicker(props = {}) { +function renderBody(props = {}) { return render( - + ); } @@ -41,7 +41,7 @@ const switchTo = (container: HTMLElement, scale: Scale) => { describe('CalendarPreview scale switching', () => { it('emits nothing on a scale switch — it only drafts', () => { const onValueChange = vi.fn(); - const { container } = renderPicker({ onValueChange }); + const { container } = renderBody({ onValueChange }); switchTo(container, 'quarter'); expect(onValueChange).not.toHaveBeenCalled(); switchTo(container, 'year'); @@ -50,7 +50,7 @@ describe('CalendarPreview scale switching', () => { it('emits once a period is picked', () => { const onValueChange = vi.fn(); - const { container } = renderPicker({ onValueChange }); + const { container } = renderBody({ onValueChange }); switchTo(container, 'quarter'); fireEvent.click(period(container, 'Q3')); expect(onValueChange).toHaveBeenCalledTimes(1); @@ -62,7 +62,7 @@ describe('CalendarPreview scale switching', () => { it('reports the scale it moved to', () => { const onScaleChange = vi.fn(); - const { container } = renderPicker({ onScaleChange }); + const { container } = renderBody({ onScaleChange }); switchTo(container, 'month'); expect(onScaleChange).toHaveBeenCalledWith('month'); }); @@ -82,7 +82,7 @@ describe('CalendarPreview trailingValue', () => { [true, trail] ] as const) { const onValueChange = vi.fn(); - const { container, unmount } = renderPicker({ + const { container, unmount } = renderBody({ onValueChange, trailingValue: trailing }); @@ -95,7 +95,7 @@ describe('CalendarPreview trailingValue', () => { it('is month-end correct in a leap February', () => { const onValueChange = vi.fn(); - const { container } = renderPicker({ + const { container } = renderBody({ onValueChange, trailingValue: true, today: new Date(2028, 1, 10), @@ -123,25 +123,25 @@ describe('CalendarPreview availability differs by field', () => { ['quarter', 'Q3'], ['month', 'Jul'] ] as const)('disables %s for a start field and allows it for an end field', (scale, label) => { - const start = renderPicker({ ...bounded, trailingValue: false }); + const start = renderBody({ ...bounded, trailingValue: false }); switchTo(start.container, scale); expect(period(start.container, label)).toBeDisabled(); start.unmount(); - const end = renderPicker({ ...bounded, trailingValue: true }); + const end = renderBody({ ...bounded, trailingValue: true }); switchTo(end.container, scale); expect(period(end.container, label)).not.toBeDisabled(); }); it('disables H1 2026 for an end field, which would emit 30 June', () => { - const { container } = renderPicker({ ...bounded, trailingValue: true }); + const { container } = renderBody({ ...bounded, trailingValue: true }); switchTo(container, 'halfYear'); expect(period(container, 'H1')).toBeDisabled(); expect(period(container, 'H2')).not.toBeDisabled(); }); it('allows July and Q3 for an end field, because they emit after the bound', () => { - const { container } = renderPicker({ ...bounded, trailingValue: true }); + const { container } = renderBody({ ...bounded, trailingValue: true }); switchTo(container, 'month'); expect(period(container, 'Jul')).not.toBeDisabled(); switchTo(container, 'quarter'); @@ -149,7 +149,7 @@ describe('CalendarPreview availability differs by field', () => { }); it('shows out-of-bounds periods rather than hiding them', () => { - const { container } = renderPicker({ + const { container } = renderBody({ maxDate: new Date(2026, 7, 31), today: TODAY }); @@ -163,14 +163,14 @@ describe('CalendarPreview.Scales', () => { it('renders nothing when only one scale is offered', () => { const { container } = render( - + ); expect(getSlot(container, 'calendar-preview-scales')).toBeNull(); }); it('renders one chip per offered scale', () => { - const { container } = renderPicker(); + const { container } = renderBody(); expect(getAllSlots(container, 'calendar-preview-scale')).toHaveLength(5); }); }); @@ -192,7 +192,7 @@ describe('CalendarPreview period views mount alone', () => { }); it('gates on the active scale, so the others stay unmounted', () => { - const { container } = renderPicker({ defaultScale: 'quarter' }); + const { container } = renderBody({ defaultScale: 'quarter' }); expect(getSlot(container, 'calendar-preview-quarters')).toBeInTheDocument(); expect(getSlot(container, 'calendar-preview-months')).toBeNull(); expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); @@ -240,7 +240,7 @@ describe('CalendarPreview.Input at scale', () => { getSlot(container, 'calendar-preview-input') as HTMLInputElement; it('advertises the formats it accepts', () => { - const { container } = renderPicker(); + const { container } = renderBody(); expect(input(container)).toHaveAttribute( 'placeholder', 'Try: May 2027, Q4, 20/05/2027' @@ -250,7 +250,7 @@ describe('CalendarPreview.Input at scale', () => { it('moves the scale to match what was typed', () => { const onValueChange = vi.fn(); const onScaleChange = vi.fn(); - const { container } = renderPicker({ onValueChange, onScaleChange }); + const { container } = renderBody({ onValueChange, onScaleChange }); fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); fireEvent.keyDown(input(container), { key: 'Enter' }); expect(onValueChange.mock.calls[0][0]).toEqual({ @@ -262,7 +262,7 @@ describe('CalendarPreview.Input at scale', () => { it('refuses a scale this root does not offer', () => { const { container } = render( - + ); fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); @@ -272,7 +272,7 @@ describe('CalendarPreview.Input at scale', () => { }); it('drops the draft on Escape and falls back to the value', () => { - const { container } = renderPicker({ + const { container } = renderBody({ value: { date: '2026-08-20', scale: 'day' } }); expect(input(container).value).toBe('20/08/2026'); @@ -281,7 +281,7 @@ describe('CalendarPreview.Input at scale', () => { expect(input(container).value).toBe('Q3 2026'); fireEvent.keyDown( - getSlot(container, 'calendar-preview-picker') as HTMLElement, + getSlot(container, 'calendar-preview-body') as HTMLElement, { key: 'Escape' } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-picker.tsx b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx similarity index 81% rename from packages/raystack/components/calendar-preview/calendar-preview-picker.tsx rename to packages/raystack/components/calendar-preview/calendar-preview-body.tsx index 0c1b930ae..be1145b00 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-picker.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx @@ -8,21 +8,21 @@ import { CalendarPreviewPanel } from './calendar-preview-panel'; import { CalendarPreviewScales } from './calendar-preview-scales'; import { CalendarPreviewSeparator } from './calendar-preview-separator'; -export type CalendarPreviewPickerProps = useRender.ComponentProps<'div'>; +export type CalendarPreviewBodyProps = useRender.ComponentProps<'div'>; /** * The popup body: label, input, scale switcher and the view for the active * scale. The input sits above the switcher, which is where the frames put it. */ -export function CalendarPreviewPicker({ +export function CalendarPreviewBody({ className, children, render, ref, ...props -}: CalendarPreviewPickerProps) { +}: CalendarPreviewBodyProps) { const { scale, dropDraft } = useCalendarPreviewContext( - 'CalendarPreview.Picker' + 'CalendarPreview.Body' ); return useRender({ @@ -31,8 +31,8 @@ export function CalendarPreviewPicker({ render, props: mergeProps<'div'>( { - className: cx(styles.picker, className), - 'data-slot': 'calendar-preview-picker', + className: cx(styles.body, className), + 'data-slot': 'calendar-preview-body', 'data-scale': scale, /* Escape drops the draft on its way to Base UI, which closes on it. */ onKeyDown: (event: React.KeyboardEvent) => { @@ -53,4 +53,4 @@ export function CalendarPreviewPicker({ }); } -CalendarPreviewPicker.displayName = 'CalendarPreview.Picker'; +CalendarPreviewBody.displayName = 'CalendarPreview.Body'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 3270b6b2f..bdaa1ac7d 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -520,7 +520,7 @@ gap: var(--rs-space-3); } -.picker { +.body { display: flex; flex-direction: column; gap: var(--rs-space-3); diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx index 6397b175a..ad4652bd4 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx @@ -1,5 +1,6 @@ 'use client'; +import { CalendarPreviewBody } from './calendar-preview-body'; import { CalendarPreviewCaption } from './calendar-preview-caption'; import { CalendarPreviewContent } from './calendar-preview-content'; import { CalendarPreviewDays } from './calendar-preview-days'; @@ -23,7 +24,6 @@ import { CalendarPreviewQuarters, CalendarPreviewYears } from './calendar-preview-periods'; -import { CalendarPreviewPicker } from './calendar-preview-picker'; import { CalendarPreviewReset } from './calendar-preview-reset'; import { CalendarPreviewRoot } from './calendar-preview-root'; import { @@ -37,7 +37,7 @@ export const CalendarPreview = Object.assign(CalendarPreviewRoot, { Trigger: CalendarPreviewTrigger, Content: CalendarPreviewContent, Input: CalendarPreviewInput, - Picker: CalendarPreviewPicker, + Body: CalendarPreviewBody, Label: CalendarPreviewLabel, Scales: CalendarPreviewScales, Scale: CalendarPreviewScale, diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index 9b83afa74..00b0c3004 100644 --- a/packages/raystack/components/calendar-preview/index.tsx +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -1,4 +1,5 @@ export { CalendarPreview } from './calendar-preview'; +export type { CalendarPreviewBodyProps } from './calendar-preview-body'; export type { CalendarPreviewCaptionProps } from './calendar-preview-caption'; export type { CalendarPreviewContentProps } from './calendar-preview-content'; export type { @@ -24,8 +25,16 @@ export type { CalendarPreviewInputProps, CalendarPreviewInputValidity } from './calendar-preview-input'; +export type { CalendarPreviewLabelProps } from './calendar-preview-label'; +export type { CalendarPreviewPanelProps } from './calendar-preview-panel'; +export type { CalendarPreviewPeriodViewProps } from './calendar-preview-periods'; export type { CalendarPreviewResetProps } from './calendar-preview-reset'; export type { CalendarPreviewProps } from './calendar-preview-root'; +export type { + CalendarPreviewScaleProps, + CalendarPreviewScalesProps +} from './calendar-preview-scales'; +export type { CalendarPreviewSeparatorProps } from './calendar-preview-separator'; export type { CalendarPreviewTriggerProps } from './calendar-preview-trigger'; export type { Scale, ScaleValue } from './lib/scale'; export { type UseCalendarReturn, useCalendar } from './use-calendar'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index aff716c65..dc84b453b 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -22,6 +22,7 @@ export { } from './components/calendar'; export { CalendarPreview, + type CalendarPreviewBodyProps, type CalendarPreviewCaptionProps, type CalendarPreviewChangeDetails, type CalendarPreviewChangeReason, From 60243390283e318af49cdf6700f15d32a9290a05 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 17:28:31 +0530 Subject: [PATCH 03/52] feat: render days as DD MMM YYYY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settles the last open item. The RFC set the default day format to DD/MM/YYYY; the frames and the shipped `DatePicker`'s own `dateFormat` both render `15 Aug 2026`. Going with the frames. `formatDayLabel` was day-first for a stated reason — a rendered value could be typed straight back into the field, because `lib/parse.ts` accepted exactly what it produced. Changing the format alone would have broken that: `parseScaleInput` had no pattern for a day with a month name, so selecting all and retyping `15 Aug 2026` verbatim came back unparseable. So the parser learns the form the formatter renders. `15 Aug 2026` and `15 August 2026` now parse at day scale, and `31 Feb 2026` is still rejected, because `dayKeyFromParts` validates against the real calendar rather than rolling forward. Every input form that worked before still works — the slashed and ISO shapes are untouched, they are simply no longer what gets rendered. The multi-scale placeholder advertises the new form too. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/calendar-preview.test.tsx | 6 ++++-- .../__tests__/date-adapter.test.ts | 8 ++++---- .../calendar-preview/__tests__/parse.test.ts | 16 ++++++++++++++++ .../calendar-preview/__tests__/picker.test.tsx | 6 +++--- .../calendar-preview/__tests__/range.test.tsx | 4 ++-- .../__tests__/scale-selection.test.tsx | 8 ++++---- .../calendar-preview/calendar-preview-input.tsx | 2 +- .../calendar-preview/calendar-preview-root.tsx | 2 +- .../components/calendar-preview/date-adapter.ts | 7 ++++--- .../components/calendar-preview/lib/parse.ts | 15 +++++++++++++++ 10 files changed, 54 insertions(+), 20 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 415edd040..08b528279 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -955,8 +955,10 @@ describe('CalendarPreview public surface', () => { }); describe('defaultFormatValue', () => { - it('formats a day as DD/MM/YYYY', () => { - expect(defaultFormatValue(new Date(2027, 4, 20), 'day')).toBe('20/05/2027'); + it('formats a day as DD MMM YYYY', () => { + expect(defaultFormatValue(new Date(2027, 4, 20), 'day')).toBe( + '20 May 2027' + ); }); it('formats the coarser scales by their own shorthand', () => { diff --git a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts index f1b0d7483..a5c06a031 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -199,9 +199,9 @@ describe('monthStart', () => { }); describe('label formatters', () => { - it('formats a day as DD/MM/YYYY', () => { - expect(formatDayLabel(new Date(2027, 4, 20))).toBe('20/05/2027'); - expect(formatDayLabel(new Date(2027, 0, 5))).toBe('05/01/2027'); + it('formats a day as DD MMM YYYY', () => { + expect(formatDayLabel(new Date(2027, 4, 20))).toBe('20 May 2027'); + expect(formatDayLabel(new Date(2027, 0, 5))).toBe('05 Jan 2027'); }); it('formats a month in short form', () => { @@ -215,7 +215,7 @@ describe('label formatters', () => { it('reads the labels in an explicit zone', () => { const instant = new Date(Date.UTC(2026, 7, 31, 20, 0)); - expect(formatDayLabel(instant, 'Asia/Tokyo')).toBe('01/09/2026'); + expect(formatDayLabel(instant, 'Asia/Tokyo')).toBe('01 Sep 2026'); expect(formatMonthLabel(instant, 'Asia/Tokyo')).toBe('Sep 2026'); expect(formatCaptionLabel(instant, 'UTC')).toBe('Aug 2026'); }); diff --git a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts index ee69349c0..93e286354 100644 --- a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts @@ -24,6 +24,22 @@ describe('parseScaleInput — day', () => { }); }); + /* `formatDayLabel` renders this form, and a field shows it. Selecting all + and retyping it verbatim has to come back as the same day. */ + it.each([ + ['15 Aug 2026', '2026-08-15'], + ['15 August 2026', '2026-08-15'], + ['5 Jan 2027', '2027-01-05'], + ['01 Sep 2026', '2026-09-01'] + ])('round-trips the rendered day form %s', (input, expected) => { + expect(parseScaleInput(input)?.date).toBe(expected); + expect(parseScaleInput(input)?.scale).toBe('day'); + }); + + it('rejects a day-named form with an impossible day', () => { + expect(parseScaleInput('31 Feb 2026')).toBeNull(); + }); + it('accepts 29 February in a leap year', () => { expect(parseScaleInput('29/02/2028', IN_2026)).toEqual({ date: '2028-02-29', diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index 77a182a54..04579b5f4 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -135,7 +135,7 @@ describe('CalendarPreview.Input commit', () => { it('emits nothing while typing', () => { const onValueChange = vi.fn(); const { input } = renderPicker({ onValueChange }); - for (const text of ['2', '20', '20/', '20/0', '20/05', '20/05/2027']) { + for (const text of ['2', '20', '20/', '20/0', '20/05', '20 May 2027']) { fireEvent.change(input, { target: { value: text } }); } expect(onValueChange).not.toHaveBeenCalled(); @@ -151,7 +151,7 @@ describe('CalendarPreview.Input commit', () => { }); it.each([ - ['20/05/2027', new Date(2027, 4, 20)], + ['20 May 2027', new Date(2027, 4, 20)], ['5/5/2027', new Date(2027, 4, 5)], ['2027-05-20', new Date(2027, 4, 20)] ])('accepts %s at day scale', (text, expected) => { @@ -283,7 +283,7 @@ describe('CalendarPreview.Trigger content', () => { ); expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent( - '20/08/2026' + '20 Aug 2026' ); }); diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 745f145ff..54ac2ee46 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -147,8 +147,8 @@ describe('CalendarPreview range inputs', () => { 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'); + expect(start.value).toBe('10 Aug 2026'); + expect(end.value).toBe('20 Aug 2026'); }); /* `lock` is gone: a read-only endpoint is one read-only `.Input`. */ diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index ad043a1a3..d5f71b6d8 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -203,7 +203,7 @@ describe('CalendarPreview period views mount alone', () => { anywhere in the tree. */ describe('CalendarPreview.Trigger annotation', () => { it.each([ - ['day', '2026-07-02', '02/07/2026'], + ['day', '2026-07-02', '02 Jul 2026'], ['month', '2026-06-01', 'Jun 2026'], ['quarter', '2026-07-01', 'Q3 2026'], ['halfYear', '2026-01-01', 'H1 2026'], @@ -243,7 +243,7 @@ describe('CalendarPreview.Input at scale', () => { const { container } = renderBody(); expect(input(container)).toHaveAttribute( 'placeholder', - 'Try: May 2027, Q4, 20/05/2027' + 'Try: 15 Aug 2026, May 2027, Q4' ); }); @@ -275,7 +275,7 @@ describe('CalendarPreview.Input at scale', () => { const { container } = renderBody({ value: { date: '2026-08-20', scale: 'day' } }); - expect(input(container).value).toBe('20/08/2026'); + expect(input(container).value).toBe('20 Aug 2026'); switchTo(container, 'quarter'); expect(input(container).value).toBe('Q3 2026'); @@ -286,6 +286,6 @@ describe('CalendarPreview.Input at scale', () => { key: 'Escape' } ); - expect(input(container).value).toBe('20/08/2026'); + expect(input(container).value).toBe('20 Aug 2026'); }); }); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 32514c0b9..df8dd1e1c 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -156,7 +156,7 @@ export function CalendarPreviewInput({ const resolvedPlaceholder = placeholder ?? (scales.length > 1 - ? 'Try: May 2027, Q4, 20/05/2027' + ? 'Try: 15 Aug 2026, May 2027, Q4' : isRange ? field === 'start' ? 'Select start date' diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index e6ff57075..c5ba378f7 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -174,7 +174,7 @@ interface CalendarPreviewSharedProps /** * Renders a value for display. - * @defaultValue `DD/MM/YYYY` at day scale + * @defaultValue `DD MMM YYYY` at day scale */ formatValue?: (value: Date | ScaleValue, scale: Scale) => string; /** Forwarded to the grid. No conversion is done here. */ diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index e7970bec2..46ccb01ab 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -131,10 +131,11 @@ export function monthStart(year: number, monthIndex: number): Date { return new Date(year, monthIndex, 1); } -/* Day-first, matching what `lib/parse.ts` accepts, so a rendered value can be - typed straight back in. */ +/* `lib/parse.ts` accepts this form back, so a rendered value can be typed + straight in. Day-first and month-named, matching the frames and the shipped + picker's `dateFormat`. */ export function formatDayLabel(date: Date, timeZone?: string): string { - return format(zoned(date, timeZone), 'dd/MM/yyyy'); + return format(zoned(date, timeZone), 'dd MMM yyyy'); } /** `'May 2027'` — the default label for a value at month scale. */ diff --git a/packages/raystack/components/calendar-preview/lib/parse.ts b/packages/raystack/components/calendar-preview/lib/parse.ts index cd4cfb70b..15c468188 100644 --- a/packages/raystack/components/calendar-preview/lib/parse.ts +++ b/packages/raystack/components/calendar-preview/lib/parse.ts @@ -35,6 +35,8 @@ export interface ParseScaleInputOptions { /* Day and month accept 1-2 digits so `5/5/2027` works; the year is pinned at * exactly 4 so a two-digit year is rejected rather than read as year 27. */ const DAY_SLASHED = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/; +/* The form `formatDayLabel` renders, so a displayed value types back in. */ +const DAY_NAMED = /^(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})$/; const DAY_ISO = /^\d{4}-\d{2}-\d{2}$/; const MONTH_NAMED = /^([A-Za-z]{3,9})(?:\s+(\d{4}))?$/; const QUARTER = /^[Qq]([1-4])(?:\s+(\d{4}))?$/; @@ -50,6 +52,7 @@ const YEAR = /^(\d{4})$/; * | Input | Scale | Notes | * |---|---|---| * | `20/05/2027`, `5/5/2027` | `day` | `dd/MM/yyyy`, day first | + * | `15 Aug 2026`, `15 August 2026` | `day` | what `formatDayLabel` renders | * | `2027-05-20` | `day` | the canonical stored form, so it round-trips | * | `May 2027`, `September 2027`, `Sep 2027` | `month` | | * | `May` | `month` | year inferred | @@ -91,6 +94,18 @@ export function parseScaleInput( return key === null ? null : { date: key, scale: 'day' }; } + const namedDay = DAY_NAMED.exec(text); + if (namedDay) { + const month = monthFromName(namedDay[2]); + if (month === null) return null; + const key = dayKeyFromParts( + Number(namedDay[3]), + month, + Number(namedDay[1]) + ); + return key === null ? null : { date: key, scale: 'day' }; + } + if (DAY_ISO.test(text)) { return isDayKey(text) ? { date: text, scale: 'day' } : null; } From 4c994ab9a59dbae856fc373bd256c4a472f3e179 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 18:13:55 +0530 Subject: [PATCH 04/52] fix: report the committed scale's period, not the view's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `toDate()` was already right for the scale arm — `selectPeriod` passes the produced date as the occasion, so it hands back the period edge that `trailingValue` chose, as a method rather than a field. `period` was not. It was computed against the root's current `scale` state, which is the scale on SCREEN, not the one being committed. On a click those agree, because switching the view is what put the cells there. On a typed commit they do not: "Q4 2026" typed while the view is still on days committed a quarter but reported a single day as its period. It now derives the scale from the value being emitted, so the two cannot drift. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/scale-selection.test.tsx | 38 +++++++++++++++++++ .../calendar-preview-root.tsx | 4 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index d5f71b6d8..46587143f 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -289,3 +289,41 @@ describe('CalendarPreview.Input at scale', () => { expect(input(container).value).toBe('20 Aug 2026'); }); }); + +describe('CalendarPreview change details at scale', () => { + const input = (container: HTMLElement) => + getSlot(container, 'calendar-preview-input') as HTMLInputElement; + + it('hands back the produced date through toDate()', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange, trailingValue: true }); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q3')); + const details = onValueChange.mock.calls[0][1]; + expect(typeof details.toDate).toBe('function'); + expect(details.toDate()).toEqual(new Date(2026, 8, 30)); + }); + + it('reports the period of the scale that was committed, not the view', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange }); + switchTo(container, 'month'); + fireEvent.click(period(container, 'Aug')); + expect(onValueChange.mock.calls[0][1].period).toEqual({ + start: '2026-08-01', + end: '2026-08-31' + }); + }); + + /* Typing commits a scale the view has not moved to yet. */ + it('reports the typed scale period, not the scale still on screen', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange }); + fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + expect(onValueChange.mock.calls[0][1].period).toEqual({ + start: '2026-10-01', + end: '2026-12-31' + }); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index c5ba378f7..480caaa82 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -311,7 +311,9 @@ export function CalendarPreviewRoot({ setValueUnwrapped(next); emit?.(next, { reason, - period: periodOf(occasion, scale), + /* The scale that was committed, not the one on screen: typing + "Q4 2026" commits a quarter while the view is still on days. */ + period: periodOf(occasion, isScaleValue(next) ? next.scale : scale), toDate: () => occasion }); }, From 58a61a6eb1221f27c43936eb73a2836fb7393fef Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 7 Sep 2026 11:41:20 +0530 Subject: [PATCH 05/52] docs: document scale-aware selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 5 shipped ten parts with no docs, so the scale surface was invisible on the docs site — which is where it was noticed. Adds the API entries for `.Body`, `.Scales`, `.Scale`, `.Panel`, the four period views, `.Label` and `.Separator`, the eleven slots they render, and a section covering the pieces that are not guessable from the props: that the value carries its own scale, that switching drafts rather than emits, what `trailingValue` does to the value, and the availability table that falls out of it. Two things the section has to say out loud, because both have already caused confusion: `ScaleValue.date` is stored as `YYYY-MM-DD` and is never what renders — `formatValue` puts `DD MMM YYYY` on screen and `toDate()` hands back a `Date`; and a start/end pair is two independent roots, not `selection='range'`, because the two ends can hold different scales. The first demo tab is the inline body rather than the popover form. The popover renders as the words "Add start date" until you click it, which is exactly why the preview looked missing. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/components/calendar-preview/demo.ts | 89 +++++++++++++++++++ .../components/calendar-preview/index.mdx | 86 ++++++++++++++++++ 2 files changed, 175 insertions(+) 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 df204e56e..aa6c4e024 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -409,3 +409,92 @@ export const rangeDemo = { } ] }; + +export const scaleDemo = { + type: 'code', + tabs: [ + { + name: 'Inline', + code: ` + + ` + }, + { + name: 'Day scale', + code: ` + + ` + }, + { + name: 'In a popover', + code: ` + + + + + ` + }, + { + name: 'Periods only', + code: ` + + ` + }, + { + name: 'One view alone', + code: ` + + ` + }, + { + name: 'Bounded', + code: ` + + ` + } + ] +}; + +export const scalePairDemo = { + type: 'code', + 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 c14e8a371..4504d7ac6 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -13,6 +13,8 @@ import { dateInfoDemo, pickerDemo, rangeDemo, + scaleDemo, + scalePairDemo, } from "./demo.ts"; @@ -102,6 +104,26 @@ The portaled popover surface. Takes `Popover.Content` props — `side`, `align`, +### CalendarPreview.Body + +The popup body: label, input, scale switcher and the view for the active scale. Renders all four when given no children. Takes `render`, `className` and `ref`. + +### CalendarPreview.Scales / CalendarPreview.Scale + +The scale switcher, built on Apsara `Tabs`. **Renders nothing when only one scale is offered**, so a plain day calendar never grows a one-tab row. `.Scale` is only needed to relabel or reorder. + +### CalendarPreview.Panel + +The view container. Mounts all five views; each gates on the active scale itself, so `.Quarters` can be mounted alone with no day grid in the tree. + +### CalendarPreview.Months / .Quarters / .HalfYears / .Years + +Year-grouped period lists at 3, 4, 2 and 1 columns. Each is one continuous 320px scroll area with the year numbers as headings inside it, opening on the active year. + +### CalendarPreview.Label / CalendarPreview.Separator + +The field label above the input, and the rule between the switcher and the view. + ### CalendarPreview.Footer The row below the calendar. A bare string is wrapped in `Text`; anything else renders as given. @@ -151,6 +173,16 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `calendar-preview-day-number` | The day number inside a day button | | `calendar-preview-day-info` | Content above the number (when `dateInfo` resolves) | | `calendar-preview-day-tooltip` | The tooltip shown on hover | +| `calendar-preview-body` | The popup body | +| `calendar-preview-label` | The field label | +| `calendar-preview-scales` | The scale switcher | +| `calendar-preview-scale` | One scale chip | +| `calendar-preview-separator` | The rule below the switcher | +| `calendar-preview-panel` | The view container | +| `calendar-preview-months` / `-quarters` / `-half-years` / `-years` | One period list | +| `calendar-preview-period-group` | One year's block inside a period list | +| `calendar-preview-period-year` | The year heading | +| `calendar-preview-period` | One period cell | | `calendar-preview-footer` | The footer row | | `calendar-preview-footer-text` | The `Text` wrapping a string footer | @@ -257,6 +289,60 @@ Instead of a `lock` prop, mark one endpoint's `.Input` as `readOnly` — the gri +### Scale-aware selection + +Pass `scales` to select at granularities coarser than a day. A single value hides the switcher; anything more shows it. + +```tsx + + + + + + +``` + + + +#### The value carries its scale + +A `Date` cannot say whether it means "August 2026" or "1 August 2026", so beyond day scale the value is a `ScaleValue`: + +```ts +interface ScaleValue { date: 'YYYY-MM-DD'; scale: Scale } +``` + +| `scales` | `value` | +|---|---| +| omitted, or `'day'` | `Date` — unchanged | +| any other scale, or any array | `ScaleValue` | + +`date` is stored as `YYYY-MM-DD` because lexicographic order is chronological order, which is what lets bounds compare without parsing. **It is never what you see** — every trigger, input and annotation renders through `formatValue`, which is `DD MMM YYYY` at day scale and the period's own shorthand above it. `onValueChange`'s details carry `toDate()` if you want a `Date`. + +#### Switching scale drafts, it does not emit + +Moving between scales moves the view and sets a draft. Nothing is emitted until a cell is clicked or Enter is pressed; Escape drops the draft and restores the input from `value`. + +#### trailingValue picks the edge + +A period has two edges, and which one a field means depends on the field. `trailingValue` emits the period's **last** day rather than its first — "July 2026" becomes `2026-07-31` instead of `2026-07-01`. It changes the value, not the formatting, and it is month-end correct: February 2028 trailing is `2028-02-29`. + +That also decides availability, which tests **the date a period would produce**. Bounded at 15 July 2026: + +| Period | A start field emits | An end field emits | Start | End | +|---|---|---|---|---| +| H1 2026 | 1 Jan | 30 Jun | disabled | disabled | +| July 2026 | 1 Jul | 31 Jul | disabled | available | +| Q3 2026 | 1 Jul | 30 Sep | disabled | available | + +Every one of those periods starts before the bound. Only the produced date separates them. + +#### A start/end pair is two roots + +Not `selection="range"`. Each end has its own `scales` and `trailingValue`, and they can hold different scales — "1 Aug 2026 → Q3 2026" is not expressible as one range value. The consumer owns the pair and any `from <= to` check. + + + ## Accessibility - Arrow keys move between days; the focused cell carries `data-draft` until it is committed From 2998a5861e15f74d6a3dea188da925b563d7fc3b Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 7 Sep 2026 12:41:33 +0530 Subject: [PATCH 06/52] fix: open the period list on the active year MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list claimed to open on the active year and never did — a real browser showed `scrollTop: 0` with the 2026 group 540px down a 320px viewport. Every scale switch landed the user twenty years early, on 2016, and clicking what looked like "Q3" committed Q3 2016. Two causes, both invisible to jsdom. The effect ran on mount, but `.Panel` mounts all five views at once and a view still runs its hooks while it returns null. So the effect fired with an empty ref, and a mount effect never fires again when the view later becomes visible. It now runs when the view becomes active. `scrollIntoView` was also the wrong instrument: it walks every scrollable ancestor, so it would move the popover along with the list. Scrolling the container directly touches nothing else. Separately, and found by the same probe: `switchScale` and the period list both anchored on `today` rather than on `month`. A consumer opening on 2030, or a user who navigated there in the day grid, was thrown back to this year by switching scale. Both now follow the month on screen — which already falls back to today when nothing else set it. jsdom cannot see any of this: it has no layout, so `scrollTop` is always 0 and `getBoundingClientRect` is always zeroes. The tests cover the anchor, which is observable; the scroll is verified in a browser. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/scale-selection.test.tsx | 61 +++++++++++++++++++ .../calendar-preview-periods.tsx | 32 +++++++--- .../calendar-preview-root.tsx | 8 ++- 3 files changed, 92 insertions(+), 9 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index 46587143f..b96ff3350 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -327,3 +327,64 @@ describe('CalendarPreview change details at scale', () => { }); }); }); + +describe('CalendarPreview scale anchors on the visible month', () => { + /* The day grid is showing 2030; switching scale must land there, not on + whatever year today happens to be. */ + it('drafts from the view month rather than today', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q1', 2030)); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2030-01-01', + scale: 'quarter' + }); + }); + + it('opens the period list on the view month year', () => { + const { container } = render( + + + + ); + switchTo(container, 'month'); + /* Both years exist in the list; the point is which one is anchored. */ + expect(period(container, 'Jan', 2030)).toBeInTheDocument(); + expect(period(container, 'Jan', 2026)).toBeInTheDocument(); + }); + + it('still follows the value when there is one', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q2', 2027)); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2027-04-01', + scale: 'quarter' + }); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx index 0baa34a65..632158782 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -71,7 +71,7 @@ function PeriodView({ selectPeriod, isPeriodAvailable, trailingValue, - today, + month, timeZone, disabled, readOnly @@ -89,7 +89,7 @@ function PeriodView({ scaleDraft?.date ?? (value && !(value instanceof Date) && 'date' in value ? (value as { date: string }).date - : dayKey(today, timeZone)) + : dayKey(month, timeZone)) ); const selectedKey = @@ -98,12 +98,29 @@ function PeriodView({ ? (value as { date: string }).date : null); - /* A twenty-year list otherwise opens on its first year. Optional-called - because jsdom does not implement scrollIntoView. */ + /* + * A twenty-year list otherwise opens on its first year, twenty scrolls from + * the one the user means. + * + * Runs when the view becomes active, not on mount: every view is mounted at + * once and hooks still run while one returns null, so a mount effect fires + * with an empty ref and never fires again when the view appears. + * + * Scrolls the container rather than calling `scrollIntoView`, which walks + * every scrollable ancestor and would move the popover with it. + */ const activeRef = useRef(null); + const isActive = scale === viewScale; useEffect(() => { - activeRef.current?.scrollIntoView?.({ block: 'start' }); - }, []); + if (!isActive) return; + const group = activeRef.current; + const list = group?.parentElement; + /* The ref lags a render behind `activeYear`, so scrolling to a group that + is no longer the active one would land on the previous year. */ + if (!group || !list || group.dataset.year !== String(activeYear)) return; + list.scrollTop += + group.getBoundingClientRect().top - list.getBoundingClientRect().top; + }, [isActive, activeYear]); const element = useRender({ defaultTagName: 'div', @@ -122,6 +139,7 @@ function PeriodView({ ref={year === activeYear ? activeRef : undefined} className={styles['period-group']} data-slot='calendar-preview-period-group' + data-year={year} >
{ + /* Falls back to the month on screen, not to today: a consumer opening on + 2030, or a user who navigated there, must not be thrown back to this + year by switching scale. `month` already resolves to today when + nothing else set it. */ const anchor = scaleValue ?? { - date: dayKey(today, timeZone), + date: dayKey(month, timeZone), scale }; setScaleDraft(convertScale(anchor, next, trailingValue)); setMonth(parseKey(convertScale(anchor, next, false).date)); setScale(next); }, - [scaleValue, today, timeZone, scale, trailingValue, setMonth, setScale] + [scaleValue, month, timeZone, scale, trailingValue, setMonth, setScale] ); const selectPeriod = useCallback( From f61b09ab0d568a05d0c64b5c9d0e83f1bd43f486 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 06:59:34 +0530 Subject: [PATCH 07/52] fix(calendar-preview): keep the value's shape in every part that reads it Dropping the `` type argument from `useCalendarPreviewContext` let `Value` fall back to `Date | null`, and TypeScript stopped checking the two shapes this family actually holds. Four defects followed from that one omission, none of them visible to the suite. `.Grid` passed `selected={(value as Date | null)}`. A scale-aware root carries `{ date, scale }` at day scale too, so react-day-picker was handed an object and no day was ever marked selected -- verified in a browser, not just jsdom. `selectDay` wrote a bare `Date` on a scale-aware root, against the RFC's own table: `scales` omitted or `'day'` keeps `Date`, anything else is a `ScaleValue`. A day click now emits `{ date, scale: 'day' }` there, and settles the draft on the way, or the input keeps showing the day the user passed through on the way back down to this scale. `.Reset` compared with `dayKey(value)`, which throws on a `ScaleValue`, and `defaultDate` was typed `Date` on all three arms. `defaultDate` now follows the value: a `ScaleValue` on the scale-aware arm. A default that cannot describe the value it restores is the same defect the range arm already fixed, and a reset that silently changed the scale would be a worse surprise than one more type. Restoring settles the scale with it. `.Trigger` and `useCalendar` narrowed on `value instanceof Date`, which no longer means "a range" now that a third shape exists. `isRange`, `isScaleValue` and `monthAnchor` are exported from the root and used instead. The audit covered all 18 call sites. Parts that only read `scale`, `month` or `timeZone` are untouched. `isDateUnavailable` is settled as **day scale only**, the last of the four questions deferred from the PR 894 review. A day predicate has no one lift to a period -- one blocked day blocking August is as wrong as it not blocking it -- and asking per cell would run it 365 times a year. Period cells stay bounded by `minDate` / `maxDate`, tested against the day the cell would emit. Documented on the prop rather than left implicit. Eight tests, each checked against the broken code first: the day a scale-aware root marks, the shape a day click emits there, the `Date` a day-only root still emits, a childless `.Trigger` labelling a period at its own scale, and four for `.Reset` at scale -- rendered while the value differs, not restored when only the day matches, disabled once day and scale both match, and restoring both. Comments trimmed throughout to the ones carrying a constraint rather than restating the code. --- .../__tests__/scale-selection.test.tsx | 115 ++++++++++++++++++ .../calendar-preview-body.tsx | 5 +- .../calendar-preview-context.tsx | 13 +- .../calendar-preview-days.tsx | 3 +- .../calendar-preview-grid.tsx | 24 +++- .../calendar-preview-input.tsx | 15 ++- .../calendar-preview-panel.tsx | 5 +- .../calendar-preview-periods.tsx | 42 +++---- .../calendar-preview-reset.tsx | 17 ++- .../calendar-preview-root.tsx | 87 +++++++------ .../components/calendar-preview/index.tsx | 3 +- 11 files changed, 223 insertions(+), 106 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index b96ff3350..62ad7d212 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -388,3 +388,118 @@ describe('CalendarPreview scale anchors on the visible month', () => { }); }); }); + +/* A scale-aware root carries `{ date, scale }` at every scale, day included. */ +describe('CalendarPreview at day scale on a scale-aware root', () => { + const dayCell = (container: HTMLElement, day: string) => { + const match = getAllSlots(container, 'calendar-preview-day').find( + cell => + getSlot(cell, 'calendar-preview-day-number')?.textContent === day && + !cell.hasAttribute('data-outside') + ); + if (!match) throw new Error(`no cell for day ${day}`); + return match; + }; + + it('marks the day the value carries', () => { + const { container } = renderBody({ + value: { date: '2026-08-20', scale: 'day' } + }); + expect(dayCell(container, '20')).toHaveAttribute('data-selected'); + }); + + it('commits a clicked day as a period at day scale', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange }); + fireEvent.click(dayCell(container, '20')); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-08-20', + scale: 'day' + }); + }); + + it('keeps a bare Date for a day-only root', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + fireEvent.click(dayCell(container, '20')); + expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2026, 7, 20)); + }); + + it('labels a childless .Trigger with the period, at its own scale', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-trigger')?.textContent).toBe( + 'Q3 2026' + ); + }); +}); + +/* `.Reset` only ever mounts at day scale; what it restores is the value, + whatever scale that holds. */ +describe('CalendarPreview.Reset at scale', () => { + const QUARTER = { date: '2026-07-01', scale: 'quarter' } as const; + const reset = (container: HTMLElement) => + getSlot(container, 'calendar-preview-reset') as HTMLElement; + + it('renders while the value differs from the period default', () => { + const { container } = renderBody({ + defaultDate: QUARTER, + value: { date: '2026-10-01', scale: 'quarter' } + }); + expect(reset(container)).toBeInTheDocument(); + expect(reset(container)).not.toBeDisabled(); + }); + + /* The same day at two scales is two different values. */ + it('is not restored when only the day matches', () => { + const { container } = renderBody({ + defaultDate: QUARTER, + value: { date: '2026-07-01', scale: 'month' } + }); + expect(reset(container)).not.toBeDisabled(); + }); + + it('stays mounted but disabled once the day and the scale both match', () => { + const { container } = renderBody({ + defaultDate: QUARTER, + value: QUARTER + }); + expect(reset(container)).toBeDisabled(); + expect(reset(container)).toHaveAttribute('data-restored'); + }); + + it('restores the day and the scale together', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ + defaultDate: QUARTER, + defaultValue: { date: '2026-08-20', scale: 'day' }, + onValueChange + }); + + fireEvent.click(reset(container)); + expect(onValueChange).toHaveBeenCalledWith( + QUARTER, + expect.objectContaining({ reason: 'reset' }) + ); + /* The scale came back with it. */ + expect(getSlot(container, 'calendar-preview-days')).toBeNull(); + expect( + (getSlot(container, 'calendar-preview-input') as HTMLInputElement).value + ).toBe('Q3 2026'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx index be1145b00..abe1ddb46 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx @@ -10,10 +10,7 @@ import { CalendarPreviewSeparator } from './calendar-preview-separator'; export type CalendarPreviewBodyProps = useRender.ComponentProps<'div'>; -/** - * The popup body: label, input, scale switcher and the view for the active - * scale. The input sits above the switcher, which is where the frames put it. - */ +/** The input sits above the switcher, which is where the frames put it. */ export function CalendarPreviewBody({ className, children, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index dad731a69..8bbb0606a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -69,7 +69,7 @@ export interface CalendarPreviewContextValue { * clears. Tracks the last close reason, never the open state. */ shouldIgnoreFocusOpen: () => boolean; - /** Read even when `value` is controlled. Whichever shape the value takes. */ + /** Read even when `value` is controlled. */ defaultDate: Date | CalendarPreviewDateRange | ScaleValue | null | undefined; /** A value reset — it never moves the view. */ reset: () => void; @@ -93,20 +93,13 @@ export interface CalendarPreviewContextValue { /** Every scale the switcher offers. One entry hides `.Scales`. */ scales: readonly Scale[]; - /** Whether a period emits its last day rather than its first. */ trailingValue: boolean; - /** - * The pending value after a scale switch or a keystroke. Never emitted — a - * cell click or Enter commits it, Escape drops it. - */ + /** Never emitted: a cell click or Enter commits it, Escape drops it. */ scaleDraft: ScaleValue | null; - /** Moves the view and sets the draft. Emits nothing. */ switchScale: (scale: Scale) => void; - /** Commits a period at `scale`, honouring `trailingValue`. */ + /** Honours `trailingValue`. */ selectPeriod: (date: Date | string, scale: Scale) => void; - /** Drops the draft; the input falls back to `value`. */ dropDraft: () => void; - /** Whether the period containing `date` can be selected at `scale`. */ isPeriodAvailable: (date: Date | string, scale: Scale) => boolean; selection: 'single' | 'range'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx index 0fd0bbae3..7d8f6abde 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -66,8 +66,7 @@ export function CalendarPreviewDays({ ) }); - /* A sibling of the period views, gating the same way, so `.Panel` can mount - all five and only the active one renders. */ + /* Gates like the period views, so `.Panel` can mount all five. */ if (scale !== 'day') return null; return ( diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 78154e1e9..323c7d7f2 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -35,7 +35,15 @@ import { CalendarPreviewNextMonth, CalendarPreviewPrevMonth } from './calendar-preview-header'; -import { formatCaptionLabel, formatWeekdayLabel } from './date-adapter'; +import { + type CalendarPreviewValue, + isScaleValue +} from './calendar-preview-root'; +import { + formatCaptionLabel, + formatWeekdayLabel, + parseKey +} from './date-adapter'; /* The only file that may import react-day-picker. It runs with `hideNavigation` and `captionLayout='label'` so it never mounts a `Select`, @@ -160,7 +168,7 @@ export function CalendarPreviewGrid({ clearable, disabled, readOnly - } = useCalendarPreviewContext('CalendarPreview.Grid'); + } = useCalendarPreviewContext('CalendarPreview.Grid'); const days = useCalendarPreviewDaysContext(); const setBusy = days?.setBusy; @@ -188,6 +196,14 @@ export function CalendarPreviewGrid({ const months = days?.numberOfMonths ?? 1; + /* A scale-aware root carries `{ date, scale }` at day scale too, so the day + to mark is inside the value rather than being it. */ + const selected = isScaleValue(value) + ? parseKey(value.date) + : value instanceof Date + ? value + : undefined; + /* Several months have no single header to caption them, so each month captions itself and `.Days` renders no `.Header` above. */ const slots = useMemo( @@ -260,7 +276,7 @@ export function CalendarPreviewGrid({ {...base} mode='single' required={false} - selected={(value as Date | null) ?? undefined} + selected={selected} onSelect={handleSelect} /> ) : ( @@ -268,7 +284,7 @@ export function CalendarPreviewGrid({ {...base} mode='single' required - selected={(value as Date | null) ?? undefined} + selected={selected} onSelect={handleSelect} /> )} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 9183deedb..d8857c231 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -5,6 +5,10 @@ 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 { + type CalendarPreviewValue, + isRange as isRangeValue +} from './calendar-preview-root'; import { dayKey, parseKey } from './date-adapter'; import { parseScaleInput } from './lib/parse'; import type { Scale } from './lib/scale'; @@ -99,7 +103,7 @@ export function CalendarPreviewInput({ activeField, setActiveField, setFieldReadOnly - } = useCalendarPreviewContext('CalendarPreview.Input'); + } = useCalendarPreviewContext('CalendarPreview.Input'); const isRange = selection === 'range'; @@ -194,8 +198,8 @@ export function CalendarPreviewInput({ } const resolved = resolve(trimmed); if ('valid' in resolved) return; - /* A range is day-only, so a typed endpoint is always a day: it writes the - field it was typed into rather than running the click machine. */ + /* A typed endpoint writes the field it was typed into; only a click means + "the next endpoint". */ if (isRange) setEndpoint(field, resolved.date); else if (resolved.scale !== 'day') selectPeriod(resolved.date, resolved.scale); @@ -208,10 +212,9 @@ export function CalendarPreviewInput({ const endpoint = isRange ? ((field === 'start' ? draft?.from : draft?.to) ?? null) - : (scaleDraft ?? (value as Date | null)); + : (scaleDraft ?? (isRangeValue(value) ? null : value)); const committedText = endpoint ? formatValue(endpoint, scale) : ''; - /* A multi-scale field has to advertise what it accepts; a day-only one does - not, and the old placeholder still reads correctly there. */ + /* A multi-scale field has to advertise what it accepts. */ const resolvedPlaceholder = placeholder ?? (scales.length > 1 diff --git a/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx index 73d9c4238..628c0125b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx @@ -13,9 +13,8 @@ import { export type CalendarPreviewPanelProps = useRender.ComponentProps<'div'>; /** - * The view container. Mounts all five views when childless; each one gates on - * the active scale itself, so a consumer can mount `.Quarters` alone with no - * day grid in the tree. + * Every view gates on the active scale itself, so a consumer can mount + * `.Quarters` alone with no day grid in the tree. */ export function CalendarPreviewPanel({ className, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx index 632158782..78256ecfe 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -3,6 +3,10 @@ import { cx } from 'class-variance-authority'; import { useEffect, useMemo, useRef } from 'react'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; +import { + type CalendarPreviewValue, + isScaleValue +} from './calendar-preview-root'; import { dayKey, monthShortNames, monthStart, yearOf } from './date-adapter'; import { anchorOf, periodOf, type Scale } from './lib/scale'; @@ -43,11 +47,8 @@ function cellsFor(scale: Scale, year: number): Cell[] { } /** - * One scale's period list. - * - * Every year is a heading inside a single scrolling column rather than a page - * of its own, so the whole list scrolls past the bounds — periods outside them - * render disabled rather than being cut off. + * Every year is a heading inside one scrolling column rather than a page of its + * own, so periods outside the bounds render disabled rather than being cut off. */ function PeriodView({ scale: viewScale, @@ -75,7 +76,9 @@ function PeriodView({ timeZone, disabled, readOnly - } = useCalendarPreviewContext('CalendarPreview.Periods'); + } = useCalendarPreviewContext( + 'CalendarPreview.Periods' + ); const years = useMemo(() => { const list: number[] = []; @@ -87,28 +90,17 @@ function PeriodView({ draft wins: it is what the user is looking at after a scale switch. */ const activeYear = yearOf( scaleDraft?.date ?? - (value && !(value instanceof Date) && 'date' in value - ? (value as { date: string }).date - : dayKey(month, timeZone)) + (isScaleValue(value) ? value.date : dayKey(month, timeZone)) ); const selectedKey = - scaleDraft?.date ?? - (value && !(value instanceof Date) && 'date' in value - ? (value as { date: string }).date - : null); + scaleDraft?.date ?? (isScaleValue(value) ? value.date : null); - /* - * A twenty-year list otherwise opens on its first year, twenty scrolls from - * the one the user means. - * - * Runs when the view becomes active, not on mount: every view is mounted at - * once and hooks still run while one returns null, so a mount effect fires - * with an empty ref and never fires again when the view appears. - * - * Scrolls the container rather than calling `scrollIntoView`, which walks - * every scrollable ancestor and would move the popover with it. - */ + /* A twenty-year list otherwise opens twenty scrolls from the year meant. + Keyed on becoming active, not on mount: every view mounts at once, so a + mount effect fires with an empty ref and never fires again. Scrolls the + container rather than `scrollIntoView`, which would walk out and move the + popover with it. */ const activeRef = useRef(null); const isActive = scale === viewScale; useEffect(() => { @@ -192,8 +184,6 @@ function PeriodView({ ) }); - /* Sibling views all mount; each gates on the active scale, so `.Quarters` - can stand alone with no day grid in the tree. */ return isActive ? element : null; } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx index 680ea0de9..62a3a28ea 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -16,17 +16,14 @@ import { dayKey } from './date-adapter'; export type CalendarPreviewResetProps = ComponentProps; /** - * Restores `defaultDate` — a day, a range at range selection, or a period at a - * coarser scale — 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`. + * Restores `defaultDate` — a day, a range, or a period at a coarser scale — or + * clears 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`. * - * With nothing to restore it stays mounted and disabled rather than - * unmounting: unmounting the focused element sends focus to ``, which - * strands a keyboard user mid-calendar, and removing a `flex: none` child - * from the header re-flows both nav buttons sideways every time the value - * crosses the default. + * With nothing to restore it stays mounted and disabled rather than unmounting: + * that would send focus to `` mid-calendar, and drop a `flex: none` child + * that keeps both nav buttons in place. */ export function CalendarPreviewReset({ className, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 97fb6ceb3..a17884238 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -42,8 +42,8 @@ export 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. - Also the day a change reports, which is always one day. */ +/* The one day a value stands for, whichever shape it takes: the month to open + on, and the day a change reports. */ export function monthAnchor( value: CalendarPreviewValue | undefined ): Date | undefined { @@ -69,7 +69,6 @@ export function isScaleValue(value: CalendarPreviewValue): value is ScaleValue { both edges. One shared `value` type would widen both. */ interface CalendarPreviewSingleProps { selection?: 'single'; - /** @defaultValue 'day' */ scales?: 'day'; /** The selected day (controlled). */ value?: Date | null; @@ -110,17 +109,13 @@ interface CalendarPreviewRangeProps { defaultDate?: CalendarPreviewDateRange | null; } -/* - * Open Item 1 in the RFC: expressing "day-only keeps `Date`" so that - * `['day','month']` still narrows. TypeScript cannot test an array's contents, - * so the discriminator is the SHAPE of `scales` rather than its members — - * omitted or the literal `'day'` keeps `Date`; any other scale, or any array, - * moves to `ScaleValue`. The wart is that `scales={['day']}` takes the - * scale-aware arm where `scales='day'` does not. - */ +/* TypeScript cannot test an array's contents, so the arms discriminate on the + SHAPE of `scales`: omitted or the literal `'day'` keeps `Date`, anything else + moves to `ScaleValue`. The wart is that `scales={['day']}` takes this arm + where `scales='day'` does not (RFC 005, Open Item 1). */ interface CalendarPreviewScaleAwareProps { - /* Ranges across scales are not a thing this ships — a start/end pair is two - independent roots, each with its own `scales` and `trailingValue`. */ + /* A start/end pair is two independent roots, each with its own `scales` and + `trailingValue`, so there is no range arm here. */ selection?: 'single'; scales: Exclude | Scale[]; /** The selected period. `date` is timeless `'YYYY-MM-DD'`. */ @@ -130,11 +125,7 @@ interface CalendarPreviewScaleAwareProps { value: ScaleValue | null, details: CalendarPreviewChangeDetails ) => void; - /** - * The period `.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. - */ + /** The period `.Reset` restores. `null` clears; omitted renders no button. */ defaultDate?: ScaleValue | null; } @@ -152,9 +143,9 @@ interface CalendarPreviewSharedProps scale?: Scale; onScaleChange?: (scale: Scale) => void; /** - * Whether a period emits its last day rather than its first — an end field - * wants 31 July from "July 2026", a start field wants the 1st. It changes - * the value, not the formatting. + * Whether a period emits its last day rather than its first: an end field + * wants 31 July from "July 2026", a start field wants the 1st. The value + * changes, not the formatting. * @defaultValue false */ trailingValue?: boolean; @@ -187,7 +178,13 @@ interface CalendarPreviewSharedProps minDate?: Date; /** Latest selectable day, inclusive. Never clamps navigation. */ maxDate?: Date; - /** Reject individual days. Applied on top of `minDate` / `maxDate`. */ + /** + * Reject individual days. Applied on top of `minDate` / `maxDate`. + * + * **Day scale only** — a day predicate has no one lift to a period, and + * asking it per cell would run it 365 times a year. Period cells are bounded + * by `minDate` / `maxDate`, tested against the day the cell would emit. + */ isDateUnavailable?: (date: Date) => boolean; /** @@ -337,6 +334,10 @@ export function CalendarPreviewRoot({ const [scaleDraft, setScaleDraft] = useState(null); + /* The only runtime read of the arms' rule: an array carries the scale even + when that scale is `'day'`. */ + const carriesScale = Array.isArray(scalesProp) || scalesProp !== 'day'; + const setMonth = useCallback( (next: Date) => { setMonthUnwrapped(next); @@ -447,11 +448,22 @@ export function CalendarPreviewRoot({ 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); + const key = dayKey(date, timeZone); + const current = isScaleValue(value) + ? value.date + : value instanceof Date + ? dayKey(value, timeZone) + : null; + /* The input reads the draft first, so leaving one behind would show + the day the user passed through on the way back down. */ + setScaleDraft(null); + if (current === key && clearable) setValue(null, 'clear', date); + else + setValue( + carriesScale ? { date: key, scale: 'day' } : date, + 'select', + date + ); return; } @@ -484,6 +496,7 @@ export function CalendarPreviewRoot({ draft, fieldReadOnly, clearable, + carriesScale, timeZone, readOnly, disabled, @@ -492,7 +505,6 @@ export function CalendarPreviewRoot({ ] ); - /* The value as a ScaleValue, whichever shape the consumer holds. */ const scaleValue = useMemo(() => { if (scaleDraft) return scaleDraft; if (value instanceof Date) return { date: dayKey(value, timeZone), scale }; @@ -500,14 +512,12 @@ export function CalendarPreviewRoot({ return null; }, [scaleDraft, value, scale, timeZone]); - /* A scale switch moves the view and drafts; it never emits. The draft is - what the user is looking at, so the input and the views read it. */ + /* Never emits: the draft is what the user is looking at, and a cell click + or Enter commits it. */ const switchScale = useCallback( (next: Scale) => { - /* Falls back to the month on screen, not to today: a consumer opening on - 2030, or a user who navigated there, must not be thrown back to this - year by switching scale. `month` already resolves to today when - nothing else set it. */ + /* The month on screen, not today: someone who navigated to 2030 must + not be thrown back by switching scale. */ const anchor = scaleValue ?? { date: dayKey(month, timeZone), scale @@ -533,13 +543,14 @@ export function CalendarPreviewRoot({ [trailingValue, timeZone, readOnly, disabled, setValue, setOpen] ); - /* Restoring the input means restoring the scale too: a day value rendered at - the drafted quarter scale would still read "Q3 2026". */ + /* The scale comes back with the value: a day rendered at the drafted quarter + scale would still read "Q3 2026". */ const dropDraft = useCallback(() => { setScaleDraft(null); setScaleUnwrapped(isScaleValue(value) ? value.scale : scales[0]); }, [value, scales, setScaleUnwrapped]); + /* Bounds only, never `isDateUnavailable` — the prop documents why. */ const isPeriodAvailable = useCallback( (date: Date | string, next: Scale) => isAvailable(date, next, trailingValue, minDate, maxDate, timeZone), @@ -574,9 +585,7 @@ export function CalendarPreviewRoot({ consumer that logs or validates on selection needs to tell them apart. */ const reset = useCallback(() => { if (defaultDate === undefined) return; - /* Restoring settles the scale the same way dropping a draft does: a - drafted quarter would otherwise keep rendering the restored value as a - period it is not. */ + /* Settles the scale the way dropping a draft does, for the same reason. */ setScaleDraft(null); setScaleUnwrapped( isScaleValue(defaultDate) ? defaultDate.scale : scales[0] diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index 066802d3c..b3b371830 100644 --- a/packages/raystack/components/calendar-preview/index.tsx +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -38,8 +38,7 @@ export type { export type { CalendarPreviewSeparatorProps } from './calendar-preview-separator'; export type { CalendarPreviewTriggerProps } from './calendar-preview-trigger'; /* Prefixed on the way out, short inside: `CalendarPreview.Scale` is a part, so - the module cannot also call its type `Scale` — and the package root exports no - unprefixed generic names. */ + the module cannot also call its type `CalendarPreviewScale`. */ export type { Scale as CalendarPreviewScale, ScaleValue as CalendarPreviewScaleValue From 8076a03acfaf906af9176b7c77965a261a574b37 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 06:59:48 +0530 Subject: [PATCH 08/52] docs(calendar-preview): document the period default, formatValue and trailing edges `formatValue` went missing from the props table when the audit withdrew it, but the prop exists and every trigger, input and annotation renders through it. It comes back with the current default, `DD MMM YYYY` at day scale. `defaultDate` gains its third shape: it follows the value, so a scale-aware root restores a period rather than a day. `isDateUnavailable` says it is day scale only, on the prop and in the bounds section, with the reason and what does bound a period cell. A Trailing value tab on the scale demo, which nothing showed before: the same quarter in a start field and an end field, with the two emitted dates printed underneath -- 2026-07-01 against 2026-09-30. The prop changes the value, not the formatting, and both triggers reading "Q3 2026" is the point. --- .../docs/components/calendar-preview/demo.ts | 35 +++++++++++++++++++ .../components/calendar-preview/index.mdx | 2 ++ .../docs/components/calendar-preview/props.ts | 4 ++- 3 files changed, 40 insertions(+), 1 deletion(-) 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 2d53d24fb..2f84f1319 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -692,6 +692,41 @@ export const scaleDemo = { > ` + }, + { + name: 'Trailing value', + code: ` +function CalendarPreviewTrailingExample() { + const scales = ['day', 'month', 'quarter', 'halfYear', 'year']; + const [start, setStart] = React.useState({ date: '2026-07-01', scale: 'quarter' }); + const [end, setEnd] = React.useState({ date: '2026-09-30', scale: 'quarter' }); + + return ( + + + + + + + + + + → + + + + + + + + + + + Emitted: {start.date} → {end.date} + + + ); +}` } ] }; 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 9ce7007b0..4bb093af3 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -266,6 +266,8 @@ Each part renders a default; children replace it. `minDate`, `maxDate` and `isDateUnavailable` disable cells. **None of them clamps navigation** — the chevrons and the scroller still reach any month. Bounds compare whole calendar days, so a `minDate` carrying a time of day still leaves its own day selectable. +`isDateUnavailable` is **day scale only**. A month, quarter, half-year or year cell never calls it — a day predicate has no single lift to a period, and answering per cell would run it 365 times a year. Period cells are bounded by `minDate` and `maxDate` instead, tested against the day the cell would emit, which is the same rule that makes a period available to one end of a pair and not the other. + ### Grid layout 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 f3830449a..ac7b5f94f 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -51,7 +51,9 @@ export interface CalendarPreviewProps { maxDate?: Date; /** - * Reject individual days, on top of `minDate` / `maxDate`. + * Reject individual days, on top of `minDate` / `maxDate`. Day scale only — + * period cells never call it, and are bounded by `minDate` / `maxDate` + * against the day they would emit. * @example isDateUnavailable={date => date.getDay() === 0} */ isDateUnavailable?: (date: Date) => boolean; From 97401be9d96bba783c159647892939ce73a996fb Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 07:00:03 +0530 Subject: [PATCH 09/52] fix(calendar-preview): one width for the panel, and labels that fit the switcher Three sizing defects, all of them visible only once five views shared a popover. The day grid is seven 40px columns; the period lists had no width of their own, so they measured 282px against the day view's 296 and the popover resized on every scale switch. The panel now fixes one width for all five views. `Tabs` gives every trigger `flex: 1 1 0%`, so five labels split that width into equal fifths and "Half-year" -- the only label that needs more than a fifth -- lost its padding and ran into "Year". The switcher takes the size the primitive already ships for a dense surface, and its labels each take the width they need and share what is left. Scoped to two classes because the primitive's own rule loads after this one and was winning on source order. `Tabs` is untouched. Under the switcher the day view drops its inset and its columns share the row, so the grid lines up with the input and the tabs instead of sitting in from them. Standalone, `.Days` is still its own inset surface -- the plain date picker is unchanged. Measured in Chrome rather than reasoned about: 296px panel at all five scales, and with half-year active the triggers come out 41.9 / 55.4 / 61.9 / 71.3 / 45.4 with nothing clipped. `styles.scale` is deleted from both parts. It named a rule that never existed, so it had been passing `undefined` since the parts were written; the switcher's tab now uses the rule this commit adds. --- .../calendar-preview-scales.tsx | 11 ++++--- .../calendar-preview.module.css | 29 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx index b864a7e7b..10271e9d4 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx @@ -15,10 +15,8 @@ const LABELS: Record = { export type CalendarPreviewScalesProps = useRender.ComponentProps<'div'>; -/** - * The scale switcher. Renders nothing when only one scale is offered, which is - * what keeps a plain day calendar from growing a one-tab row. - */ +/* Renders nothing for a single scale, so a plain day calendar does not grow a + one-tab row. */ export function CalendarPreviewScales({ className, children, @@ -40,6 +38,7 @@ export function CalendarPreviewScales({ 'data-slot': 'calendar-preview-scales', children: children ?? ( switchScale(next as Scale)} > @@ -74,7 +73,7 @@ export interface CalendarPreviewScaleProps value: Scale; } -/** One scale. Only needed to relabel or reorder what `.Scales` renders. */ +/** Only needed to relabel or reorder what `.Scales` renders. */ export function CalendarPreviewScale({ value, className, @@ -94,7 +93,7 @@ export function CalendarPreviewScale({ props: mergeProps<'button'>( { type: 'button', - className: cx(styles.scale, className), + className, 'data-slot': 'calendar-preview-scale', 'data-scale': value, 'data-active': scale === value || undefined, diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 1be803eaf..868d456f5 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -559,6 +559,35 @@ display: flex; } +/* `Tabs` gives every trigger an equal share of the row, and a fifth of the day + grid's width does not hold "Half-year". Each label takes the width it needs + and shares what is left; scoped to beat the primitive's own `flex`. */ +.scales .scale { + flex: 1 1 auto; +} + +/* Nothing inside the panel has a width of its own to hand the popover, so the + panel is the anchor for all five views — seven 40px columns, the width the + input and switcher above it run at — and the popover stops resizing. */ +.panel { + width: calc(var(--rs-space-10) * 7 + var(--rs-space-3) * 2); +} + +/* Standalone, `.Days` is its own inset surface. Under the switcher it is one + view of five, so it drops the inset and its columns share the row: the grid + lines up with the input and the tabs instead of sitting in from them. */ +.panel .days { + width: 100%; + padding-inline: 0; +} + +.panel .weekday, +.panel .day, +.panel .week-number, +.panel .week-number-header { + flex: 1; +} + /* The day view hugs; every period list is a fixed box that scrolls as one, so the year headings scroll with their cells rather than pinning. */ .panel[data-scale="day"] { From 6a607f5c67cb1b4778aacc68bd20ff923a742c54 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 10 Sep 2026 07:14:10 +0530 Subject: [PATCH 10/52] docs(calendar-preview): say the scale is read-only by decision, not by omission The PR 894 audit withdrew `useCalendar().setScale` and said it would return "with the scale switcher in phase 5". The switcher has landed here and the setter has not, so both comments now read as an unfinished job rather than a choice. It is a choice. Switching scale is `.Scales` and `.Scale`, and `.Scale` takes `render` and children for custom chrome, so a consumer who wants their own switcher already has a supported route that is not a hook setter. A setter would be public API we cannot take back, and it would not come alone: `switchScale` sets a draft that only a commit or Escape clears, so the hook would have to expose `dropDraft` beside it or ship a state it can enter and not leave. Adding it later stays additive, which is what makes waiting free. Comments only -- no behaviour, no types, no exports. --- .../src/content/docs/components/calendar-preview/props.ts | 5 ++++- .../raystack/components/calendar-preview/use-calendar.tsx | 7 ++++--- 2 files changed, 8 insertions(+), 4 deletions(-) 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 ac7b5f94f..642ed4102 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -215,7 +215,10 @@ export interface UseCalendarReturn { /** Commit a day, or clear with `null`. Emits `onValueChange`. */ setValue: (value: Date | null) => void; - /** The granularity the value is committed at. Read-only until phase 5. */ + /** + * The granularity the value is committed at. Read-only — switching scale is + * `.Scales` and `.Scale`, which take `render` for custom chrome. + */ scale: 'day' | 'month' | 'quarter' | 'halfYear' | 'year'; /** The first month currently displayed. */ diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx index d7b64461e..163cefa84 100644 --- a/packages/raystack/components/calendar-preview/use-calendar.tsx +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -12,9 +12,10 @@ export interface UseCalendarReturn { 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. */ + /* Read-only by decision, not by omission: switching scale is `.Scales` and + `.Scale`, which take `render` for custom chrome. A setter here would be + public API we cannot take back, and it stays out until something needs + one — adding it later is additive. */ scale: Scale; month: Date; /** Bounds never clamp the view. */ From 66e865c73eab9f57db31e7332c1fd03fc081700d Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 15 Sep 2026 18:35:39 +0530 Subject: [PATCH 11/52] docs(calendar-preview): retire the comments that were waiting on this PR Three comments described the scale switcher as something still to come, written when it was. `scale` is a controlled prop now, not "uncontrolled until the switcher lands in PR 5"; the day grid's `data-draft` note can say what the scale-switch draft does rather than what PR 5 will do; and the context is generic for the scale-aware arms, not for "a later phase's". The docs page still told a reader the setter arrives with the switcher in a later phase, which contradicts the decision recorded a commit ago. It now says the same thing as the type and the props table. The props table never learned this PR's props. `scales`, `defaultScale`, `scale`, `onScaleChange` and `trailingValue` are added, and `UseCalendarReturn.value` admits the three shapes it can hold instead of claiming `Date | null` while already carrying a range or a period. Comments and docs only -- no behaviour, no types, no exports. --- .../components/calendar-preview/index.mdx | 3 +- .../docs/components/calendar-preview/props.ts | 46 +++++++++++++++++-- .../calendar-preview-context.tsx | 5 +- .../calendar-preview-grid.tsx | 2 +- .../calendar-preview-root.tsx | 4 +- 5 files changed, 49 insertions(+), 11 deletions(-) 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 4bb093af3..05a7bf4ee 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -167,7 +167,8 @@ const { value, setValue, scale, month, setMonth, isDateUnavailable } = useCalend ``` Calling it outside a `CalendarPreview` throws, naming the part that asked. `scale` is -read-only for now — the setter arrives with the scale switcher in a later phase. +read-only: switching scale is `.Scales` and `.Scale`, and `.Scale` takes `render` if you +want your own chrome. `setValue(null)` clears the selection and reports `reason: 'clear'`, carrying the day that was cleared as `details.toDate()`. 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 642ed4102..f93be14f9 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -81,6 +81,31 @@ export interface CalendarPreviewProps { scale: Scale ) => string; + /** + * The granularities this root offers. One entry hides the switcher; anything + * beyond `"day"` moves the value to `{ date, scale }`. + * @default "day" + * @example scales={['day', 'month', 'quarter']} + */ + scales?: Scale | Scale[]; + + /** The scale the picker opens on. Defaults to the first of `scales`. */ + defaultScale?: Scale; + + /** The active scale (controlled). */ + scale?: Scale; + + /** Called when the switcher moves. */ + onScaleChange?: (scale: Scale) => void; + + /** + * Whether a period emits its last day rather than its first — an end field + * wants 31 July from "July 2026", a start field wants the 1st. It changes the + * value, not the formatting. + * @default false + */ + trailingValue?: boolean; + /** * The zone the grid reads days in. Forwarded to the grid; the component does * no conversion of its own. @@ -209,11 +234,24 @@ export interface CalendarPreviewResetProps { /** What the enclosing root exposes to a custom part. */ export interface UseCalendarReturn { - /** The committed day, or null. */ - value: Date | null; + /** + * The committed value, or null. A day, a range at `selection="range"`, or a + * period at a coarser scale — whichever shape this root holds. + */ + value: + | Date + | { from: Date; to: Date } + | { date: string; scale: Scale } + | null; - /** Commit a day, or clear with `null`. Emits `onValueChange`. */ - setValue: (value: Date | null) => void; + /** Commit a value, or clear with `null`. Emits `onValueChange`. */ + setValue: ( + value: + | Date + | { from: Date; to: Date } + | { date: string; scale: Scale } + | null + ) => void; /** * The granularity the value is committed at. Read-only — switching scale is diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 8bbb0606a..73cdd5a6a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -45,9 +45,8 @@ export interface CalendarPreviewChangeDetails { toDate: () => Date; } -/* Generic so a later phase's scale-aware arms carry a - `ScaleValue` without a second context: stored as `unknown`, - cast once at the hook boundary. */ +/* Generic so the scale-aware arms carry a `ScaleValue` without a second + context: stored as `unknown`, cast once at the hook boundary. */ export interface CalendarPreviewContextValue { value: Value; /** `occasion` is the day acted on, which a cleared `value` cannot carry. */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 323c7d7f2..6b5352c1f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -380,7 +380,7 @@ export interface CalendarPreviewDayProps Pick, 'render' | 'ref'> {} /* At day scale the draft is the roving-focus cell — arrowed to, not entered. - PR 5's scale-switch draft writes the same attribute. */ + The scale-switch draft writes the same attribute at the period scales. */ export function CalendarPreviewDay({ day, modifiers, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index a17884238..84c034ea2 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -316,8 +316,8 @@ export function CalendarPreviewRoot({ state: 'month' }); - /* Uncontrolled until the scale switcher lands in PR 5. The state lives here - now so the parts and `useCalendar()` read it from one place either way. */ + /* Normalised to `SCALES` order, so the switcher reads finest-first whatever + order the consumer passed, and unknown entries drop out. */ const scales = useMemo(() => { const list = (Array.isArray(scalesProp) ? scalesProp : [scalesProp]).filter( isScale From 39b05b47fd70bf1469d1e2c6024b1dd2f554a4d2 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 15 Sep 2026 20:19:20 +0530 Subject: [PATCH 12/52] docs(calendar-preview): catch props.ts up with the scale-aware arms props.ts documented the single-day arm alone while index.mdx and demo.ts were current, so the props table was missing everything this PR and the range PR added. Adds `selection`, the three value shapes on `value` / `defaultValue` / `onValueChange`, the `'reset'` reason, and `open` / `defaultOpen` / `onOpenChange`. Spells out the `scales={['day']}` wart, which a consumer cannot infer: the array form takes the scale-aware arm whatever it holds, so it types the value as a period where the bare string keeps a Date. --- .../docs/components/calendar-preview/props.ts | 62 ++++++++++++++++--- 1 file changed, 54 insertions(+), 8 deletions(-) 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 f93be14f9..fc65fcbf3 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -3,26 +3,66 @@ import { ReactNode } from 'react'; type Scale = 'day' | 'month' | 'quarter' | 'halfYear' | 'year'; export interface CalendarPreviewProps { - /** The selected day (controlled). */ - value?: Date | null; + /** + * Whether the grid picks one day or a span. A range is two edges or nothing; + * the half-built state stays internal. + * @default "single" + */ + selection?: 'single' | 'range'; - /** The initially selected day (uncontrolled). */ - defaultValue?: Date | null; + /** + * The selected value (controlled). Its shape follows the root: a `Date` by + * default, `{ from, to }` at `selection="range"`, and `{ date, scale }` once + * `scales` offers anything beyond `"day"` — `date` is a timeless + * `"YYYY-MM-DD"`. + */ + value?: + | Date + | { from: Date; to: Date } + | { date: string; scale: Scale } + | null; + + /** The initial value (uncontrolled). Same shape as `value`. */ + defaultValue?: + | Date + | { from: Date; to: Date } + | { date: string; scale: Scale } + | null; /** - * Called when a day is committed or cleared. `details.toDate()` returns the - * day acted on even when `value` is `null`. + * Called when a value is committed or cleared, with the same shape as + * `value`. `details.toDate()` returns the day acted on even when the value is + * `null`. A range fires on a complete range or not at all. * @example onValueChange={(value, details) => console.log(details.reason)} */ onValueChange?: ( - value: Date | null, + value: + | Date + | { from: Date; to: Date } + | { date: string; scale: Scale } + | null, details: { - reason: 'select' | 'input' | 'clear' | 'scale'; + reason: 'select' | 'input' | 'clear' | 'reset' | 'scale'; period: { start: string; end: string }; toDate: () => Date; } ) => void; + /** Whether the popover is open (controlled). Ignored by an inline calendar. */ + open?: boolean; + + /** @default false */ + defaultOpen?: boolean; + + /** + * Called when the popover opens or closes. `details` is Base UI's own, + * forwarded unchanged, so `details.reason` stays the union it narrows on. + */ + onOpenChange?: ( + open: boolean, + details: { reason?: string; event?: Event } + ) => void; + /** The first month the grid displays (controlled). */ month?: Date; @@ -84,6 +124,12 @@ export interface CalendarPreviewProps { /** * The granularities this root offers. One entry hides the switcher; anything * beyond `"day"` moves the value to `{ date, scale }`. + * + * The array form takes the scale-aware arm whatever it holds, so + * `scales={['day']}` types the value as `{ date, scale }` while the bare + * string `scales="day"` keeps it a `Date`. TypeScript cannot read an array's + * contents, so the two spellings of a day-only calendar are not equivalent — + * pass the string unless you want the period shape. * @default "day" * @example scales={['day', 'month', 'quarter']} */ From 02fb9091d6eb3561af40ed583adfcd4f6838be1e Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 15 Sep 2026 21:15:08 +0530 Subject: [PATCH 13/52] docs(calendar-preview): cut the comments to the constraints that need one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sibling components carry almost none — tabs.tsx has no comments in 96 lines, calendar.tsx none in 269 — and the props table is generated from props.ts, so prop prose in the source was a second copy to keep in step. Drops the doc blocks that restated the code beneath them and the test comments that restated their own test names. Keeps the constraints a reader would otherwise get wrong: why the arms discriminate on the shape of `scales`, why the period scroll effect keys on becoming active rather than mount, why the ref lags `activeYear`, and why `.Reset` stays mounted. The scale-aware arm keeps a one-line JSDoc per prop, matching the density of the single and range arms beside it. --- .../__tests__/scale-selection.test.tsx | 21 ------- .../calendar-preview-body.tsx | 1 - .../calendar-preview-context.tsx | 3 +- .../calendar-preview-grid.tsx | 3 +- .../calendar-preview-input.tsx | 3 - .../calendar-preview-panel.tsx | 5 +- .../calendar-preview-periods.tsx | 19 ++---- .../calendar-preview-reset.tsx | 16 +---- .../calendar-preview-root.tsx | 58 +++++++------------ .../calendar-preview-scales.tsx | 3 - .../calendar-preview-trigger.tsx | 3 +- .../calendar-preview/use-calendar.tsx | 5 +- 12 files changed, 33 insertions(+), 107 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index 62ad7d212..236a41e91 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -15,8 +15,6 @@ function renderBody(props = {}) { ); } -/* The list runs across every year in `yearRange`, so a label alone is - ambiguous — "Aug" exists once per year. */ const period = (container: HTMLElement, label: string, year = 2026) => { const group = getAllSlots(container, 'calendar-preview-period-group').find( node => @@ -69,8 +67,6 @@ describe('CalendarPreview scale switching', () => { }); describe('CalendarPreview trailingValue', () => { - /* The value itself changes, not the formatting — a start field emits the - period's first day and an end field its last. */ it.each([ ['month', 'Aug', '2026-08-01', '2026-08-31'], ['quarter', 'Q3', '2026-07-01', '2026-09-30'], @@ -110,15 +106,9 @@ describe('CalendarPreview trailingValue', () => { }); }); -/* The RFC's table: an end field bounded at 15 July 2026 disables H1 2026, - which would emit 30 June, while allowing July and Q3, which emit later. The - same periods are all available to a start field. */ describe('CalendarPreview availability differs by field', () => { const bounded = { minDate: new Date(2026, 6, 15), today: TODAY }; - /* The same period, opposite answers: Q3 2026 starts 1 July — before the - bound — but ends 30 September, after it. Only the produced date separates - them, which is the whole reason availability takes `trailing`. */ it.each([ ['quarter', 'Q3'], ['month', 'Jul'] @@ -199,8 +189,6 @@ describe('CalendarPreview period views mount alone', () => { }); }); -/* DataView cells and FilterChip labels render the annotation with no calendar - anywhere in the tree. */ describe('CalendarPreview.Trigger annotation', () => { it.each([ ['day', '2026-07-02', '02 Jul 2026'], @@ -315,7 +303,6 @@ describe('CalendarPreview change details at scale', () => { }); }); - /* Typing commits a scale the view has not moved to yet. */ it('reports the typed scale period, not the scale still on screen', () => { const onValueChange = vi.fn(); const { container } = renderBody({ onValueChange }); @@ -329,8 +316,6 @@ describe('CalendarPreview change details at scale', () => { }); describe('CalendarPreview scale anchors on the visible month', () => { - /* The day grid is showing 2030; switching scale must land there, not on - whatever year today happens to be. */ it('drafts from the view month rather than today', () => { const onValueChange = vi.fn(); const { container } = render( @@ -362,7 +347,6 @@ describe('CalendarPreview scale anchors on the visible month', () => { ); switchTo(container, 'month'); - /* Both years exist in the list; the point is which one is anchored. */ expect(period(container, 'Jan', 2030)).toBeInTheDocument(); expect(period(container, 'Jan', 2026)).toBeInTheDocument(); }); @@ -389,7 +373,6 @@ describe('CalendarPreview scale anchors on the visible month', () => { }); }); -/* A scale-aware root carries `{ date, scale }` at every scale, day included. */ describe('CalendarPreview at day scale on a scale-aware root', () => { const dayCell = (container: HTMLElement, day: string) => { const match = getAllSlots(container, 'calendar-preview-day').find( @@ -449,8 +432,6 @@ describe('CalendarPreview at day scale on a scale-aware root', () => { }); }); -/* `.Reset` only ever mounts at day scale; what it restores is the value, - whatever scale that holds. */ describe('CalendarPreview.Reset at scale', () => { const QUARTER = { date: '2026-07-01', scale: 'quarter' } as const; const reset = (container: HTMLElement) => @@ -465,7 +446,6 @@ describe('CalendarPreview.Reset at scale', () => { expect(reset(container)).not.toBeDisabled(); }); - /* The same day at two scales is two different values. */ it('is not restored when only the day matches', () => { const { container } = renderBody({ defaultDate: QUARTER, @@ -496,7 +476,6 @@ describe('CalendarPreview.Reset at scale', () => { QUARTER, expect.objectContaining({ reason: 'reset' }) ); - /* The scale came back with it. */ expect(getSlot(container, 'calendar-preview-days')).toBeNull(); expect( (getSlot(container, 'calendar-preview-input') as HTMLInputElement).value diff --git a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx index abe1ddb46..0230fc26b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx @@ -10,7 +10,6 @@ import { CalendarPreviewSeparator } from './calendar-preview-separator'; export type CalendarPreviewBodyProps = useRender.ComponentProps<'div'>; -/** The input sits above the switcher, which is where the frames put it. */ export function CalendarPreviewBody({ className, children, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 73cdd5a6a..c2b951c11 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -90,13 +90,12 @@ export interface CalendarPreviewContextValue { readOnly: boolean; formatValue: (value: Date | ScaleValue, scale: Scale) => string; - /** Every scale the switcher offers. One entry hides `.Scales`. */ + /** One entry hides `.Scales`. */ scales: readonly Scale[]; trailingValue: boolean; /** Never emitted: a cell click or Enter commits it, Escape drops it. */ scaleDraft: ScaleValue | null; switchScale: (scale: Scale) => void; - /** Honours `trailingValue`. */ selectPeriod: (date: Date | string, scale: Scale) => void; dropDraft: () => void; isPeriodAvailable: (date: Date | string, scale: Scale) => boolean; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 6b5352c1f..a65d23b4e 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -196,8 +196,7 @@ export function CalendarPreviewGrid({ const months = days?.numberOfMonths ?? 1; - /* A scale-aware root carries `{ date, scale }` at day scale too, so the day - to mark is inside the value rather than being it. */ + /* A scale-aware root carries `{ date, scale }` at day scale too. */ const selected = isScaleValue(value) ? parseKey(value.date) : value instanceof Date diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index d8857c231..be8b60f96 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -148,8 +148,6 @@ export function CalendarPreviewInput({ onValidityChange?.(next); }; - /* Only the scales this root offers: typing "Q4" into a day-only field is not - a quarter, it is a typo. */ const resolve = ( text: string ): CalendarPreviewInputValidity | { date: Date; scale: Scale } => { @@ -214,7 +212,6 @@ export function CalendarPreviewInput({ ? ((field === 'start' ? draft?.from : draft?.to) ?? null) : (scaleDraft ?? (isRangeValue(value) ? null : value)); const committedText = endpoint ? formatValue(endpoint, scale) : ''; - /* A multi-scale field has to advertise what it accepts. */ const resolvedPlaceholder = placeholder ?? (scales.length > 1 diff --git a/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx index 628c0125b..ad4a16414 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx @@ -12,10 +12,7 @@ import { export type CalendarPreviewPanelProps = useRender.ComponentProps<'div'>; -/** - * Every view gates on the active scale itself, so a consumer can mount - * `.Quarters` alone with no day grid in the tree. - */ +/* Each view gates on the scale itself, so `.Quarters` can be mounted alone. */ export function CalendarPreviewPanel({ className, children, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx index 78256ecfe..f5fe1cd97 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -15,7 +15,7 @@ export type CalendarPreviewPeriodViewProps = useRender.ComponentProps<'div'>; interface Cell { key: string; label: string; - /** The day this cell stands for, before `trailingValue` is applied. */ + /** Before `trailingValue` is applied. */ date: Date; } @@ -46,10 +46,6 @@ function cellsFor(scale: Scale, year: number): Cell[] { return [{ key: `${year}`, label: String(year), date: monthStart(year, 0) }]; } -/** - * Every year is a heading inside one scrolling column rather than a page of its - * own, so periods outside the bounds render disabled rather than being cut off. - */ function PeriodView({ scale: viewScale, columns, @@ -86,8 +82,6 @@ function PeriodView({ return list; }, [yearRange]); - /* Compared as day-keys so a re-rendered Date never counts as a change. The - draft wins: it is what the user is looking at after a scale switch. */ const activeYear = yearOf( scaleDraft?.date ?? (isScaleValue(value) ? value.date : dayKey(month, timeZone)) @@ -96,19 +90,16 @@ function PeriodView({ const selectedKey = scaleDraft?.date ?? (isScaleValue(value) ? value.date : null); - /* A twenty-year list otherwise opens twenty scrolls from the year meant. - Keyed on becoming active, not on mount: every view mounts at once, so a - mount effect fires with an empty ref and never fires again. Scrolls the - container rather than `scrollIntoView`, which would walk out and move the - popover with it. */ + /* Keyed on becoming active, not on mount: every view mounts at once, so a + mount effect would fire with an empty ref. Scrolls the container, not + `scrollIntoView`, which would move the popover with it. */ const activeRef = useRef(null); const isActive = scale === viewScale; useEffect(() => { if (!isActive) return; const group = activeRef.current; const list = group?.parentElement; - /* The ref lags a render behind `activeYear`, so scrolling to a group that - is no longer the active one would land on the previous year. */ + /* The ref lags a render behind `activeYear`. */ if (!group || !list || group.dataset.year !== String(activeYear)) return; list.scrollTop += group.getBoundingClientRect().top - list.getBoundingClientRect().top; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx index 62a3a28ea..4e605172a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -15,16 +15,8 @@ import { dayKey } from './date-adapter'; export type CalendarPreviewResetProps = ComponentProps; -/** - * Restores `defaultDate` — a day, a range, or a period at a coarser scale — or - * clears 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`. - * - * With nothing to restore it stays mounted and disabled rather than unmounting: - * that would send focus to `` mid-calendar, and drop a `flex: none` child - * that keeps both nav buttons in place. - */ +/* Stays mounted and disabled rather than unmounting: that would send focus to + `` mid-calendar, and drop a `flex: none` child holding the nav. */ export function CalendarPreviewReset({ className, children, @@ -41,9 +33,7 @@ export function CalendarPreviewReset({ const sameDay = (a: Date, b: Date) => dayKey(a, timeZone) === dayKey(b, timeZone); - /* Both edges have to match: a shared start is not a restored range. A - period matches on its scale as well as its day — the same day read at two - scales is two different values. */ + /* A period matches on scale too: the same day at two scales is two values. */ 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 84c034ea2..19dd5d790 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -42,8 +42,6 @@ export function isRange(value: unknown): value is CalendarPreviewDateRange { return value != null && typeof value === 'object' && 'from' in value; } -/* The one day a value stands for, whichever shape it takes: the month to open - on, and the day a change reports. */ export function monthAnchor( value: CalendarPreviewValue | undefined ): Date | undefined { @@ -114,18 +112,20 @@ interface CalendarPreviewRangeProps { moves to `ScaleValue`. The wart is that `scales={['day']}` takes this arm where `scales='day'` does not (RFC 005, Open Item 1). */ interface CalendarPreviewScaleAwareProps { - /* A start/end pair is two independent roots, each with its own `scales` and - `trailingValue`, so there is no range arm here. */ + /* A start/end pair is two roots, each with its own `scales`, so no range + arm belongs here. */ selection?: 'single'; scales: Exclude | Scale[]; - /** The selected period. `date` is timeless `'YYYY-MM-DD'`. */ + /** The selected period (controlled). `date` is timeless `'YYYY-MM-DD'`. */ value?: ScaleValue | null; + /** The initially selected period (uncontrolled). */ defaultValue?: ScaleValue | null; + /** Called when a period is committed or cleared. */ onValueChange?: ( value: ScaleValue | null, details: CalendarPreviewChangeDetails ) => void; - /** The period `.Reset` restores. `null` clears; omitted renders no button. */ + /** The period `.Reset` restores, read even when `value` is controlled. */ defaultDate?: ScaleValue | null; } @@ -138,14 +138,15 @@ export type CalendarPreviewProps = ( interface CalendarPreviewSharedProps extends Omit, 'defaultValue' | 'onChange'> { - /** The scale the picker opens on. @defaultValue the first of `scales` */ + /** @defaultValue the first of `scales` */ defaultScale?: Scale; + /** The active scale (controlled). */ scale?: Scale; + /** Called when the switcher moves. */ onScaleChange?: (scale: Scale) => void; /** - * Whether a period emits its last day rather than its first: an end field - * wants 31 July from "July 2026", a start field wants the 1st. The value - * changes, not the formatting. + * Whether a period emits its last day rather than its first — an end field + * wants 31 July from "July 2026", a start field the 1st. * @defaultValue false */ trailingValue?: boolean; @@ -178,19 +179,11 @@ interface CalendarPreviewSharedProps minDate?: Date; /** Latest selectable day, inclusive. Never clamps navigation. */ maxDate?: Date; - /** - * Reject individual days. Applied on top of `minDate` / `maxDate`. - * - * **Day scale only** — a day predicate has no one lift to a period, and - * asking it per cell would run it 365 times a year. Period cells are bounded - * by `minDate` / `maxDate`, tested against the day the cell would emit. - */ + /* Day scale only: a day predicate has no one lift to a period. Period cells + are bounded by `minDate` / `maxDate` instead. */ isDateUnavailable?: (date: Date) => boolean; - /** - * Renders a value for display. - * @defaultValue `DD MMM YYYY` at day scale - */ + /** @defaultValue `DD MMM YYYY` at day scale, the period's shorthand above it */ formatValue?: (value: Date | ScaleValue, scale: Scale) => string; /** @@ -316,8 +309,6 @@ export function CalendarPreviewRoot({ state: 'month' }); - /* Normalised to `SCALES` order, so the switcher reads finest-first whatever - order the consumer passed, and unknown entries drop out. */ const scales = useMemo(() => { const list = (Array.isArray(scalesProp) ? scalesProp : [scalesProp]).filter( isScale @@ -334,8 +325,7 @@ export function CalendarPreviewRoot({ const [scaleDraft, setScaleDraft] = useState(null); - /* The only runtime read of the arms' rule: an array carries the scale even - when that scale is `'day'`. */ + /* An array carries the scale even when that scale is `'day'`. */ const carriesScale = Array.isArray(scalesProp) || scalesProp !== 'day'; const setMonth = useCallback( @@ -360,8 +350,8 @@ export function CalendarPreviewRoot({ setValueUnwrapped(next); emit?.(next, { reason, - /* The scale that was committed, not the one on screen: typing - "Q4 2026" commits a quarter while the view is still on days. */ + /* The committed scale, not the view's: typing "Q4 2026" commits a + quarter while the view is still on days. */ period: periodOf( occasion, isScaleValue(next) ? next.scale : scale, @@ -454,8 +444,7 @@ export function CalendarPreviewRoot({ : value instanceof Date ? dayKey(value, timeZone) : null; - /* The input reads the draft first, so leaving one behind would show - the day the user passed through on the way back down. */ + /* The input reads the draft first, so a stale one would show. */ setScaleDraft(null); if (current === key && clearable) setValue(null, 'clear', date); else @@ -512,12 +501,10 @@ export function CalendarPreviewRoot({ return null; }, [scaleDraft, value, scale, timeZone]); - /* Never emits: the draft is what the user is looking at, and a cell click - or Enter commits it. */ + /* Never emits: a cell click or Enter commits the draft. */ const switchScale = useCallback( (next: Scale) => { - /* The month on screen, not today: someone who navigated to 2030 must - not be thrown back by switching scale. */ + /* The month on screen, not today, or 2030 snaps back. */ const anchor = scaleValue ?? { date: dayKey(month, timeZone), scale @@ -543,8 +530,6 @@ export function CalendarPreviewRoot({ [trailingValue, timeZone, readOnly, disabled, setValue, setOpen] ); - /* The scale comes back with the value: a day rendered at the drafted quarter - scale would still read "Q3 2026". */ const dropDraft = useCallback(() => { setScaleDraft(null); setScaleUnwrapped(isScaleValue(value) ? value.scale : scales[0]); @@ -585,7 +570,6 @@ export function CalendarPreviewRoot({ consumer that logs or validates on selection needs to tell them apart. */ const reset = useCallback(() => { if (defaultDate === undefined) return; - /* Settles the scale the way dropping a draft does, for the same reason. */ setScaleDraft(null); setScaleUnwrapped( isScaleValue(defaultDate) ? defaultDate.scale : scales[0] @@ -597,8 +581,6 @@ export function CalendarPreviewRoot({ setValue(null, 'clear', monthAnchor(value) ?? today); return; } - /* `occasion` is one day, so a range reports the day it starts on and a - period the day it anchors to. */ setValue(defaultDate, 'reset', monthAnchor(defaultDate) ?? today); }, [defaultDate, value, scales, setScaleUnwrapped, setValue, today]); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx index 10271e9d4..c4a459626 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx @@ -15,8 +15,6 @@ const LABELS: Record = { export type CalendarPreviewScalesProps = useRender.ComponentProps<'div'>; -/* Renders nothing for a single scale, so a plain day calendar does not grow a - one-tab row. */ export function CalendarPreviewScales({ className, children, @@ -73,7 +71,6 @@ export interface CalendarPreviewScaleProps value: Scale; } -/** Only needed to relabel or reorder what `.Scales` renders. */ export function CalendarPreviewScale({ value, className, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index 680e7d877..6177e2a17 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -90,8 +90,7 @@ export function CalendarPreviewTrigger({ ) } as ComponentProps; - /* `formatValue` takes a single value, so a range formats as its two ends. A - period carries its own scale, which is the one it reads back at. */ + /* A period reads back at its own scale, not the view's. */ const label = value instanceof Date ? formatValue(value, scale) diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx index 163cefa84..1b10a2307 100644 --- a/packages/raystack/components/calendar-preview/use-calendar.tsx +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -12,10 +12,7 @@ export interface UseCalendarReturn { value: CalendarPreviewValue; /** Commit a day or a range, or clear with `null`. Emits `onValueChange`. */ setValue: (value: CalendarPreviewValue) => void; - /* Read-only by decision, not by omission: switching scale is `.Scales` and - `.Scale`, which take `render` for custom chrome. A setter here would be - public API we cannot take back, and it stays out until something needs - one — adding it later is additive. */ + /* Read-only by decision: switching scale is `.Scales` and `.Scale`. */ scale: Scale; month: Date; /** Bounds never clamp the view. */ From 3c1aaa9983e45acb3aecfde8e1bc23dd8199d32c Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 15 Sep 2026 21:15:21 +0530 Subject: [PATCH 14/52] docs(calendar-preview): scope clearable and the bounds to what they do MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR changed both and left their docs behind. `clearable` reads as a rule about the selected value, but `selectPeriod` has no clearable branch: clicking the selected day emits null, while clicking an already-selected period re-commits it. Verified in a browser, not jsdom. `minDate` / `maxDate` stopped meaning "earliest selectable day" once `isAvailable` began testing the day a period *produces*. Bounded at 15 July, Q3 is rejected for a start field and allowed for an end field — the same period, opposite answers, on `trailingValue`. That was written down on `isDateUnavailable` but not on the bounds themselves. Both corrections land in the source and in props.ts so the table and the editor tooltip cannot disagree. --- .../content/docs/components/calendar-preview/props.ts | 10 +++++++--- .../calendar-preview/calendar-preview-root.tsx | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) 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 fc65fcbf3..95ca315da 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -82,12 +82,15 @@ export interface CalendarPreviewProps { yearRange?: { from: number; to: number }; /** - * Earliest selectable day, inclusive. Never clamps navigation. + * Earliest selectable day, inclusive. Never clamps navigation. A period is + * tested against the day it would emit, so `trailingValue` moves the answer: + * bounded at 15 July, Q3 is rejected for a start field and allowed for an end + * field. * @example minDate={new Date(2024, 3, 17)} */ minDate?: Date; - /** Latest selectable day, inclusive. Never clamps navigation. */ + /** Latest selectable day, inclusive. Tested as `minDate` is. */ maxDate?: Date; /** @@ -168,7 +171,8 @@ export interface CalendarPreviewProps { today?: Date; /** - * Whether clicking the selected day deselects it. + * Whether clicking the selected day deselects it. Day scale only — clicking + * an already-selected period re-commits it rather than clearing. * @default true */ clearable?: boolean; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 19dd5d790..f7c6b18c5 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -175,9 +175,12 @@ interface CalendarPreviewSharedProps */ yearRange?: { from: number; to: number }; - /** Earliest selectable day, inclusive. Never clamps navigation. */ + /** + * Earliest selectable day, inclusive. Never clamps navigation. A period is + * tested against the day it would emit, so `trailingValue` moves the answer. + */ minDate?: Date; - /** Latest selectable day, inclusive. Never clamps navigation. */ + /** Latest selectable day, inclusive. Tested as `minDate` is. */ maxDate?: Date; /* Day scale only: a day predicate has no one lift to a period. Period cells are bounded by `minDate` / `maxDate` instead. */ @@ -210,7 +213,8 @@ interface CalendarPreviewSharedProps */ today?: Date; /** - * Whether clicking the selected day deselects it. + * Whether clicking the selected day deselects it. Day scale only — clicking + * a selected period re-commits it. * @defaultValue true */ clearable?: boolean; From d4c8b15688cdb8226505138076948d151741df3e Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 08:50:50 +0530 Subject: [PATCH 15/52] fix(calendar-preview): key a period cell by the day it means MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cells were built from local `Date`s, so `dayKey` read them through `timeZone` and shifted them across the zone boundary. With `timeZone="Pacific/Niue"`, clicking "Aug" committed 2026-07-01 and the input read back "Jul 2026" — the wrong month, and the Jul cell took the highlight. "August 2026" is a calendar period, not an instant, so a cell now carries a timeless `DayKey`. `selectPeriod`, `isPeriodAvailable` and `periodOf` already accepted one, so zone conversion leaves the period grid entirely and the three of them can no longer disagree. Verified in a browser at UTC-11, UTC+14 and UTC. --- .../calendar-preview-periods.tsx | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx index f5fe1cd97..2f76baf0a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -7,7 +7,13 @@ import { type CalendarPreviewValue, isScaleValue } from './calendar-preview-root'; -import { dayKey, monthShortNames, monthStart, yearOf } from './date-adapter'; +import { + type DayKey, + dayKey, + dayKeyFromParts, + monthShortNames, + yearOf +} from './date-adapter'; import { anchorOf, periodOf, type Scale } from './lib/scale'; export type CalendarPreviewPeriodViewProps = useRender.ComponentProps<'div'>; @@ -15,35 +21,33 @@ export type CalendarPreviewPeriodViewProps = useRender.ComponentProps<'div'>; interface Cell { key: string; label: string; - /** Before `trailingValue` is applied. */ - date: Date; + /* A timeless day, not an instant: "August 2026" is a calendar period, and + keying it from a local `Date` read it a month early west of the zone. */ + date: DayKey; } const MONTHS = monthShortNames(); +/* A month out of `dayKeyFromParts`' range has no cell rather than a bad one. */ function cellsFor(scale: Scale, year: number): Cell[] { + const at = (key: string, label: string, month: number): Cell | null => { + const date = dayKeyFromParts(year, month, 1); + return date === null ? null : { key, label, date }; + }; + + let cells: (Cell | null)[]; if (scale === 'month') { - return MONTHS.map((label, index) => ({ - key: `${year}-${index}`, - label, - date: monthStart(year, index) - })); - } - if (scale === 'quarter') { - return [0, 1, 2, 3].map(q => ({ - key: `${year}-q${q}`, - label: `Q${q + 1}`, - date: monthStart(year, q * 3) - })); - } - if (scale === 'halfYear') { - return [0, 1].map(h => ({ - key: `${year}-h${h}`, - label: `H${h + 1}`, - date: monthStart(year, h * 6) - })); + cells = MONTHS.map((label, index) => + at(`${year}-${index}`, label, index + 1) + ); + } else if (scale === 'quarter') { + cells = [0, 1, 2, 3].map(q => at(`${year}-q${q}`, `Q${q + 1}`, q * 3 + 1)); + } else if (scale === 'halfYear') { + cells = [0, 1].map(h => at(`${year}-h${h}`, `H${h + 1}`, h * 6 + 1)); + } else { + cells = [at(`${year}`, String(year), 1)]; } - return [{ key: `${year}`, label: String(year), date: monthStart(year, 0) }]; + return cells.filter((cell): cell is Cell => cell !== null); } function PeriodView({ From 227407e204ea305a9cc7cd4ff3b31fb96462ccdb Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 08:51:05 +0530 Subject: [PATCH 16/52] fix(calendar-preview): settle the scale, the shape and the view as one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects, all from the same habit of writing a rule in two places. `dropDraft` and `reset` called the raw `useControlled` setter, which is a no-op once `scale` is controlled, and skipped `onScaleChange`. Dropping a draft left the switcher stuck on the scale the user had just abandoned. Both now settle through `setScale`, and only when the scale actually moves — so an uncontrolled root hears about the settle too, where it used to stay silent. A day committed by a click honoured the root's arm; the same day typed into `.Input` wrote a bare `Date` onto a root whose value is a `ScaleValue`, so a consumer reading `value.date` got `undefined`. `commitDay` is now the one place a day becomes a value and both paths go through it. `selectPeriod` loses its `as never` with it — the cast only existed because the shape was built inline. `.Input` formatted a committed period at the view's scale rather than the period's, so a quarter read "01 Jul 2026" while `.Trigger` read "Q3 2026". The view opened on `scales[0]` whatever the value held, which showed a committed quarter on the day grid with no cell marked. It now seeds from the value's own scale, the way dropping a draft settles on it; an explicit `defaultScale` still wins. `.Reset` rides in `.Header`, which only the day view mounts, so `.Body` now mounts it at the other scales — exactly one renders at every scale, and the standalone inline calendar is untouched. --- .../calendar-preview-body.tsx | 4 + .../calendar-preview-context.tsx | 2 + .../calendar-preview-input.tsx | 12 ++- .../calendar-preview-root.tsx | 75 +++++++++++++------ 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx index 0230fc26b..015f7739c 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx @@ -5,6 +5,7 @@ import { useCalendarPreviewContext } from './calendar-preview-context'; import { CalendarPreviewInput } from './calendar-preview-input'; import { CalendarPreviewLabel } from './calendar-preview-label'; import { CalendarPreviewPanel } from './calendar-preview-panel'; +import { CalendarPreviewReset } from './calendar-preview-reset'; import { CalendarPreviewScales } from './calendar-preview-scales'; import { CalendarPreviewSeparator } from './calendar-preview-separator'; @@ -39,6 +40,9 @@ export function CalendarPreviewBody({ + {/* `.Reset` rides in `.Header`, which only the day view mounts, so + a period scale would otherwise have no way back to the default. */} + {scale !== 'day' && } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index c2b951c11..84d9ae1e7 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -107,6 +107,8 @@ export interface CalendarPreviewContextValue { * the value and closes the popover. */ selectDay: (date: Date) => void; + /** Writes a day at the root's value shape, for a path that is not a click. */ + commitDay: (date: Date, reason: CalendarPreviewChangeReason) => void; /** Writes one named endpoint, for a typed `.Input`. */ setEndpoint: (field: CalendarPreviewField, date: Date) => void; /** diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index be8b60f96..115a9cdcf 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -7,7 +7,8 @@ import type { CalendarPreviewField } from './calendar-preview-context'; import { useCalendarPreviewContext } from './calendar-preview-context'; import { type CalendarPreviewValue, - isRange as isRangeValue + isRange as isRangeValue, + isScaleValue } from './calendar-preview-root'; import { dayKey, parseKey } from './date-adapter'; import { parseScaleInput } from './lib/parse'; @@ -98,6 +99,7 @@ export function CalendarPreviewInput({ selectPeriod, isPeriodAvailable, selection, + commitDay, setEndpoint, draft, activeField, @@ -201,7 +203,7 @@ export function CalendarPreviewInput({ if (isRange) setEndpoint(field, resolved.date); else if (resolved.scale !== 'day') selectPeriod(resolved.date, resolved.scale); - else setValue(resolved.date, 'input', resolved.date); + else commitDay(resolved.date, 'input'); setText(null); report(VALID); }; @@ -211,7 +213,11 @@ export function CalendarPreviewInput({ const endpoint = isRange ? ((field === 'start' ? draft?.from : draft?.to) ?? null) : (scaleDraft ?? (isRangeValue(value) ? null : value)); - const committedText = endpoint ? formatValue(endpoint, scale) : ''; + /* A period reads back at its own scale, as `.Trigger` does: the view can sit + on days while the committed value is a quarter. */ + const committedText = endpoint + ? formatValue(endpoint, isScaleValue(endpoint) ? endpoint.scale : scale) + : ''; const resolvedPlaceholder = placeholder ?? (scales.length > 1 diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index f7c6b18c5..b3363f77b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -58,7 +58,9 @@ export type CalendarPreviewValue = | ScaleValue | null; -export function isScaleValue(value: CalendarPreviewValue): value is ScaleValue { +export function isScaleValue( + value: CalendarPreviewValue | undefined +): value is ScaleValue { return value != null && !(value instanceof Date) && 'date' in value; } @@ -322,7 +324,16 @@ export function CalendarPreviewRoot({ const [scale, setScaleUnwrapped] = useControlled({ controlled: scaleProp, - default: defaultScale ?? scales[0], + /* The committed value's own scale, the way dropping a draft settles on it: + opening a quarter on the day grid showed the selection as a day and left + no cell marked. `valueProp` before `defaultValue`, as `month` does. */ + default: + defaultScale ?? + (isScaleValue(valueProp) + ? valueProp.scale + : isScaleValue(defaultValue) + ? defaultValue.scale + : scales[0]), name: 'CalendarPreview', state: 'scale' }); @@ -367,6 +378,18 @@ export function CalendarPreviewRoot({ [setValueUnwrapped, emit, scale, timeZone, readOnly, disabled] ); + /* The one place a day becomes a value: a scale-aware root carries + `{ date, scale }` at day scale too, and a click and a typed date must not + disagree about that — writing the rule twice is how they last did. */ + const commitDay = useCallback( + (date: Date, reason: CalendarPreviewChangeReason) => { + const key = dayKey(date, timeZone); + setScaleDraft(null); + setValue(carriesScale ? { date: key, scale: 'day' } : date, reason, date); + }, + [carriesScale, timeZone, setValue] + ); + const [open, setOpenUnwrapped] = useControlled({ controlled: openProp, default: defaultOpen, @@ -448,15 +471,13 @@ export function CalendarPreviewRoot({ : value instanceof Date ? dayKey(value, timeZone) : null; - /* The input reads the draft first, so a stale one would show. */ - setScaleDraft(null); - if (current === key && clearable) setValue(null, 'clear', date); - else - setValue( - carriesScale ? { date: key, scale: 'day' } : date, - 'select', - date - ); + if (current === key && clearable) { + /* The input reads the draft first, so a stale one would show. */ + setScaleDraft(null); + setValue(null, 'clear', date); + return; + } + commitDay(date, 'select'); return; } @@ -489,7 +510,7 @@ export function CalendarPreviewRoot({ draft, fieldReadOnly, clearable, - carriesScale, + commitDay, timeZone, readOnly, disabled, @@ -525,7 +546,7 @@ export function CalendarPreviewRoot({ if (readOnly || disabled) return; const key = anchorOf(periodOf(date, next, timeZone), trailingValue); setScaleDraft(null); - setValue({ date: key, scale: next } as never, 'select', parseKey(key)); + setValue({ date: key, scale: next }, 'select', parseKey(key)); setOpen( false, createChangeEventDetails(REASONS.closePress, undefined, undefined) @@ -534,10 +555,21 @@ export function CalendarPreviewRoot({ [trailingValue, timeZone, readOnly, disabled, setValue, setOpen] ); - const dropDraft = useCallback(() => { - setScaleDraft(null); - setScaleUnwrapped(isScaleValue(value) ? value.scale : scales[0]); - }, [value, scales, setScaleUnwrapped]); + /* Routed through `setScale`, not the raw setter: a controlled `scale` only + moves when the consumer is told to move it, so dropping a draft has to + report the scale it settles on the way switching to one does. */ + const settleScale = useCallback( + (next: Scale) => { + setScaleDraft(null); + if (next !== scale) setScale(next); + }, + [scale, setScale] + ); + + const dropDraft = useCallback( + () => settleScale(isScaleValue(value) ? value.scale : scales[0]), + [value, scales, settleScale] + ); /* Bounds only, never `isDateUnavailable` — the prop documents why. */ const isPeriodAvailable = useCallback( @@ -574,10 +606,7 @@ export function CalendarPreviewRoot({ consumer that logs or validates on selection needs to tell them apart. */ const reset = useCallback(() => { if (defaultDate === undefined) return; - setScaleDraft(null); - setScaleUnwrapped( - isScaleValue(defaultDate) ? defaultDate.scale : scales[0] - ); + settleScale(isScaleValue(defaultDate) ? defaultDate.scale : scales[0]); /* A `null` default clears, and reports the day it cleared: `'reset'` would claim a day was restored when none was. */ if (defaultDate === null) { @@ -586,7 +615,7 @@ export function CalendarPreviewRoot({ return; } setValue(defaultDate, 'reset', monthAnchor(defaultDate) ?? today); - }, [defaultDate, value, scales, setScaleUnwrapped, setValue, today]); + }, [defaultDate, value, scales, settleScale, 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. */ @@ -624,6 +653,7 @@ export function CalendarPreviewRoot({ isPeriodAvailable, selection, selectDay, + commitDay, setEndpoint, draft: draft ?? (isRange(value) ? value : null), activeField, @@ -662,6 +692,7 @@ export function CalendarPreviewRoot({ isPeriodAvailable, selection, selectDay, + commitDay, setEndpoint, draft, activeField, From 9a35c980dba38e0869290118b0ecdb6311eaff53 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 08:51:22 +0530 Subject: [PATCH 17/52] test(calendar-preview): cover the five defects the audit turned up Twelve tests, each written against a symptom reproduced in a real browser first. The suite pins TZ=UTC, which is what let the period cells key a month early west of it and never show up here. Covers: a period cell committing the period it is labelled with in three zones; the scale a dropped draft settles back on, controlled and not; a committed period reading the same in `.Trigger` and `.Input`; the view opening at the value's own scale, with `defaultScale` still winning; `.Reset` reachable from a period view and mounted exactly once at every scale; and a typed day committing the same shape a clicked one does, on all three arms. --- .../__tests__/scale-selection.test.tsx | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index 236a41e91..e2edee513 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { getAllSlots, getSlot } from '~/test-utils/data-slots'; import { CalendarPreview } from '../calendar-preview'; @@ -149,6 +150,162 @@ describe('CalendarPreview availability differs by field', () => { }); }); +/* The suite runs at TZ=UTC, so a cell built from a local `Date` keyed a day + early west of UTC and a period late east of it. */ +describe('CalendarPreview periods ignore the time zone', () => { + it.each([ + ['Pacific/Niue'], + ['Pacific/Kiritimati'], + ['UTC'] + ])('commits the period that was clicked in %s', timeZone => { + const onValueChange = vi.fn(); + const { container } = renderBody({ + timeZone, + defaultScale: 'month', + onValueChange + }); + fireEvent.click(period(container, 'Aug')); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-08-01', + scale: 'month' + }); + }); + + it('marks the clicked quarter, not its neighbour, west of UTC', () => { + const { container } = renderBody({ + timeZone: 'Pacific/Niue', + defaultScale: 'quarter', + value: { date: '2026-07-01', scale: 'quarter' } + }); + expect(period(container, 'Q3')).toHaveAttribute('data-selected'); + expect(period(container, 'Q2')).not.toHaveAttribute('data-selected'); + }); +}); + +describe('CalendarPreview opens at the committed scale', () => { + it('mounts the period view, not the day grid, for a committed period', () => { + const { container } = renderBody({ + value: { date: '2026-07-01', scale: 'quarter' } + }); + expect(getSlot(container, 'calendar-preview-days')).toBeNull(); + expect(getSlot(container, 'calendar-preview-quarters')).not.toBeNull(); + expect(period(container, 'Q3')).toHaveAttribute('data-selected'); + }); + + it('prefers an explicit defaultScale over the value', () => { + const { container } = renderBody({ + value: { date: '2026-07-01', scale: 'quarter' }, + defaultScale: 'day' + }); + expect(getSlot(container, 'calendar-preview-days')).not.toBeNull(); + }); +}); + +/* `.Reset` rides in `.Header`, which only the day view mounts, so a period + scale had no way back to the default at all. */ +describe('CalendarPreview.Reset is reachable at every scale', () => { + const QUARTER = { date: '2026-07-01', scale: 'quarter' } as const; + + it('restores from a period view', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ + defaultDate: QUARTER, + value: { date: '2026-10-01', scale: 'quarter' }, + onValueChange + }); + const reset = getSlot(container, 'calendar-preview-reset') as HTMLElement; + expect(reset).not.toBeNull(); + fireEvent.click(reset); + expect(onValueChange).toHaveBeenCalledWith( + QUARTER, + expect.objectContaining({ reason: 'reset' }) + ); + }); + + it.each([ + ['day'], + ['quarter'] + ] as const)('mounts exactly one reset at %s scale', scale => { + const { container } = renderBody({ + defaultScale: scale, + defaultValue: { date: '2026-08-20', scale: 'day' }, + defaultDate: { date: '2026-08-10', scale: 'day' } + }); + expect(getAllSlots(container, 'calendar-preview-reset')).toHaveLength(1); + }); +}); + +describe('CalendarPreview reads a period at its own scale', () => { + /* The view opens on `scales[0]`, so a committed quarter is shown while the + day grid is up; formatting it at the view's scale called it a day. */ + it('agrees between .Trigger and .Input on a committed period', () => { + const { container } = render( + + + + + ); + expect(getSlot(container, 'calendar-preview-trigger')?.textContent).toBe( + 'Q3 2026' + ); + expect( + (getSlot(container, 'calendar-preview-input') as HTMLInputElement).value + ).toBe('Q3 2026'); + }); +}); + +describe('CalendarPreview settles the scale out loud', () => { + const inputValue = (container: HTMLElement) => + (getSlot(container, 'calendar-preview-input') as HTMLInputElement).value; + const pressEscape = (container: HTMLElement) => + fireEvent.keyDown( + getSlot(container, 'calendar-preview-body') as HTMLElement, + { + key: 'Escape' + } + ); + + it('reports the scale a dropped draft settles back on', () => { + const onScaleChange = vi.fn(); + const { container } = renderBody({ + value: { date: '2026-08-20', scale: 'day' }, + onScaleChange + }); + switchTo(container, 'quarter'); + expect(onScaleChange).toHaveBeenLastCalledWith('quarter'); + pressEscape(container); + expect(onScaleChange).toHaveBeenLastCalledWith('day'); + }); + + /* A controlled `scale` moves only when the consumer is told to move it, so + settling through the raw setter left the switcher stuck on the draft. */ + it('moves a controlled scale back when the draft is dropped', () => { + function Controlled() { + const [scale, setScale] = useState('day'); + return ( + + + + ); + } + const { container } = render(); + switchTo(container, 'quarter'); + expect(inputValue(container)).toBe('Q3 2026'); + pressEscape(container); + expect(inputValue(container)).toBe('20 Aug 2026'); + }); +}); + describe('CalendarPreview.Scales', () => { it('renders nothing when only one scale is offered', () => { const { container } = render( @@ -373,6 +530,64 @@ describe('CalendarPreview scale anchors on the visible month', () => { }); }); +/* A click and a typed day are the same intent, so they must commit the same + shape: the grid honoured the root's arm and the input wrote a bare Date. */ +describe('CalendarPreview commits a day at one shape', () => { + const typeDay = (container: HTMLElement) => { + const input = getSlot( + container, + 'calendar-preview-input' + ) as HTMLInputElement; + fireEvent.change(input, { target: { value: '15 Aug 2026' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }; + + it('types a day as a ScaleValue on a scale-aware root', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange }); + typeDay(container); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-08-15', + scale: 'day' + }); + }); + + it("types a day as a ScaleValue under scales={['day']}", () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + typeDay(container); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-08-15', + scale: 'day' + }); + }); + + it('keeps a plain root on Date', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + + ); + typeDay(container); + expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2026, 7, 15)); + }); +}); + describe('CalendarPreview at day scale on a scale-aware root', () => { const dayCell = (container: HTMLElement, day: string) => { const match = getAllSlots(container, 'calendar-preview-day').find( From b1ccabf87e7f210f45375345f757d8e35d77dc8f Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 08:51:23 +0530 Subject: [PATCH 18/52] docs(rfc-005): note where .Reset mounts now that periods exist The approved tree mounts `.Reset` only inside `.Header`, which `.Days` alone renders. Phase 5 added the period views and let `defaultDate` hold a `ScaleValue`, which left a period default that could never be invoked. Appended as a deviation note; the approved text stands as the baseline. --- docs/rfcs/005-calendar-preview.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/rfcs/005-calendar-preview.md b/docs/rfcs/005-calendar-preview.md index f7ff8fe82..eccd046f7 100644 --- a/docs/rfcs/005-calendar-preview.md +++ b/docs/rfcs/005-calendar-preview.md @@ -243,6 +243,15 @@ Selection arms are discriminated on `selection` and `scales`; `onValueChange` re | **Five sibling view parts**, not one switched by a prop | They need different layouts (3 / 4 / 2 / 1 columns) and different heights, and a consumer must be able to mount the quarter view alone. Each self-gates on the active scale exactly as `DataView`'s `.List` / `.Timeline` / `.Custom` do. Also retires `.MonthGrid`, which collided with RDP's own `MonthGrid` slot | | **`.Caption`** | Opens our own two-column month+year scroller — never a `Select`, so the unmount loop cannot return. Standalone calendar only; inside a picker the caption is plain text and the scale switcher navigates | | **`.Reset`** | Restores `defaultDate`. Renders only when `defaultDate` is set and the value differs. A **value** reset, not a view reset | + +> **Note — deviation, phase 5.** The tree above mounts `.Reset` only inside +> `.Header`, which `.Days` alone renders. Once phase 5 added the four period +> views and let `defaultDate` hold a `ScaleValue`, that left a period default +> that could never be invoked: pick a quarter and the day view — and the reset +> with it — unmounts. `.Body` now also mounts `.Reset` when the scale is not +> `'day'`, so exactly one renders at every scale and the standalone inline +> calendar keeps the approved `.Header` placement untouched. The row it lands +> in is unstyled pending design. | **Height** | `.Days` hugs its content. The four period views are 320px and scroll — the whole list, not only the rows under a year heading | | **Every part** | Takes `render`, `className`, `ref`, `data-slot`. **Children override context-computed content** the way `Tour.Title` does, so `Q3 2026` works | | **Cell state** | `data-selected`, `data-draft`, `data-unavailable`, `data-today`, `data-outside`, `data-scale`. Slots say what an element is; these say what state it is in. `dateInfo` renders above the date number, as today | From b1e9a69f9fa92674bbdebfee7171a5d8f9ddb74c Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 15:00:34 +0530 Subject: [PATCH 19/52] fix(calendar-preview): close the three review threads the bot left open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Period cells carried no accessible name, so `Jan`, `Q1` and `H1` repeated identically in every year group — the year sits in a sibling label that names none of them. Each cell now names its own year; a year cell is already named by its label. The package root re-exported none of the six part prop types the component index already exports, so a consumer could not annotate a `.Panel` or a `.Scale` wrapper. Dropping a scale draft settled on `scales[0]`, which is the scale being undone rather than the one to come back to: with `scales={['month','year']}` and `defaultScale='year'`, switching to month and pressing Escape landed on month. The scale the run started from is captured on the first switch and restored instead. Every path that ends a draft now clears that record with it, or a later Escape would settle on a scale some earlier, already committed switch started from. --- .../__tests__/scale-selection.test.tsx | 75 +++++++++++++++++++ .../calendar-preview-periods.tsx | 5 ++ .../calendar-preview-root.tsx | 50 ++++++++++--- packages/raystack/index.tsx | 6 ++ 4 files changed, 127 insertions(+), 9 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index e2edee513..df97574bb 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -697,3 +697,78 @@ describe('CalendarPreview.Reset at scale', () => { ).toBe('Q3 2026'); }); }); + +describe('CalendarPreview period cells name their year', () => { + it('puts the year in a quarter cell name', () => { + const { container } = renderBody({ defaultScale: 'quarter' }); + expect(period(container, 'Q3', 2026)).toHaveAttribute( + 'aria-label', + 'Q3 2026' + ); + expect(period(container, 'Q3', 2027)).toHaveAttribute( + 'aria-label', + 'Q3 2027' + ); + }); + + it('puts the year in a month cell name', () => { + const { container } = renderBody({ defaultScale: 'month' }); + expect(period(container, 'Jan', 2030)).toHaveAttribute( + 'aria-label', + 'Jan 2030' + ); + }); + + it('leaves a year cell named by itself', () => { + const { container } = renderBody({ defaultScale: 'year' }); + expect(period(container, '2026', 2026)).toHaveAttribute( + 'aria-label', + '2026' + ); + }); +}); + +describe('CalendarPreview drops a draft to the scale it started from', () => { + const pressEscape = (container: HTMLElement) => + fireEvent.keyDown( + getSlot(container, 'calendar-preview-body') as HTMLElement, + { key: 'Escape' } + ); + + it('restores defaultScale rather than the first offered scale', () => { + const onScaleChange = vi.fn(); + const { container } = render( + + + + ); + switchTo(container, 'month'); + expect(onScaleChange).toHaveBeenLastCalledWith('month'); + pressEscape(container); + expect(onScaleChange).toHaveBeenLastCalledWith('year'); + }); + + it('comes back to the start of the run, not a scale passed through it', () => { + const onScaleChange = vi.fn(); + const { container } = renderBody({ defaultScale: 'year', onScaleChange }); + switchTo(container, 'month'); + switchTo(container, 'quarter'); + pressEscape(container); + expect(onScaleChange).toHaveBeenLastCalledWith('year'); + }); + + it('forgets the run once a period is committed', () => { + const onScaleChange = vi.fn(); + const { container } = renderBody({ defaultScale: 'day', onScaleChange }); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q3')); + onScaleChange.mockClear(); + pressEscape(container); + expect(onScaleChange).not.toHaveBeenCalledWith('day'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx index 2f76baf0a..a3feeec3d 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -159,6 +159,11 @@ function PeriodView({ data-selected={produced === selectedKey || undefined} data-unavailable={unavailable || undefined} disabled={disabled || unavailable} + aria-label={ + viewScale === 'year' + ? cell.label + : `${cell.label} ${year}` + } aria-current={produced === selectedKey || undefined} onClick={() => { if (readOnly) return; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index b3363f77b..fd8450165 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -340,6 +340,13 @@ export function CalendarPreviewRoot({ const [scaleDraft, setScaleDraft] = useState(null); + const scaleBeforeDraft = useRef(null); + + const clearScaleDraft = useCallback(() => { + setScaleDraft(null); + scaleBeforeDraft.current = null; + }, []); + /* An array carries the scale even when that scale is `'day'`. */ const carriesScale = Array.isArray(scalesProp) || scalesProp !== 'day'; @@ -384,10 +391,10 @@ export function CalendarPreviewRoot({ const commitDay = useCallback( (date: Date, reason: CalendarPreviewChangeReason) => { const key = dayKey(date, timeZone); - setScaleDraft(null); + clearScaleDraft(); setValue(carriesScale ? { date: key, scale: 'day' } : date, reason, date); }, - [carriesScale, timeZone, setValue] + [carriesScale, timeZone, setValue, clearScaleDraft] ); const [open, setOpenUnwrapped] = useControlled({ @@ -473,7 +480,7 @@ export function CalendarPreviewRoot({ : null; if (current === key && clearable) { /* The input reads the draft first, so a stale one would show. */ - setScaleDraft(null); + clearScaleDraft(); setValue(null, 'clear', date); return; } @@ -511,6 +518,7 @@ export function CalendarPreviewRoot({ fieldReadOnly, clearable, commitDay, + clearScaleDraft, timeZone, readOnly, disabled, @@ -529,6 +537,8 @@ export function CalendarPreviewRoot({ /* Never emits: a cell click or Enter commits the draft. */ const switchScale = useCallback( (next: Scale) => { + /* First switch of a run only: a second is still the same draft. */ + if (scaleDraft === null) scaleBeforeDraft.current = scale; /* The month on screen, not today, or 2030 snaps back. */ const anchor = scaleValue ?? { date: dayKey(month, timeZone), @@ -538,21 +548,38 @@ export function CalendarPreviewRoot({ setMonth(parseKey(convertScale(anchor, next, false, timeZone).date)); setScale(next); }, - [scaleValue, month, timeZone, scale, trailingValue, setMonth, setScale] + [ + scaleValue, + scaleDraft, + month, + timeZone, + scale, + trailingValue, + setMonth, + setScale + ] ); const selectPeriod = useCallback( (date: Date | string, next: Scale) => { if (readOnly || disabled) return; const key = anchorOf(periodOf(date, next, timeZone), trailingValue); - setScaleDraft(null); + clearScaleDraft(); setValue({ date: key, scale: next }, 'select', parseKey(key)); setOpen( false, createChangeEventDetails(REASONS.closePress, undefined, undefined) ); }, - [trailingValue, timeZone, readOnly, disabled, setValue, setOpen] + [ + trailingValue, + timeZone, + readOnly, + disabled, + setValue, + setOpen, + clearScaleDraft + ] ); /* Routed through `setScale`, not the raw setter: a controlled `scale` only @@ -560,14 +587,19 @@ export function CalendarPreviewRoot({ report the scale it settles on the way switching to one does. */ const settleScale = useCallback( (next: Scale) => { - setScaleDraft(null); + clearScaleDraft(); if (next !== scale) setScale(next); }, - [scale, setScale] + [scale, setScale, clearScaleDraft] ); + /* `scales[0]` is the scale being undone, not the one to come back to. */ const dropDraft = useCallback( - () => settleScale(isScaleValue(value) ? value.scale : scales[0]), + () => + settleScale( + scaleBeforeDraft.current ?? + (isScaleValue(value) ? value.scale : scales[0]) + ), [value, scales, settleScale] ); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index aefd30840..b8e3f4eaf 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -38,12 +38,18 @@ export { type CalendarPreviewInputInvalidReason, type CalendarPreviewInputProps, type CalendarPreviewInputValidity, + type CalendarPreviewLabelProps, type CalendarPreviewNavProps, type CalendarPreviewOpenChangeDetails, + type CalendarPreviewPanelProps, + type CalendarPreviewPeriodViewProps, type CalendarPreviewProps, type CalendarPreviewResetProps, type CalendarPreviewScale, + type CalendarPreviewScaleProps, + type CalendarPreviewScalesProps, type CalendarPreviewScaleValue, + type CalendarPreviewSeparatorProps, type CalendarPreviewTriggerProps, type CalendarPreviewWeekdayProps, type UseCalendarReturn, From 338171528738e23542de159efd2b1f6409dabddf Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 15:29:31 +0530 Subject: [PATCH 20/52] docs(calendar-preview): drop the preview disclaimer and the phase hedging The semver callout and the localization note both pointed at later RFC 005 phases. Neither says anything a consumer can act on, so they go. --- .../docs/components/calendar-preview/index.mdx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) 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 05a7bf4ee..4b2aee760 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -20,12 +20,6 @@ import { - - Later RFC 005 phases may still reshape props, slots and `useCalendar()`'s - return, so pin an exact version if you adopt it now. `Calendar` and - `DatePicker` remain the supported choice. - - ## Playground @@ -466,10 +460,9 @@ anything that re-renders often. ## Localization -English only for now. There is no `locale` prop: month and weekday names come from -date-fns' default `en-US`, and the nav, reset and caption labels are hardcoded strings. -`timeZone` is unaffected — a calendar can render in any zone, in English. Localization -is tracked against RFC 005 rather than patched in per-part. +English only. There is no `locale` prop: month and weekday names come from date-fns' +default `en-US`, and the nav, reset and caption labels are hardcoded strings. +`timeZone` is unaffected — a calendar can render in any zone, in English. ### Scale-aware selection From 7a79b752d201934af7bb2f8004dd4757a04b2bb4 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 23:47:44 +0530 Subject: [PATCH 21/52] fix(calendar-preview): settle focus and the draft when the popover closes Focus-to-open stopped working after the first Escape or trigger press. The guard that swallows the focus following such a close was armed unconditionally, but in `` focus never leaves the input, so no focus event ever arrives to consume it and the guard stayed set against the next real one. It is now armed only when focus is outside the trigger at close time. The trigger takes the guard before the press check too, since a press that returned early left it armed; and a pointer released outside the trigger never reached `onPointerUp`, so `pressing` is cleared from a window listener as well. Nothing dropped a scale draft on close. Switching to Quarter and clicking outside left `value` untouched while the shut field read `Q3 2026` and the root still carried `data-scale="quarter"`. Escape was handled in `.Body` alone, so `` never dropped one at all. The root drops it from `setOpen` on every close reason but `closePress`, which is the commit path and has already cleared it. --- .../__tests__/picker.test.tsx | 19 ++++++ .../__tests__/scale-selection.test.tsx | 59 +++++++++++++++++++ .../calendar-preview-context.tsx | 8 ++- .../calendar-preview-root.tsx | 33 ++++++++--- .../calendar-preview-trigger.tsx | 29 +++++++-- 5 files changed, 134 insertions(+), 14 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index 4c3f47258..591e6a383 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -92,6 +92,25 @@ describe('CalendarPreview picker composition', () => { expect(isOpen()).toBe(true); }); + /* Focus never leaves the input in this composition, so no focus event + follows the close for the reopen guard to consume. */ + it('opens on focus again after Escape closed it', () => { + const { input } = renderPicker(); + /* Real focus, so the guard can see where it is; the event drives it. */ + input.focus(); + fireEvent.focus(input); + expect(isOpen()).toBe(true); + + fireEvent.keyDown( + getSlot(document.body, 'calendar-preview-content') as HTMLElement, + { key: 'Escape' } + ); + expect(isOpen()).toBe(false); + + fireEvent.focus(input); + expect(isOpen()).toBe(true); + }); + it('never opens while disabled', () => { const onOpenChange = vi.fn(); const { input } = renderPicker({ disabled: true, onOpenChange }); diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index df97574bb..f1d552d0c 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -772,3 +772,62 @@ describe('CalendarPreview drops a draft to the scale it started from', () => { expect(onScaleChange).not.toHaveBeenCalledWith('day'); }); }); + +describe('CalendarPreview drops a scale draft when the popover closes', () => { + function renderScalePicker(props = {}) { + const utils = render( + + + + + + + + + + ); + const input = getSlot( + utils.container, + 'calendar-preview-input' + ) as HTMLInputElement; + return { ...utils, input }; + } + + /* `.Body` carries an Escape handler; this composition does not, which is + why the root has to drop the draft rather than the part. */ + it('restores the field and the scale when Escape closes a bare panel', () => { + const { container, input } = renderScalePicker({ + value: { date: '2026-08-20', scale: 'day' } + }); + fireEvent.focus(input); + switchTo(document.body, 'quarter'); + expect(input.value).toBe('Q3 2026'); + + fireEvent.keyDown( + getSlot(document.body, 'calendar-preview-content') as HTMLElement, + { key: 'Escape' } + ); + expect(input.value).toBe('20 Aug 2026'); + expect(getSlot(container, 'calendar-preview')).toHaveAttribute( + 'data-scale', + 'day' + ); + }); + + it('keeps a committed period, which closes having already cleared', () => { + const { container, input } = renderScalePicker(); + fireEvent.focus(input); + switchTo(document.body, 'quarter'); + fireEvent.click(period(document.body, 'Q3')); + expect(input.value).toBe('Q3 2026'); + expect(getSlot(container, 'calendar-preview')).toHaveAttribute( + 'data-scale', + 'quarter' + ); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 84d9ae1e7..9f9acb68b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -1,7 +1,12 @@ 'use client'; import type { Popover } from '@base-ui/react'; -import { createContext, type ReactNode, useContext } from 'react'; +import { + createContext, + type ReactNode, + type RefObject, + useContext +} from 'react'; import type { DayKey } from './date-adapter'; import type { Scale, ScaleValue } from './lib/scale'; @@ -68,6 +73,7 @@ export interface CalendarPreviewContextValue { * clears. Tracks the last close reason, never the open state. */ shouldIgnoreFocusOpen: () => boolean; + triggerRef: RefObject; /** Read even when `value` is controlled. */ defaultDate: Date | CalendarPreviewDateRange | ScaleValue | null | undefined; /** A value reset — it never moves the view. */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index fd8450165..d31592256 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -410,6 +410,9 @@ export function CalendarPreviewRoot({ — the rule floating-ui's own `useFocus` applies, plus `closePress`, which is ours because auto-closing on completion is. */ const focusOpenBlocked = useRef(false); + const triggerRef = useRef(null); + /* `dropDraft` closes over state declared further down. */ + const dropDraftRef = useRef<(() => void) | null>(null); const setOpen = useCallback( (next: boolean, details: CalendarPreviewOpenChangeDetails) => { @@ -419,8 +422,18 @@ export function CalendarPreviewRoot({ details.reason === REASONS.triggerPress || details.reason === REASONS.closePress) ) { - focusOpenBlocked.current = true; + /* Only when focus has to travel back. In `` + it never left, so no focus event follows, and arming here left the + guard set to swallow the next real one. */ + focusOpenBlocked.current = !triggerRef.current?.contains( + document.activeElement + ); } + /* A draft belongs to the open popover. A commit closes with `closePress` + having already cleared it; every other close throws it away, or the + shut field goes on reading a period nobody chose. */ + if (!next && details.reason !== REASONS.closePress) + dropDraftRef.current?.(); setOpenUnwrapped(next); onOpenChange?.(next, details); }, @@ -594,14 +607,15 @@ export function CalendarPreviewRoot({ ); /* `scales[0]` is the scale being undone, not the one to come back to. */ - const dropDraft = useCallback( - () => - settleScale( - scaleBeforeDraft.current ?? - (isScaleValue(value) ? value.scale : scales[0]) - ), - [value, scales, settleScale] - ); + const dropDraft = useCallback(() => { + if (scaleDraft === null) return; + settleScale( + scaleBeforeDraft.current ?? + (isScaleValue(value) ? value.scale : scales[0]) + ); + }, [scaleDraft, value, scales, settleScale]); + + dropDraftRef.current = dropDraft; /* Bounds only, never `isDateUnavailable` — the prop documents why. */ const isPeriodAvailable = useCallback( @@ -695,6 +709,7 @@ export function CalendarPreviewRoot({ open, setOpen, shouldIgnoreFocusOpen, + triggerRef, defaultDate, reset, month, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index 6177e2a17..79fd05168 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -1,8 +1,9 @@ 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 { useMergedRefs } from '@base-ui/utils/useMergedRefs'; import { cx } from 'class-variance-authority'; -import { type ComponentProps, type FocusEvent, useRef } from 'react'; +import { type ComponentProps, type FocusEvent, useEffect, useRef } from 'react'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; import { type CalendarPreviewValue, isRange } from './calendar-preview-root'; @@ -44,6 +45,7 @@ export function CalendarPreviewTrigger({ scale, setOpen, shouldIgnoreFocusOpen, + triggerRef, disabled, readOnly } = useCalendarPreviewContext( @@ -54,6 +56,22 @@ export function CalendarPreviewTrigger({ is open, and this only says whether a press is mid-flight. */ const pressing = useRef(false); + /* A pointer released outside the trigger never reaches `onPointerUp` here, + and a flag left set swallows every focus that follows. */ + useEffect(() => { + const release = () => { + pressing.current = false; + }; + window.addEventListener('pointerup', release); + window.addEventListener('pointercancel', release); + return () => { + window.removeEventListener('pointerup', release); + window.removeEventListener('pointercancel', release); + }; + }, []); + + const mergedRef = useMergedRefs(triggerRef, ref); + /* One cast at the boundary: Base UI types its trigger for the `button` it renders by default, and this one is always a `div`. Consumer props stay last, inside the merge. */ @@ -61,7 +79,7 @@ export function CalendarPreviewTrigger({ nativeButton: false, disabled, render: render ??
, - ref, + ref: mergedRef, ...mergeProps<'div'>( { className: cx(styles.trigger, className), @@ -74,8 +92,11 @@ export function CalendarPreviewTrigger({ pressing.current = false; }, onFocus: (event: FocusEvent) => { - if (disabled || readOnly || pressing.current) return; - if (shouldIgnoreFocusOpen()) return; + if (disabled || readOnly) return; + /* Consumed before the press guard: a press that returns early + without taking it leaves it armed against the next focus. */ + const returning = shouldIgnoreFocusOpen(); + if (returning || pressing.current) return; setOpen( true, createChangeEventDetails( From 07cae4109e9165c69bc89088f5aeabe718d13c30 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 23:50:08 +0530 Subject: [PATCH 22/52] fix(calendar-preview): drive the input's invalid state from state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validity was held in a ref and read back during render, so the render that set it painted the previous verdict: the border and `aria-invalid` lagged one keystroke behind the message the consumer had already been handed. A ref read during render is not a render input, and this one had no reason to be a ref — nothing here needs to survive a render without causing one. --- .../calendar-preview/calendar-preview-input.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 115a9cdcf..4eea713d0 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -1,5 +1,5 @@ import { cx } from 'class-variance-authority'; -import { type ComponentProps, useEffect, useRef, useState } from 'react'; +import { type ComponentProps, useEffect, useState } from 'react'; import { CalendarIcon } from '~/icons'; import { Input } from '../input'; import styles from './calendar-preview.module.css'; @@ -119,7 +119,7 @@ export function CalendarPreviewInput({ /* Null means "show the committed value"; a string is the user's draft. */ const [text, setText] = useState(null); - const lastReported = useRef(VALID); + const [validity, setValidity] = useState(VALID); /* Derived from the reason rather than returned alongside it, so the reason stays the single source of truth. */ @@ -140,13 +140,13 @@ export function CalendarPreviewInput({ const report = (candidate: CalendarPreviewInputValidity) => { const next = withMessage(candidate); if ( - next.valid === lastReported.current.valid && - next.reason === lastReported.current.reason && - next.message === lastReported.current.message + next.valid === validity.valid && + next.reason === validity.reason && + next.message === validity.message ) { return; } - lastReported.current = next; + setValidity(next); onValidityChange?.(next); }; @@ -248,7 +248,7 @@ export function CalendarPreviewInput({ untouched. Spread rather than set to `undefined`: these props land after Field's, and an explicit `undefined` erases the invalid state Field sets for errors this input knows nothing about. */ - {...(lastReported.current.valid + {...(validity.valid ? {} : { 'aria-invalid': true, 'data-invalid': true })} value={text ?? committedText} From 512ee176b13db43287eac4187726a99d46757881 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 23:51:03 +0530 Subject: [PATCH 23/52] fix(calendar-preview): read typed input and formatting off the root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `parseScaleInput` was called with no options, so a bare `Q4`, `H1` or `May` resolved against the wall clock rather than the root's `today` — a test or a consumer pinning `today` got a period in the wrong year — and `trailingValue` never reached the parser at all, leaving its `trailing` option dead and a typed `Q4 2026` landing on a different day than clicking Q4 2026 in an end field. Both now come from the root. `formatValue` never received `timeZone`, so a custom formatter could not render the zone the rest of the calendar renders, and the built-in one read days off the local zone: far enough from UTC it showed the neighbouring day. It is bound once on the root, where the zone already lives, rather than at the dozen call sites that format through the context. --- .../__tests__/scale-selection.test.tsx | 87 +++++++++++++++++++ .../calendar-preview-input.tsx | 9 +- .../calendar-preview-root.tsx | 26 ++++-- 3 files changed, 115 insertions(+), 7 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index f1d552d0c..3242563db 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -773,6 +773,93 @@ describe('CalendarPreview drops a draft to the scale it started from', () => { }); }); +describe('CalendarPreview.Input reads the root clock', () => { + const input = (container: HTMLElement) => + getSlot(container, 'calendar-preview-input') as HTMLInputElement; + + it('resolves a bare period in the root year, not the wall clock', () => { + const onValueChange = vi.fn(); + const FAR = new Date(2030, 0, 1); + const { container } = render( + + + + ); + fireEvent.change(input(container), { target: { value: 'Q4' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2030-10-01', + scale: 'quarter' + }); + }); + + /* The end root of a scale pair: single selection, its own `scales`, its own + `trailingValue`. The parser hands back the first day either way. */ + it('commits a typed period at the trailing edge of an end root', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-12-31', + scale: 'quarter' + }); + }); + + it('respects a bound that only the trailing edge clears', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + fireEvent.change(input(container), { target: { value: 'Q3 2026' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-09-30', + scale: 'quarter' + }); + }); +}); + +describe('CalendarPreview formatValue sees the root time zone', () => { + it('passes it as the third argument', () => { + const formatValue = vi.fn(() => 'formatted'); + renderBody({ + formatValue, + timeZone: 'Pacific/Niue', + value: { date: '2026-08-20', scale: 'day' } + }); + expect(formatValue).toHaveBeenCalledWith( + expect.anything(), + 'day', + 'Pacific/Niue' + ); + }); +}); + describe('CalendarPreview drops a scale draft when the popover closes', () => { function renderScalePicker(props = {}) { const utils = render( diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 4eea713d0..c70fbb41e 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -96,6 +96,7 @@ export function CalendarPreviewInput({ readOnly, scales, scaleDraft, + trailingValue, selectPeriod, isPeriodAvailable, selection, @@ -153,7 +154,13 @@ export function CalendarPreviewInput({ const resolve = ( text: string ): CalendarPreviewInputValidity | { date: Date; scale: Scale } => { - const parsed = parseScaleInput(text); + /* The root's clock and its edge, so what the parser reports is what the + commit writes — reading them off the wall clock is how `Q4` landed in + the wrong year. */ + const parsed = parseScaleInput(text, { + referenceDate: today, + trailing: trailingValue + }); if (!parsed || !scales.includes(parsed.scale)) { return { valid: false, reason: 'unparseable' }; } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index d31592256..c55cbf0b9 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -189,7 +189,11 @@ interface CalendarPreviewSharedProps isDateUnavailable?: (date: Date) => boolean; /** @defaultValue `DD MMM YYYY` at day scale, the period's shorthand above it */ - formatValue?: (value: Date | ScaleValue, scale: Scale) => string; + formatValue?: ( + value: Date | ScaleValue, + scale: Scale, + timeZone?: string + ) => string; /** * The zone the grid reads days in. Forwarded to the grid; this family does @@ -235,13 +239,14 @@ interface CalendarPreviewSharedProps /* Exported for its tests; `formatValue` replaces it wholesale. */ export function defaultFormatValue( value: Date | ScaleValue, - scale: Scale + scale: Scale, + timeZone?: string ): string { const date = value instanceof Date ? value : parseKey(value.date); - if (scale === 'day') return formatDayLabel(date); - if (scale === 'month') return formatMonthLabel(date); + if (scale === 'day') return formatDayLabel(date, timeZone); + if (scale === 'month') return formatMonthLabel(date, timeZone); - const key = dayKey(date); + const key = dayKey(date, timeZone); const year = yearOf(key); if (scale === 'year') return String(year); const month = monthOf(key); @@ -270,7 +275,7 @@ export function CalendarPreviewRoot({ maxDate, isDateUnavailable: isDateUnavailableProp, defaultDate, - formatValue = defaultFormatValue, + formatValue: formatValueProp = defaultFormatValue, timeZone, today: todayProp, clearable = true, @@ -686,6 +691,15 @@ export function CalendarPreviewRoot({ return { from: Math.min(...years), to: Math.max(...years) }; }, [yearRangeProp, today, minDate, maxDate]); + /* Bound here rather than at each call site: every part formats through the + context, and a `formatValue` that never saw `timeZone` rendered the + neighbouring day in any zone far enough from UTC. */ + const formatValue = useCallback( + (value: Date | ScaleValue, scale: Scale) => + formatValueProp(value, scale, timeZone), + [formatValueProp, timeZone] + ); + const context = useMemo>( () => ({ value, From 7b4f8f1410681c0a68a6dbcb8f661b521f53c1b6 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 16 Sep 2026 23:53:08 +0530 Subject: [PATCH 24/52] feat(calendar-preview): report 'scale' when a commit changes granularity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `'scale'` was a documented change reason that nothing ever emitted. RFC 005 line 144 keeps the switch itself silent, so the only place it can be reported is the commit that lands on a new granularity — a quarter replacing a day, or a day replacing a quarter. Both `commitDay` and `selectPeriod` now compare what they are about to write against what the value carried and report `'scale'` when they differ. A first selection stays `'select'`: there is no granularity it moved away from. --- .../calendar-preview-context.tsx | 1 + .../calendar-preview-root.tsx | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 9f9acb68b..86bcdf371 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -16,6 +16,7 @@ export type CalendarPreviewChangeReason = | 'input' | 'clear' | 'reset' + /** A commit that lands on a different granularity than the value carried. */ | 'scale'; export type CalendarPreviewOpenChangeDetails = Popover.Root.ChangeEventDetails; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index c55cbf0b9..2cc4a6d0f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -236,6 +236,13 @@ interface CalendarPreviewSharedProps readOnly?: boolean; } +/* RFC 005 line 144 keeps the switch itself silent, so the commit that lands on + a new granularity is the only place `'scale'` can be reported. A first + selection is a `'select'`: there is no granularity it moved away from. */ +function scaleChanged(value: CalendarPreviewValue, next: Scale): boolean { + return isScaleValue(value) && value.scale !== next; +} + /* Exported for its tests; `formatValue` replaces it wholesale. */ export function defaultFormatValue( value: Date | ScaleValue, @@ -397,9 +404,17 @@ export function CalendarPreviewRoot({ (date: Date, reason: CalendarPreviewChangeReason) => { const key = dayKey(date, timeZone); clearScaleDraft(); - setValue(carriesScale ? { date: key, scale: 'day' } : date, reason, date); + if (!carriesScale) { + setValue(date, reason, date); + return; + } + setValue( + { date: key, scale: 'day' }, + scaleChanged(value, 'day') ? 'scale' : reason, + date + ); }, - [carriesScale, timeZone, setValue, clearScaleDraft] + [carriesScale, timeZone, value, setValue, clearScaleDraft] ); const [open, setOpenUnwrapped] = useControlled({ @@ -583,7 +598,11 @@ export function CalendarPreviewRoot({ if (readOnly || disabled) return; const key = anchorOf(periodOf(date, next, timeZone), trailingValue); clearScaleDraft(); - setValue({ date: key, scale: next }, 'select', parseKey(key)); + setValue( + { date: key, scale: next }, + scaleChanged(value, next) ? 'scale' : 'select', + parseKey(key) + ); setOpen( false, createChangeEventDetails(REASONS.closePress, undefined, undefined) @@ -594,6 +613,7 @@ export function CalendarPreviewRoot({ timeZone, readOnly, disabled, + value, setValue, setOpen, clearScaleDraft From 4aec874125fecd0233f91da8745ac55de6a733de Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 00:38:13 +0530 Subject: [PATCH 25/52] fix(calendar-preview): stand the trigger down to an anchor around a field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Base UI gives a non-native trigger `role="button"` and `tabIndex={0}`, so `` was two tab stops per field — four for a range — with a focusable control sitting inside a button role. It also presses to toggle, which meant the press that carried focus from the start field to the end field read as a request to close the popover the user was still filling in. With an `.Input` mounted the trigger now takes no role, leaves the tab order, and takes the press-toggle out; focus is what opens in that composition. The inputs register themselves, as they already do for `readOnly`, because only they know they are there. Given no `.Input` the trigger is unchanged. Dropping the toggle also retires the press guard on the focus handler there: it existed so `useClick` and this handler could not both open, and with nothing left to race it would only swallow the focus a mouse press delivers before `pointerup` — leaving nothing to open the popover at all. --- .../__tests__/picker.test.tsx | 56 ++++++++++++++++++- .../calendar-preview-context.tsx | 3 + .../calendar-preview-input.tsx | 8 ++- .../calendar-preview-root.tsx | 12 ++++ .../calendar-preview-trigger.tsx | 24 +++++++- 5 files changed, 99 insertions(+), 4 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index 591e6a383..b5f231948 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -1,6 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; -import { getSlot } from '~/test-utils/data-slots'; +import { getAllSlots, getSlot } from '~/test-utils/data-slots'; import { Field } from '../../field'; import { CalendarPreview } from '../calendar-preview'; @@ -512,3 +512,57 @@ describe('CalendarPreview.Trigger content', () => { ); }); }); + +describe('CalendarPreview.Trigger is an anchor around a field', () => { + it('drops the button role and the tab stop when it wraps an input', () => { + const { container } = renderPicker(); + const trigger = getSlot(container, 'calendar-preview-trigger'); + expect(trigger).not.toHaveAttribute('role', 'button'); + expect(trigger).toHaveAttribute('tabindex', '-1'); + }); + + it('keeps both when it wraps only a label', () => { + const { container } = render( + + + + + + + ); + const trigger = getSlot(container, 'calendar-preview-trigger'); + expect(trigger).toHaveAttribute('role', 'button'); + expect(trigger).not.toHaveAttribute('tabindex', '-1'); + }); + + it('opens on a pointer press, which no longer races the focus handler', () => { + const { input } = renderPicker(); + fireEvent.pointerDown(input); + fireEvent.focus(input); + fireEvent.pointerUp(input); + fireEvent.click(input); + expect(isOpen()).toBe(true); + }); + + it('stays open when a press moves between two fields of a range', () => { + const { container } = render( + + + + + + + + + + ); + const [start, end] = getAllSlots(container, 'calendar-preview-input'); + fireEvent.focus(start); + expect(isOpen()).toBe(true); + fireEvent.pointerDown(end); + fireEvent.focus(end); + fireEvent.pointerUp(end); + fireEvent.click(end); + expect(isOpen()).toBe(true); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 86bcdf371..961d4e2b5 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -133,6 +133,9 @@ export interface CalendarPreviewContextValue { */ fieldReadOnly: Record; setFieldReadOnly: (field: CalendarPreviewField, readOnly: boolean) => void; + /** Whether an `.Input` is mounted; `.Trigger` stops being a button when one is. */ + hasInput: boolean; + registerInput: (mounted: boolean) => void; } const CalendarPreviewContext = diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index c70fbb41e..bfdf01c50 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -105,11 +105,17 @@ export function CalendarPreviewInput({ draft, activeField, setActiveField, - setFieldReadOnly + setFieldReadOnly, + registerInput } = useCalendarPreviewContext('CalendarPreview.Input'); const isRange = selection === 'range'; + useEffect(() => { + registerInput(true); + return () => registerInput(false); + }, [registerInput]); + /* 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(() => { diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 2cc4a6d0f..75cc285a6 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -489,6 +489,14 @@ export function CalendarPreviewRoot({ [] ); + /* Counted, not a flag: a range mounts two, and the first to unmount would + otherwise report that none are left. */ + const [inputCount, setInputCount] = useState(0); + + const registerInput = useCallback((mounted: boolean) => { + setInputCount(current => current + (mounted ? 1 : -1)); + }, []); + /* * The from/to machine, unchanged from the shipped picker: * no from -> set from, advance to the end input @@ -740,6 +748,8 @@ export function CalendarPreviewRoot({ setActiveField, fieldReadOnly, setFieldReadOnly, + hasInput: inputCount > 0, + registerInput, open, setOpen, shouldIgnoreFocusOpen, @@ -779,6 +789,8 @@ export function CalendarPreviewRoot({ activeField, fieldReadOnly, setFieldReadOnly, + inputCount, + registerInput, open, setOpen, shouldIgnoreFocusOpen, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index 79fd05168..0986815a3 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -1,9 +1,16 @@ 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 type { BaseUIEvent } from '@base-ui/react/types'; import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; import { cx } from 'class-variance-authority'; -import { type ComponentProps, type FocusEvent, useEffect, useRef } from 'react'; +import { + type ComponentProps, + type FocusEvent, + type MouseEvent, + useEffect, + useRef +} from 'react'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; import { type CalendarPreviewValue, isRange } from './calendar-preview-root'; @@ -46,6 +53,7 @@ export function CalendarPreviewTrigger({ setOpen, shouldIgnoreFocusOpen, triggerRef, + hasInput, disabled, readOnly } = useCalendarPreviewContext( @@ -85,6 +93,15 @@ export function CalendarPreviewTrigger({ className: cx(styles.trigger, className), 'data-slot': 'calendar-preview-trigger', 'data-scale': scale, + /* Base UI gives a non-native trigger both, which around a field is a + second tab stop and a control inside a button role. */ + role: hasInput ? undefined : 'button', + tabIndex: hasInput ? -1 : undefined, + /* Merged to the right of `useClick`, so this runs first and takes out + the press-toggle that closed the popover between two fields. */ + onClick: (event: BaseUIEvent>) => { + if (hasInput) event.preventBaseUIHandler(); + }, onPointerDown: () => { pressing.current = true; }, @@ -96,7 +113,10 @@ export function CalendarPreviewTrigger({ /* Consumed before the press guard: a press that returns early without taking it leaves it armed against the next focus. */ const returning = shouldIgnoreFocusOpen(); - if (returning || pressing.current) return; + /* With the toggle gone there is nothing to race, and the guard + would swallow the focus a press delivers before `pointerup` — + leaving nothing to open the popover at all. */ + if (returning || (!hasInput && pressing.current)) return; setOpen( true, createChangeEventDetails( From ac82afb17da11f13e6489f58a069a65b6f6f0ad0 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 00:43:07 +0530 Subject: [PATCH 26/52] fix(calendar-preview): drop a rejected draft when the value moves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing something unparseable and then picking the date from the grid left the field showing the rejected text against its red border, over a value the calendar had already taken: nothing cleared the local draft except a commit from the field itself. A value this input did not write — a grid click, `.Reset`, a controlled write — now replaces the draft and clears the invalid state, reporting the change through `onValidityChange` like any other. --- .../__tests__/picker.test.tsx | 30 +++++++++++++++++++ .../calendar-preview-input.tsx | 14 ++++++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index b5f231948..f8cfae9f6 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -566,3 +566,33 @@ describe('CalendarPreview.Trigger is an anchor around a field', () => { expect(isOpen()).toBe(true); }); }); + +describe('CalendarPreview.Input drops a rejected draft on an outside write', () => { + it('clears the text and the invalid state when a day is clicked', () => { + const onValidityChange = vi.fn(); + const { container, input } = renderPicker({}, { onValidityChange }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'not a date' } }); + expect(input).toHaveAttribute('data-invalid'); + + const cell = getAllSlots(document.body, 'calendar-preview-day').find( + one => + getSlot(one, 'calendar-preview-day-number')?.textContent === '12' && + !one.hasAttribute('data-outside') + ) as HTMLElement; + fireEvent.click(cell); + + expect(input.value).toBe('12 Aug 2026'); + expect(input).not.toHaveAttribute('data-invalid'); + expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true }); + expect(container).toBeTruthy(); + }); + + it('leaves a draft alone while the value has not moved', () => { + const { input } = renderPicker(); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'not a date' } }); + fireEvent.change(input, { target: { value: 'still not' } }); + expect(input.value).toBe('still not'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index bfdf01c50..0345c8479 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -1,5 +1,5 @@ import { cx } from 'class-variance-authority'; -import { type ComponentProps, useEffect, 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'; @@ -128,6 +128,18 @@ export function CalendarPreviewInput({ const [text, setText] = useState(null); const [validity, setValidity] = useState(VALID); + /* A value this field did not type replaces whatever it was drafting, or a + rejected draft outlives the day the user went on to click. */ + const committed = useRef(value); + useEffect(() => { + if (committed.current === value) return; + committed.current = value; + setText(null); + if (validity.valid) return; + setValidity(VALID); + onValidityChange?.(VALID); + }, [value, validity.valid, onValidityChange]); + /* Derived from the reason rather than returned alongside it, so the reason stays the single source of truth. */ const withMessage = ( From 30ab4e1526f85334e97113bcef987e69347b172c Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 00:54:21 +0530 Subject: [PATCH 27/52] fix(calendar-preview): let a click move the end past a read-only start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a committed range and no draft in flight, a grid click fell into the machine's restart arm — the arm a read-only start refuses — so clicking any day in the read-only-start composition did nothing at all, while typing the same date into the end field worked. A fixed start now anchors the click to the end instead, which is what `setEndpoint` already did for the typed path. A day before that start is refused rather than quietly restarting there. --- .../calendar-preview/__tests__/range.test.tsx | 71 +++++++++++++++++++ .../calendar-preview-root.tsx | 18 +++++ 2 files changed, 89 insertions(+) diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 783e22d9d..1848abe66 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -506,3 +506,74 @@ describe('CalendarPreview range order validation', () => { expect(day(container, '5')).toHaveAttribute('data-selected'); }); }); + +describe('CalendarPreview range with a read-only start', () => { + const COMMITTED = { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) }; + + function renderFixedStart(props = {}, endProps = {}) { + return render( + + + + + + + + ); + } + + it('moves the end against the committed start', () => { + const onValueChange = vi.fn(); + const { container } = renderFixedStart({ onValueChange }); + fireEvent.click(day(container, '25')); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toEqual({ + from: new Date(2026, 7, 10), + to: new Date(2026, 7, 25) + }); + }); + + it('refuses a day before the fixed start rather than restarting there', () => { + const onValueChange = vi.fn(); + const { container } = renderFixedStart({ onValueChange }); + fireEvent.click(day(container, '5')); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('writes nothing when both endpoints are read-only', () => { + const onValueChange = vi.fn(); + const { container } = renderFixedStart( + { onValueChange }, + { readOnly: true } + ); + fireEvent.click(day(container, '25')); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('leaves the ordinary range machine alone when nothing is read-only', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + + + + + ); + fireEvent.click(day(container, '25')); + expect(onValueChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 75cc285a6..a9a02dca1 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -529,6 +529,24 @@ export function CalendarPreviewRoot({ return; } + /* Falling through would restart the range from this day, which is the + one write a read-only start refuses — so every click did nothing. */ + const fixed = fieldReadOnly.start + ? (draft?.from ?? (isRange(value) ? value.from : undefined)) + : undefined; + if (fixed) { + if (fieldReadOnly.end) return; + if (dayKey(date, timeZone) < dayKey(fixed, timeZone)) return; + setDraft(null); + setActiveField('start'); + setValue({ from: fixed, to: date }, 'select', date); + setOpen( + false, + createChangeEventDetails(REASONS.closePress, undefined, undefined) + ); + return; + } + const from = draft?.from; if (!from || draft?.to) { if (fieldReadOnly.start) return; From 7836ba38aede565e8d908fed509e153ee1cdf802 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 00:54:22 +0530 Subject: [PATCH 28/52] docs(calendar-preview): describe the trigger around a field It behaves differently with an `.Input` inside than without one, and a consumer counting tab stops or wiring a range has no way to know which. --- apps/www/src/content/docs/components/calendar-preview/index.mdx | 2 ++ 1 file changed, 2 insertions(+) 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 4b2aee760..a7bbfb1b2 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -114,6 +114,8 @@ buttons sideways. It carries `data-restored` while there is nothing to restore. Anchors the popover and owns opening it. Renders the formatted value, or the placeholder, when given no children — wrap an `.Input` in it for a typeable field. Never renders a `button`, so the control inside stays focusable. Takes `render`, `className` and `ref`. +With an `.Input` inside, the trigger steps back and lets the field carry the interaction: it takes no `role`, sits out of the tab order, and opens on the input's focus rather than on a press. A range therefore has two tab stops, not four, and moving from the start field to the end field leaves the popover open. Given no `.Input` it stays a `role="button"` tab stop of its own. + ### CalendarPreview.Content The portaled popover surface. Takes `Popover.Content` props — `side`, `align`, `sideOffset` and the rest — and flips above the trigger on collision. From 232b3f80e1fc8df375f04ed9336cdeaa58e39a13 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 14:38:53 +0530 Subject: [PATCH 29/52] fix(calendar-preview)!: stop the calendar closing itself, and reopening itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completing a range, and picking a period, both closed the popover. Neither does now: a commit leaves it open, so a second pick needs no second trip to the trigger, and typed and clicked commits agree because neither closes. Escape, an outside press and the trigger still dismiss it. That also settles the older inconsistency where a typed period closed the popover and a typed day did not — the split was day versus period all along, on both paths. The other half is the reverse fault. An outside press dismissed the popover and Base UI handed focus back to the trigger, whose focus handler read that as the user arriving and reopened what had just been closed. The guard that exists for this armed only on Escape and on a trigger press. It could not simply read where focus is, either: an outside press is handled on pointerdown, before the browser has moved focus, so the trigger still holds it and only the reason says it is leaving. The guard is released by whatever the user does next rather than on a timer. A restore trails the close by the length of the exit transition — measured at about 217ms in a browser, far past any frame count — while a focus the user meant is always preceded by a press or a key. BREAKING CHANGE: completing a range no longer closes the popover, and neither does picking a period. A consumer relying on that close must now drive `open` itself. `onOpenChange` is no longer called with `close-press`. --- .../components/calendar-preview/index.mdx | 4 +- .../__tests__/picker.test.tsx | 36 +++++++++ .../calendar-preview/__tests__/range.test.tsx | 64 +++++++-------- .../__tests__/scale-selection.test.tsx | 8 +- .../calendar-preview-context.tsx | 3 +- .../calendar-preview-grid.tsx | 6 +- .../calendar-preview-root.tsx | 78 +++++++++---------- .../calendar-preview-trigger.tsx | 8 +- 8 files changed, 120 insertions(+), 87 deletions(-) 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 a7bbfb1b2..73335227d 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -327,11 +327,11 @@ 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, later day | completes the range and emits | | `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. +Nothing here closes the popover. A commit leaves it open, so a second pick needs no second trip to the trigger; Escape, an outside press and the trigger itself still dismiss it. **Typing is stricter than clicking.** A click means "the next endpoint", so an earlier day restarts the range, as the table above says. Typing names the field it lands in, so an endpoint that crosses diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index f8cfae9f6..06a8ffecf 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -596,3 +596,39 @@ describe('CalendarPreview.Input drops a rejected draft on an outside write', () expect(input.value).toBe('still not'); }); }); + +describe('CalendarPreview.Trigger and the focus a dismissal gives back', () => { + const pressOutside = () => { + fireEvent.pointerDown(document.body); + fireEvent.mouseDown(document.body); + fireEvent.click(document.body); + }; + + it('does not reopen on the focus an outside press hands back', () => { + const onOpenChange = vi.fn(); + const { input } = renderPicker({ onOpenChange }); + /* Real focus, so the close can see the trigger still holding it. */ + input.focus(); + fireEvent.focus(input); + expect(isOpen()).toBe(true); + + pressOutside(); + expect(isOpen()).toBe(false); + const calls = onOpenChange.mock.calls; + expect(calls[calls.length - 1][1].reason).toBe('outside-press'); + + fireEvent.focus(input); + expect(isOpen()).toBe(false); + }); + + it('releases the guard on the next press when no focus comes back', () => { + const { input } = renderPicker(); + fireEvent.focus(input); + pressOutside(); + expect(isOpen()).toBe(false); + + fireEvent.pointerDown(input); + fireEvent.focus(input); + expect(isOpen()).toBe(true); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 1848abe66..9d1c8ae87 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -177,7 +177,7 @@ describe('CalendarPreview range inputs', () => { }); }); -describe('CalendarPreview range auto-close', () => { +describe('CalendarPreview range completion leaves the popover open', () => { const picker = ( <> @@ -193,54 +193,54 @@ describe('CalendarPreview range auto-close', () => { 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); + const open = (container: HTMLElement) => fireEvent.focus( getAllSlots(container, 'calendar-preview-input')[0] as HTMLElement ); - expect(isOpen()).toBe(true); - fireEvent.click(day(document.body, '10')); + it('stays open when the range completes', () => { + const onOpenChange = vi.fn(); + const { container } = renderRange({ onOpenChange }, picker); + open(container); expect(isOpen()).toBe(true); + fireEvent.click(day(document.body, '10')); fireEvent.click(day(document.body, '20')); - expect(isOpen()).toBe(false); - expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything()); + expect(isOpen()).toBe(true); + expect(onOpenChange.mock.calls.filter(call => call[0] === false)).toEqual( + [] + ); }); - /* 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); - + it('takes a second range without a second trip to the trigger', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ onValueChange }, picker); + open(container); 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); + fireEvent.click(day(document.body, '5')); + fireEvent.click(day(document.body, '8')); + expect(isOpen()).toBe(true); + expect(onValueChange).toHaveBeenCalledTimes(2); + expect(onValueChange.mock.calls[1][0]).toEqual({ + from: new Date(2026, 7, 5), + to: new Date(2026, 7, 8) + }); }); - /* 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); + it('still dismisses on Escape once the range is complete', () => { + const { container } = renderRange({}, picker); + open(container); fireEvent.click(day(document.body, '10')); fireEvent.click(day(document.body, '20')); expect(isOpen()).toBe(true); - expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything()); + + fireEvent.keyDown( + getSlot(document.body, 'calendar-preview-content') as HTMLElement, + { key: 'Escape' } + ); + expect(isOpen()).toBe(false); }); }); diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index 3242563db..881159d71 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -906,12 +906,18 @@ describe('CalendarPreview drops a scale draft when the popover closes', () => { ); }); - it('keeps a committed period, which closes having already cleared', () => { + it('keeps a committed period, and a later Escape does not undo it', () => { const { container, input } = renderScalePicker(); fireEvent.focus(input); switchTo(document.body, 'quarter'); fireEvent.click(period(document.body, 'Q3')); expect(input.value).toBe('Q3 2026'); + + fireEvent.keyDown( + getSlot(document.body, 'calendar-preview-content') as HTMLElement, + { key: 'Escape' } + ); + expect(input.value).toBe('Q3 2026'); expect(getSlot(container, 'calendar-preview')).toHaveAttribute( 'data-scale', 'quarter' diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 961d4e2b5..ec8c6fea3 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -110,8 +110,7 @@ export interface CalendarPreviewContextValue { 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. + * from/to machine. */ selectDay: (date: Date) => void; /** Writes a day at the root's value shape, for a path that is not a click. */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index a65d23b4e..73ec25794 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -220,10 +220,8 @@ export function CalendarPreviewGrid({ ); /* 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. It - also keeps the `readOnly` / `disabled` guard in one place, so every path - in and out of the calendar inherits the same one. */ + from/to machine. It also keeps the `readOnly` / `disabled` guard in one + place, so every path in and out of the calendar inherits the same one. */ const handleSelect = useCallback( (_selected: unknown, triggerDate: Date) => { selectDay(triggerDate); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index a9a02dca1..dc43b5cfc 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -1,11 +1,10 @@ '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, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styles from './calendar-preview.module.css'; import { type CalendarPreviewChangeDetails, @@ -424,40 +423,48 @@ export function CalendarPreviewRoot({ state: 'open' }); - /* 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. */ + /* A dismissal restores focus to the trigger, which would reopen it. */ const focusOpenBlocked = useRef(false); const triggerRef = useRef(null); + + /* The restore trails the close by the exit transition, so nothing timed is + safe; what the user does next releases the guard instead. Capture phase, + so the dismissing press runs this before the close arms it again. */ + useEffect(() => { + const release = () => { + focusOpenBlocked.current = false; + }; + document.addEventListener('pointerdown', release, true); + document.addEventListener('keydown', release, true); + return () => { + document.removeEventListener('pointerdown', release, true); + document.removeEventListener('keydown', release, true); + }; + }, []); + + const armFocusGuard = useCallback((leaving: boolean) => { + /* An outside press is read on pointerdown, before focus has moved, so the + trigger still holds it here and only the reason says it is leaving. An + Escape that never moves focus must not arm: no focus follows it. */ + focusOpenBlocked.current = + leaving || !triggerRef.current?.contains(document.activeElement); + }, []); + /* `dropDraft` closes over state declared further down. */ const dropDraftRef = useRef<(() => void) | null>(null); const setOpen = useCallback( (next: boolean, details: CalendarPreviewOpenChangeDetails) => { - if ( - !next && - (details.reason === REASONS.escapeKey || - details.reason === REASONS.triggerPress || - details.reason === REASONS.closePress) - ) { - /* Only when focus has to travel back. In `` - it never left, so no focus event follows, and arming here left the - guard set to swallow the next real one. */ - focusOpenBlocked.current = !triggerRef.current?.contains( - document.activeElement - ); - } - /* A draft belongs to the open popover. A commit closes with `closePress` - having already cleared it; every other close throws it away, or the - shut field goes on reading a period nobody chose. */ - if (!next && details.reason !== REASONS.closePress) + if (!next) { + armFocusGuard(details.reason === REASONS.outsidePress); + /* A draft belongs to the open popover; a commit has already cleared + it, so this finds nothing left to drop. */ dropDraftRef.current?.(); + } setOpenUnwrapped(next); onOpenChange?.(next, details); }, - [setOpenUnwrapped, onOpenChange] + [setOpenUnwrapped, onOpenChange, armFocusGuard] ); const shouldIgnoreFocusOpen = useCallback(() => { @@ -498,15 +505,13 @@ export function CalendarPreviewRoot({ }, []); /* - * The from/to machine, unchanged from the shipped picker: + * The from/to machine: * 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, day later -> completes and emits * 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. + * It lives on the root because `.Grid` and a typed `.Input` both drive it. */ const selectDay = useCallback( (date: Date) => { @@ -540,10 +545,6 @@ export function CalendarPreviewRoot({ setDraft(null); setActiveField('start'); setValue({ from: fixed, to: date }, 'select', date); - setOpen( - false, - createChangeEventDetails(REASONS.closePress, undefined, undefined) - ); return; } @@ -565,10 +566,6 @@ export function CalendarPreviewRoot({ setDraft(null); setActiveField('start'); setValue({ from, to: date }, 'select', date); - setOpen( - false, - createChangeEventDetails(REASONS.closePress, undefined, undefined) - ); }, [ selection, @@ -629,10 +626,6 @@ export function CalendarPreviewRoot({ scaleChanged(value, next) ? 'scale' : 'select', parseKey(key) ); - setOpen( - false, - createChangeEventDetails(REASONS.closePress, undefined, undefined) - ); }, [ trailingValue, @@ -641,7 +634,6 @@ export function CalendarPreviewRoot({ disabled, value, setValue, - setOpen, clearScaleDraft ] ); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index 0986815a3..dd536e7d4 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -50,6 +50,7 @@ export function CalendarPreviewTrigger({ value, formatValue, scale, + open, setOpen, shouldIgnoreFocusOpen, triggerRef, @@ -97,10 +98,11 @@ export function CalendarPreviewTrigger({ second tab stop and a control inside a button role. */ role: hasInput ? undefined : 'button', tabIndex: hasInput ? -1 : undefined, - /* Merged to the right of `useClick`, so this runs first and takes out - the press-toggle that closed the popover between two fields. */ + /* Merged to the right of `useClick`, so this runs first. Only the + closing half goes, or a press could not reopen a field that never + lost focus. */ onClick: (event: BaseUIEvent>) => { - if (hasInput) event.preventBaseUIHandler(); + if (hasInput && open) event.preventBaseUIHandler(); }, onPointerDown: () => { pressing.current = true; From d418e170e4f58501441ed9a87caf68cfc9a50cd1 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 14:39:38 +0530 Subject: [PATCH 30/52] feat(calendar-preview): divide the month and year columns The scroller's two columns ran together with only the popup's gap between them. They are separated by the library's own `Separator` rather than a border on the second column, so the rule is one element with one token behind it. It is decorative: the columns already carry their own labelled groups, and a separator role would only add noise between them. Vertical separators are sized at `height: 100%`, which resolves to nothing inside a popup whose own height comes from its columns, so this one stretches instead. --- .../calendar-preview/calendar-preview-caption.tsx | 7 +++++++ .../calendar-preview/calendar-preview.module.css | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx index 7d841e979..a3817944b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx @@ -7,6 +7,7 @@ import { } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import { type ReactNode, useEffect, useRef } from 'react'; +import { Separator } from '../separator'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext, @@ -136,6 +137,12 @@ function CaptionDropdown({ onSelect: () => setMonth(monthStart(activeYear, index)) }))} /> + Date: Thu, 17 Sep 2026 14:40:09 +0530 Subject: [PATCH 31/52] style(calendar-preview): match the header and the scroller to the design Measured against the Figma calendar node in a browser rather than read off the cascade. The nav button was a 20px square with a 16px glyph where the design asks for 28x24 around 12px, and that size is not one `IconButton` offers, so it is set here and the per-month caption's grid tracks follow it. The month chip lost its type to a `font` shorthand that also reset the size and line-height the caption sets on the same element, and rendered at body size. The weekday row was as tall as a date row, and the card carried an 8px inset where the design has 12. Both captions now read against "Sun" rather than against the column box, which is where the header sat before and what the chip could not do while it was excluded: it pads itself, so it shifts its box by the remainder instead. The scroller's columns and options take their own spacing, and the columns hide their scrollbar. --- .../calendar-preview.module.css | 68 ++++++++++++------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 0e209b978..21bd1bb3e 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -12,7 +12,7 @@ display: flex; flex-direction: column; width: fit-content; - padding: var(--rs-space-3); + padding: var(--rs-space-4); border-radius: var(--rs-radius-4); background: var(--rs-color-background-base-primary); color: var(--rs-color-foreground-base-primary); @@ -22,16 +22,12 @@ pointer-events: none; } -/* Inset by the gap a weekday label leaves inside its 40px cell. Aligning the - header to the column box instead would sit the caption visibly left of - "Sun", because the label is centred in the cell rather than flush to it. */ .header { display: flex; align-items: center; gap: var(--rs-space-2); - min-height: var(--rs-space-9); + min-height: var(--rs-space-7); margin-bottom: var(--rs-space-3); - padding-inline: var(--rs-space-3); } /* The week-number column is a gutter, not a date column, so the caption starts @@ -39,7 +35,7 @@ header cannot read `showWeekNumber`, which is a `.Grid` prop, so it asks the rendered grid instead. */ .days:has(.week-number-header) .header { - padding-inline-start: calc(var(--rs-space-10) + var(--rs-space-3)); + padding-inline-start: var(--rs-space-10); } .nav-button { @@ -47,6 +43,21 @@ color: var(--rs-color-foreground-base-primary); } +.header .nav-button, +.month-header .nav-button { + width: var(--rs-space-8); + height: var(--rs-space-7); + padding: 0; +} + +.header .nav-button > div, +.header .nav-button > div > *, +.month-header .nav-button > div, +.month-header .nav-button > div > * { + width: var(--rs-space-4); + height: var(--rs-space-4); +} + .nav-button:disabled { color: var(--rs-color-foreground-base-tertiary); cursor: not-allowed; @@ -77,12 +88,12 @@ align-items: center; justify-content: center; gap: var(--rs-space-1); - padding: var(--rs-space-1) var(--rs-space-3); + padding: var(--rs-space-2) var(--rs-space-3); border: none; border-radius: var(--rs-radius-2); background: var(--rs-color-background-neutral-secondary); color: inherit; - font: inherit; + font-family: inherit; cursor: pointer; } @@ -119,7 +130,7 @@ /* The popup is sized by its columns, so the separator's percentage height resolves to nothing; stretching is what fills the row. */ -.caption-popup .caption-divider[data-orientation='vertical'] { +.caption-popup .caption-divider[data-orientation="vertical"] { height: auto; align-self: stretch; } @@ -131,11 +142,14 @@ overflow-y: auto; /* Six rows of the day-cell height; taller lists scroll. */ max-height: calc(var(--rs-space-10) * 6); + padding: var(--rs-space-2); + scrollbar-width: none; + -ms-overflow-style: none; } .caption-option { flex: none; - padding: var(--rs-space-2) var(--rs-space-3); + padding: var(--rs-space-3); border: none; border-radius: var(--rs-radius-2); background: transparent; @@ -169,22 +183,32 @@ } /* Both nav tracks stay reserved whether or not this month draws a button, so - the caption centres on its grid rather than on the remaining space. The - track width is the size-3 IconButton the nav renders. */ + the caption centres on its grid rather than on the remaining space. */ .month-header { display: grid; - grid-template-columns: var(--rs-space-6) 1fr var(--rs-space-6); + grid-template-columns: var(--rs-space-8) 1fr var(--rs-space-8); align-items: center; gap: var(--rs-space-2); - min-height: var(--rs-space-9); + min-height: var(--rs-space-7); margin-bottom: var(--rs-space-3); - padding-inline: var(--rs-space-3); } .month-header-prev { grid-column: 1; } +.header { + --rs-caption-inset: calc((var(--rs-space-10) - var(--rs-space-6)) / 2); +} + +.header .caption:not([data-dropdown]) { + padding-inline-start: var(--rs-caption-inset); +} + +.header .caption[data-dropdown] { + margin-inline-start: calc(var(--rs-caption-inset) - var(--rs-space-3)); +} + .month-header-caption { grid-column: 2; text-align: center; @@ -208,12 +232,8 @@ flex-direction: column; } -/* `.Header` owns the visible caption. This one stays in the tree because - react-day-picker points each grid's accessible name at the month, and a - removed node would take that name with it. */ .month-caption { position: absolute; - /* A hairline box, not a spacing value — the space scale starts at 2px. */ width: 1px; height: 1px; margin: -1px; @@ -243,10 +263,6 @@ display: flex; } -/* Cells size to the border box and drop the user-agent's table-cell padding, - so a heading and the days under it are the same 40px column. Content-box - would make the bordered day cell 4px wider than its heading, and the two - rows would drift a column apart by Saturday. */ .weekday, .day, .week-number, @@ -260,7 +276,7 @@ align-items: center; justify-content: center; width: var(--rs-space-10); - height: var(--rs-space-10); + height: var(--rs-space-9); color: var(--rs-color-foreground-base-secondary); text-align: center; font-weight: var(--rs-font-weight-medium); @@ -577,7 +593,7 @@ panel is the anchor for all five views — seven 40px columns, the width the input and switcher above it run at — and the popover stops resizing. */ .panel { - width: calc(var(--rs-space-10) * 7 + var(--rs-space-3) * 2); + width: calc(var(--rs-space-10) * 7 + var(--rs-space-4) * 2); } /* Standalone, `.Days` is its own inset surface. Under the switcher it is one From 8ffc088589019bec2b9b8f13565baffe6e71fb39 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 14:40:18 +0530 Subject: [PATCH 32/52] docs(calendar-preview): lead with the playground, like every other component This page opened with a static preview and then a playground; no other component exports a preview at all, and the playground already renders the same default composition the preview showed. The preview's tabs move to where each belongs rather than going away: the inline one was the playground's own output and the Anatomy snippet besides, two months joins the grid layout tabs because that is what `numberOfMonths` changes, and month + year sits beside the custom caption under Composition. --- .../docs/components/calendar-preview/demo.ts | 42 +++++++------------ .../components/calendar-preview/index.mdx | 7 +--- 2 files changed, 16 insertions(+), 33 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 2f84f1319..6009a928c 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -46,53 +46,35 @@ export const playground = { getCode }; -export const preview = { +export const compositionDemo = { type: 'code', tabs: [ { - name: 'Inline', + name: 'Default header', code: ` ` }, { - name: 'Two months', - code: ` - - ` - }, - { - name: 'Month + year', - code: ` + name: 'Custom caption', + code: ` - + Delivery date + ` - } - ] -}; - -export const compositionDemo = { - type: 'code', - tabs: [ - { - name: 'Default header', - code: ` - - ` }, { - name: 'Custom caption', - code: ` + name: 'Month + year', + code: ` - Delivery date - + @@ -260,6 +242,12 @@ export const gridDemo = { ` + }, + { + name: 'Two months', + 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 73335227d..0c317839e 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -5,7 +5,6 @@ source: packages/raystack/components/calendar-preview --- import { - preview, playground, compositionDemo, resetDemo, @@ -18,10 +17,6 @@ import { scalePairDemo, } from "./demo.ts"; - - -## Playground - ## Anatomy @@ -281,7 +276,7 @@ Outside days are **off by default**, so a grid ends on the last day of its month ### Month and year scroller -`` turns the caption into a filled chip that opens two adjacent scrolling columns. It is a plain popover of buttons, not a `Select` — picking from either column moves the view and never selects a value. +`` turns the caption into a filled chip that opens two adjacent scrolling columns, divided by a rule. It is a plain popover of buttons, not a `Select` — picking from either column moves the view and never selects a value. The **Month + year** tab under [Composition](#composition) shows it. ### Date picker From db786e5ed368dda178185023cfd7a3db7b47a074 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 14:40:29 +0530 Subject: [PATCH 33/52] fix(www): let a checkbox control start from a boolean default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `initialProps` coerced every checkbox with `value === 'true'`, which is only right when the value came from a search param. A `defaultValue: true` is already a boolean, so it compared `true === 'true'` and started unchecked — and because the playground passes on only what differs from the default, the control then wrote its opposite into the generated code on every load. CalendarPreview showed it plainly: `fixedWeeks` defaults on, so the page opened with `fixedWeeks={false}` and the calendar changed height as the user moved between months. `field` and `label` each declare a `required` checkbox the same way. --- apps/www/src/components/demo/demo-playground.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/www/src/components/demo/demo-playground.tsx b/apps/www/src/components/demo/demo-playground.tsx index 7e1231403..221b1ac80 100644 --- a/apps/www/src/components/demo/demo-playground.tsx +++ b/apps/www/src/components/demo/demo-playground.tsx @@ -35,7 +35,14 @@ const getInitialProps = ( const value = (searchParams && searchParams.get(key)) ?? initialValue ?? defaultValue; - initialProps[key] = type === 'checkbox' ? value === 'true' : value; + /* Only a search param arrives as a string; a `defaultValue: true` is + already a boolean, and comparing it to 'true' made it start unchecked. */ + initialProps[key] = + type === 'checkbox' + ? typeof value === 'string' + ? value === 'true' + : Boolean(value) + : value; }); return initialProps; }; From 47b03590991563a2db0307b3b6b9f19407e5d9e5 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 15:13:45 +0530 Subject: [PATCH 34/52] fix(calendar-preview): stop numbering the row fixedWeeks pads With week numbers on, the sixth row a five-row month pads to draws no days at all, and numbering it counts a week the grid never showed. It is blanked only when nothing is drawn there: a row the month reaches into keeps its number, and so does a padding row whose outside days are shown. The cell itself stays, or the row loses a column. `showOutsideDays` reaches the slot through the grid context rather than a closure, because the slot map is memoized on its own deps and rebuilding it would hand react-day-picker new component identities and remount every cell. Swept the rest of the module for dead code while here. `epoch()` had no caller but its own test, and `.range-fields` no reference at all; both go. The six `startOf*/endOf*Key` wrappers, `monthStart` and `formatCaptionLabel` all have real callers and stay. Three comments still described the build in phases that have since shipped. --- .../__tests__/calendar-preview.test.tsx | 51 +++++++++++++++++-- .../__tests__/date-adapter.test.ts | 9 ---- .../calendar-preview-grid.tsx | 50 ++++++++++-------- .../calendar-preview.module.css | 6 --- .../calendar-preview/date-adapter.ts | 6 --- 5 files changed, 77 insertions(+), 45 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 23ebb5987..a513b106c 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -1013,16 +1013,13 @@ describe('CalendarPreview part boundaries', () => { }); describe('CalendarPreview public surface', () => { - /* The scope boundary for this phase, asserted rather than described: the - popover, the input and the period views land in later PRs, and a part - appearing here early would be public API shipped by accident. */ /* `displayName` is an own property of the root function `Object.assign` writes the parts onto, so it is not one of them. */ const partNames = Object.keys(CalendarPreviewFromBarrel).filter( key => key !== 'displayName' ); - it('exports exactly the parts this phase builds', () => { + it('exports exactly the parts it means to', () => { expect(partNames.sort()).toEqual( [ 'Body', @@ -1533,3 +1530,49 @@ describe('useCalendar', () => { expect(screen.getByTestId('value')).toHaveTextContent('none'); }); }); + +describe('CalendarPreview week numbers and the padded row', () => { + /* September fills five rows; August reaches into its sixth with the 30th. */ + const SEPTEMBER = new Date(2026, 8, 1); + + const weekNumbers = (container: HTMLElement) => + getAllSlots(container, 'calendar-preview-week-number').map( + cell => cell.textContent + ); + + const grid = (props = {}) => ( + + + + + ); + + it('leaves the padded row unnumbered', () => { + const { container } = renderCalendar(grid(), { defaultMonth: SEPTEMBER }); + const numbers = weekNumbers(container); + expect(numbers).toHaveLength(6); + expect(numbers[numbers.length - 1]).toBe(''); + expect(numbers.slice(0, 5).every(Boolean)).toBe(true); + }); + + it('keeps the cell it empties', () => { + const { container } = renderCalendar(grid(), { defaultMonth: SEPTEMBER }); + const rows = Array.from(container.querySelectorAll('tbody tr')); + const cells = rows.map(row => row.children.length); + expect(new Set(cells).size).toBe(1); + }); + + it('numbers the padded row when its days are shown', () => { + const { container } = renderCalendar(grid({ showOutsideDays: true }), { + defaultMonth: SEPTEMBER + }); + expect(weekNumbers(container).every(Boolean)).toBe(true); + }); + + it('numbers a sixth row that the month reaches into', () => { + const { container } = renderCalendar(grid()); + const numbers = weekNumbers(container); + expect(numbers).toHaveLength(6); + expect(numbers.every(Boolean)).toBe(true); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts index 851056aec..3c253f952 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -6,7 +6,6 @@ import { endOfMonthKey, endOfQuarterKey, endOfYearKey, - epoch, formatCaptionLabel, formatDayLabel, formatMonthLabel, @@ -62,14 +61,6 @@ describe('dayKey', () => { }); }); -describe('epoch', () => { - it('is the instant in milliseconds', () => { - const date = new Date(Date.UTC(2026, 7, 31, 20, 0)); - expect(epoch(date)).toBe(date.getTime()); - expect(epoch(date)).toBe(Date.UTC(2026, 7, 31, 20, 0)); - }); -}); - describe('isDayKey', () => { it.each([ '2026-08-31', diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 73ec25794..ec782426b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -45,21 +45,15 @@ import { parseKey } from './date-adapter'; -/* The only file that may import react-day-picker. It runs with - `hideNavigation` and `captionLayout='label'` so it never mounts a `Select`, - and the selection props come from root context rather than from - `CalendarPreviewGridProps` — which is what lets `...props` stay last. */ -/* Split in two on purpose. Every day button and its tooltip wrapper consume - the day-facing half, so it is memoized — an unstable value there re-renders - all 42 cells per month on any grid render. The root half carries - `rootProps`, a fresh rest-spread every render that cannot be memoized - without going stale; it has exactly one consumer, so its instability costs - one element instead of 42. */ +/* The only file that may import react-day-picker, and it never mounts a + `Select`. Two contexts: the day-facing half is memoized because all 42 cells + consume it, while `rootProps` cannot be and has one consumer. */ interface GridContextValue { dateInfo?: (date: Date) => ReactNode; tooltipMessages?: (date: Date) => ReactNode; showTooltip: boolean; loading: boolean; + showOutsideDays: boolean; } interface GridRootContextValue { @@ -93,9 +87,8 @@ export interface CalendarPreviewGridProps * Always render six week rows, so the grid height never jumps between a * 4-, 5- and 6-row month. * - * On by default. Phases 3-4 put this calendar in a popover, where a grid - * that changes height on navigation resizes the surface under the user's - * cursor. Opt out with `fixedWeeks={false}` where the calendar is inline + * On by default: in a popover, a grid that changes height on navigation + * resizes the surface under the cursor. Opt out where the calendar is inline * and the trailing blank row is not wanted. * * @defaultValue true @@ -184,8 +177,14 @@ export function CalendarPreviewGrid({ inline arrows still invalidates this every render — which is why the docs ask for them to be memoized at the call site. */ const gridContext = useMemo( - () => ({ dateInfo, tooltipMessages, showTooltip, loading }), - [dateInfo, tooltipMessages, showTooltip, loading] + () => ({ + dateInfo, + tooltipMessages, + showTooltip, + loading, + showOutsideDays + }), + [dateInfo, tooltipMessages, showTooltip, loading, showOutsideDays] ); const gridRootContext: GridRootContextValue = { @@ -515,11 +514,22 @@ export function CalendarPreviewWeekday({ CalendarPreviewWeekday.displayName = 'CalendarPreview.Weekday'; -/* `showWeekNumber` renders these two, and RFC 005 asks for a `data-slot` on - every rendered element. Overridden only to carry the slot — the classes - already arrive through `GRID_CLASS_NAMES`. */ -function CalendarPreviewWeekNumber({ week: _week, ...props }: WeekNumberProps) { - return ; +/* `showWeekNumber` renders these two; the classes already arrive through + `GRID_CLASS_NAMES`, so these carry the slot and the rule below. */ +function CalendarPreviewWeekNumber({ + week, + children, + ...props +}: WeekNumberProps) { + const { showOutsideDays } = useGridContext('CalendarPreview.Grid'); + /* A `fixedWeeks` padding row draws nothing when outside days are hidden, so + its number counts a week the grid never showed. The cell stays. */ + const counts = showOutsideDays || week.days.some(day => !day.outside); + return ( + + {counts ? children : null} + + ); } function CalendarPreviewWeekNumberHeader(props: WeekNumberHeaderProps) { diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 21bd1bb3e..032c2cb3c 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -552,12 +552,6 @@ } /* Two fields side by side, sharing the trigger's width. */ -.range-fields { - display: flex; - align-items: center; - gap: var(--rs-space-3); -} - .body { display: flex; flex-direction: column; diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 2fc4260a6..bcd65fa0f 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -38,12 +38,6 @@ export function dayKey(date: Date, timeZone?: string): DayKey { return key; } -/* Not for ordering two days: an epoch carries a time and an offset, so two - Dates on the same calendar day can order either way. Compare dayKeys. */ -export function epoch(date: Date): number { - return date.getTime(); -} - /** Whether `value` is a real calendar day. `'2027-02-29'` is not. */ export function isDayKey(value: string): boolean { return DAY_KEY_SHAPE.test(value) && isValid(parseStrict(value)); From 071028601e1bf8b27adbefa5fcdabef94f1fc848 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 15:17:33 +0530 Subject: [PATCH 35/52] perf(calendar-preview): stop the grid and the period views paying for what they do not show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five period views mount together and four of them render nothing, yet the children of every one were built on each render: a cell list per year in the range, and per cell an `anchorOf(periodOf(...))` and a bounds check. The inactive views now build nothing at all, and the active one holds its cells against the inputs that shape them, so moving the selection — which changes only which key matches — costs a string compare rather than the whole range. Every day cell also mounted a `Tooltip` root whether or not tooltips were switched on: 42 of them in a month, 84 in a two-month range picker, for a feature off by default. The root is now mounted only with `showTooltip`. The span underneath stays either way, because it carries the cell's box and is the trigger a disabled day needs — a disabled button fires no pointer events. The one thing this gives up is that toggling `showTooltip` at runtime now remounts the day cells. A per-day message coming and going still does not, which is the case that actually moves. --- .../__tests__/calendar-preview.test.tsx | 20 +++++ .../calendar-preview-grid.tsx | 23 ++++-- .../calendar-preview-periods.tsx | 82 +++++++++++-------- 3 files changed, 83 insertions(+), 42 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 a513b106c..d202d970f 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -723,6 +723,26 @@ describe('CalendarPreview.Grid', () => { expect(screen.queryByText('Never shown')).toBeNull(); }); + /* The span carries the cell's box, so it outlives the Tooltip root that is + only mounted alongside it. */ + it.each([ + [false], + [true] + ])('keeps the day trigger with showTooltip=%s', showTooltip => { + const { container, unmount } = renderCalendar( + + 'Anything'} + /> + + ); + expect(getAllSlots(container, 'calendar-preview-day-trigger').length).toBe( + getAllSlots(container, 'calendar-preview-day').length + ); + unmount(); + }); + it('disables navigation while the grid is loading', () => { const { container } = renderCalendar( diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index ec782426b..c45fb5502 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -459,12 +459,23 @@ export function CalendarPreviewDay({ ) }); - /* The wrapper is unconditional. Two reasons, both measured: a disabled - button fires no pointer events, so hanging the trigger on the day itself - hid exactly the tooltip a blocked day needs; and returning `button` bare - when there is no message changes the element type at that position, which - tears down the DOM node and drops focus the moment `showTooltip` or a - per-day message flips. */ + /* The span is the trigger, not the day: a disabled button fires no pointer + events, and a blocked day is exactly the one whose tooltip is worth + reading. It stays when tooltips are off so the cell keeps its box, but + the `Tooltip` root does not — that is one per day, 84 in a two-month + range picker, for a feature nobody asked for. A per-day message coming + and going still changes nothing here; only `showTooltip` does. */ + if (!showTooltip) { + return ( + + {button} + + ); + } + return ( + isActive + ? years.map(year => ({ + year, + cells: cellsFor(viewScale, year).map(cell => ({ + ...cell, + produced: anchorOf(periodOf(cell.date, viewScale), trailingValue), + unavailable: !isPeriodAvailable(cell.date, viewScale) + })) + })) + : [], + [isActive, years, viewScale, trailingValue, isPeriodAvailable] + ); + /* Keyed on becoming active, not on mount: every view mounts at once, so a mount effect would fire with an empty ref. Scrolls the container, not `scrollIntoView`, which would move the popover with it. */ const activeRef = useRef(null); - const isActive = scale === viewScale; useEffect(() => { if (!isActive) return; const group = activeRef.current; @@ -120,7 +140,7 @@ function PeriodView({ 'data-scale': viewScale, children: children ?? ( <> - {years.map(year => ( + {groups.map(({ year, cells }) => (
- {cellsFor(viewScale, year).map(cell => { - const produced = anchorOf( - periodOf(cell.date, viewScale), - trailingValue - ); - const unavailable = !isPeriodAvailable( - cell.date, - viewScale - ); - return ( - - ); - })} + {cells.map(({ produced, unavailable, ...cell }) => ( + + ))}
))} From a97cf482104c7c2afeac61cb54261c7e0d5dd0fc Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 15:37:28 +0530 Subject: [PATCH 36/52] fix(calendar-preview): scope the trigger's input check to that trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.Trigger` gave up its button role for any `.Input` mounted anywhere under the root, not one of its own. `.Body` mounts an input inside `.Content`, so a childless trigger beside it kept its role while the popover was shut and lost it the moment the popover opened — leaving the only control unfocusable and unable to close what it had just opened. The trigger now carries its own registry and `.Input` registers with the one that contains it. `.Content` is portaled but is still a sibling in the React tree, so its input finds no trigger and registers nowhere. The root's copy of this state goes. The click-machine note also claimed the trigger always dismisses, which has not been true since it stopped closing the popover between two fields. --- .../components/calendar-preview/index.mdx | 2 +- .../__tests__/picker.test.tsx | 34 +++++++++++++++++++ .../calendar-preview-context.tsx | 3 -- .../calendar-preview-input.tsx | 11 +++--- .../calendar-preview-root.tsx | 12 ------- .../calendar-preview-trigger.tsx | 33 ++++++++++++++++-- 6 files changed, 71 insertions(+), 24 deletions(-) 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 0c317839e..c768a7d67 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -326,7 +326,7 @@ The click machine: | `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 | -Nothing here closes the popover. A commit leaves it open, so a second pick needs no second trip to the trigger; Escape, an outside press and the trigger itself still dismiss it. +Nothing here closes the popover. A commit leaves it open, so a second pick needs no second trip to the trigger. Escape and an outside press dismiss it; a press on the trigger does too, unless the trigger wraps an `.Input`, where it would close the field the user is still filling in. **Typing is stricter than clicking.** A click means "the next endpoint", so an earlier day restarts the range, as the table above says. Typing names the field it lands in, so an endpoint that crosses diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index 06a8ffecf..251166f5c 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -632,3 +632,37 @@ describe('CalendarPreview.Trigger and the focus a dismissal gives back', () => { expect(isOpen()).toBe(true); }); }); + +describe('CalendarPreview.Trigger beside a Body that owns the input', () => { + const composition = ( + <> + + + + + + ); + + it('stays a button while an input it does not own is mounted', () => { + const { container } = render( + + {composition} + + ); + expect( + getSlot(document.body, 'calendar-preview-input') + ).toBeInTheDocument(); + + const trigger = getSlot(container, 'calendar-preview-trigger'); + expect(trigger).toHaveAttribute('role', 'button'); + expect(trigger).not.toHaveAttribute('tabindex', '-1'); + }); + + it('still gives up the role for an input of its own', () => { + const { container } = renderPicker(); + expect(getSlot(container, 'calendar-preview-trigger')).not.toHaveAttribute( + 'role', + 'button' + ); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index ec8c6fea3..f5f582c0a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -132,9 +132,6 @@ export interface CalendarPreviewContextValue { */ fieldReadOnly: Record; setFieldReadOnly: (field: CalendarPreviewField, readOnly: boolean) => void; - /** Whether an `.Input` is mounted; `.Trigger` stops being a button when one is. */ - hasInput: boolean; - registerInput: (mounted: boolean) => void; } const CalendarPreviewContext = diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 0345c8479..71a4a7139 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -10,6 +10,7 @@ import { isRange as isRangeValue, isScaleValue } from './calendar-preview-root'; +import { useTriggerInput } from './calendar-preview-trigger'; import { dayKey, parseKey } from './date-adapter'; import { parseScaleInput } from './lib/parse'; import type { Scale } from './lib/scale'; @@ -105,16 +106,16 @@ export function CalendarPreviewInput({ draft, activeField, setActiveField, - setFieldReadOnly, - registerInput + setFieldReadOnly } = useCalendarPreviewContext('CalendarPreview.Input'); const isRange = selection === 'range'; + const trigger = useTriggerInput(); useEffect(() => { - registerInput(true); - return () => registerInput(false); - }, [registerInput]); + trigger?.registerInput(true); + return () => trigger?.registerInput(false); + }, [trigger]); /* 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. */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index dc43b5cfc..9fc4378c7 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -496,14 +496,6 @@ export function CalendarPreviewRoot({ [] ); - /* Counted, not a flag: a range mounts two, and the first to unmount would - otherwise report that none are left. */ - const [inputCount, setInputCount] = useState(0); - - const registerInput = useCallback((mounted: boolean) => { - setInputCount(current => current + (mounted ? 1 : -1)); - }, []); - /* * The from/to machine: * no from -> set from, advance to the end input @@ -758,8 +750,6 @@ export function CalendarPreviewRoot({ setActiveField, fieldReadOnly, setFieldReadOnly, - hasInput: inputCount > 0, - registerInput, open, setOpen, shouldIgnoreFocusOpen, @@ -799,8 +789,6 @@ export function CalendarPreviewRoot({ activeField, fieldReadOnly, setFieldReadOnly, - inputCount, - registerInput, open, setOpen, shouldIgnoreFocusOpen, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index dd536e7d4..4a585010e 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -6,15 +6,30 @@ import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; import { cx } from 'class-variance-authority'; import { type ComponentProps, + createContext, type FocusEvent, type MouseEvent, + useCallback, + useContext, useEffect, - useRef + useMemo, + useRef, + useState } from 'react'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; import { type CalendarPreviewValue, isRange } from './calendar-preview-root'; +/* Per trigger, not per root: `.Body` mounts an `.Input` inside `.Content`, and + a childless trigger beside it is still a button. */ +const TriggerInputContext = createContext<{ + registerInput: (mounted: boolean) => void; +} | null>(null); + +export function useTriggerInput() { + return useContext(TriggerInputContext); +} + export interface CalendarPreviewTriggerProps extends useRender.ComponentProps<'div'> { /** Shown when there is no value and no children. */ @@ -54,13 +69,21 @@ export function CalendarPreviewTrigger({ setOpen, shouldIgnoreFocusOpen, triggerRef, - hasInput, disabled, readOnly } = useCalendarPreviewContext( 'CalendarPreview.Trigger' ); + const [inputCount, setInputCount] = useState(0); + const hasInput = inputCount > 0; + + const registerInput = useCallback((mounted: boolean) => { + setInputCount(current => current + (mounted ? 1 : -1)); + }, []); + + const inputContext = useMemo(() => ({ registerInput }), [registerInput]); + /* 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. */ const pressing = useRef(false); @@ -144,7 +167,11 @@ export function CalendarPreviewTrigger({ : placeholder; return ( - {children ?? label} + + + {children ?? label} + + ); } From 993a9e86681b2fdb7d7a3737ae3fe0399b820405 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 16:12:23 +0530 Subject: [PATCH 37/52] fix(calendar-preview): keep a range off the days it may not cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both endpoints were checked and the days between them were not, so a range could bracket a day the consumer had marked unavailable and hand it back inside the value. A click that would complete over one now restarts from that day instead, which is the arm the machine already has for a click it cannot use. A typed endpoint cannot restart — it names its field — so it is rejected with `unavailable`, the reason the input already reports for a typed date that lands on a blocked day. Bounds need no part in this: `minDate` and `maxDate` describe one window, so two endpoints inside it cannot straddle anything outside it. Only `isDateUnavailable` can leave a hole in the middle. No prop guards it. react-day-picker makes the equivalent opt-in through `excludeDisabled`, but that arrived in a patch release and reads as compatibility rather than a default worth copying; a day declared unselectable that comes back inside a range is hard to defend. An opt-out stays additive if a consumer ever wants the span. --- .../calendar-preview/__tests__/range.test.tsx | 57 +++++++++++++++++++ .../calendar-preview-input.tsx | 7 ++- .../calendar-preview-root.tsx | 47 ++++++++++----- .../calendar-preview/date-adapter.ts | 16 ++++++ 4 files changed, 112 insertions(+), 15 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 9d1c8ae87..736fff6d7 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -577,3 +577,60 @@ describe('CalendarPreview range with a read-only start', () => { expect(onValueChange).not.toHaveBeenCalled(); }); }); + +describe('CalendarPreview range and days it may not cover', () => { + const blocked = (date: Date) => date.getDate() === 15; + + it('restarts instead of completing over a blocked day', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ + onValueChange, + isDateUnavailable: blocked + }); + fireEvent.click(day(container, '10')); + fireEvent.click(day(container, '20')); + expect(onValueChange).not.toHaveBeenCalled(); + + fireEvent.click(day(container, '22')); + expect(onValueChange.mock.calls[0][0]).toEqual({ + from: new Date(2026, 7, 20), + to: new Date(2026, 7, 22) + }); + }); + + it('completes a range that clears the blocked day', () => { + const onValueChange = vi.fn(); + const { container } = renderRange({ + onValueChange, + isDateUnavailable: blocked + }); + fireEvent.click(day(container, '16')); + fireEvent.click(day(container, '20')); + expect(onValueChange).toHaveBeenCalledTimes(1); + }); + + it('rejects a typed endpoint whose span is blocked', () => { + const onValidityChange = vi.fn(); + const { container } = renderRange( + { isDateUnavailable: blocked }, + <> + + + + + + + ); + fireEvent.click(day(container, '10')); + + const end = getAllSlots(container, 'calendar-preview-input')[1]; + fireEvent.change(end, { target: { value: '20 Aug 2026' } }); + const calls = onValidityChange.mock.calls; + const last = calls[calls.length - 1][0]; + expect(last.valid).toBe(false); + expect(last.reason).toBe('unavailable'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 71a4a7139..4b6e0c35a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -11,7 +11,7 @@ import { isScaleValue } from './calendar-preview-root'; import { useTriggerInput } from './calendar-preview-trigger'; -import { dayKey, parseKey } from './date-adapter'; +import { anyDayBetween, dayKey, parseKey } from './date-adapter'; import { parseScaleInput } from './lib/parse'; import type { Scale } from './lib/scale'; @@ -209,6 +209,11 @@ export function CalendarPreviewInput({ if (field === 'start' ? typed > against : typed < against) { return { valid: false, reason: 'out-of-order' }; } + const [lead, trail] = + typed < against ? [typed, against] : [against, typed]; + if (anyDayBetween(lead, trail, isDateUnavailable)) { + return { valid: false, reason: 'unavailable' }; + } } return { date, scale: 'day' }; }; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 9fc4378c7..1f6bf644e 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -17,6 +17,7 @@ import { CalendarPreviewProvider } from './calendar-preview-context'; import { + anyDayBetween, dayKey, formatDayLabel, formatMonthLabel, @@ -496,6 +497,28 @@ export function CalendarPreviewRoot({ [] ); + /* Day-keys, not instants: a `minDate` carrying a time of day still leaves + its own day selectable, which the current family gets wrong. */ + const isDateUnavailable = useCallback( + (date: Date) => { + const key = dayKey(date, timeZone); + if (minDate && key < dayKey(minDate, timeZone)) return true; + if (maxDate && key > dayKey(maxDate, timeZone)) return true; + return isDateUnavailableProp?.(date) ?? false; + }, + [minDate, maxDate, isDateUnavailableProp, timeZone] + ); + + const spans = useCallback( + (from: Date, to: Date) => + anyDayBetween( + dayKey(from, timeZone), + dayKey(to, timeZone), + isDateUnavailable + ), + [timeZone, isDateUnavailable] + ); + /* * The from/to machine: * no from -> set from, advance to the end input @@ -534,6 +557,8 @@ export function CalendarPreviewRoot({ if (fixed) { if (fieldReadOnly.end) return; if (dayKey(date, timeZone) < dayKey(fixed, timeZone)) return; + /* The start cannot move, so there is nothing to restart from. */ + if (spans(fixed, date)) return; setDraft(null); setActiveField('start'); setValue({ from: fixed, to: date }, 'select', date); @@ -555,6 +580,12 @@ export function CalendarPreviewRoot({ } if (fieldReadOnly.end) return; + /* A day the consumer marked unavailable cannot be handed back inside a + range, so the click restarts rather than completing over it. */ + if (spans(from, date)) { + setDraft({ from: date }); + return; + } setDraft(null); setActiveField('start'); setValue({ from, to: date }, 'select', date); @@ -570,8 +601,8 @@ export function CalendarPreviewRoot({ timeZone, readOnly, disabled, - setValue, - setOpen + spans, + setValue ] ); @@ -698,18 +729,6 @@ export function CalendarPreviewRoot({ setValue(defaultDate, 'reset', monthAnchor(defaultDate) ?? today); }, [defaultDate, value, scales, settleScale, 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. */ - const isDateUnavailable = useCallback( - (date: Date) => { - const key = dayKey(date, timeZone); - if (minDate && key < dayKey(minDate, timeZone)) return true; - if (maxDate && key > dayKey(maxDate, timeZone)) return true; - return isDateUnavailableProp?.(date) ?? false; - }, - [minDate, maxDate, isDateUnavailableProp, timeZone] - ); - /* A year the user can never scroll to is a trap, so the span stretches to cover the bounds even though bounds never clamp navigation. */ const yearRange = useMemo(() => { diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index bcd65fa0f..2852b538f 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -3,6 +3,7 @@ caused, and costs the swappability the RFC keeps for Temporal. */ import { TZDate } from '@date-fns/tz'; import { + addDays, addMonths, endOfMonth, endOfQuarter, @@ -111,6 +112,21 @@ export function monthFromName(name: string): number | null { return null; } +/** Whether any day in `[from, to]` matches. Stops at the first that does. */ +export function anyDayBetween( + from: DayKey, + to: DayKey, + match: (date: Date) => boolean +): boolean { + let cursor = from; + while (cursor <= to) { + const date = parseKey(cursor); + if (match(date)) return true; + cursor = dayKey(addDays(date, 1)); + } + return false; +} + /* Normalising to the first stops repeated navigation drifting: stepping on from 31 January would clamp to the 28th and stay there. */ export function shiftMonths(date: Date, delta: number): Date { From ee7011093d4e064b570b6fc6bf1dac113c6297b1 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 16:23:39 +0530 Subject: [PATCH 38/52] fix(calendar-preview): empty one range field without emptying both MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emptying either field called `setValue(null)`, which wiped the whole range — the user cleared one endpoint and lost the other — and left the old draft painted in the grid behind it. It also read `clearable`, a prop documented as governing whether clicking the selected day deselects it, for a gesture that prop never mentioned. Clearing now empties the field it was typed in and leaves its partner drafted, so the range can be rebuilt without retyping both. `null` is emitted at that point because one endpoint is not a range. A grid click then fills the hole rather than restarting, unless it crosses the endpoint that was kept or would span a day the consumer blocked. `CalendarPreviewDraftRange.from` becomes optional to carry an endpoint that stands alone; nothing public returns that type. `clearable` is documented for both gestures it now governs. --- .../components/calendar-preview/index.mdx | 2 + .../calendar-preview/__tests__/range.test.tsx | 69 +++++++++++++++++++ .../calendar-preview-context.tsx | 7 +- .../calendar-preview-grid.tsx | 2 +- .../calendar-preview-input.tsx | 6 +- .../calendar-preview-root.tsx | 38 +++++++++- 6 files changed, 118 insertions(+), 6 deletions(-) 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 c768a7d67..9253ccbfe 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -328,6 +328,8 @@ The click machine: Nothing here closes the popover. A commit leaves it open, so a second pick needs no second trip to the trigger. Escape and an outside press dismiss it; a press on the trigger does too, unless the trigger wraps an `.Input`, where it would close the field the user is still filling in. +Emptying one field clears that endpoint and leaves the other drafted in its own field, so the range can be rebuilt without retyping both. The value emits `null` at that point — one endpoint is not a range. `clearable={false}` turns both this and click-to-deselect off. + **Typing is stricter than clicking.** A click means "the next endpoint", so an earlier day restarts the range, as the table above says. Typing names the field it lands in, so an endpoint that crosses its partner is rejected instead: `onValidityChange` reports `out-of-order`, the field goes red, and diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 736fff6d7..f5f7f8a35 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -634,3 +634,72 @@ describe('CalendarPreview range and days it may not cover', () => { expect(last.reason).toBe('unavailable'); }); }); + +describe('CalendarPreview range emptying one field', () => { + const RANGE = { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) }; + + function renderFields(props = {}) { + const utils = renderRange( + { defaultValue: RANGE, ...props }, + <> + + + + + + + ); + const [start, end] = getAllSlots( + utils.container, + 'calendar-preview-input' + ) as HTMLInputElement[]; + return { ...utils, start, end }; + } + + const empty = (input: HTMLInputElement) => { + fireEvent.change(input, { target: { value: '' } }); + fireEvent.blur(input); + }; + + it('keeps the partner endpoint in its field', () => { + const { start, end } = renderFields(); + empty(start); + expect(start.value).toBe(''); + expect(end.value).toBe('20 Aug 2026'); + }); + + it('emits null once the range can no longer be formed', () => { + const onValueChange = vi.fn(); + const { start } = renderFields({ onValueChange }); + empty(start); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toBeNull(); + }); + + it('completes again from a grid click without losing the kept endpoint', () => { + const onValueChange = vi.fn(); + const { container, start } = renderFields({ onValueChange }); + empty(start); + fireEvent.click(day(container, '12')); + const calls = onValueChange.mock.calls; + expect(calls[calls.length - 1][0]).toEqual({ + from: new Date(2026, 7, 12), + to: new Date(2026, 7, 20) + }); + }); + + it('restarts when the click crosses the kept endpoint', () => { + const onValueChange = vi.fn(); + const { container, start } = renderFields({ onValueChange }); + empty(start); + fireEvent.click(day(container, '25')); + expect(onValueChange).toHaveBeenCalledTimes(1); + }); + + it('leaves both fields alone when clearable is off', () => { + const { start, end } = renderFields({ clearable: false }); + empty(start); + expect(start.value).toBe('10 Aug 2026'); + expect(end.value).toBe('20 Aug 2026'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index f5f582c0a..7f0afd70d 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -33,9 +33,10 @@ export interface CalendarPreviewDateRange { to: Date; } -/** A range mid-build. `to` is absent until the second click lands. */ +/** A range mid-build. Either edge may be absent: `to` until the second click + lands, `from` once a field has been emptied. */ export interface CalendarPreviewDraftRange { - from: Date; + from?: Date; to?: Date; } @@ -117,6 +118,8 @@ export interface CalendarPreviewContextValue { commitDay: (date: Date, reason: CalendarPreviewChangeReason) => void; /** Writes one named endpoint, for a typed `.Input`. */ setEndpoint: (field: CalendarPreviewField, date: Date) => void; + /** Empties one endpoint, leaving the other drafted. */ + clearEndpoint: (field: CalendarPreviewField) => 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-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index c45fb5502..a63bcbb91 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -264,7 +264,7 @@ export function CalendarPreviewGrid({ {...base} mode='range' required={false} - selected={draft ?? undefined} + selected={draft ? { from: draft.from, to: draft.to } : undefined} onSelect={handleSelect} /> ) : clearable ? ( diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 4b6e0c35a..dee4987da 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -103,6 +103,7 @@ export function CalendarPreviewInput({ selection, commitDay, setEndpoint, + clearEndpoint, draft, activeField, setActiveField, @@ -222,7 +223,10 @@ export function CalendarPreviewInput({ if (text === null) return; const trimmed = text.trim(); if (trimmed === '') { - if (clearable && value) setValue(null, 'clear', today); + if (clearable) { + if (isRange) clearEndpoint(field); + else if (value) setValue(null, 'clear', today); + } setText(null); 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 1f6bf644e..d9fc7b856 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -219,8 +219,10 @@ interface CalendarPreviewSharedProps */ today?: Date; /** - * Whether clicking the selected day deselects it. Day scale only — clicking - * a selected period re-commits it. + * Whether the selection can be emptied — by clicking the selected day, or by + * emptying an `.Input`. Day scale only for the click; clicking a selected + * period re-commits it. Emptying one field of a range clears that endpoint + * and leaves the other drafted. * @defaultValue true */ clearable?: boolean; @@ -565,6 +567,23 @@ export function CalendarPreviewRoot({ return; } + /* An emptied field leaves its partner drafted alone; a click fills the + hole rather than throwing the endpoint the user kept away. */ + const lone = draft && !draft.from ? draft.to : undefined; + if (lone) { + if (fieldReadOnly.start) return; + const ordered = dayKey(date, timeZone) <= dayKey(lone, timeZone); + if (!ordered || spans(date, lone)) { + setDraft({ from: date }); + setActiveField('end'); + return; + } + setDraft(null); + setActiveField('start'); + setValue({ from: date, to: lone }, 'select', date); + return; + } + const from = draft?.from; if (!from || draft?.to) { if (fieldReadOnly.start) return; @@ -714,6 +733,19 @@ export function CalendarPreviewRoot({ [value, draft, fieldReadOnly, timeZone, readOnly, disabled, setValue] ); + const clearEndpoint = useCallback( + (field: CalendarPreviewField) => { + if (readOnly || disabled || fieldReadOnly[field]) return; + const base = draft ?? (isRange(value) ? value : null); + const kept = field === 'start' ? { to: base?.to } : { from: base?.from }; + setDraft(kept.from || kept.to ? kept : null); + setActiveField(field); + /* One endpoint short of a range, so nothing valid is left to emit. */ + if (isRange(value)) setValue(null, 'clear', monthAnchor(value) ?? today); + }, + [value, draft, fieldReadOnly, readOnly, disabled, setValue, today] + ); + /* `'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(() => { @@ -764,6 +796,7 @@ export function CalendarPreviewRoot({ selectDay, commitDay, setEndpoint, + clearEndpoint, draft: draft ?? (isRange(value) ? value : null), activeField, setActiveField, @@ -804,6 +837,7 @@ export function CalendarPreviewRoot({ selectDay, commitDay, setEndpoint, + clearEndpoint, draft, activeField, fieldReadOnly, From 338025f36b5d6636b6680776d1918f8db5bd5b03 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 17:50:47 +0530 Subject: [PATCH 39/52] refactor(calendar-preview)!: settle the scale maths, the context and the markup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lib/scale.ts` takes day keys alone now, which drops `timeZone` from `periodOf`, `convertScale` and `isAvailable` entirely — key maths is timeless, and the zone only ever existed to convert the `Date`s they also accepted. Callers convert at their own boundary, where the zone already lives. `isAvailable` takes its bounds as an options object rather than four positional arguments, two of them usually `undefined`. Quarter, half-year and year all have fixed edges, so only a month needs leap-aware maths and the four they no longer use are deleted. The context drops its generic. The value type is a closed union, so it moves to `calendar-preview-context.tsx` where the union's own members already live, the twelve parts stop casting, and both Provider wrappers go in favour of the contexts themselves. `vitest.config.mjs` no longer pins `TZ`. Two `dayKey` tests built their instant in UTC and read it back in local, which is what the pin was hiding; they build in the zone they read in now, and the suite passes from -11 through +14. BREAKING CHANGE: `data-scale` is no longer stamped on `.Body`, `.Caption`, `.Days`, `.Input`, `.Label`, `.Trigger` or the period view containers. It stays on the root, on day and period cells, on `.Panel` and on `.Scale`, which is where it says something the root does not already say. `scales` keeps the order it is given rather than being sorted into the canonical one, so `scales={['month','day']}` now opens on month. `scales[0]` is the first listed, and the reset and drop-draft fallbacks follow it. `.Years` no longer renders a year heading above its cells: the cell is the year. `.Scale` reports `aria-pressed`. It is a button a consumer places themselves, not a tab with a tablist around it. --- .../components/calendar-preview/index.mdx | 2 +- .../__tests__/data-slots.test.tsx | 12 +- .../__tests__/date-adapter.test.ts | 41 +++--- .../__tests__/scale-selection.test.tsx | 43 +++++- .../calendar-preview/__tests__/scale.test.ts | 123 +++++++++++------- .../calendar-preview-body.tsx | 1 - .../calendar-preview-caption.tsx | 8 +- .../calendar-preview-context.tsx | 61 +++------ .../calendar-preview-days.tsx | 7 +- .../calendar-preview-grid.tsx | 7 +- .../calendar-preview-input.tsx | 9 +- .../calendar-preview-label.tsx | 4 - .../calendar-preview-periods.tsx | 24 ++-- .../calendar-preview-reset.tsx | 8 +- .../calendar-preview-root.tsx | 57 ++++---- .../calendar-preview-scales.tsx | 4 + .../calendar-preview-trigger.tsx | 7 +- .../calendar-preview/date-adapter.ts | 22 +--- .../components/calendar-preview/lib/scale.ts | 104 +++++++-------- .../calendar-preview/use-calendar.tsx | 2 +- packages/raystack/vitest.config.mjs | 4 - 21 files changed, 268 insertions(+), 282 deletions(-) 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 9253ccbfe..280c83d33 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -211,7 +211,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `calendar-preview-panel` | The view container | | `calendar-preview-months` / `-quarters` / `-half-years` / `-years` | One period list | | `calendar-preview-period-group` | One year's block inside a period list | -| `calendar-preview-period-year` | The year heading | +| `calendar-preview-period-year` | The year heading, on every view but `.Years` — there the cell is the year | | `calendar-preview-period` | One period cell | | `calendar-preview-footer` | The footer row | | `calendar-preview-footer-text` | The `Text` wrapping a string footer | diff --git a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx index a10cb933d..8f0f5394e 100644 --- a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx @@ -213,13 +213,12 @@ describe('CalendarPreview data-slot contract', () => { }); describe('CalendarPreview state attributes', () => { - it('marks the day view with its scale and its inert states', () => { + it('marks the day view with its inert states', () => { const { container } = renderCalendar(undefined, { disabled: true, readOnly: true }); const days = getSlot(container, 'calendar-preview-days'); - expect(days).toHaveAttribute('data-scale', 'day'); expect(days).toHaveAttribute('data-disabled', 'true'); expect(days).toHaveAttribute('data-readonly', 'true'); }); @@ -245,12 +244,17 @@ describe('CalendarPreview state attributes', () => { ); }); - it('carries the scale on the caption and on every cell', () => { + /* The root says what scale is committed; the cells say it per cell. Nothing + in between repeats it. */ + it('carries the scale on the root and on every cell, and nowhere between', () => { const { container } = renderCalendar(); - expect(getSlot(container, 'calendar-preview-caption')).toHaveAttribute( + expect(getSlot(container, 'calendar-preview')).toHaveAttribute( 'data-scale', 'day' ); + expect(getSlot(container, 'calendar-preview-caption')).not.toHaveAttribute( + 'data-scale' + ); for (const cell of getAllSlots(container, 'calendar-preview-day')) { expect(cell).toHaveAttribute('data-scale', 'day'); } diff --git a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts index 3c253f952..924356a8b 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -4,8 +4,6 @@ import { dayKey, dayKeyFromParts, endOfMonthKey, - endOfQuarterKey, - endOfYearKey, formatCaptionLabel, formatDayLabel, formatMonthLabel, @@ -17,8 +15,6 @@ import { parseKey, shiftMonths, startOfMonthKey, - startOfQuarterKey, - startOfYearKey, yearOf } from '../date-adapter'; @@ -40,24 +36,33 @@ describe('dayKey', () => { expect(dayKey(instant, 'America/New_York')).toBe('2026-08-31'); }); - /* `Date.UTC(0, 0, 1)` means 1900, so the far years are set explicitly. Built - in UTC, not local: at +14 a local-midnight year 10000 is year 9999 in UTC, - and the five-digit case below then has nothing to throw about. */ - const atYear = (year: number): Date => { + /* `Date.UTC(0, 0, 1)` means 1900, so the far years are set explicitly. Two + builders, because a zone-less `dayKey` reads local: an instant built in UTC + lands on the neighbouring year either side of the line, which is what the + suite's pinned `TZ` used to paper over. */ + const atLocalYear = (year: number): Date => { + const date = new Date(2000, 0, 1); + date.setFullYear(year, 0, 1); + return date; + }; + + const atUtcYear = (year: number): Date => { const date = new Date(Date.UTC(2000, 0, 1)); date.setUTCFullYear(year, 0, 1); return date; }; it('keeps year 0 distinct from year 1, and round-trips it', () => { - expect(dayKey(atYear(0))).toBe('0000-01-01'); - expect(dayKey(atYear(1))).toBe('0001-01-01'); - expect(parseKey(dayKey(atYear(0))).getFullYear()).toBe(0); + expect(dayKey(atLocalYear(0))).toBe('0000-01-01'); + expect(dayKey(atLocalYear(1))).toBe('0001-01-01'); + expect(parseKey(dayKey(atLocalYear(0))).getFullYear()).toBe(0); + expect(dayKey(atUtcYear(0), 'UTC')).toBe('0000-01-01'); }); it('throws rather than return a five-digit key', () => { - expect(() => dayKey(atYear(10000))).toThrow(RangeError); - expect(() => dayKey(atYear(10000), 'Asia/Tokyo')).toThrow(RangeError); + expect(() => dayKey(atLocalYear(10000))).toThrow(RangeError); + expect(() => dayKey(atUtcYear(10000), 'UTC')).toThrow(RangeError); + expect(() => dayKey(atUtcYear(10000), 'Asia/Tokyo')).toThrow(RangeError); }); }); @@ -149,16 +154,6 @@ describe('period key helpers', () => { expect(endOfMonthKey('2028-02-14')).toBe('2028-02-29'); expect(endOfMonthKey('2100-02-14')).toBe('2100-02-28'); }); - - it('brackets a quarter', () => { - expect(startOfQuarterKey('2026-08-15')).toBe('2026-07-01'); - expect(endOfQuarterKey('2026-08-15')).toBe('2026-09-30'); - }); - - it('brackets a year', () => { - expect(startOfYearKey('2026-08-15')).toBe('2026-01-01'); - expect(endOfYearKey('2026-08-15')).toBe('2026-12-31'); - }); }); describe('key accessors', () => { diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index 881159d71..d7550d0ef 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -18,9 +18,7 @@ function renderBody(props = {}) { const period = (container: HTMLElement, label: string, year = 2026) => { const group = getAllSlots(container, 'calendar-preview-period-group').find( - node => - getSlot(node, 'calendar-preview-period-year')?.textContent === - String(year) + node => node.getAttribute('data-year') === String(year) ); if (!group) throw new Error(`no year group ${year}`); const match = getAllSlots(group, 'calendar-preview-period').find( @@ -924,3 +922,42 @@ describe('CalendarPreview drops a scale draft when the popover closes', () => { ); }); }); + +describe('CalendarPreview.Scales honours the order it was given', () => { + const labels = (container: HTMLElement) => + getAllSlots(container, 'calendar-preview-scale').map(node => + node.getAttribute('data-scale') + ); + + it('shows them in the order listed, not a canonical one', () => { + const { container } = renderBody({ scales: ['year', 'day', 'quarter'] }); + expect(labels(container)).toEqual(['year', 'day', 'quarter']); + }); + + it('takes the first listed as the default scale', () => { + const { container } = renderBody({ scales: ['quarter', 'day'] }); + expect(getSlot(container, 'calendar-preview')).toHaveAttribute( + 'data-scale', + 'quarter' + ); + }); + + it('drops a repeat rather than rendering it twice', () => { + const { container } = renderBody({ scales: ['day', 'month', 'day'] }); + expect(labels(container)).toEqual(['day', 'month']); + }); +}); + +describe('CalendarPreview.Scale announces which scale is active', () => { + it('marks the active one pressed and the others not', () => { + const { container } = render( + + + + + ); + const [day, quarter] = getAllSlots(container, 'calendar-preview-scale'); + expect(day).toHaveAttribute('aria-pressed', 'false'); + expect(quarter).toHaveAttribute('aria-pressed', 'true'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/scale.test.ts b/packages/raystack/components/calendar-preview/__tests__/scale.test.ts index 134e1d837..94b63d88f 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/scale.test.ts @@ -70,13 +70,6 @@ describe('periodOf', () => { expect(periodOf(day, 'year')).toEqual(expected); }); - it('accepts a Date and reads its own calendar day', () => { - expect(periodOf(new Date(2026, 7, 15), 'month')).toEqual({ - start: '2026-08-01', - end: '2026-08-31' - }); - }); - it.each([ '2026-8-15', '15/08/2026', @@ -269,8 +262,10 @@ describe('convertScale — round trips', () => { describe('isAvailable', () => { it('is unbounded when neither bound is given', () => { - expect(isAvailable('1000-01-01', 'day', LEADING)).toBe(true); - expect(isAvailable('9999-12-31', 'year', TRAILING)).toBe(true); + expect(isAvailable('1000-01-01', 'day', { trailing: LEADING })).toBe(true); + expect(isAvailable('9999-12-31', 'year', { trailing: TRAILING })).toBe( + true + ); }); describe('the RFC table — an end field bounded at 15 July 2026', () => { @@ -279,21 +274,25 @@ describe('isAvailable', () => { it('disables H1 2026, which emits 30 June', () => { expect(periodOf('2026-01-01', 'halfYear').end).toBe('2026-06-30'); - expect(isAvailable('2026-01-01', 'halfYear', trailing, min)).toBe(false); + expect(isAvailable('2026-01-01', 'halfYear', { trailing, min })).toBe( + false + ); }); it('allows July 2026, which emits 31 July', () => { expect(periodOf('2026-07-01', 'month').end).toBe('2026-07-31'); - expect(isAvailable('2026-07-01', 'month', trailing, min)).toBe(true); + expect(isAvailable('2026-07-01', 'month', { trailing, min })).toBe(true); }); it('allows Q3 2026, which emits 30 September', () => { expect(periodOf('2026-07-01', 'quarter').end).toBe('2026-09-30'); - expect(isAvailable('2026-07-01', 'quarter', trailing, min)).toBe(true); + expect(isAvailable('2026-07-01', 'quarter', { trailing, min })).toBe( + true + ); }); it('allows August 2026', () => { - expect(isAvailable('2026-08-01', 'month', trailing, min)).toBe(true); + expect(isAvailable('2026-08-01', 'month', { trailing, min })).toBe(true); }); it('tests the produced date, not the period start', () => { @@ -317,7 +316,7 @@ describe('isAvailable', () => { ['2026-07-01', 'quarter'], ['2026-08-01', 'month'] ] as const) { - expect(isAvailable(day, scale, LEADING, min)).toBe( + expect(isAvailable(day, scale, { trailing: LEADING, min })).toBe( periodOf(day, scale).start >= min ); } @@ -325,88 +324,112 @@ describe('isAvailable', () => { describe('bounds are inclusive at both edges', () => { it('accepts a day exactly on min', () => { - expect(isAvailable('2026-07-15', 'day', LEADING, '2026-07-15')).toBe( - true - ); + expect( + isAvailable('2026-07-15', 'day', { + trailing: LEADING, + min: '2026-07-15' + }) + ).toBe(true); }); it('rejects the day before min', () => { - expect(isAvailable('2026-07-14', 'day', LEADING, '2026-07-15')).toBe( - false - ); + expect( + isAvailable('2026-07-14', 'day', { + trailing: LEADING, + min: '2026-07-15' + }) + ).toBe(false); }); it('accepts a day exactly on max', () => { expect( - isAvailable('2026-07-15', 'day', LEADING, undefined, '2026-07-15') + isAvailable('2026-07-15', 'day', { + trailing: LEADING, + max: '2026-07-15' + }) ).toBe(true); }); it('rejects the day after max', () => { expect( - isAvailable('2026-07-16', 'day', LEADING, undefined, '2026-07-15') + isAvailable('2026-07-16', 'day', { + trailing: LEADING, + max: '2026-07-15' + }) ).toBe(false); }); it('accepts a period whose produced date lands exactly on max', () => { expect( - isAvailable('2026-08-10', 'month', TRAILING, undefined, '2026-08-31') + isAvailable('2026-08-10', 'month', { + trailing: TRAILING, + max: '2026-08-31' + }) ).toBe(true); expect( - isAvailable('2026-08-10', 'month', TRAILING, undefined, '2026-08-30') + isAvailable('2026-08-10', 'month', { + trailing: TRAILING, + max: '2026-08-30' + }) ).toBe(false); }); it('accepts a period whose produced date lands exactly on min', () => { - expect(isAvailable('2026-08-10', 'month', LEADING, '2026-08-01')).toBe( - true - ); - expect(isAvailable('2026-08-10', 'month', LEADING, '2026-08-02')).toBe( - false - ); + expect( + isAvailable('2026-08-10', 'month', { + trailing: LEADING, + min: '2026-08-01' + }) + ).toBe(true); + expect( + isAvailable('2026-08-10', 'month', { + trailing: LEADING, + min: '2026-08-02' + }) + ).toBe(false); }); }); it('applies both bounds together', () => { expect( - isAvailable('2026-08-15', 'day', LEADING, '2026-01-01', '2026-12-31') + isAvailable('2026-08-15', 'day', { + trailing: LEADING, + min: '2026-01-01', + max: '2026-12-31' + }) ).toBe(true); expect( - isAvailable('2025-08-15', 'day', LEADING, '2026-01-01', '2026-12-31') + isAvailable('2025-08-15', 'day', { + trailing: LEADING, + min: '2026-01-01', + max: '2026-12-31' + }) ).toBe(false); expect( - isAvailable('2027-08-15', 'day', LEADING, '2026-01-01', '2026-12-31') + isAvailable('2027-08-15', 'day', { + trailing: LEADING, + min: '2026-01-01', + max: '2026-12-31' + }) ).toBe(false); }); it('can allow a period in a start field and disable it in an end field', () => { const max = '2026-08-15'; - expect(isAvailable('2026-08-01', 'month', LEADING, undefined, max)).toBe( + expect(isAvailable('2026-08-01', 'month', { trailing: LEADING, max })).toBe( true ); - expect(isAvailable('2026-08-01', 'month', TRAILING, undefined, max)).toBe( - false - ); - }); - - it('accepts Dates for the value and for either bound', () => { expect( - isAvailable( - new Date(2026, 7, 15), - 'day', - LEADING, - new Date(2026, 0, 1), - new Date(2026, 11, 31) - ) - ).toBe(true); + isAvailable('2026-08-01', 'month', { trailing: TRAILING, max }) + ).toBe(false); }); it('rejects a malformed bound rather than ignoring it', () => { expect(() => - isAvailable('2026-08-15', 'day', LEADING, '15/08/2026') + isAvailable('2026-08-15', 'day', { trailing: LEADING, min: '15/08/2026' }) ).toThrow(RangeError); expect(() => - isAvailable('2026-08-15', 'day', LEADING, undefined, '2026-13-01') + isAvailable('2026-08-15', 'day', { trailing: LEADING, max: '2026-13-01' }) ).toThrow(RangeError); }); }); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx index 015f7739c..06c3e0515 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx @@ -30,7 +30,6 @@ export function CalendarPreviewBody({ { className: cx(styles.body, className), 'data-slot': 'calendar-preview-body', - 'data-scale': scale, /* Escape drops the draft on its way to Base UI, which closes on it. */ onKeyDown: (event: React.KeyboardEvent) => { if (event.key === 'Escape') dropDraft(); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx index a3817944b..67872ad81 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx @@ -63,7 +63,6 @@ function CaptionLabel({ ref, ...props }: { dropdown?: false } & useRender.ComponentProps<'span'>) { - const { scale } = useCalendarPreviewContext('CalendarPreview.Caption'); const label = useCaptionLabel(); return useRender({ @@ -74,7 +73,6 @@ function CaptionLabel({ { className: cx(styles.caption, className), 'data-slot': 'calendar-preview-caption', - 'data-scale': scale, children: children ?? label } as useRender.ComponentProps<'span'>, props @@ -90,8 +88,9 @@ function CaptionDropdown({ ref, ...props }: { dropdown: true } & useRender.ComponentProps<'button'>) { - const { month, setMonth, yearRange, scale, disabled } = - useCalendarPreviewContext('CalendarPreview.Caption'); + const { month, setMonth, yearRange, disabled } = useCalendarPreviewContext( + 'CalendarPreview.Caption' + ); const label = useCaptionLabel(); const activeMonth = month.getMonth(); @@ -106,7 +105,6 @@ function CaptionDropdown({ Date; } -/* Generic so the scale-aware arms carry a `ScaleValue` without a second - context: stored as `unknown`, cast once at the hook boundary. */ -export interface CalendarPreviewContextValue { - value: Value; +/* The widened value every arm shares. The public props discriminate on + `selection` and `scales`; the implementation works in the union. */ +export type CalendarPreviewValue = + | Date + | CalendarPreviewDateRange + | ScaleValue + | null; + +export interface CalendarPreviewContextValue { + value: CalendarPreviewValue; /** `occasion` is the day acted on, which a cleared `value` cannot carry. */ setValue: ( - value: Value, + value: CalendarPreviewValue, reason: CalendarPreviewChangeReason, occasion: Date ) => void; @@ -137,31 +138,19 @@ export interface CalendarPreviewContextValue { setFieldReadOnly: (field: CalendarPreviewField, readOnly: boolean) => void; } -const CalendarPreviewContext = - createContext | null>(null); - -export function CalendarPreviewProvider({ - value, - children -}: { - value: CalendarPreviewContextValue; - children: ReactNode; -}) { - return ( - {children} - ); -} +export const CalendarPreviewContext = + createContext(null); /* `part` is the caller's display name, so the throw points at the element the author wrote rather than at this file. */ -export function useCalendarPreviewContext( +export function useCalendarPreviewContext( part: string -): CalendarPreviewContextValue { +): CalendarPreviewContextValue { const context = useContext(CalendarPreviewContext); if (!context) { throw new Error(`${part} must be used within `); } - return context as CalendarPreviewContextValue; + return context; } /* `.Days` owns this rather than the root, so two day views in one tree cannot @@ -172,23 +161,9 @@ export interface CalendarPreviewDaysContextValue { setBusy: (busy: boolean) => void; } -const CalendarPreviewDaysContext = +export const CalendarPreviewDaysContext = createContext(null); -export function CalendarPreviewDaysProvider({ - value, - children -}: { - value: CalendarPreviewDaysContextValue; - children: ReactNode; -}) { - return ( - - {children} - - ); -} - export function useCalendarPreviewDaysContext(): CalendarPreviewDaysContextValue | null { return useContext(CalendarPreviewDaysContext); } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx index 7d8f6abde..ddbde4065 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -5,8 +5,8 @@ import { cx } from 'class-variance-authority'; import { useMemo, useState } from 'react'; import styles from './calendar-preview.module.css'; import { + CalendarPreviewDaysContext, type CalendarPreviewDaysContextValue, - CalendarPreviewDaysProvider, useCalendarPreviewContext } from './calendar-preview-context'; import { CalendarPreviewGrid } from './calendar-preview-grid'; @@ -49,7 +49,6 @@ export function CalendarPreviewDays({ { className: cx(styles.days, className), 'data-slot': 'calendar-preview-days', - 'data-scale': scale, 'data-disabled': disabled || undefined, 'data-readonly': readOnly || undefined, 'data-busy': busy || undefined, @@ -70,9 +69,9 @@ export function CalendarPreviewDays({ if (scale !== 'day') return null; return ( - + {element} - + ); } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index a63bcbb91..b5642802b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -35,10 +35,7 @@ import { CalendarPreviewNextMonth, CalendarPreviewPrevMonth } from './calendar-preview-header'; -import { - type CalendarPreviewValue, - isScaleValue -} from './calendar-preview-root'; +import { isScaleValue } from './calendar-preview-root'; import { formatCaptionLabel, formatWeekdayLabel, @@ -161,7 +158,7 @@ export function CalendarPreviewGrid({ clearable, disabled, readOnly - } = useCalendarPreviewContext('CalendarPreview.Grid'); + } = useCalendarPreviewContext('CalendarPreview.Grid'); const days = useCalendarPreviewDaysContext(); const setBusy = days?.setBusy; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index dee4987da..4d2a0ef58 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -5,11 +5,7 @@ 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 { - type CalendarPreviewValue, - isRange as isRangeValue, - isScaleValue -} from './calendar-preview-root'; +import { isRange as isRangeValue, isScaleValue } from './calendar-preview-root'; import { useTriggerInput } from './calendar-preview-trigger'; import { anyDayBetween, dayKey, parseKey } from './date-adapter'; import { parseScaleInput } from './lib/parse'; @@ -108,7 +104,7 @@ export function CalendarPreviewInput({ activeField, setActiveField, setFieldReadOnly - } = useCalendarPreviewContext('CalendarPreview.Input'); + } = useCalendarPreviewContext('CalendarPreview.Input'); const isRange = selection === 'range'; @@ -267,7 +263,6 @@ export function CalendarPreviewInput({ ; @@ -12,8 +11,6 @@ export function CalendarPreviewLabel({ ref, ...props }: CalendarPreviewLabelProps) { - const { scale } = useCalendarPreviewContext('CalendarPreview.Label'); - return useRender({ defaultTagName: 'span', ref, @@ -22,7 +19,6 @@ export function CalendarPreviewLabel({ { className: cx(styles.label, className), 'data-slot': 'calendar-preview-label', - 'data-scale': scale, children: children ?? 'Date' } as useRender.ComponentProps<'span'>, props diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx index 89f79eb3b..e5f822091 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -3,10 +3,7 @@ import { cx } from 'class-variance-authority'; import { useEffect, useMemo, useRef } from 'react'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; -import { - type CalendarPreviewValue, - isScaleValue -} from './calendar-preview-root'; +import { isScaleValue } from './calendar-preview-root'; import { type DayKey, dayKey, @@ -76,9 +73,7 @@ function PeriodView({ timeZone, disabled, readOnly - } = useCalendarPreviewContext( - 'CalendarPreview.Periods' - ); + } = useCalendarPreviewContext('CalendarPreview.Periods'); const years = useMemo(() => { const list: number[] = []; @@ -137,7 +132,6 @@ function PeriodView({ { className: cx(styles.periods, className), 'data-slot': slot, - 'data-scale': viewScale, children: children ?? ( <> {groups.map(({ year, cells }) => ( @@ -148,12 +142,14 @@ function PeriodView({ data-slot='calendar-preview-period-group' data-year={year} > -
- {year} -
+ {viewScale !== 'year' && ( +
+ {year} +
+ )}
; @@ -24,7 +20,7 @@ export function CalendarPreviewReset({ ...props }: CalendarPreviewResetProps) { const { value, defaultDate, reset, disabled, readOnly, timeZone } = - useCalendarPreviewContext('CalendarPreview.Reset'); + 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. */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index d9fc7b856..d1117ccb8 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -9,12 +9,13 @@ import styles from './calendar-preview.module.css'; import { type CalendarPreviewChangeDetails, type CalendarPreviewChangeReason, + CalendarPreviewContext, type CalendarPreviewContextValue, type CalendarPreviewDateRange, type CalendarPreviewDraftRange, type CalendarPreviewField, type CalendarPreviewOpenChangeDetails, - CalendarPreviewProvider + type CalendarPreviewValue } from './calendar-preview-context'; import { anyDayBetween, @@ -31,7 +32,6 @@ import { isAvailable, isScale, periodOf, - SCALES, type Scale, type ScaleValue } from './lib/scale'; @@ -50,13 +50,7 @@ export function monthAnchor( return value instanceof Date ? value : parseKey(value.date); } -/* `defaultValue` is omitted because `HTMLAttributes` already declares it as a - form value, which is not what it means here. */ -export type CalendarPreviewValue = - | Date - | CalendarPreviewDateRange - | ScaleValue - | null; +export type { CalendarPreviewValue }; export function isScaleValue( value: CalendarPreviewValue | undefined @@ -333,7 +327,8 @@ export function CalendarPreviewRoot({ const list = (Array.isArray(scalesProp) ? scalesProp : [scalesProp]).filter( isScale ); - return list.length > 0 ? SCALES.filter(s => list.includes(s)) : ['day']; + /* The order given is the order shown, and `scales[0]` is the default. */ + return list.length > 0 ? Array.from(new Set(list)) : ['day']; }, [scalesProp]); const [scale, setScaleUnwrapped] = useControlled({ @@ -389,9 +384,8 @@ export function CalendarPreviewRoot({ /* The committed scale, not the view's: typing "Q4 2026" commits a quarter while the view is still on days. */ period: periodOf( - occasion, - isScaleValue(next) ? next.scale : scale, - timeZone + dayKey(occasion, timeZone), + isScaleValue(next) ? next.scale : scale ), toDate: () => occasion }); @@ -523,7 +517,9 @@ export function CalendarPreviewRoot({ /* * The from/to machine: - * no from -> set from, advance to the end input + * nothing drafted -> set from, advance to the end input + * to only -> fills the from, completing unless the click + * crosses it or the span is unavailable * from, day earlier -> that day becomes the new from * from, day later -> completes and emits * from and to -> restart from the new day @@ -642,8 +638,8 @@ export function CalendarPreviewRoot({ date: dayKey(month, timeZone), scale }; - setScaleDraft(convertScale(anchor, next, trailingValue, timeZone)); - setMonth(parseKey(convertScale(anchor, next, false, timeZone).date)); + setScaleDraft(convertScale(anchor, next, trailingValue)); + setMonth(parseKey(convertScale(anchor, next, false).date)); setScale(next); }, [ @@ -661,7 +657,13 @@ export function CalendarPreviewRoot({ const selectPeriod = useCallback( (date: Date | string, next: Scale) => { if (readOnly || disabled) return; - const key = anchorOf(periodOf(date, next, timeZone), trailingValue); + const key = anchorOf( + periodOf( + typeof date === 'string' ? date : dayKey(date, timeZone), + next + ), + trailingValue + ); clearScaleDraft(); setValue( { date: key, scale: next }, @@ -691,7 +693,8 @@ export function CalendarPreviewRoot({ [scale, setScale, clearScaleDraft] ); - /* `scales[0]` is the scale being undone, not the one to come back to. */ + /* The scale the run started from, not `scales[0]`: that is the default the + root opened at, which a committed switch has already moved away from. */ const dropDraft = useCallback(() => { if (scaleDraft === null) return; settleScale( @@ -705,7 +708,15 @@ export function CalendarPreviewRoot({ /* Bounds only, never `isDateUnavailable` — the prop documents why. */ const isPeriodAvailable = useCallback( (date: Date | string, next: Scale) => - isAvailable(date, next, trailingValue, minDate, maxDate, timeZone), + isAvailable( + typeof date === 'string' ? date : dayKey(date, timeZone), + next, + { + trailing: trailingValue, + min: minDate && dayKey(minDate, timeZone), + max: maxDate && dayKey(maxDate, timeZone) + } + ), [trailingValue, minDate, maxDate, timeZone] ); @@ -781,7 +792,7 @@ export function CalendarPreviewRoot({ [formatValueProp, timeZone] ); - const context = useMemo>( + const context = useMemo( () => ({ value, setValue, @@ -887,13 +898,11 @@ export function CalendarPreviewRoot({ /* Base UI owns dismissal — outside press, escape and focus-out all come from `Popover.Root`, which is why no file here has an outside-click listener. */ return ( - } - > + {element} - + ); } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx index c4a459626..e4ac3571c 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx @@ -93,6 +93,10 @@ export function CalendarPreviewScale({ className, 'data-slot': 'calendar-preview-scale', 'data-scale': value, + /* `.Scales` renders real `Tabs`; this one is a button a consumer has + placed themselves, so it says it is pressed rather than claiming a + `tab` role with no tablist around it. */ + 'aria-pressed': scale === value, 'data-active': scale === value || undefined, disabled, onClick: () => switchScale(value), diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index 4a585010e..18161783f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -18,7 +18,7 @@ import { } from 'react'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; -import { type CalendarPreviewValue, isRange } from './calendar-preview-root'; +import { isRange } from './calendar-preview-root'; /* Per trigger, not per root: `.Body` mounts an `.Input` inside `.Content`, and a childless trigger beside it is still a button. */ @@ -71,9 +71,7 @@ export function CalendarPreviewTrigger({ triggerRef, disabled, readOnly - } = useCalendarPreviewContext( - 'CalendarPreview.Trigger' - ); + } = useCalendarPreviewContext('CalendarPreview.Trigger'); const [inputCount, setInputCount] = useState(0); const hasInput = inputCount > 0; @@ -116,7 +114,6 @@ export function CalendarPreviewTrigger({ { className: cx(styles.trigger, className), 'data-slot': 'calendar-preview-trigger', - 'data-scale': scale, /* Base UI gives a non-native trigger both, which around a field is a second tab stop and a control inside a button role. */ role: hasInput ? undefined : 'button', diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 2852b538f..3353b60d6 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -6,14 +6,10 @@ import { addDays, addMonths, endOfMonth, - endOfQuarter, - endOfYear, format, isValid, parse, - startOfMonth, - startOfQuarter, - startOfYear + startOfMonth } from 'date-fns'; /* Lexicographic order is chronological order, so `lib/` orders days as @@ -78,22 +74,6 @@ export function endOfMonthKey(key: DayKey): DayKey { return dayKey(endOfMonth(parseKey(key))); } -export function startOfQuarterKey(key: DayKey): DayKey { - return dayKey(startOfQuarter(parseKey(key))); -} - -export function endOfQuarterKey(key: DayKey): DayKey { - return dayKey(endOfQuarter(parseKey(key))); -} - -export function startOfYearKey(key: DayKey): DayKey { - return dayKey(startOfYear(parseKey(key))); -} - -export function endOfYearKey(key: DayKey): DayKey { - return dayKey(endOfYear(parseKey(key))); -} - export function yearOf(key: DayKey): number { return Number(key.slice(0, 4)); } diff --git a/packages/raystack/components/calendar-preview/lib/scale.ts b/packages/raystack/components/calendar-preview/lib/scale.ts index 1cd2b6cb6..5db2f9cad 100644 --- a/packages/raystack/components/calendar-preview/lib/scale.ts +++ b/packages/raystack/components/calendar-preview/lib/scale.ts @@ -1,21 +1,16 @@ /* * The scale maths from RFC 005 — pure functions, no React, no UI. * - * Everything here is expressed in `DayKey`s (`'YYYY-MM-DD'`, timeless). Every - * date-library call goes through `../date-adapter`; this file makes none of - * its own. + * Everything here is expressed in `DayKey`s (`'YYYY-MM-DD'`, timeless), so + * none of it takes a zone. Callers convert at their own boundary. Every + * date-library call goes through `../date-adapter`; this file makes none. */ import { type DayKey, - dayKey, endOfMonthKey, - endOfQuarterKey, - endOfYearKey, isDayKey, monthOf, - startOfMonthKey, - startOfQuarterKey, - startOfYearKey + startOfMonthKey } from '../date-adapter'; /** The granularities a value can be selected at. */ @@ -58,32 +53,34 @@ export function isScale(value: string): value is Scale { * `halfYear` is ours to derive — no date library has it. H1 is January to June, * H2 is July to December. */ -export function periodOf( - date: Date | DayKey, - scale: Scale, - timeZone?: string -): Period { - const key = toKey(date, timeZone); - switch (scale) { - case 'day': - return { start: key, end: key }; - case 'month': - return { start: startOfMonthKey(key), end: endOfMonthKey(key) }; - case 'quarter': - return { start: startOfQuarterKey(key), end: endOfQuarterKey(key) }; - case 'halfYear': { - /* The four half-year edges exist in every year, leap or not, so the key - * can be composed from the year segment directly. */ - const year = yearSegment(key); - return monthOf(key) <= 6 - ? { start: `${year}-01-01`, end: `${year}-06-30` } - : { start: `${year}-07-01`, end: `${year}-12-31` }; - } - case 'year': - return { start: startOfYearKey(key), end: endOfYearKey(key) }; +export function periodOf(date: DayKey, scale: Scale): Period { + const key = requireKey(date); + if (scale === 'day') return { start: key, end: key }; + /* A month's last day is the only edge that moves with the calendar. */ + if (scale === 'month') { + return { start: startOfMonthKey(key), end: endOfMonthKey(key) }; } + const year = key.slice(0, 4); + const [start, end] = FIXED_EDGES[scale](monthOf(key)); + return { start: `${year}-${start}`, end: `${year}-${end}` }; } +const QUARTERS: readonly (readonly [string, string])[] = [ + ['01-01', '03-31'], + ['04-01', '06-30'], + ['07-01', '09-30'], + ['10-01', '12-31'] +]; + +const FIXED_EDGES: Record< + Exclude, + (month: number) => readonly [string, string] +> = { + quarter: month => QUARTERS[Math.floor((month - 1) / 3)], + halfYear: month => (month <= 6 ? ['01-01', '06-30'] : ['07-01', '12-31']), + year: () => ['01-01', '12-31'] +}; + /** * The single day a period stands for: its last day when `trailing`, its first * otherwise. @@ -107,17 +104,19 @@ export function anchorOf(period: Period, trailing: boolean): DayKey { export function convertScale( value: ScaleValue, to: Scale, - trailing: boolean, - timeZone?: string + trailing: boolean ): ScaleValue { - return { - date: anchorOf(periodOf(value.date, to, timeZone), trailing), - scale: to - }; + return { date: anchorOf(periodOf(value.date, to), trailing), scale: to }; +} + +export interface AvailabilityOptions { + trailing?: boolean; + min?: DayKey; + max?: DayKey; } /** - * Whether the period of `scale` containing `value` can be selected. + * Whether the period of `scale` containing `date` can be selected. * * The test is against **the date the period would produce**, not the period's * start — so availability depends on `trailing`, and one period can be @@ -130,28 +129,19 @@ export function convertScale( * never clamped. */ export function isAvailable( - value: Date | DayKey, + date: DayKey, scale: Scale, - trailing: boolean, - min?: Date | DayKey, - max?: Date | DayKey, - timeZone?: string + { trailing = false, min, max }: AvailabilityOptions = {} ): boolean { - const produced = anchorOf(periodOf(value, scale, timeZone), trailing); - if (min !== undefined && produced < toKey(min, timeZone)) return false; - if (max !== undefined && produced > toKey(max, timeZone)) return false; + const produced = anchorOf(periodOf(date, scale), trailing); + if (min !== undefined && produced < requireKey(min)) return false; + if (max !== undefined && produced > requireKey(max)) return false; return true; } -function toKey(date: Date | DayKey, timeZone?: string): DayKey { - if (typeof date !== 'string') return dayKey(date, timeZone); - if (!isDayKey(date)) { - throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(date)}`); +function requireKey(key: DayKey): DayKey { + if (!isDayKey(key)) { + throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(key)}`); } - return date; -} - -/** The `YYYY` of a key, as written — not parsed, so it never loses a leading zero. */ -function yearSegment(key: DayKey): string { - return key.slice(0, 4); + return key; } diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx index 1b10a2307..e62f5e42c 100644 --- a/packages/raystack/components/calendar-preview/use-calendar.tsx +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -26,7 +26,7 @@ export interface UseCalendarReturn { */ export function useCalendar(): UseCalendarReturn { const { value, setValue, scale, month, setMonth, isDateUnavailable } = - useCalendarPreviewContext('useCalendar'); + useCalendarPreviewContext('useCalendar'); return { value, diff --git a/packages/raystack/vitest.config.mjs b/packages/raystack/vitest.config.mjs index 6579d1bfa..440627a88 100644 --- a/packages/raystack/vitest.config.mjs +++ b/packages/raystack/vitest.config.mjs @@ -6,10 +6,6 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], globals: true, - /* Pinned so date tests do not depend on the machine's zone. Without it the - suite fails east of UTC+9, where a local-midnight year 10000 is still - year 9999 in UTC. CI passes only because its runners are UTC. */ - env: { TZ: 'UTC' }, css: { modules: { classNameStrategy: 'stable' From fa735c99a383c591e1b97be419238d88582606ca Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 17 Sep 2026 17:50:59 +0530 Subject: [PATCH 40/52] style(calendar-preview): line the panel up on one column and let the grid fill it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel was `seven 40px columns + 24px`, but `.panel .days` zeroes the padding that 24px stood for, so it was dead width the grid was left-aligned inside. The panel is the seven columns now, and the table fills it — a table is shrink-to-fit, so the columns had only their own content width to share and stopped short of the edge whatever `flex: 1` asked for. `.days` drops its padding under the panel too, where the surface around it supplies the inset. Boxes were already flush; glyphs were not. Bare text reads at the edge while every control's text sits in from it — the tabs by 6, the input by 9, a centred weekday by 11 — so `.Label` and the day caption take that same inset and one text column runs down the panel. Standalone, `.Days` is its own surface and the caption still reads against "Sun". Comments here are cut to the constraint each one records. --- .../calendar-preview.module.css | 71 ++++++++----------- 1 file changed, 30 insertions(+), 41 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 032c2cb3c..ab0b63354 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -30,10 +30,7 @@ margin-bottom: var(--rs-space-3); } -/* The week-number column is a gutter, not a date column, so the caption starts - past it — aligned with Sunday rather than with the grid's outer edge. The - header cannot read `showWeekNumber`, which is a `.Grid` prop, so it asks the - rendered grid instead. */ +/* `showWeekNumber` is a `.Grid` prop, so the header asks the rendered grid. */ .days:has(.week-number-header) .header { padding-inline-start: var(--rs-space-10); } @@ -63,9 +60,7 @@ cursor: not-allowed; } -/* Takes the space left of the buttons, so the caption sits against the start - edge and the reset and two nav buttons group at the end — the single-month - header in reference A. Source order already matches, so nothing reorders. */ +/* Takes the space left of the buttons, which group at the end. */ .caption { flex: 1; text-align: start; @@ -78,9 +73,7 @@ -webkit-user-select: none; } -/* The caption that opens the scroller is a filled chip, so the affordance - reads without an adjacent glyph. `flex: none` undoes `.caption`'s stretch — - the chip hugs its label rather than running to the nav buttons. */ +/* `flex: none` undoes `.caption`'s stretch, so the chip hugs its label. */ .caption-trigger { display: inline-flex; flex: none; @@ -115,9 +108,8 @@ z-index: 1; } -/* Our own scroller, not a Select: two plain columns of buttons in a popup we - own, so nothing here portals a listbox the surrounding popover has to - recognise as inside itself. */ +/* Plain buttons, not a Select: a portalled listbox would read as outside the + surrounding popover. */ .caption-popup { display: flex; gap: var(--rs-space-2); @@ -128,8 +120,6 @@ box-shadow: var(--rs-shadow-lifted); } -/* The popup is sized by its columns, so the separator's percentage height - resolves to nothing; stretching is what fills the row. */ .caption-popup .caption-divider[data-orientation="vertical"] { height: auto; align-self: stretch; @@ -182,8 +172,7 @@ flex: none; } -/* Both nav tracks stay reserved whether or not this month draws a button, so - the caption centres on its grid rather than on the remaining space. */ +/* Both nav tracks stay reserved, so the caption centres on its grid. */ .month-header { display: grid; grid-template-columns: var(--rs-space-8) 1fr var(--rs-space-8); @@ -197,7 +186,7 @@ grid-column: 1; } -.header { +.root { --rs-caption-inset: calc((var(--rs-space-10) - var(--rs-space-6)) / 2); } @@ -209,6 +198,15 @@ margin-inline-start: calc(var(--rs-caption-inset) - var(--rs-space-3)); } +.body .label, +.panel .header .caption:not([data-dropdown]) { + padding-inline-start: var(--rs-caption-inset); +} + +.panel .header .caption[data-dropdown] { + margin-inline-start: 0; +} + .month-header-caption { grid-column: 2; text-align: center; @@ -252,8 +250,7 @@ position: relative; } -/* The user-agent's 2px border-spacing would ring the grid, leaving the header - two pixels wider than the columns it sits above. */ +/* The UA's 2px border-spacing would leave the header wider than its columns. */ .weeks table { border-spacing: 0; } @@ -488,10 +485,7 @@ cursor: not-allowed; } -/* Wider than the trigger and centred on it, per the frames. `.Days` brings its - own padding, so the surface adds none — and it opts out of the shared - popover's `max-width: 18rem`, sized for text at 288px against the 296px - seven 40px columns need, which cropped Saturday flush to the border. */ +/* Opts out of the shared popover's `max-width: 18rem`, which cropped Saturday. */ .content { padding: 0; width: max-content; @@ -502,17 +496,14 @@ 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. */ +/* Drawn on the cell, not the button, so neighbours 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. */ +/* RDP marks every day of a range `selected`; on the grey track they keep the + ordinary colour rather than the accent pill's white. */ .range-middle .day-button { background: transparent; color: var(--rs-color-foreground-base-primary); @@ -576,26 +567,24 @@ display: flex; } -/* `Tabs` gives every trigger an equal share of the row, and a fifth of the day - grid's width does not hold "Half-year". Each label takes the width it needs - and shares what is left; scoped to beat the primitive's own `flex`. */ +/* A fifth of the row does not hold "Half-year". Scoped to beat `Tabs`' flex. */ .scales .scale { flex: 1 1 auto; } -/* Nothing inside the panel has a width of its own to hand the popover, so the - panel is the anchor for all five views — seven 40px columns, the width the - input and switcher above it run at — and the popover stops resizing. */ +/* The anchor for all five views, so the popover stops resizing. */ .panel { - width: calc(var(--rs-space-10) * 7 + var(--rs-space-4) * 2); + width: calc(var(--rs-space-10) * 7); } -/* Standalone, `.Days` is its own inset surface. Under the switcher it is one - view of five, so it drops the inset and its columns share the row: the grid - lines up with the input and the tabs instead of sitting in from them. */ +/* Under the switcher it is one view of five, so it drops its own inset. */ .panel .days { width: 100%; - padding-inline: 0; + padding: 0; +} + +.panel .weeks table { + width: 100%; } .panel .weekday, From e9536604c86822a05b8a406e5a34a70d4734cc37 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 18 Sep 2026 00:09:26 +0530 Subject: [PATCH 41/52] fix(calendar-preview): close the ten review threads still open on the stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.Input` never destructured `onValueChange`, and `{...props}` lands last on the `Input` it renders — so a consumer passing the prop it inherits replaced the handler that keeps the draft, and Enter then committed nothing at all. It is composed now, the way `onKeyDown`, `onBlur` and `onFocus` already were. Retained text is judged against things outside it, the partner endpoint and the bounds, and both move while it sits there. An end rejected for crossing a 10 Apr start stayed marked invalid once the start moved to the 1st, and a draft kept its verdict when `minDate` changed under it. One effect re-reads the text when either moves, comparing against a ref rather than listing dependencies: `resolve` closes over the whole context and is rebuilt every render. It defers to the value-change effect below it, which owns the case where a commit is already replacing the text. `setEndpoint` routed every pair that was not complete and ordered to `from`, so a date typed into an empty end field landed in the start. The day stays in the field it was typed into now, and the active field moves to whichever edge is still missing. A grid click still means "the next endpoint". A draft is a range half-built against the value it started from, so a value the consumer sets behind it leaves the grid and both inputs showing endpoints that are no longer anyone's. The root drops it. Its own writes are excluded through `emitted`, because emptying one field sets a draft and clears the value in the same pass, and that draft is the whole point of it. Base UI adds no `tabIndex` to a rendered `div`, so a `.Trigger` with no `.Input` was a `role="button"` nothing could focus: the scale picker and the trailing pair could not be opened from the keyboard, and Escape landed on `` for want of anywhere to return to. It carries `tabIndex={0}` now. A trigger around a field stays out of the tab order at `-1`. `.Reset` folds a caller's `disabled` into the state it computes rather than leaving it in the spread, where `disabled={false}` re-enabled a button whose only job was already done and clicking it emitted a second reset. The range `Invalid input` demo gave two endpoints one error state, so fixing either cleared the message while the other field was still marked; each endpoint's verdict is tracked on its own. `does not commit an out-of-bounds date` passes `onValueChange` into the picker it renders — the assertion was checking a mock nothing was wired to. Two behaviour changes worth naming, neither a public API removal: a caller's `disabled={false}` no longer re-enables `.Reset`, and a controlled range loses its draft when the value moves underneath it. Eleven tests cover the fixes, one scenario each. --- .../docs/components/calendar-preview/demo.ts | 21 ++-- .../__tests__/calendar-preview.test.tsx | 29 +++++ .../__tests__/picker.test.tsx | 110 +++++++++++++++++- .../calendar-preview/__tests__/range.test.tsx | 96 +++++++++++++++ .../calendar-preview-input.tsx | 38 +++++- .../calendar-preview-reset.tsx | 3 +- .../calendar-preview-root.tsx | 26 ++++- .../calendar-preview-trigger.tsx | 7 +- 8 files changed, 313 insertions(+), 17 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 6009a928c..fcb2c3a85 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -542,8 +542,13 @@ export const rangeDemo = { name: 'Invalid input', code: ` function CalendarPreviewRangeInvalidExample() { - const [defaultError, setDefaultError] = React.useState(); - const [customError, setCustomError] = React.useState(); + // One message per Field, but two endpoints feed it — so each endpoint's + // verdict is tracked on its own and the Field shows whichever is unhappy. + const [defaultErrors, setDefaultErrors] = React.useState({}); + const [customErrors, setCustomErrors] = React.useState({}); + const at = (set, field) => ({ message }) => + set(current => ({ ...current, [field]: message })); + const first = errors => errors.start ?? errors.end; const range = { selection: 'range', @@ -556,18 +561,18 @@ function CalendarPreviewRangeInvalidExample() { setDefaultError(message)} + onValidityChange={at(setDefaultErrors, 'start')} /> setDefaultError(message)} + onValidityChange={at(setDefaultErrors, 'end')} /> @@ -580,7 +585,7 @@ function CalendarPreviewRangeInvalidExample() { @@ -588,12 +593,12 @@ function CalendarPreviewRangeInvalidExample() { setCustomError(message)} + onValidityChange={at(setCustomErrors, 'start')} /> setCustomError(message)} + onValidityChange={at(setCustomErrors, 'end')} /> 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 d202d970f..fde6aa85f 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -348,6 +348,35 @@ describe('CalendarPreview.Reset', () => { expect(reset).toHaveAttribute('data-restored'); }); + /* `{...props}` follows the computed `disabled`, so without pulling the + caller's out of it a `disabled={false}` re-enabled a button whose only job + was already done — and clicking it emitted a second reset. */ + it('stays disabled when a caller passes disabled={false}', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar( + + + + + + , + { + defaultDate: new Date(2026, 7, 20), + defaultValue: new Date(2026, 7, 20), + onValueChange + } + ); + + const reset = getSlot( + container, + 'calendar-preview-reset' + ) as HTMLButtonElement; + expect(reset).toBeDisabled(); + + fireEvent.click(reset); + expect(onValueChange).not.toHaveBeenCalled(); + }); + it('renders once the value differs from the defaultDate', () => { const { container } = renderCalendar(undefined, { defaultDate: new Date(2026, 7, 20), diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index 251166f5c..6e0b405ad 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -284,7 +284,10 @@ describe('CalendarPreview.Input validity', () => { it('does not commit an out-of-bounds date', () => { const onValueChange = vi.fn(); - const { input } = renderPicker({ minDate: new Date(2026, 7, 10) }); + const { input } = renderPicker({ + minDate: new Date(2026, 7, 10), + onValueChange + }); fireEvent.change(input, { target: { value: '01/08/2026' } }); fireEvent.keyDown(input, { key: 'Enter' }); expect(onValueChange).not.toHaveBeenCalled(); @@ -666,3 +669,108 @@ describe('CalendarPreview.Trigger beside a Body that owns the input', () => { ); }); }); + +describe('CalendarPreview picker props the review left open', () => { + /* `onValueChange` is inherited from `Input`, so a consumer passing it used + to replace the handler that keeps the draft — and Enter then committed + nothing at all. */ + it('composes a consumer onValueChange rather than replacing it', () => { + const onInputValueChange = vi.fn(); + const onValueChange = vi.fn(); + const { input } = renderPicker( + { onValueChange }, + { onValueChange: onInputValueChange } + ); + + fireEvent.change(input, { target: { value: '20/05/2027' } }); + expect(onInputValueChange).toHaveBeenCalled(); + expect(onInputValueChange.mock.calls[0][0]).toBe('20/05/2027'); + + fireEvent.keyDown(input, { key: 'Enter' }); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2027, 4, 20)); + }); + + /* Retained text is judged against the bounds, and the bounds can move while + it sits there. */ + it('re-judges drafted text when the bounds move under it', () => { + const { input, rerender } = renderPicker({ + minDate: new Date(2026, 7, 10) + }); + fireEvent.change(input, { target: { value: '05/08/2026' } }); + expect(input).toHaveAttribute('data-invalid'); + + rerender( + + + + + + + + + ); + + expect(input.value).toBe('05/08/2026'); + expect(input).not.toHaveAttribute('data-invalid'); + }); + + it('reports the recovered validity to the consumer', () => { + const onValidityChange = vi.fn(); + const { input, rerender } = renderPicker( + { maxDate: new Date(2026, 7, 10) }, + { onValidityChange } + ); + fireEvent.change(input, { target: { value: '20/08/2026' } }); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'out-of-bounds', + message: 'Invalid input' + }); + + rerender( + + + + + + + + + ); + + expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true }); + }); + + /* Without an `.Input` the trigger is the control, so it carries the tab + stop — Base UI adds none to a rendered `div`. */ + it('gives a trigger with no input a tab stop of its own', () => { + const { container } = render( + + + + + + + ); + const trigger = getSlot(container, 'calendar-preview-trigger'); + expect(trigger).toHaveAttribute('role', 'button'); + expect(trigger).toHaveAttribute('tabindex', '0'); + }); + + it('keeps a trigger around an input out of the tab order', () => { + const { container } = renderPicker(); + expect(getSlot(container, 'calendar-preview-trigger')).toHaveAttribute( + 'tabindex', + '-1' + ); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index f5f7f8a35..f3653e696 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -703,3 +703,99 @@ describe('CalendarPreview range emptying one field', () => { expect(end.value).toBe('20 Aug 2026'); }); }); + +describe('CalendarPreview range endpoints the review left open', () => { + function renderFields(props = {}) { + const utils = renderRange( + props, + <> + + + + + + + ); + const [start, end] = getAllSlots( + utils.container, + 'calendar-preview-input' + ) as HTMLInputElement[]; + return { ...utils, start, end }; + } + + const type = (input: HTMLInputElement, text: string) => { + fireEvent.change(input, { target: { value: text } }); + fireEvent.keyDown(input, { key: 'Enter' }); + }; + + /* A click means "the next endpoint"; typing means the field typed into. */ + it('keeps a typed end in the end field when there is no start yet', () => { + const { start, end } = renderFields(); + type(end, '20 Aug 2026'); + expect(end.value).toBe('20 Aug 2026'); + expect(start.value).toBe(''); + }); + + it('completes from a typed end once the start arrives', () => { + const onValueChange = vi.fn(); + const { container, end } = renderFields({ onValueChange }); + type(end, '20 Aug 2026'); + fireEvent.click(day(container, '10')); + expect(onValueChange.mock.calls[0][0]).toEqual({ + from: new Date(2026, 7, 10), + to: new Date(2026, 7, 20) + }); + }); + + /* The verdict on retained text depends on the partner, so it has to be + re-read when the partner moves rather than left where it was. */ + it('clears a crossing end once the start moves behind it', () => { + const { container, end } = renderFields(); + fireEvent.click(day(container, '10')); + fireEvent.change(end, { target: { value: '05 Aug 2026' } }); + expect(end).toHaveAttribute('data-invalid'); + + fireEvent.click(day(container, '1')); + expect(end.value).toBe('05 Aug 2026'); + expect(end).not.toHaveAttribute('data-invalid'); + }); + + /* A draft is half a range built against the value it started from. */ + it('drops a half-built draft when the consumer moves the value', () => { + const { container, rerender, start, end } = renderFields({ + value: null + }); + fireEvent.click(day(container, '10')); + expect(start.value).toBe('10 Aug 2026'); + + rerender( + + + + + + + + ); + + expect(start.value).toBe('03 Aug 2026'); + expect(end.value).toBe('07 Aug 2026'); + }); + + /* Emptying one field writes a draft and clears the value in the same pass, + and that draft is the whole point of it. */ + it('keeps the draft that emptying one field leaves behind', () => { + const { start, end } = renderFields({ + defaultValue: { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) } + }); + fireEvent.change(start, { target: { value: '' } }); + fireEvent.blur(start); + expect(start.value).toBe(''); + expect(end.value).toBe('20 Aug 2026'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 4d2a0ef58..65c4eaa76 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -74,6 +74,7 @@ export function CalendarPreviewInput({ onKeyDown, onBlur, onFocus, + onValueChange: onValueChangeProp, className, readOnly: readOnlyProp, ...props @@ -126,9 +127,41 @@ export function CalendarPreviewInput({ const [text, setText] = useState(null); const [validity, setValidity] = useState(VALID); + const committed = useRef(value); + + /* Retained text is judged against things outside it — the partner endpoint + and the bounds — and both move while it sits there. Without this, an end + rejected for crossing a 10 Apr start stayed marked invalid after the start + moved to the 1st, and a draft kept its verdict when `minDate` changed + under it. Runs every render and compares rather than listing deps: + `resolve` closes over the whole context and is rebuilt each time. */ + const judgedAgainst = useRef([]); + useEffect(() => { + const partner = isRange + ? field === 'start' + ? draft?.to + : draft?.from + : undefined; + const next = [ + partner && dayKey(partner, timeZone), + minDate && dayKey(minDate, timeZone), + maxDate && dayKey(maxDate, timeZone), + isDateUnavailable + ]; + const moved = next.some( + (item, index) => item !== judgedAgainst.current[index] + ); + judgedAgainst.current = next; + /* A value change replaces the text outright, which the effect below owns. */ + if (!moved || text === null || committed.current !== value) return; + const trimmed = text.trim(); + if (trimmed === '') return; + const resolved = resolve(trimmed); + report('valid' in resolved ? resolved : VALID); + }); + /* A value this field did not type replaces whatever it was drafting, or a rejected draft outlives the day the user went on to click. */ - const committed = useRef(value); useEffect(() => { if (committed.current === value) return; committed.current = value; @@ -282,7 +315,8 @@ export function CalendarPreviewInput({ ? {} : { 'aria-invalid': true, 'data-invalid': true })} value={text ?? committedText} - onValueChange={text => { + onValueChange={(text, details) => { + onValueChangeProp?.(text, details); if (inert) return; setText(text); if (text.trim() === '') { diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx index 763124789..5fee502e9 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -17,6 +17,7 @@ export function CalendarPreviewReset({ className, children, onClick, + disabled: disabledProp, ...props }: CalendarPreviewResetProps) { const { value, defaultDate, reset, disabled, readOnly, timeZone } = @@ -48,7 +49,7 @@ export function CalendarPreviewReset({ (value); + /* The inertness guard lives here rather than in the grid's click handler: `useCalendar().setValue` and `reset()` reach this same function, and a guard further out would leave both of them able to write to a calendar @@ -378,6 +383,7 @@ export function CalendarPreviewRoot({ occasion: Date ) => { if (readOnly || disabled) return; + emitted.current = next; setValueUnwrapped(next); emit?.(next, { reason, @@ -493,6 +499,18 @@ export function CalendarPreviewRoot({ [] ); + /* A draft is a range half-built against the value it started from, so a + value the consumer set behind it leaves the grid and both inputs showing + endpoints that are no longer anyone's. Our own writes are excluded by + `emitted`: emptying one field sets a draft and clears the value in the + same pass, and that draft is the whole point of it. */ + useEffect(() => { + if (value === emitted.current) return; + emitted.current = value; + setDraft(null); + setActiveField('start'); + }, [value]); + /* Day-keys, not instants: a `minDate` carrying a time of day still leaves its own day selectable, which the current family gets wrong. */ const isDateUnavailable = useCallback( @@ -731,15 +749,17 @@ export function CalendarPreviewRoot({ 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. */ + a typed day that crossed its partner — keeps the day in the field it + was typed into and waits for the other. Routing it to `from` + regardless put a date typed into an empty end field in the start. */ if (from && to && dayKey(from, timeZone) <= dayKey(to, timeZone)) { setDraft(null); setActiveField('start'); setValue({ from, to }, 'input', date); return; } - setDraft({ from: date }); - setActiveField('end'); + setDraft(field === 'start' ? { from: date } : { to: date }); + setActiveField(field === 'start' ? 'end' : 'start'); }, [value, draft, fieldReadOnly, timeZone, readOnly, disabled, setValue] ); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index 18161783f..f154fbbb8 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -115,9 +115,12 @@ export function CalendarPreviewTrigger({ className: cx(styles.trigger, className), 'data-slot': 'calendar-preview-trigger', /* Base UI gives a non-native trigger both, which around a field is a - second tab stop and a control inside a button role. */ + second tab stop and a control inside a button role. Without an + input the trigger IS the control, and Base UI adds no `tabIndex` + to a rendered `div` — so it has to say so itself or no keyboard + ever reaches it. */ role: hasInput ? undefined : 'button', - tabIndex: hasInput ? -1 : undefined, + tabIndex: hasInput ? -1 : 0, /* Merged to the right of `useClick`, so this runs first. Only the closing half goes, or a press could not reopen a field that never lost focus. */ From 4a11132607f16bae0b12ac59af931e7a925c8abe Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 18 Sep 2026 10:53:32 +0530 Subject: [PATCH 42/52] fix(calendar-preview)!: close the defects the browser audit turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight fixes, all found by driving the real component rather than the suite — which is why most of them were green the whole time. `.Content` leaves focus alone when the trigger wraps a field. Base UI moves it to the first tabbable element in the popup, which around a field is the previous-month button, so opening the picker took focus off the field the user had just clicked and nothing they typed landed anywhere. The trigger registers what it wraps through the root, the way `.Input` already registers `fieldReadOnly`. A range fills from the keyboard now — focus the start, type, Tab, type — which is the model the docs have described all along. A run of scale switches converts from where it started rather than from the draft it last made. `convertScale` is lossy outward and does not undo, so day to year to day read a committed 15 August back as 1 January and showed that in the field with no cell marked anywhere. One `draftOrigin` holds the value, the month and the scale for the run; returning to the scale it began at restores that rather than converting a conversion, and dropping the draft puts the month back too, so Escape is a whole undo instead of half of one. A period cell is selected only when the value means that scale. The same day opens Q1, H1 and the year, so matching on the date alone lit a quarter cell for a half-year value — the calendar asserting something untrue about what was picked. Year groups with nothing selectable in them are left out. `yearRange` stretches to cover the bounds so no year is unreachable, which rendered the years outside them as dead buttons, a tab stop each: ten whole years and half the cells in the bounded example. A year the bound runs through keeps all of it, because hiding those would misreport where the bound falls, and a list that comes out entirely dead is kept rather than shown empty. `.Trigger` takes `nativeButton`. Base UI cannot see what a `render` prop produces and warned on every render of the documented `render={
@@ -411,7 +412,6 @@ export function CalendarPreviewDay({ { type: 'button', className: cx( - styles['day-button'], info != null && styles['day-button-with-info'], className ), diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx index e5f822091..ea621910c 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -86,8 +86,8 @@ function PeriodView({ (isScaleValue(value) ? value.date : dayKey(month, timeZone)) ); - const selectedKey = - scaleDraft?.date ?? (isScaleValue(value) ? value.date : null); + const selected = scaleDraft ?? (isScaleValue(value) ? value : null); + const selectedKey = selected?.scale === viewScale ? selected.date : null; const isActive = scale === viewScale; @@ -95,20 +95,26 @@ function PeriodView({ build no cells at all. The rest is date maths over every cell in the range — a year of months is 12 of them — and none of it moves when the selection does, so it is held rather than redone per render. */ - const groups = useMemo( - () => - isActive - ? years.map(year => ({ - year, - cells: cellsFor(viewScale, year).map(cell => ({ - ...cell, - produced: anchorOf(periodOf(cell.date, viewScale), trailingValue), - unavailable: !isPeriodAvailable(cell.date, viewScale) - })) - })) - : [], - [isActive, years, viewScale, trailingValue, isPeriodAvailable] - ); + const groups = useMemo(() => { + if (!isActive) return []; + const all = years.map(year => ({ + year, + cells: cellsFor(viewScale, year).map(cell => ({ + ...cell, + produced: anchorOf(periodOf(cell.date, viewScale), trailingValue), + unavailable: !isPeriodAvailable(cell.date, viewScale) + })) + })); + /* `yearRange` stretches to cover the bounds so no year is unreachable, + which leaves the years outside them rendered as nothing but dead + buttons — a tab stop each, half the list under a mid-range `minDate`. + They go, unless that would empty the panel, which reads as broken + rather than bounded. */ + const reachable = all.filter(group => + group.cells.some(cell => !cell.unavailable) + ); + return reachable.length > 0 ? reachable : all; + }, [isActive, years, viewScale, trailingValue, isPeriodAvailable]); /* Keyed on becoming active, not on mount: every view mounts at once, so a mount effect would fire with an empty ref. Scrolls the container, not diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx index 5fee502e9..8adb14f77 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -45,16 +45,27 @@ export function CalendarPreviewReset({ value.date === defaultDate.date && value.scale === defaultDate.scale); + /* `restored` takes `aria-disabled`, not `disabled`: the button disables + itself the moment it is activated, and a disabled element cannot hold + focus — so a keyboard reset dropped the user on `` in the middle of + the calendar, which is the thing staying mounted was meant to avoid. + Inertness that comes from outside is a real `disabled`; nothing moves + focus onto the button at that point. */ + const inert = disabled || readOnly || disabledProp; + return ( { onClick?.(event); + /* `aria-disabled` is a claim, not a guard — the press still arrives. */ + if (restored) return; reset(); }} {...props} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index e15f9c202..8ff6751f4 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -349,11 +349,15 @@ export function CalendarPreviewRoot({ const [scaleDraft, setScaleDraft] = useState(null); - const scaleBeforeDraft = useRef(null); + const draftOrigin = useRef<{ + value: ScaleValue | null; + month: Date; + scale: Scale; + } | null>(null); const clearScaleDraft = useCallback(() => { setScaleDraft(null); - scaleBeforeDraft.current = null; + draftOrigin.current = null; }, []); /* An array carries the scale even when that scale is `'day'`. */ @@ -499,6 +503,12 @@ export function CalendarPreviewRoot({ [] ); + const [triggerHasInput, setTriggerHasInputState] = useState(false); + + const setTriggerHasInput = useCallback((next: boolean) => { + setTriggerHasInputState(current => (current === next ? current : next)); + }, []); + /* A draft is a range half-built against the value it started from, so a value the consumer set behind it leaves the grid and both inputs showing endpoints that are no longer anyone's. Our own writes are excluded by @@ -650,11 +660,22 @@ export function CalendarPreviewRoot({ const switchScale = useCallback( (next: Scale) => { /* First switch of a run only: a second is still the same draft. */ - if (scaleDraft === null) scaleBeforeDraft.current = scale; + if (scaleDraft === null) { + draftOrigin.current = { value: scaleValue, month, scale }; + } + const origin = draftOrigin.current ?? { value: scaleValue, month, scale }; + + if (next === origin.scale) { + clearScaleDraft(); + setMonth(origin.month); + setScale(next); + return; + } + /* The month on screen, not today, or 2030 snaps back. */ - const anchor = scaleValue ?? { - date: dayKey(month, timeZone), - scale + const anchor = origin.value ?? { + date: dayKey(origin.month, timeZone), + scale: origin.scale }; setScaleDraft(convertScale(anchor, next, trailingValue)); setMonth(parseKey(convertScale(anchor, next, false).date)); @@ -667,6 +688,7 @@ export function CalendarPreviewRoot({ timeZone, scale, trailingValue, + clearScaleDraft, setMonth, setScale ] @@ -715,11 +737,14 @@ export function CalendarPreviewRoot({ root opened at, which a committed switch has already moved away from. */ const dropDraft = useCallback(() => { if (scaleDraft === null) return; + const origin = draftOrigin.current; + /* The month moved with the draft, so leaving it where the run ended showed + a different month than the one the run started on. */ + if (origin) setMonth(origin.month); settleScale( - scaleBeforeDraft.current ?? - (isScaleValue(value) ? value.scale : scales[0]) + origin?.scale ?? (isScaleValue(value) ? value.scale : scales[0]) ); - }, [scaleDraft, value, scales, settleScale]); + }, [scaleDraft, value, scales, settleScale, setMonth]); dropDraftRef.current = dropDraft; @@ -837,6 +862,8 @@ export function CalendarPreviewRoot({ setOpen, shouldIgnoreFocusOpen, triggerRef, + triggerHasInput, + setTriggerHasInput, defaultDate, reset, month, @@ -876,6 +903,8 @@ export function CalendarPreviewRoot({ open, setOpen, shouldIgnoreFocusOpen, + triggerHasInput, + setTriggerHasInput, defaultDate, reset, month, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index f154fbbb8..daf2f5367 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -34,6 +34,16 @@ export interface CalendarPreviewTriggerProps extends useRender.ComponentProps<'div'> { /** Shown when there is no value and no children. */ placeholder?: string; + /** + * Whether `render` produces a native ` - ))} - + +
+ {options.map(option => ( + + ))} +
+
); } From 547d6019e1401a705311e61240b02d17b490b7f7 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 23 Sep 2026 14:44:28 +0530 Subject: [PATCH 46/52] feat(calendar-preview)!: the field label is the consumer's, and optional `.Label` invented its own text: with no children it rendered "Date", so every scale panel announced itself as a date field whether or not that was true. It now renders nothing without children, and `.Body` takes a `label` prop that passes straight through, so a start field says "Start date" and a panel with nothing to say shows no label at all. The `useRender` call stays unconditional and the null check falls on its result, or hook order would shift the moment a label appeared. `.Body` also takes `showIcon`. The design's field carries no trailing glyph, so that is the default; opting in passes `undefined` to `.Input` rather than an icon, leaving `.Input` the single place that decides what the glyph is. BREAKING CHANGE: `` with no children rendered "Date" and now renders nothing. Pass the text, or `label` on `.Body`. --- .../calendar-preview-body.tsx | 19 ++++++++++++++++--- .../calendar-preview-label.tsx | 6 ++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx index 06c3e0515..ad268d516 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx @@ -1,5 +1,6 @@ import { mergeProps, useRender } from '@base-ui/react'; import { cx } from 'class-variance-authority'; +import type { ReactNode } from 'react'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; import { CalendarPreviewInput } from './calendar-preview-input'; @@ -9,9 +10,20 @@ import { CalendarPreviewReset } from './calendar-preview-reset'; import { CalendarPreviewScales } from './calendar-preview-scales'; import { CalendarPreviewSeparator } from './calendar-preview-separator'; -export type CalendarPreviewBodyProps = useRender.ComponentProps<'div'>; +export interface CalendarPreviewBodyProps + extends useRender.ComponentProps<'div'> { + /** The field label. Omitted, no label renders. */ + label?: ReactNode; + /** + * Whether the field carries the calendar glyph. + * @defaultValue false + */ + showIcon?: boolean; +} export function CalendarPreviewBody({ + label, + showIcon = false, className, children, render, @@ -36,8 +48,9 @@ export function CalendarPreviewBody({ }, children: children ?? ( <> - - + {label} + {/* `undefined` leaves `.Input` to say what the glyph is. */} + {/* `.Reset` rides in `.Header`, which only the day view mounts, so a period scale would otherwise have no way back to the default. */} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-label.tsx b/packages/raystack/components/calendar-preview/calendar-preview-label.tsx index 719be9677..08c898fa9 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-label.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-label.tsx @@ -11,7 +11,7 @@ export function CalendarPreviewLabel({ ref, ...props }: CalendarPreviewLabelProps) { - return useRender({ + const element = useRender({ defaultTagName: 'span', ref, render, @@ -19,11 +19,13 @@ export function CalendarPreviewLabel({ { className: cx(styles.label, className), 'data-slot': 'calendar-preview-label', - children: children ?? 'Date' + children } as useRender.ComponentProps<'span'>, props ) }); + + return children == null && render == null ? null : element; } CalendarPreviewLabel.displayName = 'CalendarPreview.Label'; From a8499ed6842113b291f5f6e9a9c397914a794f32 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 23 Sep 2026 14:44:40 +0530 Subject: [PATCH 47/52] style(calendar-preview): match the scale panel to the design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against Figma node 10599:14514, every value checked in a browser rather than read off a screenshot. The switcher was on `Tabs`' default `segmented` variant — a filled track with a gliding indicator — where the design is `standalone`, outlined chips with the active one filled. `size='medium'` carries the rest: 24px tall, small type, medium weight. Two scoped rules keep the row filling the panel without touching the shared component: the list drops its track padding, and the chips keep their proportional growth. The panel was 8px narrower than designed, the cells carried twice the horizontal padding on the small type ramp, the grid gaps were half what they should be, and a selected cell changed only its fill where the design lightens its border too. The field label sat on the micro ramp, indented 12px because it shared a rule with the captions, and 9px above its field instead of 4. The year headings were on micro as well. One discrepancy is not ours to close: Figma's `--rs-line-height-mini` is 16px and `styles/typography.css` defines it as 14px, so every `Body/Mini` element in the library renders two pixels tighter than drawn. --- .../docs/components/calendar-preview/demo.ts | 30 +-- .../calendar-preview-scales.tsx | 3 +- .../calendar-preview.module.css | 172 ++++++++---------- 3 files changed, 95 insertions(+), 110 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 d861c0c31..096aaa6a3 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -639,7 +639,7 @@ export const scaleDemo = { defaultMonth={new Date(2026, 7, 1)} defaultScale="quarter" > - + ` }, { @@ -648,7 +648,7 @@ export const scaleDemo = { scales={['day', 'month', 'quarter', 'halfYear', 'year']} defaultMonth={new Date(2026, 7, 1)} > - + ` }, { @@ -659,14 +659,14 @@ export const scaleDemo = { > - + ` }, { name: 'Periods only', code: ` - + ` }, { @@ -683,7 +683,7 @@ export const scaleDemo = { trailingValue minDate={new Date(2026, 6, 15)} > - + ` }, { @@ -700,7 +700,7 @@ function CalendarPreviewTrailingExample() { - + @@ -709,7 +709,7 @@ function CalendarPreviewTrailingExample() { - + @@ -731,9 +731,13 @@ export const scalePairDemo = { scales={['day', 'month', 'quarter', 'halfYear', 'year']} defaultValue={{ date: '2026-08-01', scale: 'day' }} > - + } + nativeButton + placeholder="Add start date" + /> - + @@ -745,9 +749,13 @@ export const scalePairDemo = { minDate={new Date(2026, 7, 1)} defaultValue={{ date: '2026-09-30', scale: 'quarter' }} > - + } + nativeButton + placeholder="Add end date" + /> - + ` diff --git a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx index e4ac3571c..1f464b43a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx @@ -36,7 +36,8 @@ export function CalendarPreviewScales({ 'data-slot': 'calendar-preview-scales', children: children ?? ( switchScale(next as Scale)} > diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 2d65e8ffe..48707d576 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -1,13 +1,9 @@ -/* Hugs its content and stacks its parts, so `.Days` and `.Footer` sit one - above the other whatever the surrounding layout does. */ .root { display: flex; flex-direction: column; width: fit-content; } -/* The day view hugs its content — no reserved height, so the surface around it - can size itself instead of being padded out to a fixed number. */ .days { display: flex; flex-direction: column; @@ -30,7 +26,6 @@ margin-bottom: var(--rs-space-3); } -/* `showWeekNumber` is a `.Grid` prop, so the header asks the rendered grid. */ .days:has(.week-number-header) .header { padding-inline-start: var(--rs-space-10); } @@ -40,30 +35,11 @@ color: var(--rs-color-foreground-base-primary); } -.header .nav-button, -.month-header .nav-button { - width: var(--rs-space-8); - height: var(--rs-space-7); - padding: 0; -} - -.header .nav-button > div, -.header .nav-button > div > *, -.month-header .nav-button > div, -.month-header .nav-button > div > * { - width: var(--rs-space-4); - height: var(--rs-space-4); -} - .nav-button:disabled { color: var(--rs-color-foreground-base-tertiary); cursor: not-allowed; } -/* `.Reset` says it is off with `aria-disabled`, not `disabled`, so that it can - keep the focus it was activated with — which means the look `:disabled` - would have given it has to be spelled out. Both classes, to outweigh - `IconButton`'s own `:hover:not(:disabled)`. */ .nav-button.reset[aria-disabled="true"] { opacity: 0.5; color: var(--rs-color-foreground-base-tertiary); @@ -77,7 +53,6 @@ transform: none; } -/* Takes the space left of the buttons, which group at the end. */ .caption { flex: 1; text-align: start; @@ -90,7 +65,6 @@ -webkit-user-select: none; } -/* `flex: none` undoes `.caption`'s stretch, so the chip hugs its label. */ .caption-trigger { display: inline-flex; flex: none; @@ -101,14 +75,15 @@ padding: var(--rs-space-2) var(--rs-space-3); border: none; border-radius: var(--rs-radius-2); - background: var(--rs-color-background-neutral-secondary); + background: transparent; color: inherit; font-family: inherit; cursor: pointer; } -.caption-trigger:hover:not(:disabled) { - background: var(--rs-color-background-neutral-secondary-hover); +.caption-trigger:hover:not(:disabled), +.caption-trigger:active:not(:disabled) { + background: var(--rs-color-background-base-primary-hover); } .caption-trigger:focus-visible { @@ -125,33 +100,44 @@ z-index: 1; } -/* Plain buttons, not a Select: a portalled listbox would read as outside the - surrounding popover. */ .caption-popup { display: flex; - gap: var(--rs-space-2); - padding: var(--rs-space-2); - border: 1px solid var(--rs-color-border-base-primary); - border-radius: var(--rs-radius-4); + overflow: hidden; + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); background: var(--rs-color-background-base-primary); box-shadow: var(--rs-shadow-lifted); } .caption-popup .caption-divider[data-orientation="vertical"] { + width: 0.5px; height: auto; align-self: stretch; } +.caption-popup .caption-scroller { + flex: none; + width: auto; +} + +.caption-scroller [data-slot="scroll-area-viewport"] { + max-height: calc(var(--rs-space-10) * 6); +} + .caption-column { + box-sizing: border-box; display: flex; flex-direction: column; - gap: var(--rs-space-1); - overflow-y: auto; - /* Six rows of the day-cell height; taller lists scroll. */ - max-height: calc(var(--rs-space-10) * 6); + gap: var(--rs-space-2); padding: var(--rs-space-2); - scrollbar-width: none; - -ms-overflow-style: none; +} + +.caption-column[data-slot="calendar-preview-caption-months"] { + width: var(--rs-space-12); +} + +.caption-column[data-slot="calendar-preview-caption-years"] { + width: var(--rs-space-13); } .caption-option { @@ -178,8 +164,6 @@ outline-offset: var(--rs-focus-ring-offset-inset); } -/* Grey, not accent: the scroller marks which month and year are in view, which - is a different thing from the selected day the grid fills in accent. */ .caption-option[data-active] { background: var(--rs-color-background-neutral-secondary); color: var(--rs-color-foreground-base-primary); @@ -189,7 +173,6 @@ flex: none; } -/* Both nav tracks stay reserved, so the caption centres on its grid. */ .month-header { display: grid; grid-template-columns: var(--rs-space-8) 1fr var(--rs-space-8); @@ -203,25 +186,14 @@ grid-column: 1; } -.root { - --rs-caption-inset: calc((var(--rs-space-10) - var(--rs-space-6)) / 2); -} - -.header .caption:not([data-dropdown]) { - padding-inline-start: var(--rs-caption-inset); -} - -.header .caption[data-dropdown] { - margin-inline-start: calc(var(--rs-caption-inset) - var(--rs-space-3)); -} - -.body .label, +.header .caption:not([data-dropdown]), .panel .header .caption:not([data-dropdown]) { - padding-inline-start: var(--rs-caption-inset); + padding-inline-start: var(--rs-space-4); } +.header .caption[data-dropdown], .panel .header .caption[data-dropdown] { - margin-inline-start: 0; + margin-inline-start: var(--rs-space-2); } .month-header-caption { @@ -267,7 +239,6 @@ position: relative; } -/* The UA's 2px border-spacing would leave the header wider than its columns. */ .weeks table { border-spacing: 0; } @@ -356,8 +327,6 @@ visibility: hidden; } -/* Sits between the cell and the button, so it has to pass the cell's box - through: `.day-button` inherits its radius and sizes against it. */ .day-trigger { display: block; width: 100%; @@ -393,8 +362,6 @@ cursor: not-allowed; } -/* The same border hover paints, so the two rings match. An outline on the - button cannot: its radius grows outward and misses the arc by a pixel. */ .day:has(.day-button:focus-visible) { border-color: var(--rs-color-border-accent-emphasis); } @@ -403,7 +370,6 @@ outline: none; } -/* Today's dot sits under the number, and rides up when a day carries info. */ .day-button[data-today]::after { content: ""; position: absolute; @@ -452,7 +418,7 @@ .skeleton { position: absolute; inset: 0; - /* Solid backing so the grid underneath doesn't ghost through mid-fade. */ + background: var(--rs-color-background-base-primary); opacity: 0; visibility: hidden; @@ -462,7 +428,6 @@ .skeleton[data-visible] { opacity: 1; visibility: visible; - /* Block clicks on the day grid underneath while loading. */ pointer-events: auto; } @@ -475,14 +440,12 @@ @media (prefers-reduced-motion: no-preference) { .skeleton { - /* Exiting: fade opacity, then flip visibility after the fade. */ transition: opacity var(--rs-duration-fast) var(--rs-ease-out), visibility 0s linear var(--rs-duration-fast); } .skeleton[data-visible] { - /* Entering: visibility flips immediately, opacity fades in. */ transition: opacity var(--rs-duration-fast) var(--rs-ease-out); } } @@ -492,7 +455,6 @@ margin-top: var(--rs-space-2); } -/* The trigger is a plain box: it wraps `.Input`, which draws its own field. */ .trigger { display: inline-flex; align-items: center; @@ -502,7 +464,6 @@ cursor: not-allowed; } -/* Opts out of the shared popover's `max-width: 18rem`, which cropped Saturday. */ .content { padding: 0; width: max-content; @@ -513,14 +474,11 @@ width: 100%; } -/* Drawn on the cell, not the button, so neighbours meet with no seam. */ .range-middle { background: var(--rs-color-background-neutral-secondary); border-radius: 0; } -/* RDP marks every day of a range `selected`; on the grey track they keep the - ordinary colour rather than the accent pill's white. */ .range-middle .day-button { background: transparent; color: var(--rs-color-foreground-base-primary); @@ -531,8 +489,6 @@ 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); @@ -559,20 +515,31 @@ background-color: var(--rs-color-foreground-base-emphasis); } -/* Two fields side by side, sharing the trigger's width. */ .body { display: flex; flex-direction: column; gap: var(--rs-space-3); - padding: var(--rs-space-3); + padding: var(--rs-space-3) var(--rs-space-4); width: max-content; } .label { color: var(--rs-color-foreground-base-secondary); - font-size: var(--rs-font-size-micro); - line-height: var(--rs-line-height-micro); - letter-spacing: var(--rs-letter-spacing-micro); + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); +} + +.body .label { + margin-bottom: calc(var(--rs-space-2) - var(--rs-space-3)); +} + +.body [data-slot="input-container"] { + box-sizing: border-box; + height: var(--rs-space-9); + border-width: 0.5px; + border-color: var(--rs-color-border-base-tertiary); } .separator { @@ -584,17 +551,21 @@ display: flex; } -/* A fifth of the row does not hold "Half-year". Scoped to beat `Tabs`' flex. */ +.scales [data-slot="tabs-list"] { + width: auto; + gap: var(--rs-space-3); + padding: 0; +} + .scales .scale { flex: 1 1 auto; + padding-inline: var(--rs-space-2); } -/* The anchor for all five views, so the popover stops resizing. */ .panel { width: calc(var(--rs-space-10) * 7); } -/* Under the switcher it is one view of five, so it drops its own inset. */ .panel .days { width: 100%; padding: 0; @@ -611,8 +582,6 @@ flex: 1; } -/* The day view hugs; every period list is a fixed box that scrolls as one, so - the year headings scroll with their cells rather than pinning. */ .panel[data-scale="day"] { display: block; } @@ -628,31 +597,36 @@ .period-group { display: flex; flex-direction: column; - gap: var(--rs-space-2); + gap: var(--rs-space-3); } .period-year { color: var(--rs-color-foreground-base-secondary); - font-size: var(--rs-font-size-micro); - line-height: var(--rs-line-height-micro); - letter-spacing: var(--rs-letter-spacing-micro); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); } .period-cells { display: grid; grid-template-columns: repeat(var(--rs-period-columns), 1fr); - gap: var(--rs-space-2); + gap: var(--rs-space-4); } .period { - padding: var(--rs-space-2) var(--rs-space-3); - border: 1px solid var(--rs-color-border-base-primary); + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + padding: 0 var(--rs-space-2); + border: 0.5px solid var(--rs-color-border-base-primary); border-radius: var(--rs-radius-2); background: transparent; - color: var(--rs-color-foreground-base-primary); - font-size: var(--rs-font-size-small); - line-height: var(--rs-line-height-small); - letter-spacing: var(--rs-letter-spacing-small); + color: var(--rs-color-foreground-base-secondary); + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); cursor: pointer; } @@ -666,7 +640,9 @@ } .period[data-selected] { - background: var(--rs-color-background-neutral-secondary); + border-color: var(--rs-color-border-base-secondary); + background: var(--rs-color-background-neutral-primary); + color: var(--rs-color-foreground-base-primary); } .period[data-unavailable] { From ec3d9eda3ac0ad5f7807f279866bd049f387b090 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 23 Sep 2026 16:50:29 +0530 Subject: [PATCH 48/52] fix(calendar-preview): show what a typed date committed, and stop the range faking focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects that all ended with the field saying one thing and the calendar showing another. A typed date left the view behind. Typing "2 May 2026" while the switcher sat on Quarter committed a day, and the quarter grid has no cell for a day value, so nothing was marked; the same text typed on the day grid while the value was a quarter had the mirror problem. The commit now settles the active scale to what it committed, through `settleScale`, so a controlled `scale` hears about it. The view month follows too — `commitDay` never moved it, so any typed date outside the visible month committed off-screen, which was true of the plain date picker long before scales existed. The placeholder advertised formats the field rejects. It was a fixed string naming a day, a month and a quarter, shown even by a root offering only months and quarters — type what it suggests, get "Invalid input". It is built from `scales` now, through the same `formatValue` that renders a committed value, so it cannot drift from what the parser accepts; a test types every suggestion back in and asserts none is refused. A range marked its next endpoint whether or not anything was open. `Input` paints `data-active` with the same accent border as `:focus-within`, so the start field wore a focus ring from first paint and kept it after the popover closed. It is marked only while the popover is open, or when there is no trigger in the tree at all — an inline range has nothing to open and is always live. --- .../calendar-preview/__tests__/range.test.tsx | 27 +++++++- .../__tests__/scale-selection.test.tsx | 68 ++++++++++++++++++- .../calendar-preview-input.tsx | 22 +++++- .../calendar-preview-root.tsx | 25 ++++++- 4 files changed, 135 insertions(+), 7 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 399f35772..40acbd5ba 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -128,19 +128,44 @@ describe('CalendarPreview range inputs', () => { expect(end).toHaveAttribute('placeholder', 'Select end date'); }); + /* `Input` paints `data-active` with the focus border, so marking an endpoint + behind a shut popover left the start field looking focused on load. */ + it('marks no endpoint while the popover is shut', () => { + const { container } = renderRange({}, picker); + const [start, end] = inputs(container); + expect(start).not.toHaveAttribute('data-active'); + expect(end).not.toHaveAttribute('data-active'); + }); + it('advances the active endpoint to the end after the first click', () => { const { container } = renderRange({}, picker); const [start, end] = inputs(container); + + fireEvent.focus(start); 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'); }); + /* An inline range has no popover to open, so it is always live. */ + it('marks the active endpoint with no trigger in the tree', () => { + const { container } = renderRange( + {}, + <> + + + + + ); + const [start, end] = inputs(container); + expect(start).toHaveAttribute('data-active', 'true'); + expect(end).not.toHaveAttribute('data-active'); + }); + it('shows each endpoint in its own field', () => { const { container } = renderRange({}, picker); fireEvent.focus(inputs(container)[0]); diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx index 6b6fcec6f..cec39000d 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -344,6 +344,46 @@ describe('CalendarPreview settles the scale once when Escape both drops and clos }); }); +describe('CalendarPreview settles the view on what a typed date commits', () => { + const input = (container: HTMLElement) => + getSlot(container, 'calendar-preview-input') as HTMLInputElement; + + const marked = (container: HTMLElement) => + getAllSlots(container, 'calendar-preview-period') + .filter(cell => cell.hasAttribute('data-selected')) + .map(cell => cell.textContent); + + it('moves to day scale when a day is typed on a period view', () => { + const onScaleChange = vi.fn(); + const { container } = renderBody({ + defaultScale: 'quarter', + onScaleChange + }); + + fireEvent.change(input(container), { target: { value: '2 May 2026' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + + expect(onScaleChange).toHaveBeenLastCalledWith('day'); + expect(getSlot(container, 'calendar-preview')).toHaveAttribute( + 'data-scale', + 'day' + ); + }); + + it('moves to the typed period and marks its cell', () => { + const { container } = renderBody({ defaultScale: 'day' }); + + fireEvent.change(input(container), { target: { value: 'Q2 2026' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + + expect(getSlot(container, 'calendar-preview')).toHaveAttribute( + 'data-scale', + 'quarter' + ); + expect(marked(container)).toEqual(['Q2']); + }); +}); + describe('CalendarPreview.Scales', () => { it('renders nothing when only one scale is offered', () => { const { container } = render( @@ -426,10 +466,36 @@ describe('CalendarPreview.Input at scale', () => { const { container } = renderBody(); expect(input(container)).toHaveAttribute( 'placeholder', - 'Try: 15 Aug 2026, May 2027, Q4' + 'Try: 15 Aug 2026, Aug 2026, Q3 2026' ); }); + /* A hardcoded list suggested a day format to a field that rejects one. */ + it('suggests only the scales the root offers', () => { + const { container } = renderBody({ scales: ['month', 'quarter', 'year'] }); + expect(input(container)).toHaveAttribute( + 'placeholder', + 'Try: Aug 2026, Q3 2026, 2026' + ); + }); + + it('suggests the single scale a one-scale root takes', () => { + const { container } = renderBody({ scales: 'quarter' }); + expect(input(container)).toHaveAttribute('placeholder', 'Try: Q3 2026'); + }); + + it('every suggestion is a format the field accepts', () => { + const { container } = renderBody({ scales: ['month', 'quarter', 'year'] }); + const suggestions = ( + input(container).getAttribute('placeholder') ?? '' + ).replace('Try: ', ''); + + for (const text of suggestions.split(', ')) { + fireEvent.change(input(container), { target: { value: text } }); + expect(input(container)).not.toHaveAttribute('aria-invalid'); + } + }); + it('moves the scale to match what was typed', () => { const onValueChange = vi.fn(); const onScaleChange = vi.fn(); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 65c4eaa76..277360d2e 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -103,6 +103,7 @@ export function CalendarPreviewInput({ clearEndpoint, draft, activeField, + open, setActiveField, setFieldReadOnly } = useCalendarPreviewContext('CalendarPreview.Input'); @@ -282,10 +283,17 @@ export function CalendarPreviewInput({ const committedText = endpoint ? formatValue(endpoint, isScaleValue(endpoint) ? endpoint.scale : scale) : ''; + /* Built from the scales this root actually offers, through the same + formatter that renders a committed value — a hardcoded list suggested + `15 Aug 2026` to a field that only takes months and quarters. */ + const carriesScale = scales.length > 1 || scales[0] !== 'day'; const resolvedPlaceholder = placeholder ?? - (scales.length > 1 - ? 'Try: 15 Aug 2026, May 2027, Q4' + (carriesScale + ? `Try: ${scales + .slice(0, 3) + .map(one => formatValue(today, one)) + .join(', ')}` : isRange ? field === 'start' ? 'Select start date' @@ -298,7 +306,15 @@ export function CalendarPreviewInput({ data-slot='calendar-preview-input' placeholder={resolvedPlaceholder} data-field={isRange ? field : undefined} - data-active={isRange && activeField === field ? 'true' : undefined} + /* `Input` paints `data-active` with the same accent border as focus, so + marking the next endpoint while the popover is shut left the start + field looking permanently focused. An inline range has no popover to + open, and is always live. */ + data-active={ + isRange && activeField === field && (open || trigger === null) + ? 'true' + : undefined + } onFocus={event => { onFocus?.(event); if (isRange) setActiveField(field); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 63512a5f2..74a21176b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -166,7 +166,8 @@ interface CalendarPreviewSharedProps /** Called when the view moves. */ onMonthChange?: (month: Date) => void; /** - * The years the caption's year column offers. + * The years the period views and the caption's year column offer. Passing it + * replaces the default, so a bound outside it stays unreachable. * @defaultValue ten years either side of `today`, widened to cover any bound */ yearRange?: { from: number; to: number }; @@ -372,6 +373,11 @@ export function CalendarPreviewRoot({ /* An array carries the scale even when that scale is `'day'`. */ const carriesScale = Array.isArray(scalesProp) || scalesProp !== 'day'; + /* A typed date commits a day the grid may not be showing, which leaves the + selection off-screen and no cell marked. A click needs nothing: the cell + it landed on is already in view. */ + const revealMonthRef = useRef<((date: Date) => void) | null>(null); + const setMonth = useCallback( (next: Date) => { setMonthUnwrapped(next); @@ -428,6 +434,10 @@ export function CalendarPreviewRoot({ scaleChanged(value, 'day') ? 'scale' : reason, date ); + /* A typed day lands while the view sits on a coarser scale, which then + has no cell to mark. The view follows what was committed. */ + settleScaleRef.current?.('day'); + revealMonthRef.current?.(date); }, [carriesScale, timeZone, value, setValue, clearScaleDraft] ); @@ -466,8 +476,9 @@ export function CalendarPreviewRoot({ leaving || !triggerRef.current?.contains(document.activeElement); }, []); - /* `dropDraft` closes over state declared further down. */ + /* `dropDraft` and `settleScale` close over state declared further down. */ const dropDraftRef = useRef<(() => void) | null>(null); + const settleScaleRef = useRef<((scale: Scale) => void) | null>(null); const dismissedByOutsidePress = useRef(false); @@ -722,6 +733,8 @@ export function CalendarPreviewRoot({ scaleChanged(value, next) ? 'scale' : 'select', parseKey(key) ); + settleScaleRef.current?.(next); + revealMonthRef.current?.(parseKey(key)); }, [ trailingValue, @@ -759,6 +772,14 @@ export function CalendarPreviewRoot({ }, [value, scales, settleScale, setMonth]); dropDraftRef.current = dropDraft; + settleScaleRef.current = settleScale; + revealMonthRef.current = (date: Date) => { + if ( + dayKey(date, timeZone).slice(0, 7) === dayKey(month, timeZone).slice(0, 7) + ) + return; + setMonth(date); + }; /* Bounds only, never `isDateUnavailable` — the prop documents why. */ const isPeriodAvailable = useCallback( From da0d5926ab63a997b9f1d79261d848e01949dbcb Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 23 Sep 2026 16:50:44 +0530 Subject: [PATCH 49/52] docs(calendar-preview): restructure the page around examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page led with API reference and buried the examples under it, stated the same rule in three places, and explained the reasoning behind decisions nobody reading the docs has to make. It now reads simple to advanced: usage, examples, anatomy, API, behaviour, styling, accessibility, notes, migration. Ten sections of demos became nine examples, one idea each, variants as tabs — the day view, the two pickers, periods, limits, states, validation, reset and customising. The tab code moved verbatim rather than being retyped, so every demo still matches what it renders. Rules that were paragraphs are tables: the value shape, what drafting emits, what a range click does, the validation reasons, and how `trailingValue` moves availability. Gotchas that were buried mid-paragraph are callouts. Each fact now appears once — navigation being unbounded lives in Limits, `readOnly` in Accessibility, and `showOutsideDays`' default in the Grid table. `.Trigger` gains a props table, `.Input` documents `field`, `.Body` documents `label` and `showIcon`, and `useCalendar`'s second table is named for what it describes. `yearRange` no longer claims to govern only the caption's year column, which stopped being true when the period views started reading it. --- .../docs/components/calendar-preview/demo.ts | 812 +++++++----------- .../components/calendar-preview/index.mdx | 576 +++++-------- .../docs/components/calendar-preview/props.ts | 45 +- 3 files changed, 581 insertions(+), 852 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 096aaa6a3..8fa3716fb 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -46,121 +46,223 @@ export const playground = { getCode }; -export const compositionDemo = { +export const calendarDemo = { type: 'code', tabs: [ { - name: 'Default header', + name: 'Default', code: ` ` }, { - name: 'Custom caption', - code: ` - - - Delivery date - - - - - - + name: 'Two months', + code: ` + ` }, { - name: 'Month + year', + name: 'Monday first', code: ` - - - - - - + + ` }, { - name: 'With footer', + name: 'Week numbers', code: ` - - Dates are inclusive + + + + ` }, { - name: 'Node footer', + name: 'Outside days', code: ` - - - - Beta - Times are UTC - - + + + + ` } ] }; -export const resetDemo = { +export const pickerDemo = { type: 'code', tabs: [ { - name: 'Reset', - code: ` - + name: 'Basic', + code: ` + + + + + + ` }, { - name: 'Nothing to restore', + name: 'Custom trigger', code: ` - + } /> + + + + ` + }, + { + name: 'No icon', + code: ` + + + + + + + ` + } + ] +}; + +export const rangeDemo = { + type: 'code', + tabs: [ + { + name: 'Basic', + code: ` + + + + + + + + + ` }, { - name: 'Range', + name: 'Read-only start', code: ` - + + + + + + + + + + ` + } + ] +}; + +export const periodsDemo = { + type: 'code', + tabs: [ + { + name: 'All scales', + code: ` + ` }, { - name: 'Clear the selection', + name: 'In a popover', code: ` - + + + + ` }, { - name: 'No defaultDate', + name: 'Year range', code: ` - + + ` + }, + { + name: 'Month', + code: ` + + ` + }, + { + name: 'Quarter', + code: ` + ` + }, + { + name: 'Trailing value', + code: `function CalendarPreviewTrailingExample() { + const scales = ['day', 'month', 'quarter', 'halfYear', 'year']; + const [start, setStart] = React.useState({ date: '2026-07-01', scale: 'quarter' }); + const [end, setEnd] = React.useState({ date: '2026-09-30', scale: 'quarter' }); + + return ( + + + + } + nativeButton + placeholder="Add start date" + /> + + + + + + → + + + } + nativeButton + placeholder="Add end date" + /> + + + + + + + + Emitted: {start.date} → {end.date} + + + ); +}` } ] }; -export const boundsDemo = { +export const limitsDemo = { type: 'code', tabs: [ { @@ -173,7 +275,7 @@ export const boundsDemo = { ` }, { - name: 'Min and max', + name: 'Min/max', code: `` }, { - name: 'Read only', + name: 'Bounded periods', code: ` - + ` } ] }; -export const gridDemo = { +export const statesDemo = { type: 'code', tabs: [ { - name: 'Outside days', - code: ` - - - - - ` - }, - { - name: 'Week numbers', - code: ` - - - - + name: 'Disabled', + code: ` + + + + + + ` }, { - name: 'Monday first', - code: ` - - - - + name: 'Read only', + code: ` + ` }, { @@ -242,175 +339,30 @@ export const gridDemo = { ` - }, - { - name: 'Two months', - code: ` - - ` } ] }; -export const dateInfoDemo = { +export const validationDemo = { type: 'code', tabs: [ - { - name: 'Date info', - code: ` - - - - date.getDate() % 7 === 0 ? ( - $ - ) : null - } - /> - - ` - }, - { - name: 'Tooltips', - code: ` - - - - date.getDay() === 0 ? 'Weekend rate applies' : null - } - /> - - ` - } - ] -}; - -export const pickerDemo = { - 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: 'With Field', - code: ` - - - - - - - - - ` - }, - { - name: 'Reset', - code: ` - - - - - - - ` - }, { name: 'Invalid input', - code: ` -function CalendarPreviewInvalidExample() { - const [defaultError, setDefaultError] = React.useState(); - const [customError, setCustomError] = React.useState(); - - const bounds = { - defaultMonth: new Date(2024, 3, 1), - minDate: new Date(2024, 3, 1), - maxDate: new Date(2024, 3, 30) - }; + code: `function CalendarPreviewInvalidExample() { + const [error, setError] = React.useState(); return ( - - - - - setDefaultError(message)} - /> - - - - - - - - - + + + setCustomError(message)} + errorMessages={{ unparseable: 'Use DD MMM YYYY' }} + onValidityChange={({ message }) => setError(message)} /> @@ -423,340 +375,176 @@ function CalendarPreviewInvalidExample() { }` }, { - name: 'Custom trigger', + name: 'Custom messages', code: ` - } /> + + + ` + }, + { + name: 'With Field', + code: ` + + + + + + + + + + + ` } ] }; -export const rangeDemo = { +export const resetDemo = { type: 'code', tabs: [ { - name: 'Basic', - code: ` - - - - - - - - - - ` - }, - { - name: 'Disabled', - code: ` - - - - - - - - - + name: 'Reset to date', + code: ` + ` }, { - name: 'Disabled dates', + name: 'Reset range', code: ` date.getDay() === 0 || date.getDay() === 6} + defaultDate={{ from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) }} + defaultValue={{ from: new Date(2024, 3, 3), to: new Date(2024, 3, 7) }} > - - - - - - - - - - ` - }, - { - name: 'Without calendar icon', - code: ` - - - - - - - - - + ` }, { - name: 'Read-only start', + name: 'Clear', code: ` - - - - - - - - - + ` }, { - name: 'Reset', + name: 'Nothing to restore', code: ` - - - - - - - - - + ` }, { - name: 'Invalid input', - code: ` -function CalendarPreviewRangeInvalidExample() { - // One message per Field, but two endpoints feed it — so each endpoint's - // verdict is tracked on its own and the Field shows whichever is unhappy. - const [defaultErrors, setDefaultErrors] = React.useState({}); - const [customErrors, setCustomErrors] = React.useState({}); - const at = (set, field) => ({ message }) => - set(current => ({ ...current, [field]: message })); - const first = errors => errors.start ?? errors.end; - - const range = { - selection: 'range', - defaultMonth: new Date(2024, 3, 1), - defaultValue: { from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) } - }; - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}` - }, - { - name: 'Custom trigger', + name: 'No defaultDate', code: ` - }> - 10 Apr – 20 Apr - - - - + ` } ] }; -export const scaleDemo = { +export const customisingDemo = { type: 'code', tabs: [ { - name: 'Inline', - code: ` - + name: 'Caption', + code: ` + + + Delivery date + + + + + + ` }, { - name: 'Day scale', - code: ` - + name: 'Month/year dropdown', + code: ` + + + + + + + + ` }, { - name: 'In a popover', - code: ` - - - - + name: 'Footer', + code: ` + + Dates are inclusive ` }, { - name: 'Periods only', - code: ` - + name: 'Node footer', + code: ` + + + + Beta + Times are UTC + + ` }, { - name: 'One view alone', - code: ` - + name: 'Date info', + code: ` + + + + date.getDate() % 7 === 0 ? ( + $ + ) : null + } + /> + ` }, { - name: 'Bounded', - code: ` - + name: 'Tooltips', + code: ` + + + + date.getDay() === 0 ? 'Weekend rate applies' : null + } + /> + ` - }, - { - name: 'Trailing value', - code: ` -function CalendarPreviewTrailingExample() { - const scales = ['day', 'month', 'quarter', 'halfYear', 'year']; - const [start, setStart] = React.useState({ date: '2026-07-01', scale: 'quarter' }); - const [end, setEnd] = React.useState({ date: '2026-09-30', scale: 'quarter' }); - - return ( - - - - - - - - - - → - - - - - - - - - - - Emitted: {start.date} → {end.date} - - - ); -}` } ] }; - -export const scalePairDemo = { - type: 'code', - code: ` - - } - nativeButton - placeholder="Add start date" - /> - - - - - - → - - - } - nativeButton - placeholder="Add end date" - /> - - - - - ` -}; 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 3d62bcdce..167f853d3 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -6,169 +6,317 @@ source: packages/raystack/components/calendar-preview import { playground, - compositionDemo, - resetDemo, - boundsDemo, - gridDemo, - dateInfoDemo, + calendarDemo, pickerDemo, rangeDemo, - scaleDemo, - scalePairDemo, + periodsDemo, + limitsDemo, + statesDemo, + validationDemo, + resetDemo, + customisingDemo, } from "./demo.ts"; - - -## Anatomy +A calendar that owns its selection and view state, composed from parts you mount only as deep as you need. -Every part renders its own default, so composition is opt-in depth: + ```tsx import { CalendarPreview } from '@raystack/apsara' +``` +## Usage + +```tsx ``` -Expanded, the day view is a header and a grid: +## Examples + +### Calendar + +The day view. Every layout option lives on `.Grid`, so two grids can differ. + + + +### Date picker + +A trigger wrapping an `.Input`, with the day view in a popover. + + + +### Range picker + +`selection="range"` turns clicks into endpoints. Give each `.Input` a `field`. + + + +### Time periods + +`scales` selects at granularities coarser than a day. + + + +### Limits + +`minDate`, `maxDate` and `isDateUnavailable` disable cells. + + + None of them clamps navigation — the chevrons and the scroller still reach any month. `isDateUnavailable` is day scale only; period cells are bounded by `minDate` and `maxDate` instead. + + + + +### States + +`disabled` makes the whole calendar inert; `readOnly` keeps it focusable. + + + +### Validation + +Typed dates are checked on every keystroke, and a date that fails is never committed. + + + Blurring or pressing Enter on a date that does not resolve leaves the typed text in the field. The field stays marked invalid but now shows something other than the committed value — read the value from `onValueChange`, never from the input's text. + + + + +### Reset + +Restores `defaultDate`. Stays visible but disabled when there is nothing to restore. + + + `defaultDate={null}` clears the selection and reports `reason: 'clear'`; omitting it hides the button. + + + + +### Customising + +Children replace the content a part computes from context. + + + +## Anatomy ```tsx - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ``` -`.Grid` renders the day cells itself and takes no children. `.Day` and `.Weekday` are -not written inside it — they are overrides, passed through `components`: +Every part renders its own default, so none of this is required. `.Grid` renders the day cells itself and takes no children — `.Day` and `.Weekday` are overrides passed through `components`: ```tsx ``` -Children override the content a part computes from context, so -`Q3 2024` replaces the month label. - -## API Reference +## API reference ### CalendarPreview -The root. Owns the selected value and the visible month, provides both to every part, and renders a column that hugs its content. Also takes `render`, `className` and `ref`. +The root. Owns the selected value and the visible month. Also takes `render`, `className` and `ref`. -### CalendarPreview.Days +### CalendarPreview.Trigger -The day view — a header and a grid. Hugs its content rather than reserving a fixed height. +Anchors the popover and owns opening it. Renders the formatted value, or the placeholder, when given no children. - + -### CalendarPreview.Caption +### CalendarPreview.Content -The month label above the grid, and optionally the trigger for the month and year scroller. +The portaled popover surface. Takes `Popover.Content` props — `side`, `align`, `sideOffset` — and flips above the trigger on collision. - +### CalendarPreview.Input -### CalendarPreview.Grid +The typeable date field. -The day grid. Layout and per-day data live here rather than on the root, so a calendar with two grids can configure them independently. + - +### CalendarPreview.Body -### CalendarPreview.Header +The popup body: label, input, scale switcher and the view for the active scale. -The row above the grid. Composes `.Caption`, `.Reset`, `.PrevMonth` and `.NextMonth` when given no children. Takes `render`, `className` and `ref`. + - +### CalendarPreview.Days -### CalendarPreview.PrevMonth / CalendarPreview.NextMonth +The day view — a header and a grid. -Step the view one month. Never disabled by `minDate` or `maxDate` — bounds limit selection, not navigation. + - +### CalendarPreview.Grid -### CalendarPreview.Reset +The day grid. Layout and per-day data live here rather than on the root. -Restores `defaultDate`, reporting `reason: 'reset'`. Rendered whenever `defaultDate` -is set, and disabled once the value already equals it — it stays mounted rather than -disappearing, so activating it does not send focus to the page body or shift the nav -buttons sideways. It carries `data-restored` while there is nothing to restore. + - +### CalendarPreview.Header -### CalendarPreview.Trigger +The row above the grid. Composes `.Caption`, `.Reset`, `.PrevMonth` and `.NextMonth` when given no children. -Anchors the popover and owns opening it. Renders the formatted value, or the placeholder, when given no children — wrap an `.Input` in it for a typeable field. Renders a `div` rather than a `button`, so a control inside it stays focusable; pass `nativeButton` alongside a `render` that does produce one, so Base UI stops adding a role and a tab stop it already has. Takes `render`, `className` and `ref`. + -With an `.Input` inside, the trigger steps back and lets the field carry the interaction: it takes no `role`, sits out of the tab order, and opens on the input's focus rather than on a press. A range therefore has two tab stops, not four, and moving from the start field to the end field leaves the popover open. Given no `.Input` it stays a `role="button"` tab stop of its own. +### CalendarPreview.Caption -### CalendarPreview.Content +The month label above the grid, and optionally the trigger for the month and year scroller. -The portaled popover surface. Takes `Popover.Content` props — `side`, `align`, `sideOffset` and the rest — and flips above the trigger on collision. + -### CalendarPreview.Input +### CalendarPreview.PrevMonth / CalendarPreview.NextMonth - +Step the view one month. -### CalendarPreview.Body + -The popup body: label, input, scale switcher and the view for the active scale. Renders all four when given no children. Takes `render`, `className` and `ref`. +### CalendarPreview.Reset + +Restores `defaultDate`, reporting `reason: 'reset'`. Carries `data-restored` while there is nothing to restore. + + ### CalendarPreview.Scales / CalendarPreview.Scale -The scale switcher, built on Apsara `Tabs`. **Renders nothing when only one scale is offered**, so a plain day calendar never grows a one-tab row. `.Scale` is only needed to relabel or reorder. +The scale switcher, built on Apsara `Tabs`. Renders nothing when only one scale is offered. `.Scale` is only needed to relabel or reorder. ### CalendarPreview.Panel -The view container. Mounts all five views; each gates on the active scale itself, so `.Quarters` can be mounted alone with no day grid in the tree. +The view container. Mounts all five views; each gates on the active scale itself. ### CalendarPreview.Months / .Quarters / .HalfYears / .Years -Year-grouped period lists at 3, 4, 2 and 1 columns. Each is one continuous 320px scroll area with the year numbers as headings inside it, opening on the active year. +Year-grouped period lists at 3, 4, 2 and 1 columns, opening on the active year. ### CalendarPreview.Label / CalendarPreview.Separator -The field label above the input, and the rule between the switcher and the view. +The field label above the input, and the rule between the switcher and the view. `.Label` renders nothing without children. ### CalendarPreview.Footer The row below the calendar. A bare string is wrapped in `Text`; anything else renders as given. -It needs no container of its own: the root renders a column that hugs its content, so `.Days` and `.Footer` stack whatever the surrounding layout does. - ### useCalendar -Reads the enclosing root's state, for building parts the library does not ship. Deliberately narrow: +Reads the enclosing root's state, for building parts the library does not ship. Throws outside a `CalendarPreview`, naming the part that asked. ```tsx import { useCalendar } from '@raystack/apsara' -const { value, setValue, scale, draft, month, setMonth, isDateUnavailable } = +const { value, setValue, scale, draft, scaleDraft, month, setMonth, isDateUnavailable } = useCalendar() ``` -Calling it outside a `CalendarPreview` throws, naming the part that asked. `scale` is -read-only: switching scale is `.Scales` and `.Scale`, and `.Scale` takes `render` if you -want your own chrome. + -`setValue(null)` clears the selection and reports `reason: 'clear'`, carrying the day -that was cleared as `details.toDate()`. +#### details (onValueChange) - -### Slots +## Behaviour + +### Value shape + +| `scales` | `value` | +|---|---| +| omitted, or `'day'` | `Date` | +| any other scale, or any array | `ScaleValue` | + +```ts +interface ScaleValue { date: 'YYYY-MM-DD'; scale: Scale } +``` + +`date` is stored as `YYYY-MM-DD` so lexicographic order is chronological order. It is never what you see — every trigger, input and annotation renders through `formatValue`. + +### Drafting + +| Action | Result | +|---|---| +| Switch scale | moves the view, sets a draft, emits nothing | +| Click a cell, or press Enter | commits the draft | +| Escape | drops the draft, restores the input from `value` | +| Range, one endpoint | stays internal; the grid styles the track from it | + +### Range clicks + +| State | A click does | +|---|---| +| Nothing selected | sets `from`, moves focus to the end field | +| `from` only, later day | completes the range and emits | +| `from` only, earlier day | that day becomes the new `from` | +| Complete range | restarts — the new day is `from` | + +`onValueChange` fires on a complete range or not at all. Typing is stricter than clicking: an endpoint that crosses its partner is rejected as `out-of-order` rather than restarting. + + + 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. + + +### Validation reasons + +| `reason` | Means | +|---|---| +| `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 | + +`onValidityChange` fires only when validity changes, and carries a ready-to-render `message` — `undefined` while valid, which is what [Field](/docs/components/field)'s `error` wants. Override per reason with `errorMessages`. + +### trailingValue + +Emits a period's last day rather than its first, and is month-end correct. It changes the value, not the formatting. Availability tests the date a period would produce, here with `minDate={15 Jul 2026}`: + +| Period | A start field emits | An end field emits | Start | End | +|---|---|---|---|---| +| H1 2026 | 1 Jan | 30 Jun | disabled | disabled | +| July 2026 | 1 Jul | 31 Jul | disabled | available | +| Q3 2026 | 1 Jul | 30 Sep | disabled | available | + +A start/end pair is two roots, not `selection="range"` — each end has its own `scales` and `trailingValue`, and they can hold different scales. + +## Styling + +
+Slots and state attributes Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): @@ -212,12 +360,12 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test | `calendar-preview-panel` | The view container | | `calendar-preview-months` / `-quarters` / `-half-years` / `-years` | One period list | | `calendar-preview-period-group` | One year's block inside a period list | -| `calendar-preview-period-year` | The year heading, on every view but `.Years` — there the cell is the year | +| `calendar-preview-period-year` | The year heading, on every view but `.Years` | | `calendar-preview-period` | One period cell | | `calendar-preview-footer` | The footer row | | `calendar-preview-footer-text` | The `Text` wrapping a string footer | -Day cells also carry their state, so a stylesheet can target it without a class: +Day cells also carry their state: | Attribute | Set when | |------|------| @@ -228,201 +376,32 @@ Day cells also carry their state, so a stylesheet can target it without a class: | `data-outside` | The day belongs to an adjacent month | | `data-scale` | The granularity the value is committed at | -## Examples - -### Composition - -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 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. - -`defaultDate={null}` is a default of **nothing selected**, so the button clears the day and reports `reason: 'clear'`. Omitting the prop is the different case: the part has no job and renders nothing. - - - -### Selection bounds - -`minDate`, `maxDate` and `isDateUnavailable` disable cells. **None of them clamps navigation** — the chevrons and the scroller still reach any month. Bounds compare whole calendar days, so a `minDate` carrying a time of day still leaves its own day selectable. - -`isDateUnavailable` is **day scale only**. A month, quarter, half-year or year cell never calls it — a day predicate has no single lift to a period, and answering per cell would run it 365 times a year. Period cells are bounded by `minDate` and `maxDate` instead, tested against the day the cell would emit, which is the same rule that makes a period available to one end of a pair and not the other. - - - -### Grid layout - -Outside days are **off by default**, so a grid ends on the last day of its month with the leading cells blank. - - - -### Date information and tooltips - -`dateInfo` and `tooltipMessages` are functions of the date, not records keyed by a formatted string. `dateInfo` content renders above the day number; today's dot sits below it, so the two never collide. - - - -### Month and year scroller - -`` turns the caption into a filled chip that opens two adjacent scrolling columns, divided by a rule. It is a plain popover of buttons, not a `Select` — picking from either column moves the view and never selects a value. The **Month + year** tab under [Composition](#composition) shows it. - -### Date picker - -The date picker is not a separate export — it is this composition: - -```tsx - - - - - - - - -``` - -The popover opens when the input takes focus. Enter, blur and an outside click all commit — there is no Apply button. Dismissal is Base UI's, so escape and outside press behave like every other popover in the library. - -"Without calendar icon" is composition rather than a prop: pass `trailingIcon={null}` to `.Input`. - - - -### 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 and emits | -| `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 | - -Nothing here closes the popover. A commit leaves it open, so a second pick needs no second trip to the trigger. Escape and an outside press dismiss it; a press on the trigger does too, unless the trigger wraps an `.Input`, where it would close the field the user is still filling in. - -Emptying one field clears that endpoint and leaves the other drafted in its own field, so the range can be rebuilt without retyping both. The value emits `null` at that point — one endpoint is not a range. `clearable={false}` turns both this and click-to-deselect off. - -**Typing is stricter than clicking.** A click means "the next endpoint", so an earlier day restarts -the range, as the table above says. Typing names the field it lands in, so an endpoint that crosses -its partner is rejected instead: `onValidityChange` reports `out-of-order`, the field goes red, and -nothing is emitted. Two endpoints on the same day are a valid range. - -```tsx - 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. - - -### Invalid typed dates - -Typing is checked on every keystroke, and a date that fails is **never committed** — `onValueChange` -does not fire and the previous value stands. - -`.Input` marks itself `aria-invalid` and `data-invalid`, and `data-invalid` is what -[Input](/docs/components/input) paints its error border from, so the field turns red on its own with -nothing wired up. - -`onValidityChange` carries a ready-to-render `message`, so a message under the field is one line — -it is `undefined` while valid, which is exactly what [Field](/docs/components/field)'s `error` wants: - -```tsx - - - - setError(message)} /> - - - - - - -``` - -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: +
-```tsx - setError(message)} -/> -``` +## Accessibility -The reason is also on the payload if you would rather branch on it yourself: +- Arrow keys move between days; the focused cell carries `data-draft` until it is committed +- `readOnly` is conveyed with `aria-readonly` on the grid and `aria-disabled` on each day, and the grid stays focusable and arrow-navigable — unlike `disabled` +- Each grid is labelled with its month, so the caption is not the only announcement +- Nav buttons carry `aria-label`, and the scroller's columns are labelled groups +- Selected and unavailable days are announced through their native button state -| `reason` | Means | -|----------|-------| -| `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 | +## Notes -It fires only when validity *changes*, not on every keystroke, so it is safe to drive state with. +**Performance.** `dateInfo`, `tooltipMessages` and `isDateUnavailable` are functions, so an inline arrow re-renders every day cell. Wrap them in `useCallback` or hoist them out. - - Blurring or pressing Enter on a date that does not resolve leaves the typed text in the field - rather than discarding what was typed. The field stays marked invalid, but it now shows something - other than the committed value — so read the value from `onValueChange`, never from the input's - text. - +**Localization.** English only — month and weekday names come from date-fns' `en-US`, and the nav, reset and caption labels are hardcoded. `timeZone` is unaffected. ## Migrating from Calendar -`CalendarPreview` is not a drop-in replacement. Two props keep their names and change -their meaning, so they are the ones to check first — neither produces a type error in -every case, and both fail quietly. +Not a drop-in replacement. Two props keep their names and change their meaning: | Prop | On `Calendar` | On `CalendarPreview` | |------|---------------|----------------------| -| `disabled` | A day matcher — `disabled={{ before: today }}` blocks those days | A boolean that makes the **whole calendar** inert. Use `isDateUnavailable` or `minDate` / `maxDate` for days | +| `disabled` | A day matcher | A boolean that makes the whole calendar inert. Use `isDateUnavailable` or `minDate` / `maxDate` for days | | `showOutsideDays` | Defaults to `true` | Defaults to `false` | -The rest are renames. Most follow the repo's conventions (`onValueChange`, `loading`, a -boolean `disabled`), which is why the names moved rather than the behaviour: +The rest are renames: | `Calendar` | `CalendarPreview` | |-----------|-------------------| @@ -436,12 +415,7 @@ boolean `disabled`), which is why the names moved rather than the behaviour: | `footer` prop | `` part | | `captionLayout="dropdown"` | `` | -Two things have **no replacement** yet: the record forms of `dateInfo` and -`tooltipMessages` (both are functions here), and the `classNames` escape hatch — style -through the `data-slot` attributes in the table above instead. - -Slot names changed too, so a stylesheet written against `Calendar` needs a second set of -selectors rather than an edit: +Slot names changed too: | `Calendar` slot | `CalendarPreview` slot | |-----------------|------------------------| @@ -450,78 +424,4 @@ selectors rather than an edit: | `calendar-month-grid` | `calendar-preview-weeks` | | `calendar-nav-previous` | `calendar-preview-prev-month` | -## Performance - -`dateInfo`, `tooltipMessages` and `isDateUnavailable` are functions rather than records, -so the grid cannot tell a changed rule from a re-created one. Passing an inline arrow -re-renders every day cell on every render of the surrounding component. Wrap them in -`useCallback`, or hoist them out of the component, whenever the calendar is inside -anything that re-renders often. - -## Localization - -English only. There is no `locale` prop: month and weekday names come from date-fns' -default `en-US`, and the nav, reset and caption labels are hardcoded strings. -`timeZone` is unaffected — a calendar can render in any zone, in English. - -### Scale-aware selection - -Pass `scales` to select at granularities coarser than a day. A single value hides the switcher; anything more shows it. - -```tsx - - - - - - -``` - - - -#### The value carries its scale - -A `Date` cannot say whether it means "August 2026" or "1 August 2026", so beyond day scale the value is a `ScaleValue`: - -```ts -interface ScaleValue { date: 'YYYY-MM-DD'; scale: Scale } -``` - -| `scales` | `value` | -|---|---| -| omitted, or `'day'` | `Date` — unchanged | -| any other scale, or any array | `ScaleValue` | - -`date` is stored as `YYYY-MM-DD` because lexicographic order is chronological order, which is what lets bounds compare without parsing. **It is never what you see** — every trigger, input and annotation renders through `formatValue`, which is `DD MMM YYYY` at day scale and the period's own shorthand above it. `onValueChange`'s details carry `toDate()` if you want a `Date`. - -#### Switching scale drafts, it does not emit - -Moving between scales moves the view and sets a draft. Nothing is emitted until a cell is clicked or Enter is pressed; Escape drops the draft and restores the input from `value`. - -#### trailingValue picks the edge - -A period has two edges, and which one a field means depends on the field. `trailingValue` emits the period's **last** day rather than its first — "July 2026" becomes `2026-07-31` instead of `2026-07-01`. It changes the value, not the formatting, and it is month-end correct: February 2028 trailing is `2028-02-29`. - -That also decides availability, which tests **the date a period would produce**. Bounded at 15 July 2026: - -| Period | A start field emits | An end field emits | Start | End | -|---|---|---|---|---| -| H1 2026 | 1 Jan | 30 Jun | disabled | disabled | -| July 2026 | 1 Jul | 31 Jul | disabled | available | -| Q3 2026 | 1 Jul | 30 Sep | disabled | available | - -Every one of those periods starts before the bound. Only the produced date separates them. - -#### A start/end pair is two roots - -Not `selection="range"`. Each end has its own `scales` and `trailingValue`, and they can hold different scales — "1 Aug 2026 → Q3 2026" is not expressible as one range value. The consumer owns the pair and any `from <= to` check. - - - -## Accessibility - -- Arrow keys move between days; the focused cell carries `data-draft` until it is committed -- `readOnly` is conveyed with `aria-readonly` on the grid and `aria-disabled` on each day, and the grid stays focusable and arrow-navigable — unlike `disabled` -- Each grid is labelled with its month, so the caption is not the only announcement -- Nav buttons carry `aria-label`, and the scroller's columns are labelled groups -- Selected and unavailable days are announced through their native button state +Two things have no replacement: the record forms of `dateInfo` and `tooltipMessages` (both are functions here), and the `classNames` escape hatch — style through `data-slot` instead. 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 9e8535e5d..5b1d2ebd9 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -76,7 +76,8 @@ export interface CalendarPreviewProps { onMonthChange?: (month: Date) => void; /** - * The years the caption's year column offers. + * The years the period views and the caption's year column offer. Passing it + * replaces the default, so a bound outside it stays unreachable. * Defaults to ten years either side of `today`, widened to cover any bound. */ yearRange?: { from: number; to: number }; @@ -272,6 +273,17 @@ export interface CalendarPreviewNavProps { className?: string; } +export interface CalendarPreviewBodyProps { + /** The field label, passed to `.Label`. Omitted, no label renders. */ + label?: ReactNode; + + /** + * Whether the field carries the calendar glyph. + * @default false + */ + showIcon?: boolean; +} + export interface CalendarPreviewFooterProps { /** Merged with the part's own classes. */ className?: string; @@ -337,9 +349,38 @@ export interface CalendarPreviewChangeDetails { toDate: () => Date; } +export interface CalendarPreviewTriggerProps { + /** + * Shown when there is no value and no children. + * @default "Select date" + */ + placeholder?: string; + + /** + * Replaces the rendered element. The trigger is a `div` by default, so a + * control inside it stays focusable. + * @default
+ */ + render?: ReactNode; + + /** + * Whether `render` produces a native `