From 29ae7fbe05cd07f77b079e0970eb53be2db3047d Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 16:52:17 +0530 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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(