From 9449ba586f295527e6d17346ae74fbbfbb663bb2 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 04:34:24 +0530 Subject: [PATCH 01/37] =?UTF-8?q?feat(calendar-preview):=20foundation=20?= =?UTF-8?q?=E2=80=94=20root,=20trigger,=20content,=20grid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of RFC 005. Ships alongside the existing calendar family; nothing existing changes, and the barrel additions are purely additive. The root owns every piece of state explicitly — value, open and month each via `useControlled` — and renders `Popover.Root` itself, so Base UI owns dismissal and `use-picker-popover.ts` gets no successor. Context is stored as `unknown` and cast at a part-aware hook that names the offending part. `date-adapter.ts` performs every `dayjs.extend()` once, in dependency order, which retires the import-order failure class behind the 0.49.0 P0. `calendar-preview-grid.tsx` is the only file importing react-day-picker. It renders three `DayPicker` call sites rather than one assembled object, because `mode` discriminates RDP's prop union — that keeps the boundary fully type-checked with no cast, and the union never reaches a consumer. `.Nav` being ours means RDP runs with `hideNavigation` and `captionLayout='label'`, so no `Select` is ever mounted. Zero biome-ignore, zero slotProps, `...props` last at every part. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/calendar-preview.test.tsx | 176 ++++++++++++++ .../__tests__/data-slots.test.tsx | 95 ++++++++ .../calendar-preview-content.tsx | 59 +++++ .../calendar-preview-context.tsx | 65 ++++++ .../calendar-preview-grid.tsx | 179 ++++++++++++++ .../calendar-preview-root.tsx | 219 ++++++++++++++++++ .../calendar-preview-trigger.tsx | 36 +++ .../calendar-preview.module.css | 196 ++++++++++++++++ .../calendar-preview/calendar-preview.tsx | 10 + .../calendar-preview/date-adapter.ts | 89 +++++++ .../components/calendar-preview/index.tsx | 17 ++ packages/raystack/index.tsx | 11 + 12 files changed, 1152 insertions(+) create mode 100644 packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx create mode 100644 packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-content.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-context.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-grid.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-root.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview.module.css create mode 100644 packages/raystack/components/calendar-preview/calendar-preview.tsx create mode 100644 packages/raystack/components/calendar-preview/date-adapter.ts create mode 100644 packages/raystack/components/calendar-preview/index.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 new file mode 100644 index 000000000..7f374c915 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -0,0 +1,176 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; +import styles from '../calendar-preview.module.css'; +import type { DateRangeValue } from '../calendar-preview-context'; +import { + DEFAULT_FORMAT, + dayKey, + formatDate, + isWithinBounds, + parseDate, + startOfMonth +} from '../date-adapter'; + +const MONTH = new Date(2024, 3, 1); + +const inline = (props = {}) => ( + + + +); + +/* + * Query days by react-day-picker's `data-day`, not by accessible name — the + * name is a full localized date ("Wednesday, April 17th, 2024"), so a bare + * /17/ would also match a 2017 in the string. + */ +const dayCell = (container: HTMLElement, iso: string) => + container.querySelector(`[data-day="${iso}"]`) as HTMLElement; + +const dayButton = (container: HTMLElement, iso: string) => + dayCell(container, iso).querySelector('button') as HTMLButtonElement; + +describe('CalendarPreview root', () => { + it('selects a date and reports it uncontrolled', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = render(inline({ onValueChange })); + + await user.click(dayButton(container, '2024-04-17')); + + expect(onValueChange).toHaveBeenCalledTimes(1); + const [selected] = onValueChange.mock.calls[0]; + expect(dayKey(selected as Date)).toBe('2024-04-17'); + }); + + it('does not move a controlled value on its own', async () => { + const user = userEvent.setup(); + const value = new Date(2024, 3, 10); + const onValueChange = vi.fn(); + const { container } = render(inline({ value, onValueChange })); + + await user.click(dayButton(container, '2024-04-17')); + + expect(onValueChange).toHaveBeenCalledTimes(1); + // Still showing the controlled value, because the parent never wrote back. + expect(dayCell(container, '2024-04-10').className).toContain( + styles.selected + ); + expect(dayCell(container, '2024-04-17').className).not.toContain( + styles.selected + ); + }); + + it('emits a complete range value at every step', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + + await user.click(dayButton(container, '2024-04-17')); + + /* + * react-day-picker opens a range as a one-day range, so the first click + * already yields both ends. What the root guarantees is the *shape*: + * always a complete DateRangeValue, with `null` rather than `undefined` + * for a missing end — consumers never have to gate on `undefined`. + */ + const first = onValueChange.mock.calls[0][0] as DateRangeValue; + expect(dayKey(first.from as Date)).toBe('2024-04-17'); + expect(first.to).not.toBeUndefined(); + expect(dayKey(first.to as Date)).toBe('2024-04-17'); + + await user.click(dayButton(container, '2024-04-20')); + + const second = onValueChange.mock.calls[1][0] as DateRangeValue; + expect(dayKey(second.from as Date)).toBe('2024-04-17'); + expect(dayKey(second.to as Date)).toBe('2024-04-20'); + }); + + it('exposes open state on the root', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + Pick + + + + + ); + + expect(screen.queryByRole('grid')).not.toBeInTheDocument(); + await user.click(screen.getByText('Pick')); + + expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + }); + + it('honours minDate and maxDate', () => { + const { container } = render( + inline({ minDate: new Date(2024, 3, 10), maxDate: new Date(2024, 3, 20) }) + ); + + expect(dayButton(container, '2024-04-09')).toBeDisabled(); + expect(dayButton(container, '2024-04-15')).not.toBeDisabled(); + expect(dayButton(container, '2024-04-21')).toBeDisabled(); + }); + + it('honours isDateUnavailable', () => { + const { container } = render( + inline({ isDateUnavailable: (d: Date) => d.getDate() === 15 }) + ); + + expect(dayButton(container, '2024-04-15')).toBeDisabled(); + expect(dayButton(container, '2024-04-16')).not.toBeDisabled(); + }); + + it('throws a part-named error when a part escapes the root', () => { + // React logs the thrown error; silence it so the run stays readable. + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => render()).toThrow( + 'CalendarPreview.Grid must be used within ' + ); + spy.mockRestore(); + }); +}); + +describe('date-adapter', () => { + it('keys a day stably regardless of Date identity', () => { + expect(dayKey(new Date(2024, 3, 17, 9))).toBe( + dayKey(new Date(2024, 3, 17, 23)) + ); + }); + + it('round-trips through the canonical format', () => { + const formatted = formatDate(new Date(2024, 3, 17)); + expect(formatted).toBe('17 Apr 2024'); + expect(dayKey(parseDate(formatted) as Date)).toBe('2024-04-17'); + }); + + it('rejects input the format does not describe exactly', () => { + expect(parseDate('not a date')).toBeNull(); + expect(parseDate('2024-04-17', DEFAULT_FORMAT)).toBeNull(); + }); + + it('normalises to the start of the month', () => { + expect(dayKey(startOfMonth(new Date(2024, 3, 17)))).toBe('2024-04-01'); + }); + + it('bounds-checks inclusively', () => { + const min = new Date(2024, 3, 10); + const max = new Date(2024, 3, 20); + expect(isWithinBounds(new Date(2024, 3, 10), min, max)).toBe(true); + expect(isWithinBounds(new Date(2024, 3, 20), min, max)).toBe(true); + expect(isWithinBounds(new Date(2024, 3, 9), min, max)).toBe(false); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx new file mode 100644 index 000000000..c0428a339 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; + +const MONTH = new Date(2024, 3, 1); + +describe('CalendarPreview data-slot contract', () => { + it('exposes grid slots when composed inline, with no popover', () => { + const { container } = render( + + + + ); + + expectSlots(container, [ + 'calendar-preview-grid', + 'calendar-preview-weeks', + 'calendar-preview-table', + 'calendar-preview-day', + 'calendar-preview-day-number' + ]); + // Nothing portals when there is no `.Content`. + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); + + it('exposes trigger, positioner and content slots when open', () => { + render( + + Pick a date + + + + + ); + + // Portaled parts are asserted against the document, not the container. + expectSlots(document.body, [ + 'calendar-preview-trigger', + 'calendar-preview-positioner', + 'calendar-preview-content', + 'calendar-preview-grid' + ]); + }); + + it('omits the content slot while closed', () => { + render( + + Pick a date + + + + + ); + + expect(getSlot(document.body, 'calendar-preview-trigger')).not.toBeNull(); + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); + + it('renders one day slot per day button', () => { + const { container } = render( + + + + ); + + // April 2024 has 30 days and outside days are off by default. + expect(getAllSlots(container, 'calendar-preview-day')).toHaveLength(30); + expect(screen.getByText('April 2024')).toBeInTheDocument(); + }); + + it('renders two months of day slots when months is 2', () => { + const { container } = render( + + + + ); + + // April (30) + May (31). + expect(getAllSlots(container, 'calendar-preview-day')).toHaveLength(61); + expect(getAllSlots(container, 'calendar-preview-table')).toHaveLength(2); + }); + + it('never mounts a Select — the caption is a plain label', () => { + const { container } = render( + + + + ); + + expect(getSlot(container, 'select-trigger')).toBeNull(); + expect(getSlot(container, 'calendar-preview-nav-month')).toBeNull(); + expect(container.querySelector('select')).toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-content.tsx b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx new file mode 100644 index 000000000..293395823 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { Popover as PopoverPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import styles from './calendar-preview.module.css'; + +export interface CalendarPreviewContentProps + extends Omit< + PopoverPrimitive.Positioner.Props, + 'render' | 'className' | 'style' | 'ref' + >, + PopoverPrimitive.Popup.Props {} + +/** + * The portaled surface: `Portal > Positioner > Popup`, exported as `Content` + * per the house convention. Positioner props (`side`, `align`, `sideOffset`) + * are passed here directly; `ref`, `className`, and `style` land on the popup. + * + * `side` defaults to `bottom-start` — date inputs conventionally drop down, + * and the old family's `top` default collided with on-screen keyboards. + */ +export function CalendarPreviewContent({ + ref, + className, + style, + render, + children, + initialFocus, + finalFocus, + ...positionerProps +}: CalendarPreviewContentProps) { + return ( + + + + {children} + + + + ); +} + +CalendarPreviewContent.displayName = 'CalendarPreview.Content'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx new file mode 100644 index 000000000..77644db66 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export type CalendarSelection = 'single' | 'range' | 'multiple'; + +export type CalendarGranularity = + | 'day' + | 'month' + | 'quarter' + | 'half-year' + | 'year'; + +/** Ours, not react-day-picker's `DateRange` — that type never leaves the grid. */ +export interface DateRangeValue { + from: Date | null; + to: Date | null; +} + +export type CalendarValue = Date | DateRangeValue | Date[] | null; + +export interface CalendarPreviewContextValue { + selection: CalendarSelection; + granularity: CalendarGranularity; + value: Value; + setValue: (value: Value) => void; + /** The visible month. Independent of selection, and owned by the root. */ + month: Date; + setMonth: (month: Date) => void; + open: boolean; + setOpen: (open: boolean) => void; + minDate?: Date; + maxDate?: Date; + isDateUnavailable?: (date: Date) => boolean; + format: string; + timeZone?: string; + weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6; + disabled: boolean; + readOnly: boolean; +} + +/* + * Stored as `unknown` and cast at the hook so the root stays generic over the + * selection mode without a generic `createContext` — the technique + * `combobox-root.tsx` uses. + */ +const CalendarPreviewContext = + createContext | null>(null); + +export const CalendarPreviewProvider = CalendarPreviewContext; + +/** + * @param part The part name, for the error message — e.g. `'Grid'`. + */ +export function useCalendarPreviewContext( + part: string +): CalendarPreviewContextValue { + const context = useContext(CalendarPreviewContext); + if (!context) { + throw new Error( + `CalendarPreview.${part} must be used within ` + ); + } + return context as CalendarPreviewContextValue; +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx new file mode 100644 index 000000000..908eb6977 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -0,0 +1,179 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { + type DateRange, + type DayButtonProps, + DayPicker, + type DayPickerProps, + type Matcher +} from 'react-day-picker'; +import styles from './calendar-preview.module.css'; +import type { DateRangeValue } from './calendar-preview-context'; +import { useCalendarPreviewContext } from './calendar-preview-context'; + +/** + * Everything react-day-picker owns is derived from root context and is + * deliberately absent from this interface: `mode`, `selected`, `onSelect`, + * `required`, `month`, `onMonthChange`, and `timeZone` cannot be passed here + * at all. That is what makes spreading `...props` last honest — nothing is + * force-overridden after the consumer's spread. + */ +export interface CalendarPreviewGridProps + extends Pick< + DayPickerProps, + 'showWeekNumber' | 'modifiers' | 'modifiersClassNames' | 'classNames' + > { + /** @defaultValue 1 */ + months?: 1 | 2; + /** @defaultValue false */ + showOutsideDays?: boolean; + className?: string; +} + +export function CalendarPreviewGrid({ + months = 1, + showOutsideDays = false, + className, + classNames, + ...props +}: CalendarPreviewGridProps) { + const { + selection, + value, + setValue, + month, + setMonth, + minDate, + maxDate, + isDateUnavailable, + timeZone, + weekStartsOn, + disabled + } = useCalendarPreviewContext('Grid'); + + const disabledMatchers: Matcher[] = []; + if (minDate) disabledMatchers.push({ before: minDate }); + if (maxDate) disabledMatchers.push({ after: maxDate }); + if (isDateUnavailable) disabledMatchers.push(isDateUnavailable); + + /* + * Everything except the mode discriminator. `...props` sits last inside it, + * so it stays last at every call site below — and because `mode`, + * `selected`, and `onSelect` are not in `CalendarPreviewGridProps`, putting + * them ahead of the spread overrides nothing a consumer could have passed. + */ + const shared = { + month, + onMonthChange: setMonth, + timeZone, + weekStartsOn, + numberOfMonths: months, + showOutsideDays, + disabled: (disabled ? true : disabledMatchers) satisfies + | Matcher + | Matcher[], + // `.Nav` is ours: RDP renders no navigation and never mounts a `Select`. + hideNavigation: true, + captionLayout: 'label' as const, + components: { + DayButton: ({ + day: _day, + modifiers: _modifiers, + ...buttonProps + }: DayButtonProps) => ( + + ), + MonthGrid: (gridProps: ComponentProps<'table'>) => ( +
+ + + ) + }, + classNames: { + months: styles.months, + month_caption: styles.monthCaption, + caption_label: styles.captionLabel, + week: styles.week, + weekdays: styles.week, + weekday: styles.weekday, + day: styles.day, + today: styles.today, + outside: styles.outside, + disabled: styles.disabled, + selected: styles.selected, + day_button: styles.dayButton, + range_start: styles.rangeStart, + range_middle: styles.rangeMiddle, + range_end: styles.rangeEnd, + hidden: styles.hidden, + ...classNames + }, + className: cx(styles.grid, className), + ...props + }; + + /* + * Three call sites rather than one assembled object: `mode` discriminates + * react-day-picker's prop union, so a single spread would need a cast. This + * keeps the boundary fully type-checked — and the union still never reaches + * a consumer, because it stops here. + */ + if (selection === 'range') { + const range = value as DateRangeValue | null; + return ( + + setValue( + next ? { from: next.from ?? null, to: next.to ?? null } : null + ) + } + data-slot='calendar-preview-grid' + {...shared} + /> + ); + } + + if (selection === 'multiple') { + return ( + setValue(next ?? [])} + data-slot='calendar-preview-grid' + {...shared} + /> + ); + } + + return ( + setValue(next ?? null)} + data-slot='calendar-preview-grid' + {...shared} + /> + ); +} + +CalendarPreviewGrid.displayName = 'CalendarPreview.Grid'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx new file mode 100644 index 000000000..eb92f1231 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -0,0 +1,219 @@ +'use client'; + +import { Popover as PopoverPrimitive } from '@base-ui/react'; +import { useControlled } from '@base-ui/utils/useControlled'; +import { type ReactNode, useCallback, useMemo } from 'react'; +import { + type CalendarGranularity, + type CalendarPreviewContextValue, + CalendarPreviewProvider, + type CalendarSelection, + type CalendarValue, + type DateRangeValue +} from './calendar-preview-context'; +import { DEFAULT_FORMAT, startOfMonth } from './date-adapter'; + +export interface CalendarPreviewBaseProps { + /** @defaultValue 'day' */ + granularity?: CalendarGranularity; + + /** Whether the popover is open (controlled). */ + open?: boolean; + /** @defaultValue false */ + defaultOpen?: boolean; + onOpenChange?: (open: boolean, details?: { reason?: string }) => void; + + /** The visible month (controlled). Independent of the selected value. */ + month?: Date; + defaultMonth?: Date; + onMonthChange?: (month: Date) => void; + + minDate?: Date; + maxDate?: Date; + /** Covers the common predicate without learning RDP's matcher DSL. */ + isDateUnavailable?: (date: Date) => boolean; + + /** @defaultValue 'DD MMM YYYY' */ + format?: string; + timeZone?: string; + /** @defaultValue 0 */ + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + + /** @defaultValue false */ + disabled?: boolean; + /** @defaultValue false */ + readOnly?: boolean; + children?: ReactNode; +} + +export interface CalendarPreviewSingleProps extends CalendarPreviewBaseProps { + selection?: 'single'; + value?: Date | null; + defaultValue?: Date | null; + onValueChange?: (value: Date | null) => void; +} + +export interface CalendarPreviewRangeProps extends CalendarPreviewBaseProps { + selection: 'range'; + value?: DateRangeValue | null; + defaultValue?: DateRangeValue | null; + onValueChange?: (value: DateRangeValue | null) => void; +} + +export interface CalendarPreviewMultipleProps extends CalendarPreviewBaseProps { + selection: 'multiple'; + value?: Date[]; + defaultValue?: Date[]; + onValueChange?: (value: Date[]) => void; +} + +export type CalendarPreviewRootProps = + | CalendarPreviewSingleProps + | CalendarPreviewRangeProps + | CalendarPreviewMultipleProps; + +/** + * The union collapsed into one shape, for internal use only. Reading `props` + * as the union directly would narrow `selection` to `'single'`, making the + * other arms unreachable inside the body. + */ +interface NormalizedRootProps extends CalendarPreviewBaseProps { + selection?: CalendarSelection; + value?: CalendarValue; + defaultValue?: CalendarValue; + onValueChange?: (value: CalendarValue) => void; +} + +export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { + const { + selection = 'single', + granularity = 'day', + value: valueProp, + defaultValue, + onValueChange, + open: openProp, + defaultOpen = false, + onOpenChange, + month: monthProp, + defaultMonth, + onMonthChange, + minDate, + maxDate, + isDateUnavailable, + format = DEFAULT_FORMAT, + timeZone, + weekStartsOn = 0, + disabled = false, + readOnly = false, + children + } = props as NormalizedRootProps; + + const [value, setValueUnwrapped] = useControlled({ + controlled: valueProp, + default: defaultValue ?? (selection === 'multiple' ? [] : null), + name: 'CalendarPreview', + state: 'value' + }); + + const [open, setOpenUnwrapped] = useControlled({ + controlled: openProp, + default: defaultOpen, + name: 'CalendarPreview', + state: 'open' + }); + + const [month, setMonthUnwrapped] = useControlled({ + controlled: monthProp, + default: startOfMonth(defaultMonth ?? new Date(), timeZone), + name: 'CalendarPreview', + state: 'month' + }); + + const setValue = useCallback( + (next: CalendarValue) => { + setValueUnwrapped(next); + onValueChange?.(next); + }, + [setValueUnwrapped, onValueChange] + ); + + const setOpen = useCallback( + (next: boolean, details?: { reason?: string }) => { + setOpenUnwrapped(next); + onOpenChange?.(next, details); + }, + [setOpenUnwrapped, onOpenChange] + ); + + const setMonth = useCallback( + (next: Date) => { + setMonthUnwrapped(next); + onMonthChange?.(next); + }, + [setMonthUnwrapped, onMonthChange] + ); + + const handleOpenChange = useCallback( + (next: boolean, eventDetails: PopoverPrimitive.Root.ChangeEventDetails) => { + setOpen(next, { reason: eventDetails?.reason }); + }, + [setOpen] + ); + + const contextValue = useMemo( + () => ({ + selection, + granularity, + value, + setValue, + month, + setMonth, + open, + setOpen, + minDate, + maxDate, + isDateUnavailable, + format, + timeZone, + weekStartsOn, + disabled, + readOnly + }), + [ + selection, + granularity, + value, + setValue, + month, + setMonth, + open, + setOpen, + minDate, + maxDate, + isDateUnavailable, + format, + timeZone, + weekStartsOn, + disabled, + readOnly + ] + ); + + /* + * `Popover.Root` renders no element, so wrapping unconditionally costs + * nothing and keeps dismissal with Base UI even when the composition has no + * popover at all (parts rendered outside `.Content` are simply inline). + * This is the whole reason `use-picker-popover.ts` has no successor. + */ + return ( + } + > + + {children} + + + ); +} + +CalendarPreviewRoot.displayName = 'CalendarPreview'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx new file mode 100644 index 000000000..1eb3adc40 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { Popover as PopoverPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import styles from './calendar-preview.module.css'; + +export interface CalendarPreviewTriggerProps + extends PopoverPrimitive.Trigger.Props {} + +/** + * Anchors the popover. Renders a `div`, not a ` ), + /* + * `.Nav` owns the caption, and the design shows none inside the grid. + * Leaving RDP's in place renders the month twice and announces it + * twice, so it is dropped here rather than hidden with CSS. + */ + MonthCaption: () => <>, MonthGrid: (gridProps: ComponentProps<'table'>) => (
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx new file mode 100644 index 000000000..fea9da2a3 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { useRef, useState } from 'react'; +import { Input, type InputProps } from '../input/input'; +import styles from './calendar-preview.module.css'; +import type { CalendarValidity } from './calendar-preview-context'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey, formatDate, isWithinBounds, parseDate } from './date-adapter'; + +export interface CalendarPreviewInputProps + extends Omit {} + +/** + * The typed single-date field. Owns parse and format; renders no error UI of + * its own, reporting to the root through `onValidityChange` so a surrounding + * `Field` can present it. + */ +export function CalendarPreviewInput({ + className, + ...props +}: CalendarPreviewInputProps) { + const { + selection, + value, + setValue, + setMonth, + reportValidity, + minDate, + maxDate, + isDateUnavailable, + format, + timeZone, + disabled, + readOnly + } = useCalendarPreviewContext('Input'); + + const committed = value ? formatDate(value, format, timeZone) : ''; + const committedKey = value ? dayKey(value, timeZone) : ''; + + /** `null` means "not editing — show the committed value". */ + const [draft, setDraft] = useState(null); + + /* + * Drop the draft once the committed value moves underneath it — a grid + * click, a preset, or a controlled parent writing back. Adjusted during + * render rather than in an effect, the way `tour-root.tsx` does, and keyed + * on `dayKey` because a format without a year renders the same text for two + * different years. + */ + const lastCommitted = useRef(committedKey); + if (lastCommitted.current !== committedKey) { + lastCommitted.current = committedKey; + if (draft !== null) setDraft(null); + } + + if (selection !== 'single') { + throw new Error( + 'CalendarPreview.Input requires the default selection="single" — use CalendarPreview.RangeInput for ranges' + ); + } + + const validate = (date: Date): CalendarValidity => { + if (!isWithinBounds(date, minDate, maxDate)) { + return { valid: false, reason: 'out-of-bounds' }; + } + if (isDateUnavailable?.(date)) { + return { valid: false, reason: 'unavailable' }; + } + return { valid: true }; + }; + + const commit = (text: string) => { + // An emptied field clears the value; that is not an error state. + if (text.trim() === '') { + reportValidity({ valid: true }); + setValue(null); + return; + } + + const parsed = parseDate(text, format, timeZone); + if (!parsed) { + reportValidity({ valid: false, reason: 'unparseable' }); + return; + } + + const validity = validate(parsed); + reportValidity(validity); + if (!validity.valid) return; + + setValue(parsed); + // Typing navigates the grid, so the committed day is actually visible. + setMonth(parsed); + }; + + return ( +
+ setDraft(event.target.value)} + onBlur={() => { + if (draft === null) return; + commit(draft); + setDraft(null); + }} + onKeyDown={event => { + if (event.key === 'Enter') { + event.preventDefault(); + if (draft === null) return; + commit(draft); + setDraft(null); + } + if (event.key === 'Escape') setDraft(null); + }} + {...props} + /> +
+ ); +} + +CalendarPreviewInput.displayName = 'CalendarPreview.Input'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-nav.tsx b/packages/raystack/components/calendar-preview/calendar-preview-nav.tsx new file mode 100644 index 000000000..afbe5b5c8 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-nav.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '~/icons'; +import { IconButton } from '../icon-button'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { + addMonths, + endOfMonth, + formatDate, + startOfMonth +} from './date-adapter'; + +export interface CalendarPreviewNavProps + extends Omit, 'children'> { + /** + * Where the caption sits relative to the buttons. + * @defaultValue 'start' + */ + align?: 'start' | 'end'; + /** + * Month-caption format, passed to the date adapter. + * @defaultValue 'MMMM YYYY' + */ + captionFormat?: string; +} + +/** + * Caption plus previous / next buttons. **Ours, not react-day-picker's** — + * `.Grid` runs with `hideNavigation` and `captionLayout='label'`, so RDP never + * mounts a `Select` and the unmount loop that disabled `captionLayout` has no + * surface to occur on. + * + * The design places a third button here, left of the chevrons. Its action is + * unsettled (RFC 005 open item 9), so it is deliberately not built yet. + */ +export function CalendarPreviewNav({ + className, + align = 'start', + captionFormat = 'MMMM YYYY', + ...props +}: CalendarPreviewNavProps) { + const { month, setMonth, minDate, maxDate, disabled, timeZone } = + useCalendarPreviewContext('Nav'); + + const previousMonth = addMonths(month, -1, timeZone); + const nextMonth = addMonths(month, 1, timeZone); + + /* + * A step is offered when the target month holds at least one selectable day. + * Testing only its first day would strand a `minDate` that falls mid-month. + */ + const monthIsReachable = (target: Date) => { + if (minDate && endOfMonth(target, timeZone) < minDate) return false; + if (maxDate && startOfMonth(target, timeZone) > maxDate) return false; + return true; + }; + + const canGoBack = !disabled && monthIsReachable(previousMonth); + const canGoForward = !disabled && monthIsReachable(nextMonth); + + return ( +
+ + {formatDate(month, captionFormat, timeZone)} + +
+ setMonth(previousMonth)} + data-slot='calendar-preview-nav-previous' + > + + + setMonth(nextMonth)} + data-slot='calendar-preview-nav-next' + > + + +
+
+ ); +} + +CalendarPreviewNav.displayName = 'CalendarPreview.Nav'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 1d99592cf..ecdf4d3a6 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -217,3 +217,35 @@ .rangeField[data-active] [data-slot="input-container"] { border-color: var(--rs-color-border-accent-emphasis); } + +.nav { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--rs-space-3); + margin-bottom: var(--rs-space-3); +} + +.nav[data-align="end"] { + flex-direction: row-reverse; +} + +.navCaption { + 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); + color: var(--rs-color-foreground-base-primary); + user-select: none; + -webkit-user-select: none; +} + +.navButtons { + display: flex; + align-items: center; + gap: var(--rs-space-2); +} + +.field { + display: inline-flex; +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx index 727c48deb..4febaca16 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx @@ -1,5 +1,7 @@ import { CalendarPreviewContent } from './calendar-preview-content'; import { CalendarPreviewGrid } from './calendar-preview-grid'; +import { CalendarPreviewInput } from './calendar-preview-input'; +import { CalendarPreviewNav } from './calendar-preview-nav'; import { CalendarPreviewRangeInput } from './calendar-preview-range-input'; import { CalendarPreviewRoot } from './calendar-preview-root'; import { CalendarPreviewTrigger } from './calendar-preview-trigger'; @@ -7,6 +9,8 @@ import { CalendarPreviewTrigger } from './calendar-preview-trigger'; export const CalendarPreview = Object.assign(CalendarPreviewRoot, { Trigger: CalendarPreviewTrigger, Content: CalendarPreviewContent, + Input: CalendarPreviewInput, RangeInput: CalendarPreviewRangeInput, + Nav: CalendarPreviewNav, Grid: CalendarPreviewGrid }); diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 2cc5558b0..6ea193230 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -45,6 +45,10 @@ export function addMonths(date: Date, count: number, timeZone?: string): Date { return zoned(date, timeZone).add(count, 'month').toDate(); } +export function endOfMonth(date: Date, timeZone?: string): Date { + return zoned(date, timeZone).endOf('month').toDate(); +} + export function formatDate( date: Date, format: string = DEFAULT_FORMAT, diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index 05243bb37..0217123e3 100644 --- a/packages/raystack/components/calendar-preview/index.tsx +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -9,6 +9,8 @@ export type { DateRangeValue } from './calendar-preview-context'; export type { CalendarPreviewGridProps } from './calendar-preview-grid'; +export type { CalendarPreviewInputProps } from './calendar-preview-input'; +export type { CalendarPreviewNavProps } from './calendar-preview-nav'; export type { CalendarPreviewRangeInputProps } from './calendar-preview-range-input'; export type { CalendarPreviewBaseProps, diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 3fbdeb1c8..40021b500 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -25,6 +25,8 @@ export { CalendarPreview, type CalendarPreviewContentProps, type CalendarPreviewGridProps, + type CalendarPreviewInputProps, + type CalendarPreviewNavProps, type CalendarPreviewProps, type CalendarPreviewTriggerProps, type CalendarSelection, From 49272ece1120cb63ba304ccc15d30cba9f2d5e33 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 13:45:22 +0530 Subject: [PATCH 05/37] feat(calendar-preview): GranularityTabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Day | Month | Quarter | Half-year | Year, as Apsara `Tabs` with `variant='standalone'` — the variant the design uses, and the same one its month and quarter cells are built from, so the switcher and the grids it switches between share one visual language. The root now owns granularity the way it owns every other piece of state: `useControlled` over `granularity` / `defaultGranularity` / `onGranularityChange`, with `granularities` listing what may be switched between. `defaultGranularity` and `onGranularityChange` are additions to the RFC's Root Props block, which named only `granularity` and `granularities` — without them a tab click has nowhere to go. The part renders nothing unless more than one granularity is offered, so it can sit in a shared composition without appearing on single-granularity pickers, and it always renders in the canonical order whatever order the prop gave. `.Grid` now renders for the day granularity only. Showing the day grid under a Month tab would misstate what is selectable; `.MonthGrid` covers the rest and lands in phase 3. The slot sits on a wrapper, not on `Tabs` — passing `data-slot` to it would overwrite its own `data-slot="tabs"`, the defect the audit found in `.RangeInput`. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/granularity.test.tsx | 176 ++++++++++++++++++ .../calendar-preview-context.tsx | 3 + .../calendar-preview-granularity-tabs.tsx | 84 +++++++++ .../calendar-preview-grid.tsx | 10 +- .../calendar-preview-root.tsx | 36 +++- .../calendar-preview.module.css | 5 + .../calendar-preview/calendar-preview.tsx | 2 + .../components/calendar-preview/index.tsx | 1 + packages/raystack/index.tsx | 1 + 9 files changed, 315 insertions(+), 3 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/granularity.test.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-granularity-tabs.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/granularity.test.tsx b/packages/raystack/components/calendar-preview/__tests__/granularity.test.tsx new file mode 100644 index 000000000..a5f45f02c --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/granularity.test.tsx @@ -0,0 +1,176 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; + +const MONTH = new Date(2024, 3, 1); +const ALL = ['day', 'month', 'quarter', 'half-year', 'year'] as const; +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0]; + +describe('CalendarPreview.GranularityTabs', () => { + it('renders nothing when only one granularity is offered', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-granularity')).toBeNull(); + }); + + it('renders the design labels in the design order', () => { + render( + + + + ); + expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual([ + 'Day', + 'Month', + 'Quarter', + 'Half-year', + 'Year' + ]); + }); + + it('keeps the canonical order whatever order the prop gave', () => { + render( + + + + ); + expect(screen.getAllByRole('tab').map(t => t.textContent)).toEqual([ + 'Day', + 'Quarter', + 'Year' + ]); + }); + + it('does not clobber Tabs own data-slot', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-granularity')).not.toBeNull(); + expect(getSlot(container, 'tabs')).not.toBeNull(); + expect(container.querySelectorAll('[data-slot="tabs-tab"]')).toHaveLength( + 2 + ); + }); + + it('switches granularity and reports it', async () => { + const user = userEvent.setup(); + const onGranularityChange = vi.fn(); + render( + + + + ); + + await user.click(screen.getByRole('tab', { name: 'Month' })); + expect(lastArg(onGranularityChange)).toBe('month'); + expect(screen.getByRole('tab', { name: 'Month' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('honours a controlled granularity', async () => { + const user = userEvent.setup(); + const onGranularityChange = vi.fn(); + render( + + + + ); + + await user.click(screen.getByRole('tab', { name: 'Month' })); + expect(onGranularityChange).toHaveBeenCalledWith('month'); + // The parent never wrote back, so Day stays selected. + expect(screen.getByRole('tab', { name: 'Day' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + }); + + it('accepts label overrides', () => { + render( + + + + ); + expect(screen.getByRole('tab', { name: 'H1 / H2' })).toBeInTheDocument(); + }); + + it('disables every tab when the picker is disabled', () => { + render( + + + + ); + for (const tab of screen.getAllByRole('tab')) { + expect(tab).toHaveAttribute('aria-disabled', 'true'); + } + }); +}); + +describe('granularity gates the grid', () => { + it('renders the day grid only for the day granularity', async () => { + const user = userEvent.setup(); + const { container } = render( + + + + + ); + + expect(getSlot(container, 'calendar-preview-grid')).not.toBeNull(); + + await user.click(screen.getByRole('tab', { name: 'Month' })); + // `.MonthGrid` covers the rest; showing the day grid under a Month tab + // would be a lie about what is selectable. + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + + await user.click(screen.getByRole('tab', { name: 'Day' })); + expect(getSlot(container, 'calendar-preview-grid')).not.toBeNull(); + }); + + it('defaultGranularity picks the starting tab', () => { + const { container } = render( + + + + + ); + expect(screen.getByRole('tab', { name: 'Month' })).toHaveAttribute( + 'aria-selected', + 'true' + ); + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 4089dd31b..12680fada 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -30,6 +30,9 @@ export interface CalendarValidity { export interface CalendarPreviewContextValue { selection: CalendarSelection; granularity: CalendarGranularity; + setGranularity: (granularity: CalendarGranularity) => void; + /** Switchable granularities. `.GranularityTabs` renders when >1. */ + granularities: CalendarGranularity[]; value: Value; setValue: (value: Value) => void; /** The visible month. Independent of selection, and owned by the root. */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-granularity-tabs.tsx b/packages/raystack/components/calendar-preview/calendar-preview-granularity-tabs.tsx new file mode 100644 index 000000000..e5bd246df --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-granularity-tabs.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { Tabs } from '../tabs'; +import styles from './calendar-preview.module.css'; +import type { CalendarGranularity } from './calendar-preview-context'; +import { useCalendarPreviewContext } from './calendar-preview-context'; + +/** Fixed order and wording, matching the design. */ +const GRANULARITY_LABELS: Record = { + day: 'Day', + month: 'Month', + quarter: 'Quarter', + 'half-year': 'Half-year', + year: 'Year' +}; + +const GRANULARITY_ORDER: CalendarGranularity[] = [ + 'day', + 'month', + 'quarter', + 'half-year', + 'year' +]; + +export interface CalendarPreviewGranularityTabsProps + extends Omit, 'onChange' | 'defaultValue'> { + /** Override the label for one or more granularities. */ + labels?: Partial>; +} + +/** + * Day | Month | Quarter | Half-year | Year, as Apsara `Tabs`. Renders nothing + * unless the root offers more than one granularity, so it can sit in a shared + * composition without appearing on single-granularity pickers. + * + * The tabs are `variant='standalone'` because the design's cells are that + * variant — the same one its month and quarter grids use. + */ +export function CalendarPreviewGranularityTabs({ + className, + labels, + ...props +}: CalendarPreviewGranularityTabsProps) { + const { granularity, setGranularity, granularities, disabled } = + useCalendarPreviewContext('GranularityTabs'); + + if (granularities.length <= 1) return null; + + // Always rendered in the canonical order, whatever order the prop gave. + const ordered = GRANULARITY_ORDER.filter(item => + granularities.includes(item) + ); + + return ( + /* + * The slot sits on a wrapper: `Tabs` spreads `...props` last, so passing + * `data-slot` to it would overwrite its own `data-slot="tabs"`. + */ +
+ setGranularity(next as CalendarGranularity)} + > + + {ordered.map(item => ( + + {labels?.[item] ?? GRANULARITY_LABELS[item]} + + ))} + + +
+ ); +} + +CalendarPreviewGranularityTabs.displayName = 'CalendarPreview.GranularityTabs'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 2b41e99e7..6fcc9f251 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -53,9 +53,17 @@ export function CalendarPreviewGrid({ weekStartsOn, disabled, readOnly, - lock + lock, + granularity } = useCalendarPreviewContext('Grid'); + /* + * The day grid renders for the day granularity only; `.MonthGrid` covers + * month, quarter, half-year and year. Both sit in the same composition and + * each shows itself for its own granularities. + */ + if (granularity !== 'day') return null; + /* * `readOnly` shows the value but refuses writes, so days stay legible and * focusable rather than dimmed — that is what separates it from `disabled`. diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 12a21c15b..1df50d883 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -16,8 +16,17 @@ import { import { DEFAULT_FORMAT, startOfMonth } from './date-adapter'; export interface CalendarPreviewBaseProps { - /** @defaultValue 'day' */ + /** The active granularity (controlled). */ granularity?: CalendarGranularity; + /** @defaultValue 'day' */ + defaultGranularity?: CalendarGranularity; + onGranularityChange?: (granularity: CalendarGranularity) => void; + /** + * Granularities the user may switch between. `.GranularityTabs` renders + * only when there is more than one. + * @defaultValue ['day'] + */ + granularities?: CalendarGranularity[]; /** Whether the popover is open (controlled). */ open?: boolean; @@ -109,7 +118,10 @@ function firstDateIn(value: CalendarValue | undefined): Date | undefined { export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { const { selection = 'single', - granularity = 'day', + granularity: granularityProp, + defaultGranularity = 'day', + onGranularityChange, + granularities = ['day'], value: valueProp, defaultValue, onValueChange, @@ -162,6 +174,22 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { state: 'month' }); + const [granularity, setGranularityUnwrapped] = + useControlled({ + controlled: granularityProp, + default: defaultGranularity, + name: 'CalendarPreview', + state: 'granularity' + }); + + const setGranularity = useCallback( + (next: CalendarGranularity) => { + setGranularityUnwrapped(next); + onGranularityChange?.(next); + }, + [setGranularityUnwrapped, onGranularityChange] + ); + const setValue = useCallback( (next: CalendarValue) => { setValueUnwrapped(next); @@ -227,6 +255,8 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { () => ({ selection, granularity, + setGranularity, + granularities, value, setValue, month, @@ -249,6 +279,8 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { [ selection, granularity, + setGranularity, + granularities, value, setValue, month, diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index ecdf4d3a6..6a5aed0a3 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -249,3 +249,8 @@ .field { display: inline-flex; } + +.granularity { + display: flex; + margin-bottom: 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 4febaca16..06af0d672 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx @@ -1,4 +1,5 @@ import { CalendarPreviewContent } from './calendar-preview-content'; +import { CalendarPreviewGranularityTabs } from './calendar-preview-granularity-tabs'; import { CalendarPreviewGrid } from './calendar-preview-grid'; import { CalendarPreviewInput } from './calendar-preview-input'; import { CalendarPreviewNav } from './calendar-preview-nav'; @@ -11,6 +12,7 @@ export const CalendarPreview = Object.assign(CalendarPreviewRoot, { Content: CalendarPreviewContent, Input: CalendarPreviewInput, RangeInput: CalendarPreviewRangeInput, + GranularityTabs: CalendarPreviewGranularityTabs, Nav: CalendarPreviewNav, Grid: CalendarPreviewGrid }); diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index 0217123e3..c8c8deb6d 100644 --- a/packages/raystack/components/calendar-preview/index.tsx +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -8,6 +8,7 @@ export type { CalendarValue, DateRangeValue } from './calendar-preview-context'; +export type { CalendarPreviewGranularityTabsProps } from './calendar-preview-granularity-tabs'; export type { CalendarPreviewGridProps } from './calendar-preview-grid'; export type { CalendarPreviewInputProps } from './calendar-preview-input'; export type { CalendarPreviewNavProps } from './calendar-preview-nav'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 40021b500..5661dcfdd 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -24,6 +24,7 @@ export { type CalendarGranularity, CalendarPreview, type CalendarPreviewContentProps, + type CalendarPreviewGranularityTabsProps, type CalendarPreviewGridProps, type CalendarPreviewInputProps, type CalendarPreviewNavProps, From d4c974d163bf9c47ce4e753262d5da2616f6f1bf Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 14:31:17 +0530 Subject: [PATCH 06/37] fix(calendar-preview): four defects from a second audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking console output during the test run — which grepping for pass/fail had been hiding — surfaced a `useControlled` warning that had been printing for two commits. Deriving the initial month from a live `value` recomputed the default on every render, so a controlled value moving fed `useControlled` a changing `default`: it warns, and risks re-initialising the visible month underneath the user. Computed once into a ref instead. Console output is now part of the check. `.Nav` captioned a two-month grid with a single month, naming April while April and May were both shown. It takes `months` and captions the range. `.Nav` also rendered under non-day granularities and stepped by month there, which means nothing for a year view. It now renders for the day granularity only — as the design does, hiding that header entirely in its month variant, because those views scroll rather than page. A granularity outside `granularities` produced a tab strip with nothing selected and no grid. `granularities` defaults to the active granularity rather than `['day']`, so the active one is always offered. Two suspicions were cleared rather than fixed: inside a `Field`, `.Input` receives `aria-invalid` and a label association identically to a plain `Input`, so the RFC's Field-integration claim holds; and `disabled` on `.Trigger` renders as `aria-disabled`, not an invalid attribute on a div. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/regressions.test.tsx | 64 +++++++++++++++++++ .../calendar-preview/calendar-preview-nav.tsx | 25 +++++++- .../calendar-preview-root.tsx | 37 ++++++++--- 3 files changed, 116 insertions(+), 10 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx b/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx index 953492218..28c1dc1ed 100644 --- a/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx @@ -199,3 +199,67 @@ describe('regressions', () => { expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); }); }); + +describe('regressions: second audit', () => { + it('never warns that the month default changed', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { rerender } = render( + + + + ); + // A controlled value moving must not re-initialise the uncontrolled month. + rerender( + + + + ); + const warnings = spy.mock.calls.filter(call => + String(call[0]).includes('changing the default') + ); + spy.mockRestore(); + expect(warnings).toHaveLength(0); + }); + + it('captions a two-month grid as a range', () => { + const { container } = render( + + + + + ); + expect( + getSlot(container, 'calendar-preview-nav-caption') + ).toHaveTextContent('April 2024 – May 2024'); + expect( + container.querySelectorAll('[data-slot="calendar-preview-table"]') + ).toHaveLength(2); + }); + + it('hides the nav outside the day granularity, as the design does', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-nav')).toBeNull(); + }); + + it('never renders a tab strip with nothing selected', () => { + render( + + + + ); + // granularities defaults to the active granularity, so a lone tab is not + // worth showing at all. + expect(screen.queryAllByRole('tab')).toHaveLength(0); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-nav.tsx b/packages/raystack/components/calendar-preview/calendar-preview-nav.tsx index afbe5b5c8..2e0f23edc 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-nav.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-nav.tsx @@ -25,6 +25,13 @@ export interface CalendarPreviewNavProps * @defaultValue 'MMMM YYYY' */ captionFormat?: string; + /** + * How many months the grid beside this nav shows. Keep it in step with + * `.Grid`'s `months`, or the caption will name a month the grid does not + * show on its own. + * @defaultValue 1 + */ + months?: 1 | 2; } /** @@ -40,11 +47,19 @@ export function CalendarPreviewNav({ className, align = 'start', captionFormat = 'MMMM YYYY', + months = 1, ...props }: CalendarPreviewNavProps) { - const { month, setMonth, minDate, maxDate, disabled, timeZone } = + const { month, setMonth, minDate, maxDate, disabled, timeZone, granularity } = useCalendarPreviewContext('Nav'); + /* + * Month stepping only makes sense for the day granularity, and the design + * hides this header entirely in its month variant. `.MonthGrid` scrolls + * rather than pages, so it needs no nav of its own. + */ + if (granularity !== 'day') return null; + const previousMonth = addMonths(month, -1, timeZone); const nextMonth = addMonths(month, 1, timeZone); @@ -73,7 +88,13 @@ export function CalendarPreviewNav({ aria-live='polite' data-slot='calendar-preview-nav-caption' > - {formatDate(month, captionFormat, timeZone)} + {months > 1 + ? `${formatDate(month, captionFormat, timeZone)} – ${formatDate( + addMonths(month, months - 1, timeZone), + captionFormat, + timeZone + )}` + : formatDate(month, captionFormat, timeZone)}
({ - controlled: monthProp, - default: startOfMonth( + /* + * Computed once. `useControlled` reads `default` as the initial value and + * warns if it changes, so deriving it from a live `value` on every render + * both trips that warning and risks re-initialising the visible month + * underneath the user. + */ + const initialMonth = useRef(null); + if (initialMonth.current === null) { + initialMonth.current = startOfMonth( defaultMonth ?? firstDateIn(valueProp ?? defaultValue) ?? new Date(), timeZone - ), + ); + } + + const [month, setMonthUnwrapped] = useControlled({ + controlled: monthProp, + default: initialMonth.current, name: 'CalendarPreview', state: 'month' }); @@ -182,6 +193,16 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { state: 'granularity' }); + /* + * Defaults to just the active granularity, so a single-granularity picker + * shows no tabs and the active one is always present in the list. + */ + const offeredGranularities = useMemo( + () => + granularities && granularities.length > 0 ? granularities : [granularity], + [granularities, granularity] + ); + const setGranularity = useCallback( (next: CalendarGranularity) => { setGranularityUnwrapped(next); @@ -256,7 +277,7 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { selection, granularity, setGranularity, - granularities, + granularities: offeredGranularities, value, setValue, month, @@ -280,7 +301,7 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { selection, granularity, setGranularity, - granularities, + offeredGranularities, value, setValue, month, From 788d22a9a509f78a92aa4352bcd898447d8d3fbb Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 14:34:45 +0530 Subject: [PATCH 07/37] feat(calendar-preview): MonthGrid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Month, quarter, half-year and year selection, shaped from the design rather than guessed: month, quarter and half-year group under a year heading at three, four and two columns; year is a flat full-width list with no heading at all. It scrolls through years rather than paging, which is why `.Nav` renders for the day granularity only — there is nothing to page here. **It emits the first day of the chosen period.** Whether quarter and half-year should instead emit a `{ from, to }` range is RFC 005 open item 1, still undecided. The `Date` form is taken because it leaves the value union unchanged and can be widened later without a break, where the reverse would not be true. Cells are plain buttons, not Apsara `Tabs`. The design reuses the standalone tab *visual* for them, but tab semantics without tabpanels would give a month picker the wrong ARIA. Works across all three selection modes: single writes the period start, range writes it into the active endpoint while honouring `lock`, and multiple toggles. Out-of-bounds periods are disabled and `readOnly` refuses writes, matching `.Grid`. The scroll viewport is a component-local custom property rather than a bare hardcoded height — no `--rs-*` size fits 192px, and the pattern matches `tabs.module.css`. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/month-grid.test.tsx | 176 ++++++++++++ .../calendar-preview-month-grid.tsx | 252 ++++++++++++++++++ .../calendar-preview.module.css | 68 +++++ .../calendar-preview/calendar-preview.tsx | 4 +- .../calendar-preview/date-adapter.ts | 16 ++ .../components/calendar-preview/index.tsx | 1 + packages/raystack/index.tsx | 1 + 7 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx new file mode 100644 index 000000000..01d037838 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx @@ -0,0 +1,176 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import type { DateRangeValue } from '../calendar-preview-context'; +import { dayKey } from '../date-adapter'; + +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0]; + +const at = (granularity: string, props: Record = {}) => + render( + + + + ); + +describe('CalendarPreview.MonthGrid', () => { + it('renders nothing for the day granularity', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-month-grid')).toBeNull(); + }); + + it('groups months under a year heading, three years deep', () => { + const { container } = at('month'); + expect( + getAllSlots(container, 'calendar-preview-month-grid-year') + ).toHaveLength(3); + // 12 months per year across 2023-2025. + expect(getAllSlots(container, 'calendar-preview-month-cell')).toHaveLength( + 36 + ); + expect(screen.getAllByRole('button', { name: 'Jan' })).toHaveLength(3); + }); + + it('renders four quarters per year', () => { + const { container } = at('quarter'); + expect(getAllSlots(container, 'calendar-preview-month-cell')).toHaveLength( + 12 + ); + expect(screen.getAllByRole('button', { name: 'Q4' })).toHaveLength(3); + }); + + it('renders two halves per year', () => { + const { container } = at('half-year'); + expect(getAllSlots(container, 'calendar-preview-month-cell')).toHaveLength( + 6 + ); + expect(screen.getAllByRole('button', { name: 'H2' })).toHaveLength(3); + }); + + it('renders years as a flat list with no year headings', () => { + const { container } = at('year'); + expect( + getAllSlots(container, 'calendar-preview-month-grid-year') + ).toHaveLength(0); + expect(getAllSlots(container, 'calendar-preview-month-cell')).toHaveLength( + 3 + ); + expect(screen.getByRole('button', { name: '2024' })).toBeInTheDocument(); + }); + + it('emits the first day of the chosen period', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + at('quarter', { onValueChange }); + + await user.click(screen.getAllByRole('button', { name: 'Q3' })[1]); + expect(dayKey(lastArg(onValueChange) as Date)).toBe('2024-07-01'); + }); + + it('emits January for a year pick, and June for H2', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { unmount } = at('year', { onValueChange }); + await user.click(screen.getByRole('button', { name: '2025' })); + expect(dayKey(lastArg(onValueChange) as Date)).toBe('2025-01-01'); + unmount(); + + at('half-year', { onValueChange }); + await user.click(screen.getAllByRole('button', { name: 'H2' })[0]); + expect(dayKey(lastArg(onValueChange) as Date)).toBe('2023-07-01'); + }); + + it('marks the selected period', () => { + const { container } = at('month', { value: new Date(2024, 4, 1) }); + const selected = container.querySelectorAll( + '[data-slot="calendar-preview-month-cell"][data-selected]' + ); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveTextContent('May'); + }); + + it('writes a range into the active endpoint and respects lock', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + at('month', { + selection: 'range', + lock: 'from', + value: { from: new Date(2023, 0, 1), to: null }, + onValueChange + }); + + await user.click(screen.getAllByRole('button', { name: 'Sep' })[1]); + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2023-01-01'); + expect(dayKey(next.to as Date)).toBe('2024-09-01'); + }); + + it('disables periods outside the bounds', () => { + render( + + + + ); + // The window starts at minDate's year, so January 2024 is offered but out + // of range. + expect(screen.getAllByRole('button', { name: 'Jan' })[0]).toBeDisabled(); + expect( + screen.getAllByRole('button', { name: 'Jul' })[0] + ).not.toBeDisabled(); + }); + + it('refuses writes when readOnly', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + at('month', { readOnly: true, onValueChange }); + await user.click(screen.getAllByRole('button', { name: 'Mar' })[0]); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('toggles in multiple selection', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + at('month', { selection: 'multiple', onValueChange }); + + await user.click(screen.getAllByRole('button', { name: 'Feb' })[0]); + expect((lastArg(onValueChange) as Date[]).map(d => dayKey(d))).toEqual([ + '2023-02-01' + ]); + }); + + it('pairs with GranularityTabs to swap grids', async () => { + const user = userEvent.setup(); + const { container } = render( + + + + + + ); + + expect(getSlot(container, 'calendar-preview-grid')).not.toBeNull(); + expect(getSlot(container, 'calendar-preview-month-grid')).toBeNull(); + + await user.click(screen.getByRole('tab', { name: 'Month' })); + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + expect(getSlot(container, 'calendar-preview-month-grid')).not.toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx new file mode 100644 index 000000000..382ad55c6 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { + type ComponentProps, + type CSSProperties, + useEffect, + useRef +} from 'react'; +import styles from './calendar-preview.module.css'; +import type { + CalendarGranularity, + DateRangeValue +} from './calendar-preview-context'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey, firstOfMonth, getYear, isWithinBounds } from './date-adapter'; + +const MONTH_LABELS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' +]; + +/** + * Shape of each non-day granularity, taken from the design: month, quarter and + * half-year group under a year heading at 3, 4 and 2 columns; year is a flat + * full-width list with no heading at all. + */ +const PERIODS = { + month: { + perYear: 12, + columns: 3, + grouped: true, + label: (index: number) => MONTH_LABELS[index], + startMonth: (index: number) => index + }, + quarter: { + perYear: 4, + columns: 4, + grouped: true, + label: (index: number) => `Q${index + 1}`, + startMonth: (index: number) => index * 3 + }, + 'half-year': { + perYear: 2, + columns: 2, + grouped: true, + label: (index: number) => `H${index + 1}`, + startMonth: (index: number) => index * 6 + }, + year: { + perYear: 1, + columns: 1, + grouped: false, + label: () => '', + startMonth: () => 0 + } +} as const satisfies Record, unknown>; + +export interface CalendarPreviewMonthGridProps + extends Omit, 'children'> { + /** + * How many years either side of the active one to offer when no `minDate` + * or `maxDate` bounds the list. + * @defaultValue 5 + */ + yearWindow?: number; +} + +/** + * Month, quarter, half-year and year selection. A scrolling list of years + * rather than a paged grid, which is why `.Nav` does not render for these + * granularities — there is nothing to page. + * + * **Emits the first day of the chosen period.** Whether quarter and half-year + * should instead emit a `{ from, to }` range is RFC 005 open item 1; the + * `Date` form is chosen here because it leaves the value union unchanged and + * can be widened later without a break. + */ +export function CalendarPreviewMonthGrid({ + className, + yearWindow = 5, + ...props +}: CalendarPreviewMonthGridProps) { + const { + granularity, + selection, + value, + setValue, + activeField, + lock, + minDate, + maxDate, + isDateUnavailable, + timeZone, + disabled, + readOnly + } = useCalendarPreviewContext('MonthGrid'); + + const activeYearRef = useRef(null); + + /* + * Bring the active year into view once. The list can span decades, so + * opening it scrolled to the top would usually show the wrong era. + */ + useEffect(() => { + activeYearRef.current?.scrollIntoView?.({ block: 'center' }); + }, []); + + if (granularity === 'day') return null; + + const period = PERIODS[granularity]; + const writable = !disabled && !readOnly; + + const anchor = firstSelected(value) ?? new Date(); + const anchorYear = getYear(anchor, timeZone); + + const firstYear = minDate + ? getYear(minDate, timeZone) + : anchorYear - yearWindow; + const lastYear = maxDate + ? getYear(maxDate, timeZone) + : anchorYear + yearWindow; + const years: number[] = []; + for (let year = firstYear; year <= lastYear; year += 1) years.push(year); + + const selectedKeys = selectedPeriodKeys(value, timeZone); + + const commit = (start: Date) => { + if (!writable) return; + if (selection === 'range') { + const range = (value as DateRangeValue | null) ?? { + from: null, + to: null + }; + const field = lock ? (lock === 'from' ? 'to' : 'from') : activeField; + setValue({ ...range, [field]: start }); + return; + } + if (selection === 'multiple') { + const current = (value as Date[]) ?? []; + const key = dayKey(start, timeZone); + const without = current.filter(item => dayKey(item, timeZone) !== key); + setValue( + without.length === current.length ? [...current, start] : without + ); + return; + } + setValue(start); + }; + + const renderCell = (year: number, index: number) => { + const start = firstOfMonth(year, period.startMonth(index), timeZone); + const key = dayKey(start, timeZone); + const unavailable = + !isWithinBounds(start, minDate, maxDate) || isDateUnavailable?.(start); + + return ( + + ); + }; + + return ( +
+ {period.grouped + ? years.map(year => ( +
+
+ {year} +
+
+ {Array.from({ length: period.perYear }, (_, index) => + renderCell(year, index) + )} +
+
+ )) + : years.map(year => ( +
+ {renderCell(year, 0)} +
+ ))} +
+ ); +} + +CalendarPreviewMonthGrid.displayName = 'CalendarPreview.MonthGrid'; + +function firstSelected(value: unknown): Date | undefined { + if (!value) return undefined; + if (value instanceof Date) return value; + if (Array.isArray(value)) return value[0]; + const range = value as DateRangeValue; + return range.from ?? range.to ?? undefined; +} + +/** + * Cells are marked selected when a selected date *starts* the period, so a + * value emitted by this grid round-trips. A date mid-period does not light a + * cell — that would claim a precision the value does not carry. + */ +function selectedPeriodKeys(value: unknown, timeZone?: string): Set { + const dates: Date[] = []; + if (value instanceof Date) dates.push(value); + else if (Array.isArray(value)) dates.push(...(value as Date[])); + else if (value) { + const range = value as DateRangeValue; + if (range.from) dates.push(range.from); + if (range.to) dates.push(range.to); + } + return new Set(dates.map(date => dayKey(date, timeZone))); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 6a5aed0a3..ab168dbe9 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -254,3 +254,71 @@ display: flex; margin-bottom: var(--rs-space-3); } + +/* The design's viewport is 192px of scrolling year sections. No --rs-* size + fits, so it is a component-local custom property — the pattern + tabs.module.css uses — rather than a bare hardcoded value. */ +.monthGrid { + --calendar-preview-month-grid-height: 192px; + + display: flex; + flex-direction: column; + gap: var(--rs-space-4); + max-height: var(--calendar-preview-month-grid-height); + overflow-y: auto; +} + +.monthGridSection { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); +} + +.monthGridYear { + 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); +} + +.monthGridCells { + display: grid; + grid-template-columns: repeat(var(--columns), 1fr); + gap: var(--rs-space-4); +} + +.monthCell { + display: flex; + align-items: center; + justify-content: center; + height: var(--rs-space-5); + padding: 0 var(--rs-space-2); + border: none; + border-radius: var(--rs-radius-2); + background: transparent; + color: var(--rs-color-foreground-base-primary); + cursor: pointer; + 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); +} + +.monthCell:hover:not(:disabled) { + background: var(--rs-color-background-base-primary-hover); +} + +.monthCell[data-selected] { + background: var(--rs-color-background-accent-emphasis); + color: var(--rs-color-foreground-base-emphasis); +} + +.monthCell:disabled { + opacity: 0.5; + cursor: default; +} + +.monthCell:focus-visible { + outline: var(--rs-focus-ring); + outline-offset: var(--rs-focus-ring-offset-accent); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx index 06af0d672..fa12dc410 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx @@ -2,6 +2,7 @@ import { CalendarPreviewContent } from './calendar-preview-content'; import { CalendarPreviewGranularityTabs } from './calendar-preview-granularity-tabs'; import { CalendarPreviewGrid } from './calendar-preview-grid'; import { CalendarPreviewInput } from './calendar-preview-input'; +import { CalendarPreviewMonthGrid } from './calendar-preview-month-grid'; import { CalendarPreviewNav } from './calendar-preview-nav'; import { CalendarPreviewRangeInput } from './calendar-preview-range-input'; import { CalendarPreviewRoot } from './calendar-preview-root'; @@ -14,5 +15,6 @@ export const CalendarPreview = Object.assign(CalendarPreviewRoot, { RangeInput: CalendarPreviewRangeInput, GranularityTabs: CalendarPreviewGranularityTabs, Nav: CalendarPreviewNav, - Grid: CalendarPreviewGrid + Grid: CalendarPreviewGrid, + MonthGrid: CalendarPreviewMonthGrid }); diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 6ea193230..2fe0647e5 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -45,6 +45,22 @@ export function addMonths(date: Date, count: number, timeZone?: string): Date { return zoned(date, timeZone).add(count, 'month').toDate(); } +/** First instant of a month, built from parts rather than parsed. */ +export function firstOfMonth( + year: number, + monthIndex: number, + timeZone?: string +): Date { + const iso = `${year}-${String(monthIndex + 1).padStart(2, '0')}-01`; + return timeZone + ? dayjs.tz(iso, 'YYYY-MM-DD', timeZone).toDate() + : dayjs(iso, 'YYYY-MM-DD', true).toDate(); +} + +export function getYear(date: Date, timeZone?: string): number { + return zoned(date, timeZone).year(); +} + export function endOfMonth(date: Date, timeZone?: string): Date { return zoned(date, timeZone).endOf('month').toDate(); } diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index c8c8deb6d..0fd5f6aa6 100644 --- a/packages/raystack/components/calendar-preview/index.tsx +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -11,6 +11,7 @@ export type { export type { CalendarPreviewGranularityTabsProps } from './calendar-preview-granularity-tabs'; export type { CalendarPreviewGridProps } from './calendar-preview-grid'; export type { CalendarPreviewInputProps } from './calendar-preview-input'; +export type { CalendarPreviewMonthGridProps } from './calendar-preview-month-grid'; export type { CalendarPreviewNavProps } from './calendar-preview-nav'; export type { CalendarPreviewRangeInputProps } from './calendar-preview-range-input'; export type { diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 5661dcfdd..53d2113c5 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -27,6 +27,7 @@ export { type CalendarPreviewGranularityTabsProps, type CalendarPreviewGridProps, type CalendarPreviewInputProps, + type CalendarPreviewMonthGridProps, type CalendarPreviewNavProps, type CalendarPreviewProps, type CalendarPreviewTriggerProps, From 5e3afa6ca3bef6d5712c3f795b9ddad756902ad8 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 14:37:19 +0530 Subject: [PATCH 08/37] feat(calendar-preview): Footer, Apply, Cancel and commit='explicit' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root buffers edits under `commit='explicit'` so a popover can be abandoned without the parent ever seeing intermediate states. `.Apply` commits and closes, `.Cancel` discards and closes, and dismissing the surface any other way discards too — only `.Apply` keeps a buffered value. `.Apply` is disabled while there is nothing buffered. Under the default `commit='immediate'` the value is already committed on each interaction, so `.Apply` is simply a close button. This is what made presets and a footer expressible: the RFC's `footer` prop was a bare ReactNode with no way to write back into state. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/footer.test.tsx | 100 +++++++++++++++++ .../calendar-preview-context.tsx | 8 ++ .../calendar-preview-footer.tsx | 102 ++++++++++++++++++ .../calendar-preview-root.tsx | 48 ++++++++- .../calendar-preview.module.css | 10 ++ .../calendar-preview/calendar-preview.tsx | 10 +- .../components/calendar-preview/index.tsx | 5 + packages/raystack/index.tsx | 3 + 8 files changed, 282 insertions(+), 4 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/footer.test.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-footer.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/footer.test.tsx b/packages/raystack/components/calendar-preview/__tests__/footer.test.tsx new file mode 100644 index 000000000..9c5b8190f --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/footer.test.tsx @@ -0,0 +1,100 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import { dayKey } from '../date-adapter'; + +const MONTH = new Date(2024, 3, 1); +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0]; + +const day = (iso: string) => + document.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; + +const tree = (props: Record = {}) => + render( + + Pick + + + + + + + + + ); + +describe('CalendarPreview.Footer', () => { + it('renders all three slots', async () => { + tree(); + await screen.findByRole('grid'); + for (const slot of ['footer', 'apply', 'cancel']) { + expect(getSlot(document.body, `calendar-preview-${slot}`)).not.toBeNull(); + } + }); + + it('buffers edits under commit="explicit" until Apply', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ commit: 'explicit', onValueChange }); + await screen.findByRole('grid'); + + await user.click(day('2024-04-17')); + // Nothing has reached the parent yet. + expect(onValueChange).not.toHaveBeenCalled(); + // But the grid shows the pending pick. + expect(day('2024-04-17').closest('td')?.className).toContain('selected'); + + await user.click(screen.getByRole('button', { name: 'Apply' })); + expect(dayKey(lastArg(onValueChange) as Date)).toBe('2024-04-17'); + }); + + it('discards buffered edits on Cancel', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ commit: 'explicit', onValueChange }); + await screen.findByRole('grid'); + + await user.click(day('2024-04-17')); + await user.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('discards buffered edits when the surface is dismissed', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onOpenChange = vi.fn(); + tree({ commit: 'explicit', onValueChange, onOpenChange }); + await screen.findByRole('grid'); + + await user.click(day('2024-04-17')); + await user.keyboard('{Escape}'); + expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything()); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('disables Apply until there is something to commit', async () => { + const user = userEvent.setup(); + tree({ commit: 'explicit' }); + await screen.findByRole('grid'); + + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + await user.click(day('2024-04-17')); + expect(screen.getByRole('button', { name: 'Apply' })).not.toBeDisabled(); + }); + + it('commits immediately by default, and Apply just closes', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ onValueChange }); + await screen.findByRole('grid'); + + await user.click(day('2024-04-17')); + expect(dayKey(lastArg(onValueChange) as Date)).toBe('2024-04-17'); + + await user.click(screen.getByRole('button', { name: 'Apply' })); + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 12680fada..e440f177f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -40,6 +40,14 @@ export interface CalendarPreviewContextValue { setMonth: (month: Date) => void; open: boolean; setOpen: (open: boolean) => void; + /** `'explicit'` buffers edits until `.Apply` commits them. */ + commitMode: 'immediate' | 'explicit'; + /** True when `commit='explicit'` and there are buffered edits. */ + hasPendingChanges: boolean; + /** Commit buffered edits. A no-op under `commit='immediate'`. */ + applyValue: () => void; + /** Discard buffered edits. A no-op under `commit='immediate'`. */ + cancelValue: () => void; /** Range only. Which endpoint the next grid click writes to. */ activeField: CalendarRangeField; setActiveField: (field: CalendarRangeField) => void; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx new file mode 100644 index 000000000..c807af8e8 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { Button } from '../button'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; + +export interface CalendarPreviewFooterProps extends ComponentProps<'div'> {} + +/** Action row. Holds `.Apply` and `.Cancel`, or anything else. */ +export function CalendarPreviewFooter({ + className, + ...props +}: CalendarPreviewFooterProps) { + return ( +
+ ); +} + +CalendarPreviewFooter.displayName = 'CalendarPreview.Footer'; + +export type CalendarPreviewApplyProps = ComponentProps; + +/** + * Commits buffered edits and closes the popover. Only meaningful under + * `commit='explicit'`; under `'immediate'` the value is already committed, so + * this is just a close button and is disabled by nothing. + */ +export function CalendarPreviewApply({ + className, + children = 'Apply', + disabled, + onClick, + ...props +}: CalendarPreviewApplyProps) { + const { + applyValue, + setOpen, + commitMode, + hasPendingChanges, + disabled: rootDisabled + } = useCalendarPreviewContext('Apply'); + + return ( + + ); +} + +CalendarPreviewApply.displayName = 'CalendarPreview.Apply'; + +export type CalendarPreviewCancelProps = ComponentProps; + +/** Discards buffered edits and closes the popover. */ +export function CalendarPreviewCancel({ + className, + children = 'Cancel', + onClick, + ...props +}: CalendarPreviewCancelProps) { + const { cancelValue, setOpen } = useCalendarPreviewContext('Cancel'); + + return ( + + ); +} + +CalendarPreviewCancel.displayName = 'CalendarPreview.Cancel'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index e0fe46c7b..5d51848d0 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -56,6 +56,14 @@ export interface CalendarPreviewBaseProps { */ onValidityChange?: (validity: CalendarValidity) => void; + /** + * `'immediate'` fires `onValueChange` on every interaction. `'explicit'` + * buffers edits until `.Apply` commits them, which is what makes a footer + * with actions expressible. + * @defaultValue 'immediate' + */ + commit?: 'immediate' | 'explicit'; + /** @defaultValue false */ disabled?: boolean; /** @defaultValue false */ @@ -132,6 +140,7 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { defaultMonth, onMonthChange, lock, + commit: commitMode = 'immediate', onValidityChange, minDate, maxDate, @@ -211,14 +220,36 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { [setGranularityUnwrapped, onGranularityChange] ); + /* + * Under `commit='explicit'` every part writes here instead of to the real + * value, so the popover can be abandoned without the parent ever seeing the + * intermediate states. `undefined` means "nothing buffered". + */ + const [buffer, setBuffer] = useState(undefined); + + const effectiveValue = buffer === undefined ? value : buffer; + const setValue = useCallback( (next: CalendarValue) => { + if (commitMode === 'explicit') { + setBuffer(next); + return; + } setValueUnwrapped(next); onValueChange?.(next); }, - [setValueUnwrapped, onValueChange] + [commitMode, setValueUnwrapped, onValueChange] ); + const applyValue = useCallback(() => { + if (commitMode !== 'explicit' || buffer === undefined) return; + setValueUnwrapped(buffer); + onValueChange?.(buffer); + setBuffer(undefined); + }, [commitMode, buffer, setValueUnwrapped, onValueChange]); + + const cancelValue = useCallback(() => setBuffer(undefined), []); + const setOpen = useCallback( (next: boolean, details?: { reason?: string }) => { setOpenUnwrapped(next); @@ -239,6 +270,9 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { (next: boolean, eventDetails: PopoverPrimitive.Root.ChangeEventDetails) => { // A disabled picker cannot be opened, only closed. if (next && disabled) return; + // Abandoning the surface discards buffered edits; only `.Apply` keeps + // them. Closing via `.Apply` clears the buffer before this runs. + if (!next) setBuffer(undefined); setOpen(next, { reason: eventDetails?.reason }); }, [setOpen, disabled] @@ -278,12 +312,16 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { granularity, setGranularity, granularities: offeredGranularities, - value, + value: effectiveValue, setValue, month, setMonth, open, setOpen, + commitMode, + hasPendingChanges: buffer !== undefined, + applyValue, + cancelValue, activeField, setActiveField, lock, @@ -302,12 +340,16 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { granularity, setGranularity, offeredGranularities, - value, + effectiveValue, setValue, month, setMonth, open, setOpen, + commitMode, + buffer, + applyValue, + cancelValue, activeField, setActiveField, lock, diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index ab168dbe9..b5f20a87a 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -322,3 +322,13 @@ outline: var(--rs-focus-ring); outline-offset: var(--rs-focus-ring-offset-accent); } + +.footer { + display: flex; + align-items: center; + justify-content: flex-end; + gap: var(--rs-space-3); + margin-top: var(--rs-space-4); + padding-top: var(--rs-space-4); + border-top: 1px solid var(--rs-color-border-base-primary); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx index fa12dc410..96738f619 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx @@ -1,4 +1,9 @@ import { CalendarPreviewContent } from './calendar-preview-content'; +import { + CalendarPreviewApply, + CalendarPreviewCancel, + CalendarPreviewFooter +} from './calendar-preview-footer'; import { CalendarPreviewGranularityTabs } from './calendar-preview-granularity-tabs'; import { CalendarPreviewGrid } from './calendar-preview-grid'; import { CalendarPreviewInput } from './calendar-preview-input'; @@ -16,5 +21,8 @@ export const CalendarPreview = Object.assign(CalendarPreviewRoot, { GranularityTabs: CalendarPreviewGranularityTabs, Nav: CalendarPreviewNav, Grid: CalendarPreviewGrid, - MonthGrid: CalendarPreviewMonthGrid + MonthGrid: CalendarPreviewMonthGrid, + Footer: CalendarPreviewFooter, + Apply: CalendarPreviewApply, + Cancel: CalendarPreviewCancel }); diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index 0fd5f6aa6..7328a2c3c 100644 --- a/packages/raystack/components/calendar-preview/index.tsx +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -8,6 +8,11 @@ export type { CalendarValue, DateRangeValue } from './calendar-preview-context'; +export type { + CalendarPreviewApplyProps, + CalendarPreviewCancelProps, + CalendarPreviewFooterProps +} from './calendar-preview-footer'; export type { CalendarPreviewGranularityTabsProps } from './calendar-preview-granularity-tabs'; export type { CalendarPreviewGridProps } from './calendar-preview-grid'; export type { CalendarPreviewInputProps } from './calendar-preview-input'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 53d2113c5..c41345244 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -23,7 +23,10 @@ export { export { type CalendarGranularity, CalendarPreview, + type CalendarPreviewApplyProps, + type CalendarPreviewCancelProps, type CalendarPreviewContentProps, + type CalendarPreviewFooterProps, type CalendarPreviewGranularityTabsProps, type CalendarPreviewGridProps, type CalendarPreviewInputProps, From 04cb90c35b701557b360741a4ec0a675b95c4735 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 14:45:56 +0530 Subject: [PATCH 09/37] docs(calendar-preview): component page, demos and playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The component built and exported correctly but was invisible on `pnpm start`: the docs sidebar auto-discovers from apps/www/src/content/docs/components//, and calendar-preview had no directory there. Adds the page, six demo groups and a seven-control playground. No scope registration was needed — the demo renderer spreads `...Apsara`, so the root barrel export is enough. Every demo with a typed trigger passes `initialFocus={false}` on `.Content`. That is load-bearing, not decoration: without it the popup takes focus on open and keystrokes never reach the field. It also puts the unresolved focus decision somewhere visible rather than buried in a test. Verified generated at /docs/components/calendar-preview. Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/components/calendar-preview/demo.ts | 230 ++++++++++++++++++ .../components/calendar-preview/index.mdx | 170 +++++++++++++ .../docs/components/calendar-preview/props.ts | 187 ++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 apps/www/src/content/docs/components/calendar-preview/demo.ts create mode 100644 apps/www/src/content/docs/components/calendar-preview/index.mdx create mode 100644 apps/www/src/content/docs/components/calendar-preview/props.ts diff --git a/apps/www/src/content/docs/components/calendar-preview/demo.ts b/apps/www/src/content/docs/components/calendar-preview/demo.ts new file mode 100644 index 000000000..b4f1e949a --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -0,0 +1,230 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const preview = { + type: 'code', + tabs: [ + { + name: 'Inline', + code: ` + + +` + }, + { + name: 'Date picker', + code: ` + + + + + + + +` + }, + { + name: 'Range picker', + code: ` + + + + + + + +` + } + ] +}; + +export const stateDemo = { + type: 'code', + tabs: [ + { + name: 'Open state', + code: ` console.log(open)}> + + + + + + + +` + }, + { + name: 'Visible month', + code: ` + + +` + }, + { + name: 'Bounds', + code: ` + + +` + }, + { + name: 'Unavailable days', + code: ` date.getDay() === 0 || date.getDay() === 6} +> + + +` + } + ] +}; + +export const granularityDemo = { + type: 'code', + tabs: [ + { + name: 'Switchable', + code: ` + + + + +` + }, + { + name: 'Month only', + code: ` + +` + }, + { + name: 'Quarter', + code: ` + +` + } + ] +}; + +export const commitDemo = { + type: 'code', + tabs: [ + { + name: 'Explicit commit', + code: ` + + + + + + + + + + + +` + }, + { + name: 'Locked endpoint', + code: ` + + + +` + } + ] +}; + +export const fieldDemo = { + type: 'code', + code: ` + Starts + + + + + + + + + + +` +}; + +export const getCode = (props: Record) => { + const { + selection = 'single', + months = '1', + switchable = false, + withFooter = false, + ...rest + } = props; + + const monthCount = Number(months); + const rootProps = getPropsString({ + ...(selection !== 'single' ? { selection } : {}), + ...(switchable + ? { granularities: ['day', 'month', 'quarter', 'half-year', 'year'] } + : {}), + ...(withFooter ? { commit: 'explicit' } : {}), + ...rest + }); + + const input = + selection === 'range' + ? '' + : ''; + + const monthsProp = monthCount > 1 ? ` months={${monthCount}}` : ''; + + return ` + + ${input} + + +${switchable ? ' \n' : ''} + +${switchable ? ' \n' : ''}${ + withFooter + ? ` + + + \n` + : '' +} +`; +}; + +export const playground = { + type: 'playground', + controls: { + selection: { + type: 'select', + options: ['single', 'range', 'multiple'], + defaultValue: 'single' + }, + months: { type: 'select', options: ['1', '2'], defaultValue: '1' }, + switchable: { type: 'checkbox', defaultValue: false }, + withFooter: { type: 'checkbox', defaultValue: false }, + disabled: { type: 'checkbox', defaultValue: false }, + readOnly: { type: 'checkbox', defaultValue: false }, + format: { type: 'text', initialValue: 'DD MMM YYYY' } + }, + getCode +}; diff --git a/apps/www/src/content/docs/components/calendar-preview/index.mdx b/apps/www/src/content/docs/components/calendar-preview/index.mdx new file mode 100644 index 000000000..3f675baea --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -0,0 +1,170 @@ +--- +title: CalendarPreview +description: One subcomposed date component that owns date state and popover state explicitly. +source: packages/raystack/components/calendar-preview +tag: new +--- + +import { + preview, + playground, + stateDemo, + granularityDemo, + commitDemo, + fieldDemo, +} from "./demo.ts"; + + + +`CalendarPreview` replaces `Calendar`, `DatePicker` and `RangePicker` with a +single root and dot-notation parts. Every piece of state is owned explicitly — +selection, visible month, open, granularity — so nothing is private and no part +needs to reach around another. + +It ships alongside the current calendar family; those exports are removed a +release after this one is documented. + + + +## Anatomy + +```tsx +import { CalendarPreview } from '@raystack/apsara' + + + + + + + + + + + + + + + + +``` + +Drop any part you do not need. `Grid` renders for the day granularity and +`MonthGrid` for the rest, so a picker offering both keeps both in the tree. + +## API Reference + +### Root + +Owns every piece of state and provides it to the parts. + + + +### Trigger + +Anchors the popover. Renders a `div`, never a ` + ); + })} +
+ )} +
+ ); +} + +CalendarPreviewTimeField.displayName = 'CalendarPreview.TimeField'; + +/** The date whose time this field edits, per selection mode. */ +function targetDate( + selection: string, + value: unknown, + lock: 'from' | 'to' | undefined, + activeField: 'from' | 'to' +): Date | null { + if (selection === 'range') { + const range = value as DateRangeValue | null; + if (!range) return null; + const field = lock ? (lock === 'from' ? 'to' : 'from') : activeField; + return range[field] ?? null; + } + if (selection === 'multiple') { + const list = (value as Date[]) ?? []; + return list[list.length - 1] ?? null; + } + return (value as Date | null) ?? null; +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index b5f20a87a..59088d81f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -332,3 +332,49 @@ padding-top: var(--rs-space-4); border-top: 1px solid var(--rs-color-border-base-primary); } + +.timeField { + display: flex; + align-items: center; + gap: var(--rs-space-2); +} + +.timeInput { + width: var(--rs-space-11); + text-align: center; +} + +.timeSeparator { + color: var(--rs-color-foreground-base-secondary); + user-select: none; + -webkit-user-select: none; +} + +.meridiem { + display: flex; + margin-left: var(--rs-space-2); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + overflow: hidden; +} + +.meridiemButton { + padding: var(--rs-space-1) var(--rs-space-3); + border: none; + background: transparent; + color: var(--rs-color-foreground-base-primary); + cursor: pointer; + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); +} + +.meridiemButton[data-selected] { + background: var(--rs-color-background-accent-emphasis); + color: var(--rs-color-foreground-base-emphasis); +} + +.meridiemButton:disabled { + opacity: 0.5; + cursor: default; +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx index 96738f619..03841deb1 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx @@ -11,6 +11,7 @@ import { CalendarPreviewMonthGrid } from './calendar-preview-month-grid'; import { CalendarPreviewNav } from './calendar-preview-nav'; import { CalendarPreviewRangeInput } from './calendar-preview-range-input'; import { CalendarPreviewRoot } from './calendar-preview-root'; +import { CalendarPreviewTimeField } from './calendar-preview-time-field'; import { CalendarPreviewTrigger } from './calendar-preview-trigger'; export const CalendarPreview = Object.assign(CalendarPreviewRoot, { @@ -22,6 +23,7 @@ export const CalendarPreview = Object.assign(CalendarPreviewRoot, { Nav: CalendarPreviewNav, Grid: CalendarPreviewGrid, MonthGrid: CalendarPreviewMonthGrid, + TimeField: CalendarPreviewTimeField, Footer: CalendarPreviewFooter, Apply: CalendarPreviewApply, Cancel: CalendarPreviewCancel diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 2fe0647e5..4d761ac10 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -57,6 +57,29 @@ export function firstOfMonth( : dayjs(iso, 'YYYY-MM-DD', true).toDate(); } +export function getHours(date: Date, timeZone?: string): number { + return zoned(date, timeZone).hour(); +} + +export function getMinutes(date: Date, timeZone?: string): number { + return zoned(date, timeZone).minute(); +} + +/** The same calendar day, at a different time of day. */ +export function setTime( + date: Date, + hours: number, + minutes: number, + timeZone?: string +): Date { + return zoned(date, timeZone) + .hour(hours) + .minute(minutes) + .second(0) + .millisecond(0) + .toDate(); +} + export function getYear(date: Date, timeZone?: string): number { return zoned(date, timeZone).year(); } diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index 7328a2c3c..4013729e5 100644 --- a/packages/raystack/components/calendar-preview/index.tsx +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -26,4 +26,5 @@ export type { CalendarPreviewRootProps as CalendarPreviewProps, CalendarPreviewSingleProps } from './calendar-preview-root'; +export type { CalendarPreviewTimeFieldProps } from './calendar-preview-time-field'; export type { CalendarPreviewTriggerProps } from './calendar-preview-trigger'; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index c41345244..e27c84ed7 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -33,6 +33,7 @@ export { type CalendarPreviewMonthGridProps, type CalendarPreviewNavProps, type CalendarPreviewProps, + type CalendarPreviewTimeFieldProps, type CalendarPreviewTriggerProps, type CalendarSelection, type CalendarValue, From 0278fc67518d0d9caaaaac7922baafc738d959a3 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 14:54:39 +0530 Subject: [PATCH 11/37] fix(calendar-preview): publish the types consumers need to wrap it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running the build surfaced seven public types reaching the component index but not the root barrel — among them `CalendarPreviewRangeInputProps`, so `import type { … } from '@raystack/apsara'` failed and a consumer could not type a RangeInput wrapper. That is RFC 005 problem 10 word for word, reproduced inside the rewrite meant to fix it. Nothing caught it: type-only exports are invisible at runtime so tests cannot see them, and `tsc` is satisfied because the types do exist — just not where a consumer can reach them. Only reading the built `dist/index.d.ts` shows it. Adds a guard comparing the component index against the root barrel, verified by deleting an export and confirming it fails with the offending name before restoring it. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/exports.test.ts | 64 +++++++++++++++++++ packages/raystack/index.tsx | 7 ++ 2 files changed, 71 insertions(+) create mode 100644 packages/raystack/components/calendar-preview/__tests__/exports.test.ts diff --git a/packages/raystack/components/calendar-preview/__tests__/exports.test.ts b/packages/raystack/components/calendar-preview/__tests__/exports.test.ts new file mode 100644 index 000000000..f516b232d --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/exports.test.ts @@ -0,0 +1,64 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/* + * RFC 005 problem 10 is that the old family's types are unexported and absent + * from the barrel, so "consumers cannot type a RangePicker wrapper". That is + * easy to reproduce by adding a part and forgetting the root barrel, which is + * exactly what happened once here — so it is asserted rather than remembered. + */ +const root = join(__dirname, '../../..'); + +const exportedNames = (source: string, from: string) => { + const blocks = source.match( + new RegExp(`export (?:type )?\\{[^}]*\\} from '${from}'`, 'g') + ); + if (!blocks) return new Set(); + return new Set( + blocks + .flatMap(block => block.replace(/^[^{]*\{|\}[^}]*$/g, '').split(',')) + .map(entry => entry.trim().replace(/^type\s+/, '')) + .map(entry => (entry.includes(' as ') ? entry.split(' as ')[1] : entry)) + .map(entry => entry.trim()) + .filter(Boolean) + ); +}; + +describe('CalendarPreview published surface', () => { + it('re-exports every public name from the root barrel', () => { + const componentIndex = readFileSync( + join(root, 'components/calendar-preview/index.tsx'), + 'utf8' + ); + const barrel = readFileSync(join(root, 'index.tsx'), 'utf8'); + + const fromParts = new Set(); + for (const block of componentIndex.split('\n\n')) { + for (const name of exportedNames( + componentIndex, + './calendar-preview.*?' + )) { + fromParts.add(name); + } + void block; + } + + // Every name the component index publishes, however it is spelled. + const published = new Set( + [...componentIndex.matchAll(/^\s{2}(?:type\s+)?([A-Za-z][\w]*)/gm)] + .map(match => match[1]) + .filter(name => name !== 'type') + ); + // Aliased re-exports land under their alias, not their local name. + published.delete('CalendarPreviewRootProps'); + for (const name of fromParts) published.add(name); + + const barrelNames = exportedNames(barrel, './components/calendar-preview'); + + const missing = [...published].filter(name => !barrelNames.has(name)); + expect(missing, `not re-exported from packages/raystack/index.tsx`).toEqual( + [] + ); + }); +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index e27c84ed7..5fe81c244 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -24,6 +24,7 @@ export { type CalendarGranularity, CalendarPreview, type CalendarPreviewApplyProps, + type CalendarPreviewBaseProps, type CalendarPreviewCancelProps, type CalendarPreviewContentProps, type CalendarPreviewFooterProps, @@ -31,11 +32,17 @@ export { type CalendarPreviewGridProps, type CalendarPreviewInputProps, type CalendarPreviewMonthGridProps, + type CalendarPreviewMultipleProps, type CalendarPreviewNavProps, type CalendarPreviewProps, + type CalendarPreviewRangeInputProps, + type CalendarPreviewRangeProps, + type CalendarPreviewSingleProps, type CalendarPreviewTimeFieldProps, type CalendarPreviewTriggerProps, + type CalendarRangeField, type CalendarSelection, + type CalendarValidity, type CalendarValue, type DateRangeValue } from './components/calendar-preview'; From 85d9d2e0a280621ebf81bfecaafed29a7e0c55f5 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 16:07:46 +0530 Subject: [PATCH 12/37] chore(deps): dayjs 1.11.23, @base-ui/utils 0.3.2, @base-ui/react 1.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three of the four upgrades the RFC's dependency table flags. dayjs and @base-ui/utils were already inside their manifest ranges and needed only the lockfile; @base-ui/react moves a minor, which touches every component in the library. Verified: 2812 tests pass, `tsc` reports the same six pre-existing errors in the same six files as before the bump, and the turbo build is clean across the library and the docs site. react-day-picker is left at 9.6.7 deliberately — see the next commit message or RFC 005's Alternatives table. Co-Authored-By: Claude Opus 5 (1M context) --- packages/raystack/package.json | 6 +- pnpm-lock.yaml | 160 +++++++++++++++++++++++---------- 2 files changed, 117 insertions(+), 49 deletions(-) diff --git a/packages/raystack/package.json b/packages/raystack/package.json index 1ea09e940..e78217b13 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -114,8 +114,8 @@ "vitest": "^3.2.4" }, "dependencies": { - "@base-ui/react": "~1.6.0", - "@base-ui/utils": "~0.3.1", + "@base-ui/react": "~1.7.0", + "@base-ui/utils": "~0.3.2", "@dnd-kit/core": "^6.3.1", "@tanstack/match-sorter-utils": "^8.8.4", "@tanstack/react-table": "^8.9.2", @@ -123,7 +123,7 @@ "@tanstack/table-core": "^8.9.2", "class-variance-authority": "^0.7.1", "culori": "^4.0.2", - "dayjs": "^1.11.20", + "dayjs": "^1.11.23", "prism-react-renderer": "^2.4.1", "prosemirror-commands": "^1.7.1", "prosemirror-history": "^1.4.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f7baba41..7694be0e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,11 +170,11 @@ importers: packages/raystack: dependencies: '@base-ui/react': - specifier: ~1.6.0 - version: 1.6.0(@date-fns/tz@1.2.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ~1.7.0 + version: 1.7.0(@date-fns/tz@1.2.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@base-ui/utils': - specifier: ~0.3.1 - version: 0.3.1(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + specifier: ~0.3.2 + version: 0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -197,8 +197,8 @@ importers: specifier: ^4.0.2 version: 4.0.2 dayjs: - specifier: ^1.11.20 - version: 1.11.20 + specifier: ^1.11.23 + version: 1.11.23 prism-react-renderer: specifier: ^2.4.1 version: 2.4.1(react@19.2.1) @@ -395,10 +395,18 @@ packages: resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.25.9': resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/runtime-corejs3@7.24.8': resolution: {integrity: sha512-DXG/BhegtMHhnN7YPIvxWd303/9aXvYFD1TjNL3CD6tUrhI2LVsg3Lck0aql5TRH29n4sj3emcROypkZVUfSuA==} engines: {node: '>=6.9.0'} @@ -411,8 +419,12 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} - '@base-ui/react@1.6.0': - resolution: {integrity: sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@base-ui/react@1.7.0': + resolution: {integrity: sha512-j+8QjX44C32jrXD/qyEAGpFr70FRpGL2CY61mQd9nBPWN737CK0xxD1ceJ055rW4RtdvFDT1e7otzdlfxvsYug==} engines: {node: '>=14.0.0'} peerDependencies: '@date-fns/tz': ^1.2.0 @@ -428,8 +440,8 @@ packages: date-fns: optional: true - '@base-ui/utils@0.3.1': - resolution: {integrity: sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==} + '@base-ui/utils@0.3.2': + resolution: {integrity: sha512-oWy1aq/I2GmYjpl4PhEAhzflF8VPGKgZeq0xAWTbfD5KBWyxcN0ZP2+WHSUm/5Z6lVMBDLReLcoXwSYoRc/zNQ==} peerDependencies: '@types/react': ^17 || ^18 || ^19 react: ^17 || ^18 || ^19 @@ -859,20 +871,26 @@ packages: '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + '@floating-ui/dom@1.7.4': resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} '@floating-ui/dom@1.7.6': resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + '@floating-ui/react-dom@2.1.1': resolution: {integrity: sha512-4h84MJt3CHrtG18mGsXuLCHMrug49d7DFkU0RMIyshRveBeyV2hmV/pDaF2Uxtu8kgq5r46llp5E5FQiR0K2Yg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react-dom@2.1.8': - resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' @@ -880,6 +898,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@formatjs/intl-localematcher@0.6.2': resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==} @@ -1021,14 +1042,13 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@jridgewell/gen-mapping@0.3.5': resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} engines: {node: '>=6.0.0'} - '@jridgewell/gen-mapping@0.3.8': - resolution: {integrity: sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==} - engines: {node: '>=6.0.0'} - '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -1037,15 +1057,21 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} - '@jridgewell/source-map@0.3.6': - resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} @@ -2235,8 +2261,8 @@ packages: '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} - '@swc/types@0.1.21': - resolution: {integrity: sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} '@tanstack/match-sorter-utils@8.8.4': resolution: {integrity: sha512-rKH8LjZiszWEvmi01NR72QWZ8m4xmXre0OOwlRGnjU01Eqz/QnN+cqpty2PJ0efHblq09+KilvyR7lsbzmXVEw==} @@ -2546,6 +2572,11 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@7.1.1: resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} engines: {node: '>= 14'} @@ -3020,6 +3051,9 @@ packages: dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -6409,8 +6443,16 @@ snapshots: js-tokens: 4.0.0 picocolors: 1.1.1 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/helper-validator-identifier@7.25.9': {} + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/runtime-corejs3@7.24.8': dependencies: core-js-pure: 3.37.1 @@ -6422,12 +6464,14 @@ snapshots: '@babel/runtime@7.29.2': {} - '@base-ui/react@1.6.0(@date-fns/tz@1.2.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@babel/runtime@7.29.7': {} + + '@base-ui/react@1.7.0(@date-fns/tz@1.2.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: - '@babel/runtime': 7.29.2 - '@base-ui/utils': 0.3.1(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@floating-ui/react-dom': 2.1.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1) - '@floating-ui/utils': 0.2.11 + '@babel/runtime': 7.29.7 + '@base-ui/utils': 0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@floating-ui/utils': 0.2.12 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) use-sync-external-store: 1.6.0(react@19.2.1) @@ -6436,10 +6480,10 @@ snapshots: '@types/react': 19.1.9 date-fns: 4.1.0 - '@base-ui/utils@0.3.1(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@base-ui/utils@0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@babel/runtime': 7.29.2 - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) reselect: 5.2.0 @@ -6718,30 +6762,41 @@ snapshots: dependencies: '@floating-ui/utils': 0.2.11 + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + '@floating-ui/dom@1.7.4': dependencies: '@floating-ui/core': 1.7.5 - '@floating-ui/utils': 0.2.11 + '@floating-ui/utils': 0.2.12 '@floating-ui/dom@1.7.6': dependencies: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + '@floating-ui/react-dom@2.1.1(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@floating-ui/dom': 1.7.6 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) - '@floating-ui/react-dom@2.1.8(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@floating-ui/react-dom@2.1.9(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: - '@floating-ui/dom': 1.7.6 + '@floating-ui/dom': 1.8.0 react: 19.2.1 react-dom: 19.2.1(react@19.2.1) '@floating-ui/utils@0.2.11': {} + '@floating-ui/utils@0.2.12': {} + '@formatjs/intl-localematcher@0.6.2': dependencies: tslib: 2.8.1 @@ -6850,36 +6905,44 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 - '@jridgewell/gen-mapping@0.3.5': + '@jridgewell/gen-mapping@0.3.13': dependencies: - '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.5.0 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + optional: true - '@jridgewell/gen-mapping@0.3.8': + '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 - optional: true '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/set-array@1.2.1': {} - '@jridgewell/source-map@0.3.6': + '@jridgewell/source-map@0.3.11': dependencies: - '@jridgewell/gen-mapping': 0.3.8 - '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 optional: true '@jridgewell/sourcemap-codec@1.5.0': {} + '@jridgewell/sourcemap-codec@1.6.0': + optional: true + '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + optional: true + '@jridgewell/trace-mapping@0.3.9': dependencies: '@jridgewell/resolve-uri': 3.1.2 @@ -7799,7 +7862,7 @@ snapshots: class-variance-authority: 0.7.1 cmdk: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) color: 5.0.0 - dayjs: 1.11.20 + dayjs: 1.11.23 prism-react-renderer: 2.4.1(react@19.2.1) radix-ui: 1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) react: 19.2.1 @@ -8099,7 +8162,7 @@ snapshots: '@swc/core@1.11.21': dependencies: '@swc/counter': 0.1.3 - '@swc/types': 0.1.21 + '@swc/types': 0.1.28 optionalDependencies: '@swc/core-darwin-arm64': 1.11.21 '@swc/core-darwin-x64': 1.11.21 @@ -8120,7 +8183,7 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/types@0.1.21': + '@swc/types@0.1.28': dependencies: '@swc/counter': 0.1.3 optional: true @@ -8147,8 +8210,8 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.26.2 - '@babel/runtime': 7.29.2 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 chalk: 4.1.2 @@ -8499,6 +8562,9 @@ snapshots: acorn@8.15.0: {} + acorn@8.18.0: + optional: true + agent-base@7.1.1: dependencies: debug: 4.4.1 @@ -9019,6 +9085,8 @@ snapshots: dayjs@1.11.20: {} + dayjs@1.11.23: {} + debug@4.4.1: dependencies: ms: 2.1.3 @@ -12369,8 +12437,8 @@ snapshots: terser@5.39.0: dependencies: - '@jridgewell/source-map': 0.3.6 - acorn: 8.15.0 + '@jridgewell/source-map': 0.3.11 + acorn: 8.18.0 commander: 2.20.3 source-map-support: 0.5.21 optional: true From 4f8180a726cde156271a7ffb0c3fc2763b48414c Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 16:39:19 +0530 Subject: [PATCH 13/37] chore(deps): react-day-picker 10.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two majors, kept as its own commit so it can be reverted or cherry-picked without touching the rewrite — the RFC's reason for scoping it out was that a day-grid regression should be attributable to either the upgrade or the rewrite, not both at once. Verified against the published 10.0.1 package rather than assumed: all five identifiers `CalendarPreview` imports are present, all three custom components it overrides (`DayButton`, `MonthCaption`, `MonthGrid`), all seventeen props it sets, and all sixteen `classNames` keys. Its `types/selection.d.ts` is byte-identical to 9.6.7 ignoring comments, so the mode/required union that forces `.Grid` into three call sites is unchanged — the upgrade neither helps nor hinders the rewrite. The deprecated v8-era props v10 drops are referenced nowhere in the package; the old family's `DropdownProps` import survives v10. 2812 tests pass, the six pre-existing tsc errors are unchanged in count and location, the turbo build is clean, and the calendar suites emit no new runtime warnings. Co-Authored-By: Claude Opus 5 (1M context) --- packages/raystack/package.json | 2 +- pnpm-lock.yaml | 33 ++++++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/raystack/package.json b/packages/raystack/package.json index e78217b13..1f750600e 100644 --- a/packages/raystack/package.json +++ b/packages/raystack/package.json @@ -131,7 +131,7 @@ "prosemirror-model": "^1.25.1", "prosemirror-state": "^1.4.3", "prosemirror-view": "^1.40.0", - "react-day-picker": "^9.6.7" + "react-day-picker": "^10.0.1" }, "peerDependencies": { "@types/react": "^19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7694be0e4..ed0a4d14c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,7 +171,7 @@ importers: dependencies: '@base-ui/react': specifier: ~1.7.0 - version: 1.7.0(@date-fns/tz@1.2.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + version: 1.7.0(@date-fns/tz@1.5.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) '@base-ui/utils': specifier: ~0.3.2 version: 0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -221,8 +221,8 @@ importers: specifier: ^1.40.0 version: 1.42.2 react-day-picker: - specifier: ^9.6.7 - version: 9.6.7(react@19.2.1) + specifier: ^10.0.1 + version: 10.0.1(@types/react@19.1.9)(react@19.2.1) devDependencies: '@figma/code-connect': specifier: ^1.4.7 @@ -538,6 +538,9 @@ packages: '@date-fns/tz@1.2.0': resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==} + '@date-fns/tz@1.5.0': + resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -5134,6 +5137,16 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true + react-day-picker@10.0.1: + resolution: {integrity: sha512-eNh6BlwcYInWaJtRv18mXQ06Ys/H6rdTZAnTaSdOYJuTpwP1JMCHNd1FDRadA+gbeinq+psdULN5Xnowy9mV8w==} + engines: {node: '>=18'} + peerDependencies: + '@types/react': '>=16.8.0' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + react-day-picker@9.6.7: resolution: {integrity: sha512-rCSt6X8FXQWpjykns/azRXjJk3cMSzkzGbDEXuEveFGNZgOjZULdJQ5wsu8Zfyo8ZgPBoYCBKQ5wRrgJfhJGbg==} engines: {node: '>=18'} @@ -6466,7 +6479,7 @@ snapshots: '@babel/runtime@7.29.7': {} - '@base-ui/react@1.7.0(@date-fns/tz@1.2.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': + '@base-ui/react@1.7.0(@date-fns/tz@1.5.0)(@types/react@19.1.9)(date-fns@4.1.0)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)': dependencies: '@babel/runtime': 7.29.7 '@base-ui/utils': 0.3.2(@types/react@19.1.9)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -6476,7 +6489,7 @@ snapshots: react-dom: 19.2.1(react@19.2.1) use-sync-external-store: 1.6.0(react@19.2.1) optionalDependencies: - '@date-fns/tz': 1.2.0 + '@date-fns/tz': 1.5.0 '@types/react': 19.1.9 date-fns: 4.1.0 @@ -6552,6 +6565,8 @@ snapshots: '@date-fns/tz@1.2.0': {} + '@date-fns/tz@1.5.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.1)': dependencies: react: 19.2.1 @@ -11699,6 +11714,14 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 + react-day-picker@10.0.1(@types/react@19.1.9)(react@19.2.1): + dependencies: + '@date-fns/tz': 1.5.0 + date-fns: 4.1.0 + react: 19.2.1 + optionalDependencies: + '@types/react': 19.1.9 + react-day-picker@9.6.7(react@19.2.1): dependencies: '@date-fns/tz': 1.2.0 From d0a2ed6b812e661e5b1576685efbe2a30308beb1 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 16:55:05 +0530 Subject: [PATCH 14/37] fix(calendar-preview): two defects from a cross-part audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auditing combinations rather than parts — twelve parts each had their own tests, but the interactions between them had none. `.MonthGrid` lit a cell only when a selected date *started* its period. Picking 17 April in the day grid and switching to Month therefore showed an empty grid, which reads as lost state. Cells now light when the value falls anywhere inside the period, at every granularity, while a click still writes the period start. The earlier reasoning — that lighting April claims a precision the value lacks — loses to the reading that the selection has vanished. `.Cancel` ignored root `disabled` while `.Apply` honoured it, so a disabled picker rendered one live button and one dead one. Also checked and found correct, so left alone: `commit='explicit'` buffering through `.TimeField` and `.MonthGrid`, dismissal discarding those buffers, `readOnly` across every part, `lock` targeting with a null unlocked endpoint, and multiple-selection time editing. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/footer.test.tsx | 15 ++++++ .../__tests__/month-grid.test.tsx | 49 +++++++++++++++++++ .../calendar-preview-footer.tsx | 8 ++- .../calendar-preview-month-grid.tsx | 38 ++++++++------ 4 files changed, 93 insertions(+), 17 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/footer.test.tsx b/packages/raystack/components/calendar-preview/__tests__/footer.test.tsx index 9c5b8190f..261dfaf52 100644 --- a/packages/raystack/components/calendar-preview/__tests__/footer.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/footer.test.tsx @@ -98,3 +98,18 @@ describe('CalendarPreview.Footer', () => { expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); }); }); + +describe('CalendarPreview.Cancel', () => { + it('honours root disabled, as Apply does', () => { + render( + + + + + + + ); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx index 01d037838..6834d29c7 100644 --- a/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx @@ -174,3 +174,52 @@ describe('CalendarPreview.MonthGrid', () => { expect(getSlot(container, 'calendar-preview-month-grid')).not.toBeNull(); }); }); + +describe('MonthGrid: third audit', () => { + it('lights the period containing the value, not only its first day', () => { + // Picking 17 April in the day grid then switching to Month must not show + // an empty grid — that reads as lost state. + const { container } = at('month', { value: new Date(2024, 3, 17) }); + const selected = container.querySelectorAll( + '[data-slot="calendar-preview-month-cell"][data-selected]' + ); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveTextContent('Apr'); + }); + + it('lights the right period at every granularity', () => { + const midNovember = new Date(2024, 10, 20); + for (const [granularity, label] of [ + ['month', 'Nov'], + ['quarter', 'Q4'], + ['half-year', 'H2'], + ['year', '2024'] + ] as const) { + const { container, unmount } = at(granularity, { value: midNovember }); + const selected = container.querySelectorAll( + '[data-slot="calendar-preview-month-cell"][data-selected]' + ); + expect(selected, granularity).toHaveLength(1); + expect(selected[0], granularity).toHaveTextContent(label); + unmount(); + } + }); + + it('does not bleed a selection into the neighbouring period', () => { + // 1 July is H2/Q3, never H1/Q2 — an off-by-one in the span maths shows here. + const { container } = at('quarter', { value: new Date(2024, 6, 1) }); + const selected = container.querySelectorAll( + '[data-slot="calendar-preview-month-cell"][data-selected]' + ); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveTextContent('Q3'); + }); + + it('still emits the period start when a mid-period value is showing', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + at('month', { value: new Date(2024, 3, 17), onValueChange }); + await user.click(screen.getAllByRole('button', { name: 'Apr' })[1]); + expect(dayKey(lastArg(onValueChange) as Date)).toBe('2024-04-01'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx index c807af8e8..5d372863f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx @@ -75,16 +75,22 @@ export type CalendarPreviewCancelProps = ComponentProps; export function CalendarPreviewCancel({ className, children = 'Cancel', + disabled, onClick, ...props }: CalendarPreviewCancelProps) { - const { cancelValue, setOpen } = useCalendarPreviewContext('Cancel'); + const { + cancelValue, + setOpen, + disabled: rootDisabled + } = useCalendarPreviewContext('Cancel'); return ( + ), + // `.Nav` owns the caption; RDP's would render the month twice. + MonthCaption: () => <>, + MonthGrid: gridProps => ( +
+
+ + ) +}; + +const GRID_CLASS_NAMES: DayPickerProps['classNames'] = { + months: styles.months, + week: styles.week, + weekdays: styles.week, + weekday: styles.weekday, + day: styles.day, + today: styles.today, + outside: styles.outside, + disabled: styles.disabled, + selected: styles.selected, + day_button: styles.dayButton, + range_start: styles.rangeStart, + range_middle: styles.rangeMiddle, + range_end: styles.rangeEnd, + hidden: styles.hidden +}; + export interface CalendarPreviewGridProps extends Pick< DayPickerProps, @@ -59,6 +107,19 @@ export function CalendarPreviewGrid({ loading } = useCalendarPreviewContext('Grid'); + const disabledMatchers = useMemo(() => { + const matchers: Matcher[] = []; + if (minDate) matchers.push({ before: minDate }); + if (maxDate) matchers.push({ after: maxDate }); + if (isDateUnavailable) matchers.push(isDateUnavailable); + return matchers; + }, [minDate, maxDate, isDateUnavailable]); + + const mergedClassNames = useMemo( + () => ({ ...GRID_CLASS_NAMES, ...classNames }), + [classNames] + ); + /* * The day grid renders for the day granularity only; `.MonthGrid` covers * month, quarter, half-year and year. Both sit in the same composition and @@ -92,11 +153,6 @@ export function CalendarPreviewGrid({ */ const writable = !disabled && !readOnly; - const disabledMatchers: Matcher[] = []; - if (minDate) disabledMatchers.push({ before: minDate }); - if (maxDate) disabledMatchers.push({ after: maxDate }); - if (isDateUnavailable) disabledMatchers.push(isDateUnavailable); - /* * Everything except the mode discriminator. `...props` sits last inside it, * so it stays last at every call site below — and because `mode`, @@ -116,57 +172,8 @@ export function CalendarPreviewGrid({ // `.Nav` is ours: RDP renders no navigation and never mounts a `Select`. hideNavigation: true, captionLayout: 'label' as const, - components: { - DayButton: ({ - day: _day, - modifiers: _modifiers, - ...buttonProps - }: DayButtonProps) => ( - - ), - /* - * `.Nav` owns the caption, and the design shows none inside the grid. - * Leaving RDP's in place renders the month twice and announces it - * twice, so it is dropped here rather than hidden with CSS. - */ - MonthCaption: () => <>, - MonthGrid: (gridProps: ComponentProps<'table'>) => ( -
-
- - ) - }, - classNames: { - months: styles.months, - month_caption: styles.monthCaption, - caption_label: styles.captionLabel, - week: styles.week, - weekdays: styles.week, - weekday: styles.weekday, - day: styles.day, - today: styles.today, - outside: styles.outside, - disabled: styles.disabled, - selected: styles.selected, - day_button: styles.dayButton, - range_start: styles.rangeStart, - range_middle: styles.rangeMiddle, - range_end: styles.rangeEnd, - hidden: styles.hidden, - ...classNames - }, + components: GRID_COMPONENTS, + classNames: mergedClassNames, className: cx(styles.grid, className), ...props }; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index 1c21dd63f..c3bdf1510 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -1,7 +1,8 @@ 'use client'; +import { mergeProps } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { useRef, useState } from 'react'; +import { type ChangeEvent, type KeyboardEvent, useRef, useState } from 'react'; import { Input, type InputProps } from '../input/input'; import styles from './calendar-preview.module.css'; import type { CalendarValidity } from './calendar-preview-context'; @@ -139,27 +140,38 @@ export function CalendarPreviewInput({ className={cx(styles.field, className)} data-slot='calendar-preview-input' > + {/* + * Merged, not just spread-last. Spread-last alone lets a consumer + * `onChange`/`onBlur`/`onKeyDown` *replace* parse-and-commit, leaving a + * field that accepts text and reports nothing — RFC problem 9 in a new + * shape. `.Preset` already merges; these now do too. + */} setDraft(event.target.value)} - onBlur={() => { - if (draft === null) return; - commit(draft); - setDraft(null); - }} - onKeyDown={event => { - if (event.key === 'Enter') { - event.preventDefault(); - if (draft === null) return; - commit(draft); - setDraft(null); - } - if (event.key === 'Escape') setDraft(null); - }} - {...props} + {...(mergeProps<'input'>( + { + value: draft ?? committed, + placeholder: patternForGranularity(granularity, format), + disabled, + readOnly, + onChange: (event: ChangeEvent) => + setDraft(event.target.value), + onBlur: () => { + if (draft === null) return; + commit(draft); + setDraft(null); + }, + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (draft === null) return; + commit(draft); + setDraft(null); + } + if (event.key === 'Escape') setDraft(null); + } + } as never, + props as never + ) as InputProps)} /> ); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx index c6a8321e2..b17187424 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx @@ -109,14 +109,26 @@ export function CalendarPreviewMonthGrid({ } = useCalendarPreviewContext('MonthGrid'); const activeYearRef = useRef(null); + const scrollRef = useRef(null); + + const anchor = firstSelected(value) ?? new Date(); + const anchorYear = getYear(anchor, timeZone); /* - * Bring the active year into view once. The list can span decades, so - * opening it scrolled to the top would usually show the wrong era. + * Bring the active year into view. With an empty dependency array this ran + * once against a null ref, because the component returns `null` while the + * day granularity is active — which is what `.Content` mounts with — so + * switching to Month landed the reader at the top of a list spanning + * decades. Scoped to the scroll container: an unqualified `scrollIntoView` + * inside a portal can move the page behind the popover. */ useEffect(() => { - activeYearRef.current?.scrollIntoView?.({ block: 'center' }); - }, []); + const target = activeYearRef.current; + const container = scrollRef.current; + if (!target || !container) return; + container.scrollTop = + target.offsetTop - container.clientHeight / 2 + target.clientHeight / 2; + }, [granularity, anchorYear]); if (granularity === 'day') return null; @@ -140,9 +152,6 @@ export function CalendarPreviewMonthGrid({ const monthSpan = 12 / period.perYear; const writable = !disabled && !readOnly; - const anchor = firstSelected(value) ?? new Date(); - const anchorYear = getYear(anchor, timeZone); - const firstYear = minDate ? getYear(minDate, timeZone) : anchorYear - yearWindow; @@ -188,8 +197,15 @@ export function CalendarPreviewMonthGrid({ const selected = selectedDates.some( date => date >= start && date < nextStart ); - const unavailable = - !isWithinBounds(start, minDate, maxDate) || isDateUnavailable?.(start); + /* + * Overlap, not first-day: a `minDate` falling mid-month used to disable the + * whole month and make every valid day in it unreachable. `.Nav` answers + * the same question this way. + */ + const lastInstant = new Date(nextStart.getTime() - 1); + const outOfBounds = + (minDate && lastInstant < minDate) || (maxDate && start > maxDate); + const unavailable = !!outOfBounds || isDateUnavailable?.(start); return ( - ); - }; + const renderCell = (cell: PeriodCell) => ( + + ); return (
- {period.grouped - ? years.map(year => ( + {sections.map(({ year, cells }) => + period.grouped ? ( +
-
- {year} -
-
- {Array.from({ length: period.perYear }, (_, index) => - renderCell(year, index) - )} -
+ {year}
- )) - : years.map(year => (
- {renderCell(year, 0)} + {cells.map(renderCell)}
- ))} +
+ ) : ( +
+ {cells.map(renderCell)} +
+ ) + )}
); } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx index a37df7a33..23c023d58 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx @@ -2,7 +2,13 @@ import { mergeProps } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { type ChangeEvent, type KeyboardEvent, useRef, useState } from 'react'; +import { + type ChangeEvent, + type KeyboardEvent, + useEffect, + useRef, + useState +} from 'react'; import { Input, type InputProps } from '../input/input'; import styles from './calendar-preview.module.css'; import type { @@ -10,7 +16,10 @@ import type { CalendarValidity, DateRangeValue } from './calendar-preview-context'; -import { useCalendarPreviewContext } from './calendar-preview-context'; +import { + useCalendarPreviewContext, + useInsideTrigger +} from './calendar-preview-context'; import { dayKey, formatForGranularity, @@ -65,9 +74,22 @@ export function CalendarPreviewRangeInput({ format, timeZone, disabled, - readOnly + readOnly, + setOpen, + registerTriggerField } = useCalendarPreviewContext('RangeInput'); + /* + * Both fields count as one registration — the root counts registrations, and + * one is enough to say the trigger owns focus. See `.Input` for what the + * flag changes. + */ + const insideTrigger = useInsideTrigger(); + useEffect(() => { + if (!insideTrigger) return; + return registerTriggerField(); + }, [insideTrigger, registerTriggerField]); + const range = value ?? EMPTY_RANGE; const committedFrom = range.from @@ -121,7 +143,7 @@ export function CalendarPreviewRangeInput({ } const validate = (date: Date): CalendarValidity => { - if (!isWithinBounds(date, minDate, maxDate)) { + if (!isWithinBounds(date, minDate, maxDate, timeZone)) { return { valid: false, reason: 'out-of-bounds' }; } if (isDateUnavailable?.(date)) { @@ -259,7 +281,16 @@ export function CalendarPreviewRangeInput({ endRef.current?.focus(); } } - if (event.key === 'Escape') { + // See `.Input`: ArrowDown is the keyboard's way into a calendar + // whose trigger carries no tab stop. + if (event.key === 'ArrowDown' && insideTrigger) { + event.preventDefault(); + setOpen(true); + } + // Two-stage, as a combobox is: revert the text first, dismiss on + // the second press. + if (event.key === 'Escape' && draft[field] !== null) { + event.stopPropagation(); setDraft(current => ({ ...current, [field]: null })); } } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 65098b55e..a95133ab0 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -2,7 +2,14 @@ import { Popover as PopoverPrimitive } from '@base-ui/react'; import { useControlled } from '@base-ui/utils/useControlled'; -import { type ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; import { type CalendarGranularity, type CalendarPreviewContextValue, @@ -14,7 +21,7 @@ import { type DateRangeValue, isSameValue } from './calendar-preview-context'; -import { DEFAULT_FORMAT, startOfMonth } from './date-adapter'; +import { DEFAULT_FORMAT, dayKey, startOfMonth } from './date-adapter'; /** * Accompanies every value change with the granularity that produced it. A @@ -267,10 +274,20 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { const effectiveValue = buffer === undefined ? value : buffer; + /* + * Read through a ref, not closed over. The active granularity is only ever + * the fallback for a value change that does not name one, so depending on it + * turned `setValue`'s identity over — and with it the whole context object — + * every time the tab changed. Reading it at call time is also the more + * correct of the two: it cannot be a stale closure. + */ + const granularityRef = useRef(granularity); + granularityRef.current = granularity; + const setValue = useCallback( (next: CalendarValue, details?: { granularity?: string }) => { const resolved = (details?.granularity ?? - granularity) as CalendarGranularity; + granularityRef.current) as CalendarGranularity; if (commitMode === 'explicit') { setBuffer(next); setBufferGranularity(resolved); @@ -279,23 +296,18 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { setValueUnwrapped(next); onValueChange?.(next, { granularity: resolved }); }, - [commitMode, setValueUnwrapped, onValueChange, granularity] + [commitMode, setValueUnwrapped, onValueChange] ); const applyValue = useCallback(() => { if (commitMode !== 'explicit' || buffer === undefined) return; setValueUnwrapped(buffer); - onValueChange?.(buffer, { granularity: bufferGranularity ?? granularity }); + onValueChange?.(buffer, { + granularity: bufferGranularity ?? granularityRef.current + }); setBuffer(undefined); setBufferGranularity(undefined); - }, [ - commitMode, - buffer, - bufferGranularity, - setValueUnwrapped, - onValueChange, - granularity - ]); + }, [commitMode, buffer, bufferGranularity, setValueUnwrapped, onValueChange]); const cancelValue = useCallback(() => { setBuffer(undefined); @@ -331,6 +343,64 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { [setMonthUnwrapped, onMonthChange] ); + /* + * The initial month above is computed once, which is right for a mount and + * wrong forever after: a value that arrived asynchronously was never shown, + * and reopening the popover left the user wherever they had last navigated + * rather than back on the selection. + * + * So the visible month follows the value at exactly two moments, and only + * while the consumer is not driving `month` themselves — on the closed → open + * transition, and when the value's anchor day changes. Never while the + * popover sits open with that anchor unchanged, because then it is the user + * navigating and their navigation has to win. + * + * Every comparison goes through `dayKey`, never `Date` identity: a fresh + * `Date` for the same day must not count as a change, or this becomes the + * render loop the RFC diagnosed in `DatePicker`. + */ + const anchorDate = firstDateIn(effectiveValue); + const anchorKey = anchorDate ? dayKey(anchorDate, timeZone) : null; + const previousOpen = useRef(open); + const previousAnchorKey = useRef(anchorKey); + + useEffect(() => { + const justOpened = open && !previousOpen.current; + const anchorChanged = anchorKey !== previousAnchorKey.current; + previousOpen.current = open; + previousAnchorKey.current = anchorKey; + + if (monthProp !== undefined) return; + // Clearing a value must not yank an open calendar back to today. + if (!justOpened && !(anchorChanged && anchorDate)) return; + + const target = startOfMonth( + anchorDate ?? defaultMonth ?? new Date(), + timeZone + ); + /* + * Compared as months, not as days. `.Input` and `.Preset` move the month + * by handing over the date the user named, mid-month and all; normalising + * that here would fire a second `onMonthChange` for one action and report + * a month change that nobody can see. + */ + if ( + dayKey(target, timeZone) === + dayKey(startOfMonth(month, timeZone), timeZone) + ) + return; + setMonth(target); + }, [ + open, + anchorKey, + anchorDate, + month, + monthProp, + defaultMonth, + timeZone, + setMonth + ]); + const handleOpenChange = useCallback( (next: boolean, eventDetails: PopoverPrimitive.Root.ChangeEventDetails) => { // A disabled picker cannot be opened, only closed. @@ -369,11 +439,39 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { [lock] ); + /* + * Counted rather than flagged: `.RangeInput` mounts two fields, and a + * composition may hold more than one input. Registration happens in a child + * effect, so the flag is false for the first commit — irrelevant for the + * click-to-open path, which is every real use, and `initialFocus` stays + * overridable for a picker that opens already mounted. + */ + const [triggerFieldCount, setTriggerFieldCount] = useState(0); + + const registerTriggerField = useCallback(() => { + setTriggerFieldCount(count => count + 1); + return () => setTriggerFieldCount(count => count - 1); + }, []); + const reportValidity = useCallback( (validity: CalendarValidity) => onValidityChange?.(validity), [onValidityChange] ); + /* + * One context object, so any state change re-renders every part — a month + * step re-renders `.Presets`, `.GranularityTabs`, `.TimeField` and + * `.Footer` too. + * + * Splitting stable actions from volatile state was considered and does not + * pay here: those parts all read state as well as actions, so they would + * still subscribe to the volatile half. The shape that would actually fix it + * is a store read through selectors, which is an architecture change rather + * than a tuning one, and no part of this component is expensive enough to + * render to justify it — `.MonthGrid`, the one that was, now resolves its + * cells in a memo. The action identities above are stable, which is the + * prerequisite if that day comes. + */ const contextValue = useMemo( () => ({ selection, @@ -404,7 +502,9 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { weekStartsOn, loading, disabled, - readOnly + readOnly, + triggerOwnsFocus: triggerFieldCount > 0, + registerTriggerField }), [ selection, @@ -435,7 +535,9 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { weekStartsOn, loading, disabled, - readOnly + readOnly, + triggerFieldCount, + registerTriggerField ] ); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx index affc943cb..587f398dd 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -3,7 +3,10 @@ import { Popover as PopoverPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import styles from './calendar-preview.module.css'; -import { useCalendarPreviewContext } from './calendar-preview-context'; +import { + CalendarPreviewTriggerScope, + useCalendarPreviewContext +} from './calendar-preview-context'; export interface CalendarPreviewTriggerProps extends PopoverPrimitive.Trigger.Props {} @@ -14,29 +17,72 @@ export interface CalendarPreviewTriggerProps * * Opening is Base UI's job. Nothing here calls `setOpen` from a focus handler, * which is the race that cost the old family three suppression branches. + * + * When a typed field registers itself from inside this subtree the trigger + * drops its button semantics. `role="button"` around a textbox makes the + * field's own role presentational in ARIA, so assistive tech may never announce + * it as editable at all, and the tab stop it adds sits immediately before the + * input doing nothing a keyboard user wants. With a plain button trigger — a + * calendar icon, a label — the semantics are correct and are kept. */ export function CalendarPreviewTrigger({ className, render =
, nativeButton = false, disabled, + onClick, ...props }: CalendarPreviewTriggerProps) { - const { disabled: rootDisabled } = useCalendarPreviewContext('Trigger'); + const { + disabled: rootDisabled, + open, + triggerOwnsFocus + } = useCalendarPreviewContext('Trigger'); const isDisabled = disabled ?? rootDisabled; + /* + * Spread as one object rather than written as `role={undefined}`: an + * explicit `undefined` is still an own key, and Base UI's merge would take + * it as an instruction to erase the role even for a plain button trigger. + */ + const fieldOverrides = triggerOwnsFocus + ? ({ role: undefined, tabIndex: -1 } as const) + : {}; + return ( - , which this part deliberately never renders. - nativeButton={nativeButton} - data-slot='calendar-preview-trigger' - {...props} - /> + + , which this part deliberately never renders. + nativeButton={nativeButton} + onClick={event => { + // Chained, not replaced: a consumer handler runs first and may stop + // the rest with `preventBaseUIHandler`, as Base UI's own do. + onClick?.(event); + if (event.baseUIHandlerPrevented) return; + /* + * Base UI's click trigger toggles, and the field lives inside it, so + * clicking the text to reposition the caret — an ordinary thing to do + * while editing a date — closed the calendar. Opening still works; + * only the close half is suppressed, and only from inside a field. + */ + if (!triggerOwnsFocus || !open) return; + const target = event.target as HTMLElement | null; + if ( + target?.closest('input, textarea, [contenteditable="true"]') != null + ) { + event.preventBaseUIHandler(); + } + }} + data-slot='calendar-preview-trigger' + {...fieldOverrides} + {...props} + /> + ); } diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index cd77894c7..9f4087b85 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -32,28 +32,6 @@ position: relative; } -.monthCaption { - display: flex; - align-items: center; - height: var(--rs-space-7); - margin-bottom: var(--rs-space-3); -} - -.captionLabel { - 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); - color: var(--rs-color-foreground-base-primary); - user-select: none; - -webkit-user-select: none; -} - -/* `.Nav` owns navigation, so RDP's caption is structural only. */ -.captionLabel[aria-hidden="true"] { - display: none; -} - .weeks { position: relative; } @@ -433,9 +411,15 @@ outline-offset: var(--rs-focus-ring-offset-accent); } +/* Holds the popover at the width the day grid will have, so the surface does + not jump when the data lands. Seven day columns is not a --rs-* size, so it + is a component-local custom property, as `.monthGrid` does with its height. + The `--rs-space-12` that used to sit here was decoration: it resolves to + 56px and the hardcoded minimum overrode it every time. */ .gridSkeleton { - width: var(--rs-space-12); - min-width: 280px; + --calendar-preview-grid-width: 280px; + + width: var(--calendar-preview-grid-width); } .gridSkeletonRows { diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 54842739c..e02eccd7a 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -248,17 +248,34 @@ export function parseAcrossGranularities( return null; } +/** + * The same day identity as `dayKey`, as a sortable integer — `20240417`. + * + * The comparisons below used to format both sides to `YYYY-MM-DD` and compare + * the strings. Correct, but formatting is the slow half of dayjs, and these + * three are now the date predicates behind `DataTable` and `DataView` + * filtering, where they run once per row per filter. Reading the three fields + * costs no string building and orders identically. + * + * `dayKey` keeps returning the string: it is a React key and a `data-*` value + * as much as a comparison key, and it reads as a date when debugging. + */ +function dayOrdinal(date: Date, timeZone?: string): number { + const value = zoned(date, timeZone); + return value.year() * 10000 + (value.month() + 1) * 100 + value.date(); +} + /** Day-granularity comparisons, so callers never touch a date library. */ export function isSameDay(a: Date, b: Date, timeZone?: string): boolean { - return dayKey(a, timeZone) === dayKey(b, timeZone); + return dayOrdinal(a, timeZone) === dayOrdinal(b, timeZone); } export function isBeforeDay(a: Date, b: Date, timeZone?: string): boolean { - return dayKey(a, timeZone) < dayKey(b, timeZone); + return dayOrdinal(a, timeZone) < dayOrdinal(b, timeZone); } export function isAfterDay(a: Date, b: Date, timeZone?: string): boolean { - return dayKey(a, timeZone) > dayKey(b, timeZone); + return dayOrdinal(a, timeZone) > dayOrdinal(b, timeZone); } /** @@ -280,11 +297,10 @@ export function isWithinBounds( maxDate?: Date, timeZone?: string ): boolean { - // Compared on `dayKey`, which is zone-aware; the previous `isSameOrAfter` - // pair worked on unzoned dayjs objects, so near midnight the typed field and - // the grid disagreed about whether the same date was in range. - const key = dayKey(date, timeZone); - if (minDate && key < dayKey(minDate, timeZone)) return false; - if (maxDate && key > dayKey(maxDate, timeZone)) return false; + // Compared by zoned day, inclusive at both ends. The previous + // `isSameOrAfter` pair worked on unzoned dayjs objects, so near midnight the + // typed field and the grid disagreed about whether a date was in range. + if (minDate && isBeforeDay(date, minDate, timeZone)) return false; + if (maxDate && isAfterDay(date, maxDate, timeZone)) return false; return true; } diff --git a/packages/raystack/components/data-table/utils/filter-operations.tsx b/packages/raystack/components/data-table/utils/filter-operations.tsx index 660f7147d..f54fdede9 100644 --- a/packages/raystack/components/data-table/utils/filter-operations.tsx +++ b/packages/raystack/components/data-table/utils/filter-operations.tsx @@ -26,8 +26,11 @@ import { DataTableFilterValues } from '../data-table.types'; * that registers dayjs plugins. Extending them here as well made the module * order-dependent — the failure class behind the 0.49.0 P0. * - * A row value that will not parse compares false against every operator, - * which is what an unfilterable cell should do. + * A row value that will not parse compares false against every operator that + * asserts a relationship, which is what an unfilterable cell should do. `neq` + * is the exception, and deliberately: it negates `eq`, so an empty or + * unparseable cell is "not equal to" any date and survives the filter. That + * matches the behaviour the old operators had. */ const compare = ( a: unknown, diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx index c1fadc050..ade40b28a 100644 --- a/packages/raystack/components/data-view/utils/filter-operations.tsx +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -26,8 +26,11 @@ import { DataViewFilterValues } from '../data-view.types'; * that registers dayjs plugins. Extending them here as well made the module * order-dependent — the failure class behind the 0.49.0 P0. * - * A row value that will not parse compares false against every operator, - * which is what an unfilterable cell should do. + * A row value that will not parse compares false against every operator that + * asserts a relationship, which is what an unfilterable cell should do. `neq` + * is the exception, and deliberately: it negates `eq`, so an empty or + * unparseable cell is "not equal to" any date and survives the filter. That + * matches the behaviour the old operators had. */ const compare = ( a: unknown, diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 58d2152f9..bd150f131 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -11,7 +11,7 @@ import { FilterTypes, filterOperators } from '~/types/filters'; -import type { CalendarPreviewProps } from '../calendar-preview'; +import type { CalendarPreviewBaseProps } from '../calendar-preview'; import { CalendarPreview } from '../calendar-preview'; import { toDateLoose } from '../calendar-preview/date-adapter'; import { Flex } from '../flex'; @@ -42,10 +42,18 @@ export type FilterChipValue = string | string[] | number | Date; * `defaultValue` are owned by `FilterChip`; `children` would replace the * composed trigger and break the chip layout; `selection` is fixed to single, * because the chip carries one value. + * + * Built from the base props rather than as `Omit`. + * `CalendarPreviewProps` is a three-arm discriminated union and `Omit` does + * not distribute over one: it collapses to the keys common to all three and + * takes the discriminant with it. The old form happened to land close to this + * set, but it was right by accident, and it dropped `lock` in silence. The + * base interface holds exactly the selection-independent props, so this says + * what it means and survives a fourth arm being added. */ export type FilterChipCalendarProps = Omit< - CalendarPreviewProps, - 'value' | 'onValueChange' | 'defaultValue' | 'children' | 'selection' + CalendarPreviewBaseProps, + 'children' >; export interface FilterChipProps @@ -175,9 +183,9 @@ export const FilterChip = ({ * `Input` through that component's public slots, so a * consumer-supplied class can no longer replace it. * - * `initialFocus={false}` is required, not cosmetic: the trigger - * holds a typed field, and without it the popup takes focus on - * open and keystrokes never reach the input. + * No `initialFocus={false}` here: `.Content` declines that focus + * by itself once a typed field registers from inside `.Trigger`, + * which is exactly this shape. */} - + From 10ee5e5d04f321cef1150f4dd7cb9bdcc72895fd Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 31 Aug 2026 12:23:39 +0530 Subject: [PATCH 26/37] =?UTF-8?q?fix(calendar-preview):=20the=20audit's=20?= =?UTF-8?q?second=20pass,=2024=E2=80=9328,=20and=20a=20range=20invert?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 24 (high) `setTime` returned the wrong hour for every time of day after a daylight-saving transition, not only the hour that does not exist. `zoned()` freezes the UTC offset of the instant it is handed and a day arrives as its own midnight, so chaining `.hour(10)` onto 9 Mar 2025 in `America/New_York` built 10:00 at −5 and read back as 11:00 EDT. It is built from calendar parts now, through `dayjs.tz`, which resolves the offset from the wall clock it is given; a time that genuinely does not exist resolves forward into the shift. Checked against the spring and autumn shifts, Auckland's southern-hemisphere one, and a UTC instant whose New York day is the previous day. 25 `.TimeField` called `write()` → `setValue()` with nothing in between, so the one writer whose whole job is the time inside a day ignored the picker's bounds and `onValidityChange` never fired for it. It validates through the same shape `.Input` and `.RangeInput` use now. The bounds it needs are not the ones they use. Finding 11 made `isWithinBounds` day-granular, which is right for the grid and the typed field and useless here — a `maxDate` of 17 Apr 10:00 admits 23:00 on the 17th. A plain instant comparison is wrong in the other direction and worse: `maxDate={new Date(2024, 3, 17)}` is how a picker is ordinarily bounded, and reading that midnight literally forbids every time of day on the last day it allows. So `isWithinTimeBounds` applies the day bound first, inclusive, as everywhere else, and lets a bound that actually names a time constrain within its own day. Both directions are tested — the first shape of this fix carried that regression, and the tests written beside it could not have caught it. 26 Three public `data-slot` names shipped undocumented, and the guard written to catch exactly that was blind to all three: it matched `data-slot='…'` in the source, so a ternary and a `mergeProps` property were invisible, and because it compares detected against documented they passed in both directions. It collects from the DOM now, across four compositions that between them render all 29 slots, which is what the component actually promises. The source scan survives as a second assertion in the other direction, so a new part whose slot no composition renders fails loudly rather than quietly. `input-start`, `input-end` and `preset` join the docs table. 28 `toDateLoose` read a bare number as milliseconds, so an epoch in seconds landed in January 1970 and the filter compared against a wrong date instead of declining. Numbers are split by magnitude at 1e11. The string path is unchanged and still reads `'1741046400'` as the year 1741 — deliberately, since a bare `'2025'` has to keep parsing as a year, so a digit-string rule needs a length guard and a decision this function should not make alone. Pinned by a test so changing it has to be deliberate. Not from the audit: `.TimeField` could invert a range. Both endpoints can sit on one day, and moving a time past the other end inverts it without any day changing — which the `isAfterDay` guard in `.RangeInput` cannot see. Refused rather than repaired: `.RangeInput` clears the opposite endpoint, which suits typing a whole date over a field, but here the user nudged an hour and deleting the other end of their range would throw away far more than they touched. `CalendarValidity` gains a `range-order` reason — the component is unreleased, so widening the union costs nothing. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/calendar-preview/index.mdx | 3 + packages/raystack/CHANGELOG.md | 6 + .../__tests__/audit-fixed.test.tsx | 291 +++++++++++++++++- .../__tests__/slots-documented.test.ts | 46 --- .../__tests__/slots-documented.test.tsx | 166 ++++++++++ .../calendar-preview-context.tsx | 7 +- .../calendar-preview-time-field.tsx | 82 ++++- .../calendar-preview/date-adapter.ts | 119 ++++++- 8 files changed, 655 insertions(+), 65 deletions(-) delete mode 100644 packages/raystack/components/calendar-preview/__tests__/slots-documented.test.ts create mode 100644 packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx 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 92b6d1190..fd350e275 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -230,6 +230,8 @@ by semver, so styling may target them and a rename is a breaking change. | `calendar-preview-granularity` | | `calendar-preview-grid` | | `calendar-preview-input` | +| `calendar-preview-input-end` | +| `calendar-preview-input-start` | | `calendar-preview-meridiem` | | `calendar-preview-month-cell` | | `calendar-preview-month-grid` | @@ -240,6 +242,7 @@ by semver, so styling may target them and a rename is a breaking change. | `calendar-preview-nav-previous` | | `calendar-preview-nav-undo` | | `calendar-preview-positioner` | +| `calendar-preview-preset` | | `calendar-preview-presets` | | `calendar-preview-range-inputs` | | `calendar-preview-skeleton` | diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index 157de42b0..2b8442627 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -75,6 +75,12 @@ named here: - `DataTable` and `DataView` filter operations no longer register dayjs plugins themselves. Date comparison lives in one adapter, which removes the import-order dependence behind the 0.49.0 keystroke crash. +- **A date cell holding a numeric Unix timestamp in seconds now filters + correctly.** A bare number was read as milliseconds, so an epoch in seconds + — the more common serialization — landed in January 1970 and the row + compared against that instead of its real date. Numbers under 1e11 in + magnitude are now read as seconds. A timestamp arriving as a *string* of + digits is unchanged, and still reads as a year. ### Icons — lucide replaces @radix-ui/react-icons (BREAKING) diff --git a/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx b/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx index a399fbe7e..8d4c1b7e6 100644 --- a/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx @@ -4,7 +4,17 @@ import { describe, expect, it, vi } from 'vitest'; import { getSlot } from '~/test-utils/data-slots'; import { CalendarPreview } from '../calendar-preview'; import type { DateRangeValue } from '../calendar-preview-context'; -import { dayKey, isWithinBounds } from '../date-adapter'; +import { + DEFAULT_FORMAT, + dayKey, + getHours, + getMinutes, + isWithinBounds, + isWithinTimeBounds, + parseDate, + setTime, + toDateLoose +} from '../date-adapter'; const MONTH = new Date(2024, 3, 1); const lastArg = (fn: { mock: { calls: unknown[][] } }) => @@ -271,4 +281,283 @@ describe('audit findings stay fixed', () => { expect(screen.queryByRole('grid')).not.toBeInTheDocument() ); }); + + /* + * 24. `zoned()` freezes the offset of the instant it is given. A day arrives + * as its own midnight, so on a spring-forward day that offset is the *old* + * one and every time set on top of it came back an hour late — not only the + * hour that does not exist. + */ + describe('24: setTime survives a daylight-saving shift', () => { + const TZ = 'America/New_York'; + // 9 Mar 2025: EST -> EDT at 02:00, so 02:00-02:59 never happens. + const shiftDay = parseDate('09 Mar 2025', DEFAULT_FORMAT, TZ) as Date; + + it.each([ + [1, 30], + [3, 0], + [10, 0], + [23, 45] + ])('returns %i:%i as asked', (hours, minutes) => { + const result = setTime(shiftDay, hours, minutes, TZ); + expect(getHours(result, TZ)).toBe(hours); + expect(getMinutes(result, TZ)).toBe(minutes); + }); + + it('resolves a time that does not exist forward into the shift', () => { + const result = setTime(shiftDay, 2, 30, TZ); + expect(getHours(result, TZ)).toBe(3); + expect(getMinutes(result, TZ)).toBe(30); + }); + + it('stays on the day it was handed', () => { + expect(dayKey(setTime(shiftDay, 23, 45, TZ), TZ)).toBe('2025-03-09'); + }); + + it('holds on the autumn shift too', () => { + // 2 Nov 2025: 01:00-01:59 happens twice; either instant reads back as 1. + const fallBack = parseDate('02 Nov 2025', DEFAULT_FORMAT, TZ) as Date; + expect(getHours(setTime(fallBack, 1, 30, TZ), TZ)).toBe(1); + expect(getHours(setTime(fallBack, 10, 0, TZ), TZ)).toBe(10); + }); + }); + + describe('25: .TimeField honours the picker bounds', () => { + const setup = (props: Record) => { + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + render( + + + + ); + return { onValueChange, onValidityChange }; + }; + + it('refuses an hour past maxDate and reports why', async () => { + const user = userEvent.setup(); + // Bounded at 10:00 *on the selected day*, so only a time comparison can + // catch this — `isWithinBounds` compares whole days and would pass it. + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17, 10, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'out-of-bounds' + }); + }); + + it('refuses an hour before minDate', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = setup({ + minDate: new Date(2024, 3, 17, 8, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '07{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'out-of-bounds' + }); + }); + + it('leaves the whole last day usable under a day-level maxDate', async () => { + const user = userEvent.setup(); + // The ordinary way a picker is bounded: a plain day, at midnight. Read + // literally as an instant it would forbid every time on the 17th, which + // is not what it means anywhere else in the component. + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '10{Enter}'); + + expect(getHours(lastArg(onValueChange) as Date)).toBe(10); + expect(lastArg(onValidityChange)).toEqual({ valid: true }); + }); + + it('still rejects the day after a day-level maxDate', () => { + // The day bound has not gone soft — it is applied first, inclusive. + expect( + isWithinTimeBounds( + new Date(2024, 3, 18, 9, 0), + undefined, + new Date(2024, 3, 17) + ) + ).toBe(false); + expect( + isWithinTimeBounds( + new Date(2024, 3, 17, 23, 59), + undefined, + new Date(2024, 3, 17) + ) + ).toBe(true); + }); + + it('commits an in-bounds hour and reports valid', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17, 10, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '10{Enter}'); + + expect(getHours(lastArg(onValueChange) as Date)).toBe(10); + expect(lastArg(onValidityChange)).toEqual({ valid: true }); + }); + }); + + describe('.TimeField cannot invert a range', () => { + const range = (props: Record = {}) => { + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + render( + + + + ); + return { onValueChange, onValidityChange }; + }; + + it('refuses a start pushed past the end inside the shared day', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = range(); + + // `from` is the active endpoint by default. + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'range-order' + }); + }); + + it('refuses an end pulled before the start', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = range({ lock: 'from' }); + + // `lock="from"` makes `to` the endpoint this field edits. + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '08{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'range-order' + }); + }); + + it('allows a time that keeps the endpoints ordered', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = range(); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '08{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(getHours(next.from as Date)).toBe(8); + // The endpoint the user did not touch is untouched. + expect(getHours(next.to as Date)).toBe(10); + expect(lastArg(onValidityChange)).toEqual({ valid: true }); + }); + + it('leaves a multi-day range alone, where the days already order it', async () => { + const user = userEvent.setup(); + const { onValueChange } = range({ + value: { + from: new Date(2024, 3, 17, 9, 0), + to: new Date(2024, 3, 18, 8, 0) + } + }); + + // 23:00 on the 17th is still before 08:00 on the 18th. + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(getHours(next.from as Date)).toBe(23); + }); + + it('commits normally when the other endpoint is empty', async () => { + const user = userEvent.setup(); + const { onValueChange } = range({ + value: { from: new Date(2024, 3, 17, 9, 0), to: null } + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(getHours(next.from as Date)).toBe(23); + expect(next.to).toBeNull(); + }); + }); + + describe('28: toDateLoose reads an epoch in seconds', () => { + it('reads seconds as seconds rather than landing in January 1970', () => { + expect(toDateLoose(1741046400)?.toISOString()).toBe( + '2025-03-04T00:00:00.000Z' + ); + }); + + it('still reads milliseconds as milliseconds', () => { + expect(toDateLoose(1741046400000)?.toISOString()).toBe( + '2025-03-04T00:00:00.000Z' + ); + }); + + it('splits at the ceiling, and symmetrically about the epoch', () => { + // 1e11 is the first value read as milliseconds; one less is seconds. + expect(toDateLoose(1e11)?.getUTCFullYear()).toBe(1973); + expect(toDateLoose(1e11 - 1)?.getUTCFullYear()).toBe(5138); + // Negative seconds are a real pre-1970 date, not a parse failure. + expect(toDateLoose(-86400)?.toISOString()).toBe( + '1969-12-31T00:00:00.000Z' + ); + }); + + it('still reads a digit *string* as a year, which it always did', () => { + // Pinned, not endorsed: the number path is split by magnitude but the + // string path cannot be, because a bare '2025' has to stay a year. + // Changing this should be a deliberate edit that trips this test. + // Local year, not UTC: a bare year parses to *local* midnight, so in a + // zone ahead of UTC the UTC year is the one before. + expect(toDateLoose('1741046400')?.getFullYear()).toBe(1741); + expect(toDateLoose('2025')?.getFullYear()).toBe(2025); + }); + + it('declines what it cannot read', () => { + expect(toDateLoose('not a date')).toBeNull(); + expect(toDateLoose(null)).toBeNull(); + expect(toDateLoose(undefined)).toBeNull(); + expect(toDateLoose(Number.NaN)).toBeNull(); + }); + }); }); diff --git a/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.ts b/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.ts deleted file mode 100644 index c5eda4429..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { readdirSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; - -/* - * `data-slot` names are public API covered by semver, and three separate - * audits found slots shipping without ever reaching a document. Rather than - * re-checking by hand, the docs page is asserted to list exactly what the - * component emits. - */ -const componentDir = join(__dirname, '..'); -const docsPage = join( - __dirname, - '../../../../../apps/www/src/content/docs/components/calendar-preview/index.mdx' -); - -describe('CalendarPreview data-slot documentation', () => { - it('documents every slot the component emits, and no others', () => { - const emitted = new Set(); - for (const file of readdirSync(componentDir)) { - if (!file.endsWith('.tsx')) continue; - const source = readFileSync(join(componentDir, file), 'utf8'); - for (const match of source.matchAll( - /data-slot='(calendar-preview-[a-z-]+)'/g - )) { - emitted.add(match[1]); - } - } - - const page = readFileSync(docsPage, 'utf8'); - const documented = new Set( - [...page.matchAll(/^\| `(calendar-preview-[a-z-]+)` \|$/gm)].map( - match => match[1] - ) - ); - - expect( - [...emitted].filter(slot => !documented.has(slot)).sort(), - 'emitted but not in the docs Slots table' - ).toEqual([]); - expect( - [...documented].filter(slot => !emitted.has(slot)).sort(), - 'documented but no longer emitted' - ).toEqual([]); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx b/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx new file mode 100644 index 000000000..e8b7baa35 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx @@ -0,0 +1,166 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanup, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; + +/* + * `data-slot` names are public API covered by semver, and three separate + * audits found slots shipping without ever reaching a document. + * + * The first version of this guard scanned the source for + * `data-slot='calendar-preview-…'` — single-quoted JSX literals only. Two + * slots are written as a ternary and one as an object property inside + * `mergeProps`, so the regex never saw them, and because it compared detected + * against documented, all three passed unnoticed in *both* directions. A + * fourth audit then found them. + * + * So this collects from the DOM instead: what the component actually promises + * is what it renders, not how the attribute happens to be spelled. The source + * scan survives as a cross-check in the other direction — a new part whose + * slot no composition below renders would otherwise slip past a DOM-only + * collector just as quietly. + */ +const componentDir = join(__dirname, '..'); +const docsPage = join( + __dirname, + '../../../../../apps/www/src/content/docs/components/calendar-preview/index.mdx' +); + +const MONTH = new Date(2024, 3, 1); +const DAY = new Date(2024, 3, 17, 9, 30); +const OTHER = new Date(2024, 3, 20, 9, 30); + +/** + * Between them these must render every slot the component can emit. A slot + * reachable only under a prop needs its own case: the meridiem wants + * `hourCycle={12}`, the skeleton wants `loading`, the revert button wants a + * value that differs from its default, and `.MonthGrid` renders nothing at + * all under the default day granularity. + */ +const compositions = [ + // The headline composition, opened, with every optional part present. + + + + + + + + This week + + + + + + + + + + + + , + + // Single selection, so the one-field `.Input` rather than `.RangeInput`. + + + , + + // `.MonthGrid` returns null under the day granularity. + + + , + + // Skeletons stand in for the nav and the grid while loading. + + + + +]; + +/** Every slot name rendered by any composition above, portals included. */ +function collectEmitted(): Set { + const emitted = new Set(); + for (const composition of compositions) { + render(composition); + const slotted = Array.from( + document.body.querySelectorAll('[data-slot^="calendar-preview-"]') + ); + for (const element of slotted) { + emitted.add(element.getAttribute('data-slot') as string); + } + cleanup(); + } + return emitted; +} + +/** + * Slot-shaped string literals in the source, however they are spelled — a JSX + * attribute, a ternary arm, an object property. Nothing else in this folder + * uses a `calendar-preview-` string for anything but a slot; if that changes, + * this fails loudly rather than silently, which is the point. + */ +function collectDeclared(): Set { + const declared = new Set(); + for (const file of readdirSync(componentDir)) { + if (!file.endsWith('.tsx')) continue; + const source = readFileSync(join(componentDir, file), 'utf8'); + for (const match of source.matchAll(/'(calendar-preview-[a-z-]+)'/g)) { + declared.add(match[1]); + } + } + return declared; +} + +function collectDocumented(): Set { + const page = readFileSync(docsPage, 'utf8'); + return new Set( + [...page.matchAll(/^\| `(calendar-preview-[a-z-]+)` \|$/gm)].map( + match => match[1] + ) + ); +} + +const missing = (from: Set, against: Set) => + [...from].filter(slot => !against.has(slot)).sort(); + +describe('CalendarPreview data-slot documentation', () => { + it('renders every slot the source declares', () => { + // Guards the collector, not the component: a slot no composition above + // reaches cannot be checked against the docs at all. + expect( + missing(collectDeclared(), collectEmitted()), + 'declared in the source but not rendered by any composition in this test' + ).toEqual([]); + }); + + it('documents every slot the component emits, and no others', () => { + const emitted = collectEmitted(); + const documented = collectDocumented(); + + expect( + missing(emitted, documented), + 'emitted but not in the docs Slots table' + ).toEqual([]); + expect( + missing(documented, emitted), + 'documented but no longer emitted' + ).toEqual([]); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 06c1770f8..033a50909 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -24,7 +24,12 @@ export type CalendarRangeField = 'from' | 'to'; export interface CalendarValidity { valid: boolean; - reason?: 'unparseable' | 'out-of-bounds' | 'unavailable'; + /** + * `range-order` is reported by `.TimeField` only: it is the one writer that + * can invert a range without changing either day, by moving a time past the + * opposite endpoint inside the shared day. + */ + reason?: 'unparseable' | 'out-of-bounds' | 'unavailable' | 'range-order'; } export interface CalendarPreviewContextValue { diff --git a/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx b/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx index 5bde38238..56024722c 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx @@ -4,9 +4,17 @@ import { cx } from 'class-variance-authority'; import { type ComponentProps, useRef, useState } from 'react'; import { Input } from '../input/input'; import styles from './calendar-preview.module.css'; -import type { DateRangeValue } from './calendar-preview-context'; +import type { + CalendarValidity, + DateRangeValue +} from './calendar-preview-context'; import { useCalendarPreviewContext } from './calendar-preview-context'; -import { getHours, getMinutes, setTime } from './date-adapter'; +import { + getHours, + getMinutes, + isWithinTimeBounds, + setTime +} from './date-adapter'; export interface CalendarPreviewTimeFieldProps extends Omit, 'children'> { @@ -46,7 +54,11 @@ export function CalendarPreviewTimeField({ lock, timeZone, disabled, - readOnly + readOnly, + minDate, + maxDate, + isDateUnavailable, + reportValidity } = useCalendarPreviewContext('TimeField'); const [draft, setDraft] = useState<{ @@ -70,16 +82,66 @@ export function CalendarPreviewTimeField({ const displayHour = hourCycle === 12 ? hours24 % 12 || 12 : hours24; + /* + * The same shape `.Input` and `.RangeInput` validate through, but through + * bounds that respect a time of day: this is the one writer whose whole job + * is the time inside the day, so a plain day comparison would wave through + * 23:00 under a `maxDate` of 10:00. Without this the picker had one writer + * that ignored its own bounds and one callback that never fired for it. + */ + const validate = ( + date: Date, + nextRange: DateRangeValue | null + ): CalendarValidity => { + if (!isWithinTimeBounds(date, minDate, maxDate, timeZone)) { + return { valid: false, reason: 'out-of-bounds' }; + } + if (isDateUnavailable?.(date)) { + return { valid: false, reason: 'unavailable' }; + } + /* + * By instant, not by day. `.RangeInput` guards ordering with `isAfterDay` + * because it commits whole typed dates; both endpoints of a range can sit + * on one day, and moving a time past the other end inverts the range + * without any day changing — which no day comparison can see. + * + * Refused rather than repaired. `.RangeInput` clears the opposite + * endpoint, which is right when the user has typed a whole date over a + * field, but here they nudged an hour: deleting the other end of their + * range in response would throw away far more than they touched, and + * swapping would move a value into a field they were not editing. + */ + if ( + nextRange?.from && + nextRange.to && + nextRange.from.getTime() > nextRange.to.getTime() + ) { + return { valid: false, reason: 'range-order' }; + } + return { valid: true }; + }; + const write = (nextHour24: number, nextMinute: number) => { if (!target || !editable) return; const updated = setTime(target, nextHour24, nextMinute, timeZone); - if (selection === 'range') { - const range = (value as DateRangeValue | null) ?? { - from: null, - to: null - }; - const field = lock ? (lock === 'from' ? 'to' : 'from') : activeField; - setValue({ ...range, [field]: updated }); + + const field = lock ? (lock === 'from' ? 'to' : 'from') : activeField; + const nextRange = + selection === 'range' + ? { + ...((value as DateRangeValue | null) ?? { from: null, to: null }), + [field]: updated + } + : null; + + const validity = validate(updated, nextRange); + reportValidity(validity); + // The draft is cleared by the caller either way, so a rejected edit snaps + // the field back to the committed time rather than leaving it stranded. + if (!validity.valid) return; + + if (nextRange) { + setValue(nextRange); return; } if (selection === 'multiple') { diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index e02eccd7a..db6f1fe58 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -28,6 +28,8 @@ export const DEFAULT_FORMAT = 'DD MMM YYYY'; const zoned = (date: Date, timeZone?: string) => timeZone ? dayjs(date).tz(timeZone) : dayjs(date); +const pad = (value: number) => String(value).padStart(2, '0'); + /** * A stable identity for a calendar day, for memo keys and effect deps. * Two `Date`s for the same day compare equal here; by reference they never do, @@ -65,18 +67,42 @@ export function getMinutes(date: Date, timeZone?: string): number { return zoned(date, timeZone).minute(); } -/** The same calendar day, at a different time of day. */ +/** + * The same calendar day, at a different time of day. + * + * Built from calendar parts rather than by mutating a zoned object. `zoned()` + * freezes the UTC offset of the instant it is handed, and a day's midnight + * carries the *pre*-transition offset: chaining `.hour(10)` onto 9 Mar 2025 in + * `America/New_York` built 10:00 at -5, which reads back as 11:00 EDT. Every + * time after a spring-forward landed an hour late — not just the hour that + * does not exist — in every DST zone, twice a year. + * + * `dayjs.tz` resolves the offset from the wall-clock time it is given, so the + * hour asked for is the hour that comes back. A time that genuinely does not + * exist (02:30 on a spring-forward day) resolves forward into the shift, which + * is the conventional reading and what the grid's own day arithmetic assumes. + */ export function setTime( date: Date, hours: number, minutes: number, timeZone?: string ): Date { - return zoned(date, timeZone) - .hour(hours) - .minute(minutes) - .second(0) - .millisecond(0) + if (!timeZone) { + // No zone: dayjs delegates to `Date`, which already handles local DST. + return dayjs(date) + .hour(hours) + .minute(minutes) + .second(0) + .millisecond(0) + .toDate(); + } + return dayjs + .tz( + `${dayKey(date, timeZone)} ${pad(hours)}:${pad(minutes)}`, + 'YYYY-MM-DD HH:mm', + timeZone + ) .toDate(); } @@ -278,15 +304,43 @@ export function isAfterDay(a: Date, b: Date, timeZone?: string): boolean { return dayOrdinal(a, timeZone) > dayOrdinal(b, timeZone); } +/** + * Epoch numbers below this are read as seconds, above it as milliseconds. + * 1e11 ms is 3 Mar 1973; 1e11 seconds is the year 5138. So the split covers + * every plausible seconds value and every millisecond value from 1973 on. + */ +const EPOCH_SECONDS_CEILING = 1e11; + /** * Best-effort parse for values arriving from outside the component — a * serialized query string, an epoch number, an ISO timestamp. Deliberately * loose, unlike `parseDate`, which is strict against a display format. + * + * Epoch seconds are the most common serialization of an epoch, and `dayjs` + * reads a bare number as milliseconds — so `1741046400` used to land in + * January 1970 and come back as a `Date`, leaving the filter to compare + * against a wrong date rather than decline. Numbers are now split at + * `EPOCH_SECONDS_CEILING`, by magnitude, so the split is symmetric about the + * epoch. The cost is a millisecond timestamp within roughly three years of it + * — late 1966 to early 1973 — which reads as seconds and lands far from where + * it meant. That was the cheaper of the two errors: the alternative is being + * silently wrong about every epoch-seconds value a consumer hands us. + * + * Only the `number` type is split. A *string* of digits still goes to dayjs, + * which reads `'1741046400'` as the year 1741 — the same failure in the shape + * a query string actually arrives in. Left alone deliberately: a bare `'2025'` + * has to keep parsing as a year, so a digit-string rule needs a length guard + * and a decision this function should not make on its own. */ export function toDateLoose(value: unknown): Date | null { if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value; - if (typeof value !== 'string' && typeof value !== 'number') return null; + if (typeof value === 'number') { + const ms = Math.abs(value) < EPOCH_SECONDS_CEILING ? value * 1000 : value; + const parsed = dayjs(ms); + return parsed.isValid() ? parsed.toDate() : null; + } + if (typeof value !== 'string') return null; const parsed = dayjs(value); return parsed.isValid() ? parsed.toDate() : null; } @@ -304,3 +358,54 @@ export function isWithinBounds( if (maxDate && isAfterDay(date, maxDate, timeZone)) return false; return true; } + +/** Whether a bound carries a time of day, or is a plain midnight-anchored day. */ +function hasTimeOfDay(date: Date, timeZone?: string): boolean { + const value = zoned(date, timeZone); + return ( + value.hour() !== 0 || + value.minute() !== 0 || + value.second() !== 0 || + value.millisecond() !== 0 + ); +} + +/** + * Bounds for time-of-day editing: the day check every other part applies, + * plus the bound's own time of day when it has one. + * + * `isWithinBounds` alone compares whole days, which is right for the grid and + * the typed field but useless to `.TimeField` — a `maxDate` of 17 Apr 10:00 + * admits 23:00 on the 17th. A plain instant comparison is wrong in the other + * direction, and worse: `maxDate={new Date(2024, 3, 17)}` is how a picker is + * ordinarily bounded, and reading that midnight literally forbids *every* + * time of day on the last day it allows. Every other part reads a midnight + * bound as "through the end of that day", so this does too. + * + * So the day bound always applies, inclusive at both ends, and a bound that + * actually names a time additionally constrains within its own day. + */ +export function isWithinTimeBounds( + date: Date, + minDate?: Date, + maxDate?: Date, + timeZone?: string +): boolean { + if (!isWithinBounds(date, minDate, maxDate, timeZone)) return false; + const instant = date.getTime(); + if ( + minDate && + hasTimeOfDay(minDate, timeZone) && + instant < minDate.getTime() + ) { + return false; + } + if ( + maxDate && + hasTimeOfDay(maxDate, timeZone) && + instant > maxDate.getTime() + ) { + return false; + } + return true; +} From 43cd61b18d59da3e501014e15488f9f7f346a0aa Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Mon, 31 Aug 2026 15:44:02 +0530 Subject: [PATCH 27/37] =?UTF-8?q?fix(calendar-preview):=20the=20fourth=20p?= =?UTF-8?q?ass=20=E2=80=94=20reuse=20and=20optimisation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse - One loose date parser: `toTimestamp` delegates to `toDateLoose`, so epoch seconds no longer filter as 2025 through DataTable while drawing at Jan 1970 on the timeline. Both `utils/index.tsx` barrels gate through it too, dropping an unsound `as string | Date` over a `value: unknown`. - `Popover.Content` and `CalendarPreview.Content` share one Portal > Positioner > Popup surface, so the prop-routing limitation both documented separately now has one home. - `quarterOfMonth` shared with `time-scale.tsx`, which rendered the identical expression; `pad` exported rather than defined twice; the mini type triplet and the user-select pair composed rather than restated seven times. Optimisation - `.MonthGrid`'s memo keys on instants and year numbers, not `Date` identities — inline `minDate={new Date(...)}` bounds meant it never held once. `selected` derives at render, so a time-of-day edit costs comparisons rather than date construction. Same for `.Grid`'s `disabledMatchers`. - React keys use the integer day ordinal; the loading skeleton derives its width from the spacing token and follows `months` rather than pinning one month at 280px. RFC 005 amended to the rule the code actually supports: the adapter owns every module needing a plugin. `time-scale.tsx` and `timeline.tsx` use core dayjs APIs only and register nothing, so migrating them would rewrite the axis arithmetic for no correctness gain. Not done: swapping the AM/PM pair to `Toggle.Group` and the period cells to `Chip`. Both carry their own border and filled background, so the swap needs more override CSS than it deletes and visibly changes the controls. Co-Authored-By: Claude Opus 5 (1M context) --- .../components/calendar-preview/index.mdx | 4 + .../docs/components/calendar-preview/props.ts | 2 + docs/rfcs/005-calendar-preview.md | 3 +- .../__tests__/exports.test.ts | 11 +- .../__tests__/memo-stability.test.tsx | 71 +++++++++++ .../calendar-preview-content.tsx | 61 ++++------ .../calendar-preview-grid.tsx | 20 ++- .../calendar-preview-month-grid.tsx | 114 ++++++++++-------- .../calendar-preview-root.tsx | 7 +- .../calendar-preview-time-field.tsx | 3 +- .../calendar-preview.module.css | 58 +++++---- .../calendar-preview/date-adapter.ts | 16 ++- .../components/data-table/utils/index.tsx | 6 +- .../data-view/__tests__/timeline.test.tsx | 11 +- .../components/data-view/utils/index.tsx | 8 +- .../components/data-view/utils/time-scale.tsx | 28 ++--- .../__tests__/surface-routing.test.tsx | 65 ++++++++++ .../components/popover/popover-surface.tsx | 64 ++++++++++ .../raystack/components/popover/popover.tsx | 47 ++------ 19 files changed, 411 insertions(+), 188 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/memo-stability.test.tsx create mode 100644 packages/raystack/components/popover/__tests__/surface-routing.test.tsx create mode 100644 packages/raystack/components/popover/popover-surface.tsx 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 fd350e275..b91ee63cb 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -80,6 +80,10 @@ contain a typed input. The portaled surface. Positioning props are passed here directly. +`side='bottom'`, `align='start'`, `sideOffset={4}` and `collisionPadding={3}` +are plain defaults — pass any of them to replace it. `ref`, `className` and +`style` land on the popup; everything else lands on the positioner. + ### Input 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 b8f7dbe16..6dbd2edb5 100644 --- a/apps/www/src/content/docs/components/calendar-preview/props.ts +++ b/apps/www/src/content/docs/components/calendar-preview/props.ts @@ -136,6 +136,8 @@ export interface CalendarPreviewContentProps { align?: 'start' | 'center' | 'end'; /** @defaultValue 4 */ sideOffset?: number; + /** @defaultValue 3 */ + collisionPadding?: number; /** * Whether the popup takes focus when it opens. Defaults to `false` when the * trigger contains a typed field — otherwise keystrokes would reach the grid diff --git a/docs/rfcs/005-calendar-preview.md b/docs/rfcs/005-calendar-preview.md index ac6a835d3..825a878ee 100644 --- a/docs/rfcs/005-calendar-preview.md +++ b/docs/rfcs/005-calendar-preview.md @@ -367,7 +367,8 @@ export function epoch(date: Date): number; | Job | Effect | |---|---| -| Import-order dependence goes away | Every module needing a date operation imports from here, so the plugin set is one fact in one place and the 0.49.0 `TypeError` class becomes impossible. Both `filter-operations.tsx` modules migrate onto it. | +| Import-order dependence goes away | Every module needing a date *plugin* imports from here, so the plugin set is one fact in one place and the 0.49.0 `TypeError` class becomes impossible. Both `filter-operations.tsx` modules and both `utils/index.tsx` barrels migrate onto it, as does `time-scale.tsx`'s loose parser. | +| Scope of that rule | Plugins, not the identifier. `time-scale.tsx` and `timeline.tsx` still `import dayjs` for core APIs only (`startOf`, `add`, `format`) and register no plugin, so no `extend()` order can break them; migrating them would mean rewriting the axis arithmetic for no correctness gain. The adapter owns every module that needs a plugin, and every module that must agree with another about what a loose value *means*. | | `Date` identity churn goes away internally | All internal comparisons, memo keys, and effect dependencies use `dayKey()` or `epoch()`. The public API stays `Date`, so migration is mechanical — but the three `biome-ignore`s and the unguarded loop have nowhere left to live. | | The date library becomes swappable | The exported surface is identical whichever library backs it, so the decision is reversible in one file. | diff --git a/packages/raystack/components/calendar-preview/__tests__/exports.test.ts b/packages/raystack/components/calendar-preview/__tests__/exports.test.ts index 07e2f62cf..316d36007 100644 --- a/packages/raystack/components/calendar-preview/__tests__/exports.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/exports.test.ts @@ -64,16 +64,7 @@ describe('CalendarPreview published surface', () => { ); const barrel = readFileSync(join(root, 'index.tsx'), 'utf8'); - const fromParts = new Set(); - for (const block of componentIndex.split('\n\n')) { - for (const name of exportedNames( - componentIndex, - './calendar-preview.*?' - )) { - fromParts.add(name); - } - void block; - } + const fromParts = exportedNames(componentIndex, './calendar-preview.*?'); // Every name the component index publishes, however it is spelled. const published = new Set( diff --git a/packages/raystack/components/calendar-preview/__tests__/memo-stability.test.tsx b/packages/raystack/components/calendar-preview/__tests__/memo-stability.test.tsx new file mode 100644 index 000000000..c1ede4483 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/memo-stability.test.tsx @@ -0,0 +1,71 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; + +/* + * Measured by counting `isDateUnavailable` calls, which run once per cell + * inside the memo. Asserting on the DOM gives a false all-clear: React reuses + * a node whenever type and key match, recomputed props or not. + * + * Bounds are written inline as `minDate={new Date(...)}` throughout, since + * that is the shape that used to bust the memo on every parent render. + */ + +/** 2015–2035 at month granularity: 21 years × 12 = 252 cells. */ +const CELLS = 252; + +function Harness({ + isDateUnavailable +}: { + isDateUnavailable: (date: Date) => boolean; +}) { + const [, setTick] = useState(0); + + return ( + <> + + + + + + ); +} + +describe('.MonthGrid memo stability', () => { + it('rebuilds nothing on an unrelated parent re-render', async () => { + const user = userEvent.setup(); + const isDateUnavailable = vi.fn(() => false); + render(); + expect(isDateUnavailable).toHaveBeenCalledTimes(CELLS); + isDateUnavailable.mockClear(); + + await user.click(screen.getByRole('button', { name: 'rerender parent' })); + + expect(isDateUnavailable).toHaveBeenCalledTimes(0); + }); + + it('rebuilds nothing when only the selected period changes', async () => { + const user = userEvent.setup(); + const isDateUnavailable = vi.fn(() => false); + render(); + isDateUnavailable.mockClear(); + + // `value` moves, but the dates do not — only which one is selected. + await user.click(screen.getAllByRole('button', { name: 'Mar' })[0]); + + expect(isDateUnavailable).toHaveBeenCalledTimes(0); + expect(screen.getAllByRole('button', { name: 'Mar' })[0]).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-content.tsx b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx index e9c2c8675..f8be92cea 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-content.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx @@ -1,22 +1,27 @@ 'use client'; -import { Popover as PopoverPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; +import { + PopoverSurface, + type PopoverSurfaceProps +} from '../popover/popover-surface'; import styles from './calendar-preview.module.css'; import { useCalendarPreviewContext } from './calendar-preview-context'; export interface CalendarPreviewContentProps extends Omit< - PopoverPrimitive.Positioner.Props, - 'render' | 'className' | 'style' | 'ref' - >, - PopoverPrimitive.Popup.Props {} + PopoverSurfaceProps, + 'positionerClassName' | 'positionerSlot' | 'popupSlot' + > {} /** * The portaled surface: `Portal > Positioner > Popup`, exported as `Content` * per the house convention. Positioner props (`side`, `align`, `sideOffset`) * are passed here directly; `ref`, `className`, and `style` land on the popup. * + * The tree is `PopoverSurface`, shared with `Popover.Content`; what is left + * here is only what differs — the positioning defaults and the focus rule. + * * `side` defaults to `bottom-start` — date inputs conventionally drop down, * and the old family's `top` default collided with on-screen keyboards. * @@ -26,49 +31,25 @@ export interface CalendarPreviewContentProps * keystrokes to the grid, where Enter selects a day instead of committing what * they typed. A default that every correct use had to override was the wrong * default; a plain button trigger still gets the focus move it should. - * - * Known limitation, shared with Apsara's own `Popover.Content`: anything not - * destructured above lands on the positioner, so a popup-only prop such as - * `id` reaches the wrong element. Partitioning by an enumerated key list was - * tried and rejected — Base UI has 20 positioning props and a minor bump that - * adds one would misroute it silently, which is worse than the limitation. */ export function CalendarPreviewContent({ - ref, className, - style, - render, - children, initialFocus, - finalFocus, - ...positionerProps + ...props }: CalendarPreviewContentProps) { const { triggerOwnsFocus } = useCalendarPreviewContext('Content'); return ( - - - - {children} - - - + ); } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 7fd104cb5..077bf46be 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -1,7 +1,7 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { useMemo } from 'react'; +import { type CSSProperties, useMemo } from 'react'; import { type DateRange, DayPicker, @@ -106,13 +106,20 @@ export function CalendarPreviewGrid({ loading } = useCalendarPreviewContext('Grid'); + /* + * Keyed on the instants, not the `Date`s: this array is handed to RDP as + * `disabled`, so a fresh identity every render propagates into its own memos. + */ + const minTime = minDate ? minDate.getTime() : null; + const maxTime = maxDate ? maxDate.getTime() : null; + const disabledMatchers = useMemo(() => { const matchers: Matcher[] = []; - if (minDate) matchers.push({ before: minDate }); - if (maxDate) matchers.push({ after: maxDate }); + if (minTime !== null) matchers.push({ before: new Date(minTime) }); + if (maxTime !== null) matchers.push({ after: new Date(maxTime) }); if (isDateUnavailable) matchers.push(isDateUnavailable); return matchers; - }, [minDate, maxDate, isDateUnavailable]); + }, [minTime, maxTime, isDateUnavailable]); const mergedClassNames = useMemo( () => ({ ...GRID_CLASS_NAMES, ...classNames }), @@ -134,6 +141,11 @@ export function CalendarPreviewGrid({ return (
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx index d1fe49341..c6fb86e21 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx @@ -14,7 +14,7 @@ import type { DateRangeValue } from './calendar-preview-context'; import { useCalendarPreviewContext } from './calendar-preview-context'; -import { dayKey, firstOfMonth, getYear } from './date-adapter'; +import { dayKey, dayOrdinal, firstOfMonth, getYear } from './date-adapter'; const MONTH_LABELS = [ 'Jan', @@ -67,20 +67,28 @@ const PERIODS = { } } as const satisfies Record, unknown>; -/** One period button, fully resolved: no date maths left for render time. */ +/** + * One period button, fully resolved: no date maths left for render time. + * `selected` is not here — it is the only value-dependent field, and folding + * it in made a time-of-day nudge rebuild every date in the list. + */ interface PeriodCell { - key: string; + key: number; label: string; start: Date; - selected: boolean; + /** First instant of the *next* period, so `selected` needs no date maths. */ + end: Date; unavailable: boolean; } export interface CalendarPreviewMonthGridProps extends Omit, 'children'> { /** - * How many years either side of the active one to offer when no `minDate` - * or `maxDate` bounds the list. + * How many years either side of the active one to offer. + * + * Per edge, and only where that edge is unbounded: `minDate` fixes the first + * year and `maxDate` the last. With both supplied this is inert and the list + * spans the bounds in full — 1970–2035 really does render 792 buttons. * @defaultValue 5 */ yearWindow?: number; @@ -144,33 +152,41 @@ export function CalendarPreviewMonthGrid({ ); /* - * Every cell costs two `firstOfMonth` parses, a `dayKey` format and a bounds - * pair — around five dayjs constructions. A picker bounded to a couple of - * decades has hundreds of cells, and rebuilding them on every context change - * (a keystroke in the input, the popover opening) was the whole list each - * time. Resolved once here instead, keyed on what the cells actually depend - * on. `disabled` is deliberately absent: it gates the button at render time - * and must not rebuild the dates. + * Each cell costs about five dayjs constructions, and a picker bounded to a + * couple of decades has hundreds of them. `disabled` is deliberately absent + * from the deps: it gates the button at render time, not the dates. + * + * Bounds enter as numbers, never as the `Date`s. `minDate={new Date(...)}` + * is how a bounded picker is ordinarily written, so a `Date` in the deps is + * a fresh identity every parent render and the memo never held at all. */ + const minTime = minDate ? minDate.getTime() : null; + const maxTime = maxDate ? maxDate.getTime() : null; + + /* + * Resolved out here so the memo depends on the two year numbers, not on + * `anchorYear` — which follows the selection, and which a bounded list never + * reads, so leaving it in the deps rebuilt every cell for an unmoved span. + */ + const firstYear = minDate + ? getYear(minDate, timeZone) + : anchorYear - yearWindow; + const lastYear = maxDate + ? getYear(maxDate, timeZone) + : anchorYear + yearWindow; + const sections = useMemo(() => { if (granularity === 'day') return []; const period = PERIODS[granularity]; const monthSpan = 12 / period.perYear; - const firstYear = minDate - ? getYear(minDate, timeZone) - : anchorYear - yearWindow; - const lastYear = maxDate - ? getYear(maxDate, timeZone) - : anchorYear + yearWindow; - const selectedDates = selectedDatesIn(value); const built: { year: number; cells: PeriodCell[] }[] = []; for (let year = firstYear; year <= lastYear; year += 1) { const cells = Array.from({ length: period.perYear }, (_, index) => { const startMonth = period.startMonth(index); const start = firstOfMonth(year, startMonth, timeZone); - const nextStart = firstOfMonth( + const end = firstOfMonth( year + (startMonth + monthSpan >= 12 ? 1 : 0), (startMonth + monthSpan) % 12, timeZone @@ -180,18 +196,17 @@ export function CalendarPreviewMonthGrid({ * disable the whole month and make every valid day in it unreachable. * `.Nav` answers the same question this way. */ - const lastInstant = new Date(nextStart.getTime() - 1); const outOfBounds = - (minDate && lastInstant < minDate) || (maxDate && start > maxDate); + (minTime !== null && end.getTime() - 1 < minTime) || + (maxTime !== null && start.getTime() > maxTime); return { - key: dayKey(start, timeZone), + // Integer identity, not `dayKey`: React stringifies keys anyway. + key: dayOrdinal(start, timeZone), label: granularity === 'year' ? String(year) : period.label(index), start, - selected: selectedDates.some( - date => date >= start && date < nextStart - ), - unavailable: !!outOfBounds || !!isDateUnavailable?.(start) + end, + unavailable: outOfBounds || !!isDateUnavailable?.(start) } satisfies PeriodCell; }); built.push({ year, cells }); @@ -199,11 +214,10 @@ export function CalendarPreviewMonthGrid({ return built; }, [ granularity, - minDate, - maxDate, - anchorYear, - yearWindow, - value, + firstYear, + lastYear, + minTime, + maxTime, isDateUnavailable, timeZone ]); @@ -228,6 +242,7 @@ export function CalendarPreviewMonthGrid({ const period = PERIODS[granularity]; const writable = !disabled && !readOnly; + const selectedTimes = selectedDatesIn(value).map(date => date.getTime()); const commit = (start: Date) => { if (!writable) return; @@ -252,20 +267,25 @@ export function CalendarPreviewMonthGrid({ setValue(start); }; - const renderCell = (cell: PeriodCell) => ( - - ); + const renderCell = (cell: PeriodCell) => { + const selected = selectedTimes.some( + time => time >= cell.start.getTime() && time < cell.end.getTime() + ); + return ( + + ); + }; return (
boolean; /** @defaultValue 'DD MMM YYYY' */ diff --git a/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx b/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx index 56024722c..d61a40aed 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx @@ -13,6 +13,7 @@ import { getHours, getMinutes, isWithinTimeBounds, + pad, setTime } from './date-adapter'; @@ -30,8 +31,6 @@ export interface CalendarPreviewTimeFieldProps hourCycle?: 12 | 24; } -const pad = (value: number) => String(value).padStart(2, '0'); - /** * Hour and minute for the selected date, plus AM/PM under a 12-hour cycle. * diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 9f4087b85..c284489b1 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -1,3 +1,18 @@ +/* Two local helpers: the mini type triplet had four copies in this file and + the user-select pair three. File-local — repetition across modules is still + the house norm. The `:disabled { opacity: 0.5 }` sites stay as they are; + `composes:` cannot target a pseudo-class selector. */ +.miniText { + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); +} + +.unselectable { + user-select: none; + -webkit-user-select: none; +} + .trigger { display: inline-flex; align-items: center; @@ -42,6 +57,7 @@ /* The weekday header row is shorter than a day row — 32 against 40. */ .weekday { + composes: miniText; display: flex; align-items: center; justify-content: center; @@ -50,9 +66,6 @@ color: var(--rs-color-foreground-base-secondary); text-align: center; 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); } .day { @@ -180,9 +193,8 @@ } .rangeSeparator { + composes: unselectable; color: var(--rs-color-foreground-base-tertiary); - user-select: none; - -webkit-user-select: none; } .rangeField { @@ -209,13 +221,9 @@ } .navCaption { + composes: miniText unselectable; 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); color: var(--rs-color-foreground-base-primary); - user-select: none; - -webkit-user-select: none; } .navButtons { @@ -266,6 +274,7 @@ } .monthCell { + composes: miniText; display: flex; align-items: center; justify-content: center; @@ -277,9 +286,6 @@ color: var(--rs-color-foreground-base-primary); cursor: pointer; 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); } .monthCell:hover:not(:disabled) { @@ -323,9 +329,8 @@ } .timeSeparator { + composes: unselectable; color: var(--rs-color-foreground-base-secondary); - user-select: none; - -webkit-user-select: none; } .meridiem { @@ -337,14 +342,12 @@ } .meridiemButton { + composes: miniText; padding: var(--rs-space-1) var(--rs-space-3); border: none; background: transparent; color: var(--rs-color-foreground-base-primary); cursor: pointer; - font-size: var(--rs-font-size-mini); - line-height: var(--rs-line-height-mini); - letter-spacing: var(--rs-letter-spacing-mini); } .meridiemButton[data-selected] { @@ -415,11 +418,22 @@ not jump when the data lands. Seven day columns is not a --rs-* size, so it is a component-local custom property, as `.monthGrid` does with its height. The `--rs-space-12` that used to sit here was decoration: it resolves to - 56px and the hardcoded minimum overrode it every time. */ -.gridSkeleton { - --calendar-preview-grid-width: 280px; + 56px and the hardcoded minimum overrode it every time. - width: var(--calendar-preview-grid-width); + `.week` is a bare flex row with no gap and `.day` is one --rs-space-10, so + seven columns is exactly 7 * --rs-space-10 rather than a literal 280px. + --calendar-preview-grid-months follows `.Grid`'s `months`, since two months + sit side by side with one --rs-space-5 between; it defaults to 1. */ +.gridSkeleton { + --calendar-preview-grid-width: calc(7 * var(--rs-space-10)); + --calendar-preview-grid-months: 1; + + width: calc( + var(--calendar-preview-grid-width) * + var(--calendar-preview-grid-months) + + var(--rs-space-5) * + (var(--calendar-preview-grid-months) - 1) + ); } .gridSkeletonRows { diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index db6f1fe58..9651d6f3e 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -28,7 +28,8 @@ export const DEFAULT_FORMAT = 'DD MMM YYYY'; const zoned = (date: Date, timeZone?: string) => timeZone ? dayjs(date).tz(timeZone) : dayjs(date); -const pad = (value: number) => String(value).padStart(2, '0'); +/** Zero-pads to two digits. Exported: `.TimeField` had its own copy. */ +export const pad = (value: number) => String(value).padStart(2, '0'); /** * A stable identity for a calendar day, for memo keys and effect deps. @@ -53,7 +54,7 @@ export function firstOfMonth( monthIndex: number, timeZone?: string ): Date { - const iso = `${year}-${String(monthIndex + 1).padStart(2, '0')}-01`; + const iso = `${year}-${pad(monthIndex + 1)}-01`; return timeZone ? dayjs.tz(iso, 'YYYY-MM-DD', timeZone).toDate() : dayjs(iso, 'YYYY-MM-DD', true).toDate(); @@ -146,6 +147,13 @@ export function parseDate( return zonedParse.isValid() ? zonedParse.toDate() : null; } +/** + * 1-based quarter containing a zero-based month index. Shared with + * `time-scale.tsx`, which rendered the identical expression for its tick. + */ +export const quarterOfMonth = (monthIndex: number): number => + Math.floor(monthIndex / 3) + 1; + /** * How a value reads at each granularity, mirroring the reference app: a month * shows `Jun 2026`, a quarter `Q3 2026`, a half-year `H1 2026`, a year `2025`. @@ -163,7 +171,7 @@ export function formatForGranularity( case 'month': return formatDate(date, 'MMM YYYY', timeZone); case 'quarter': - return `Q${Math.floor(month / 3) + 1} ${year}`; + return `Q${quarterOfMonth(month)} ${year}`; case 'half-year': return `H${month < 6 ? 1 : 2} ${year}`; case 'year': @@ -286,7 +294,7 @@ export function parseAcrossGranularities( * `dayKey` keeps returning the string: it is a React key and a `data-*` value * as much as a comparison key, and it reads as a date when debugging. */ -function dayOrdinal(date: Date, timeZone?: string): number { +export function dayOrdinal(date: Date, timeZone?: string): number { const value = zoned(date, timeZone); return value.year() * 10000 + (value.month() + 1) * 100 + value.date(); } diff --git a/packages/raystack/components/data-table/utils/index.tsx b/packages/raystack/components/data-table/utils/index.tsx index 299093d42..3176ea521 100644 --- a/packages/raystack/components/data-table/utils/index.tsx +++ b/packages/raystack/components/data-table/utils/index.tsx @@ -1,8 +1,8 @@ import type { Row, Table } from '@tanstack/react-table'; import { TableState } from '@tanstack/table-core'; -import dayjs from 'dayjs'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; +import { toDateLoose } from '../../calendar-preview/date-adapter'; import { DataTableColumnDef, DataTableQuery, @@ -25,7 +25,7 @@ export function queryToTableState(query: InternalQuery): Partial { query.filters ?.filter(data => { if (data._type === FilterType.date) - return dayjs(data.value as string | Date).isValid(); + return toDateLoose(data.value) !== null; if (data.value !== '') return true; return false; }) @@ -223,7 +223,7 @@ export function transformToDataTableQuery( ?.filter(data => { if (data._type === FilterType.select) return true; if (data._type === FilterType.date) - return dayjs(data.value as string | Date).isValid(); + return toDateLoose(data.value) !== null; if (data.value !== '') return true; return false; }) diff --git a/packages/raystack/components/data-view/__tests__/timeline.test.tsx b/packages/raystack/components/data-view/__tests__/timeline.test.tsx index b3120804d..99f7ebf3c 100644 --- a/packages/raystack/components/data-view/__tests__/timeline.test.tsx +++ b/packages/raystack/components/data-view/__tests__/timeline.test.tsx @@ -2,7 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import dayjs from 'dayjs'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; - +import { toDateLoose } from '../../calendar-preview/date-adapter'; // biome-ignore lint/suspicious/noShadowRestrictedNames: legitimate export name import { DataView } from '../data-view'; import type { @@ -51,6 +51,15 @@ describe('toTimestamp', () => { expect(toTimestamp(Number.NaN)).toBeNull(); expect(toTimestamp({})).toBeNull(); }); + + // Two loose parsers used to disagree about this; see `toTimestamp`. + it('agrees with the filter parser about an epoch in seconds', () => { + const seconds = 1741046400; + expect(toTimestamp(seconds)).toBe(toDateLoose(seconds)?.getTime()); + expect(new Date(toTimestamp(seconds) as number).getUTCFullYear()).toBe( + 2025 + ); + }); }); describe('createTimeScale', () => { diff --git a/packages/raystack/components/data-view/utils/index.tsx b/packages/raystack/components/data-view/utils/index.tsx index f4c9e21bd..e9cca2b78 100644 --- a/packages/raystack/components/data-view/utils/index.tsx +++ b/packages/raystack/components/data-view/utils/index.tsx @@ -4,9 +4,9 @@ import { type RowModel, TableState } from '@tanstack/table-core'; -import dayjs from 'dayjs'; import { FilterOperatorTypes, FilterType } from '~/types/filters'; +import { toDateLoose } from '../../calendar-preview/date-adapter'; import { DataViewField, DataViewQuery, @@ -29,7 +29,8 @@ export function queryToTableState(query: InternalQuery): Partial { const columnFilters = query.filters ?.filter(data => { - if (data._type === FilterType.date) return dayjs(data.value).isValid(); + if (data._type === FilterType.date) + return toDateLoose(data.value) !== null; if (data.value !== '') return true; return false; }) @@ -262,7 +263,8 @@ export function transformToDataViewQuery(query: InternalQuery): DataViewQuery { filters ?.filter(data => { if (data._type === FilterType.select) return true; - if (data._type === FilterType.date) return dayjs(data.value).isValid(); + if (data._type === FilterType.date) + return toDateLoose(data.value) !== null; if (data.value !== '') return true; return false; }) diff --git a/packages/raystack/components/data-view/utils/time-scale.tsx b/packages/raystack/components/data-view/utils/time-scale.tsx index 15fdbd51a..e07427dc6 100644 --- a/packages/raystack/components/data-view/utils/time-scale.tsx +++ b/packages/raystack/components/data-view/utils/time-scale.tsx @@ -1,5 +1,9 @@ import dayjs, { type Dayjs } from 'dayjs'; +import { + quarterOfMonth, + toDateLoose +} from '../../calendar-preview/date-adapter'; import type { TimelineScale } from '../data-view.types'; /** @@ -25,21 +29,15 @@ export const TIMELINE_DEFAULT_UNIT_WIDTH: Record = { /** Minimum px between rendered tick labels — denser ticks skip labels. */ const TICK_LABEL_MIN_SPACE = 28; -/** Coerce a consumer-provided date (Date | epoch ms | parseable string) to ms. */ +/** + * Coerce a consumer-provided date (Date | epoch | parseable string) to ms. + * + * Delegates to the adapter's `toDateLoose`. This module used to parse for + * itself, so the epoch-seconds fix landed in one parser and not the other: + * `1741046400` filtered as March 2025 but drew at January 1970 here. + */ export function toTimestamp(value: unknown): number | null { - if (value == null) return null; - if (value instanceof Date) { - const time = value.getTime(); - return Number.isNaN(time) ? null : time; - } - if (typeof value === 'number') { - return Number.isFinite(value) ? value : null; - } - if (typeof value === 'string') { - const parsed = dayjs(value); - return parsed.isValid() ? parsed.valueOf() : null; - } - return null; + return toDateLoose(value)?.getTime() ?? null; } /** `startOf` that also understands quarters without a dayjs plugin. */ @@ -145,7 +143,7 @@ function tickLabel(date: Dayjs, scale: TimelineScale): string { case 'month': return date.format('MMM'); case 'quarter': - return `Q${Math.floor(date.month() / 3) + 1}`; + return `Q${quarterOfMonth(date.month())}`; } } diff --git a/packages/raystack/components/popover/__tests__/surface-routing.test.tsx b/packages/raystack/components/popover/__tests__/surface-routing.test.tsx new file mode 100644 index 000000000..6e63c6c44 --- /dev/null +++ b/packages/raystack/components/popover/__tests__/surface-routing.test.tsx @@ -0,0 +1,65 @@ +import { render } from '@testing-library/react'; +import { createRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { CalendarPreview } from '../../calendar-preview/calendar-preview'; +import { Popover } from '../popover'; + +// Asserts every prop the pre-extraction implementations routed by hand. +describe('surface prop routing survives the extraction', () => { + it('Popover.Content routes each prop to the element it used to', () => { + const ref = createRef(); + const { baseElement } = render( + + t + + body + + + ); + const popup = baseElement.querySelector('[data-slot="popover-content"]'); + const positioner = baseElement.querySelector( + '[data-slot="popover-positioner"]' + ); + expect(positioner).not.toBeNull(); + expect(popup).not.toBeNull(); + expect(ref.current).toBe(popup); + expect(popup?.className).toContain('mine'); + expect(popup?.className).toMatch(/_popover_/); + expect((popup as HTMLElement).style.zIndex).toBe('42'); + expect(positioner?.className).toMatch(/_popoverPositioner_/); + // rest-spread still reaches the positioner (side/sideOffset overrides) + expect(positioner?.getAttribute('style')).toContain('--'); + }); + + it('CalendarPreview.Content keeps its own classes, slots and focus rule', () => { + const ref = createRef(); + const { baseElement } = render( + + t + + + + + ); + const popup = baseElement.querySelector( + '[data-slot="calendar-preview-content"]' + ); + const positioner = baseElement.querySelector( + '[data-slot="calendar-preview-positioner"]' + ); + expect(positioner).not.toBeNull(); + expect(popup).not.toBeNull(); + expect(ref.current).toBe(popup); + expect(popup?.className).toContain('mine'); + expect(popup?.className).toMatch(/_content_/); + expect(positioner?.className).toMatch(/_positioner_/); + // it must NOT have inherited Popover's own popup class + expect(popup?.className).not.toMatch(/_popover_/); + }); +}); diff --git a/packages/raystack/components/popover/popover-surface.tsx b/packages/raystack/components/popover/popover-surface.tsx new file mode 100644 index 000000000..858b899cc --- /dev/null +++ b/packages/raystack/components/popover/popover-surface.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Popover as PopoverPrimitive } from '@base-ui/react'; + +/** + * The `Portal > Positioner > Popup` surface, shared by `Popover.Content` and + * `CalendarPreview.Content` — previously the same component written twice. + * + * Known limitation, now in one place: anything not destructured below lands on + * the positioner, so a popup-only prop such as `id` reaches the wrong element. + * Partitioning by an enumerated key list was rejected — Base UI has 20 + * positioning props and a minor bump adding one would misroute it silently. + */ +export interface PopoverSurfaceProps + extends Omit< + PopoverPrimitive.Positioner.Props, + 'render' | 'className' | 'style' | 'ref' + >, + PopoverPrimitive.Popup.Props { + /** Class for the positioner — in practice the z-index layer. */ + positionerClassName?: string; + positionerSlot?: string; + popupSlot?: string; +} + +export function PopoverSurface({ + ref, + initialFocus, + finalFocus, + className, + style, + render, + children, + positionerClassName, + positionerSlot, + popupSlot, + ...positionerProps +}: PopoverSurfaceProps) { + return ( + + + + {children} + + + + ); +} + +PopoverSurface.displayName = 'PopoverSurface'; diff --git a/packages/raystack/components/popover/popover.tsx b/packages/raystack/components/popover/popover.tsx index 2508757fd..b2def7774 100644 --- a/packages/raystack/components/popover/popover.tsx +++ b/packages/raystack/components/popover/popover.tsx @@ -3,46 +3,23 @@ import { Popover as PopoverPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; import styles from './popover.module.css'; +import { PopoverSurface, type PopoverSurfaceProps } from './popover-surface'; export interface PopoverContentProps extends Omit< - PopoverPrimitive.Positioner.Props, - 'render' | 'className' | 'style' | 'ref' - >, - PopoverPrimitive.Popup.Props {} + PopoverSurfaceProps, + 'positionerClassName' | 'positionerSlot' | 'popupSlot' + > {} -function PopoverContent({ - ref, - initialFocus, - finalFocus, - className, - style, - render, - children, - ...positionerProps -}: PopoverContentProps) { +function PopoverContent({ className, ...props }: PopoverContentProps) { return ( - - - - {children} - - - + ); } PopoverContent.displayName = 'Popover.Content'; From 6cc5ca15525462d91464519336a67b9902fcf910 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 1 Sep 2026 18:16:58 +0530 Subject: [PATCH 28/37] fix(calendar-preview): the independent audit's four blockers, and H1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B1 — the day grid's arrow keys never moved focus. The `DayButton` override discarded `modifiers` and dropped the ref, which together are react-day-picker's roving-tabindex mechanism; crossing a month boundary left `activeElement` on ``, stranding the user inside a popover. Restored the ref and the `modifiers.focused` effect. The docs in this same diff already claimed this worked, and the suite had zero arrow-key tests. B2 — `addMonths` skipped a month going back and stalled going forward in every DST zone. `zoned().add()` carried the source instant's offset into months where it does not apply: 1 Apr → Previous gave 29 Feb 23:00 in New York. Built from wall-clock parts instead, as `firstOfMonth` already was. `startOfMonth` had the same flaw (Sydney, Oct 2023 → 30 Sep 23:00). `endOfMonth` did not — a sweep found no case where it left the month — and is rebuilt only so there is one construction path. Sweep of 418 zones × 2024–2027: 1,045 wrong-month results → 0. B3 — `.MonthGrid` emitted values outside `minDate`. The overlap rule correctly enables a period a mid-month bound only partly allows, but the value emitted was the period's first day, which `.Input` would then refuse. Each cell now carries the date it emits, clamped into the bound, and availability is asked about that date rather than the period start. `commit` reports validity, which had been silent for every non-day pick. B4 — `.RangeInput` committed inverted ranges and reported them valid. The ordering guard compares days, deliberately, because a bare typed date is midnight while `.TimeField` writes a clock time — so a same-day inversion was invisible. Cross-day still clears the opposite endpoint; a same-day inversion now resolves by reading a bare end date as through the end of that day, which is how `isWithinTimeBounds` already reads a midnight bound. A retyped endpoint keeps its time-of-day, at day granularity only: `Q4 2024` means the quarter, not 09:30 on 1 October. H1 — every zoned read was host-timezone dependent. dayjs's prototype `.tz()` round-trips through `toLocaleString` and re-parses in the host zone, so one instant read 02:30 in Asia/Kolkata under TZ=UTC and 03:30 under TZ=America/New_York, and a +15-minute edit moved the value by 75. Reconstructing a `dayjs.tz` from Intl parts does not help: measured, its field accessors are host-dependent the same way. So the read path leaves dayjs entirely — fields come off `Intl.formatToParts`, with formatters cached per zone. `formatDate` keeps dayjs for tokens, backed by `dayjs.utc` because UTC has no transitions to renormalise into. Behaviour changes, none removing API: `.MonthGrid` and `.RangeInput` emit different values in the cases above, and `Z`/`z` in a consumer `format` now describe UTC rather than the display zone. No format this component uses contains one. Tests: arrow keys (5), DST month walks (6), bounds and ordering (11), host independence (4). Each is the sole guard for its fix — reintroducing any of these bugs leaves 400+ existing tests green. The host-independence suite passes under six host zones; reverting H1 fails it under New_York and passes under UTC, which is the shape of the bug. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/bounds-and-order.test.tsx | 257 ++++++++++++++++++ .../__tests__/dst-month-nav.test.tsx | 89 ++++++ .../__tests__/grid-keyboard.test.tsx | 79 ++++++ .../__tests__/host-independence.test.tsx | 56 ++++ .../calendar-preview-grid.tsx | 44 ++- .../calendar-preview-month-grid.tsx | 37 ++- .../calendar-preview-range-input.tsx | 56 +++- .../calendar-preview/date-adapter.ts | 214 +++++++++++++-- 8 files changed, 785 insertions(+), 47 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx create mode 100644 packages/raystack/components/calendar-preview/__tests__/dst-month-nav.test.tsx create mode 100644 packages/raystack/components/calendar-preview/__tests__/grid-keyboard.test.tsx create mode 100644 packages/raystack/components/calendar-preview/__tests__/host-independence.test.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx b/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx new file mode 100644 index 000000000..f45492df0 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx @@ -0,0 +1,257 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; +import type { + CalendarValidity, + DateRangeValue +} from '../calendar-preview-context'; +import { dayKey } from '../date-adapter'; + +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0] as T; + +/* + * `.MonthGrid` enables a cell when any day in its period is in range, so a + * mid-month `minDate` does not make the rest of that month unreachable. The + * value it emitted was still the period's first day, which for that cell is + * before the bound — and `.Input` then refused the value the field displayed. + */ +describe('.MonthGrid commits inside its bounds', () => { + const MIN = new Date(2026, 5, 15); // 15 Jun 2026 + + const monthPicker = (onValueChange: () => void, extra = {}) => + render( + + + + ); + + it('leaves a partially valid month selectable', () => { + monthPicker(vi.fn()); + expect( + screen.getAllByRole('button', { name: 'Jun' })[0] + ).not.toBeDisabled(); + }); + + it('emits the earliest allowed day, not the period start', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + monthPicker(onValueChange); + + await user.click(screen.getAllByRole('button', { name: 'Jun' })[0]); + + const emitted = lastArg(onValueChange); + expect(dayKey(emitted)).toBe('2026-06-15'); + expect(emitted.getTime()).toBeGreaterThanOrEqual(MIN.getTime()); + }); + + it('emits the period start for a month wholly in range', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + monthPicker(onValueChange); + + await user.click(screen.getAllByRole('button', { name: 'Aug' })[0]); + + expect(dayKey(lastArg(onValueChange))).toBe('2026-08-01'); + }); + + /* + * `isDateUnavailable` must be asked about the date the cell emits. Testing + * the period's first day instead disabled periods the picker could reach — + * the 1st is unavailable but the 15th, the day it would actually emit, is + * fine — and passed through unavailable ones in the mirror case. + */ + it('judges availability by the date it would emit, not the period start', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + // Only 1 Jun is unavailable; the clamped value, 15 Jun, is not. + const isDateUnavailable = (date: Date) => dayKey(date) === '2026-06-01'; + render( + + + + ); + + const jun = screen.getAllByRole('button', { name: 'Jun' })[0]; + expect(jun).not.toBeDisabled(); + await user.click(jun); + expect(dayKey(lastArg(onValueChange))).toBe('2026-06-15'); + }); + + it('disables a cell whose emitted date is unavailable', () => { + render( + dayKey(date) === '2026-06-15'} + > + + + ); + expect(screen.getAllByRole('button', { name: 'Jun' })[0]).toBeDisabled(); + }); + + it('reports validity for a non-day pick, which used to stay silent', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + await user.click(screen.getAllByRole('button', { name: 'Jun' })[0]); + + expect(onValidityChange).toHaveBeenCalled(); + expect(lastArg(onValidityChange).valid).toBe(true); + }); + + it('agrees with .Input about the month it emitted', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + + ); + + await user.click(screen.getAllByRole('button', { name: 'Jun' })[0]); + // The field now shows Jun 2026; committing that same text must be accepted. + await user.type(screen.getByRole('textbox'), '{Enter}'); + + expect(lastArg(onValidityChange).valid).toBe(true); + }); +}); + +/* + * `.RangeInput`'s ordering guard compares days, because a bare typed date is + * midnight while `.TimeField` and presets write a clock time — an instant + * comparison there deleted the user's start. That left the same-day inversion + * invisible: it reached `onValueChange` and reported `{valid: true}`. + */ +describe('.RangeInput cannot commit an inverted range', () => { + const MONTH = new Date(2024, 3, 1); + + const rangePicker = (props: Record) => + render( + + + + ); + + it('orders a typed end against a timed start on the same day', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + rangePicker({ + value: { from: new Date(2024, 3, 17, 8, 0), to: null }, + onValueChange + }); + + await user.type(screen.getByLabelText('End date'), '17 Apr 2024{Enter}'); + + const next = lastArg(onValueChange); + // The start survives — the regression finding 03 guarded — *and* the range + // is ordered, which is the half that assertion never checked. + expect(next.from).not.toBeNull(); + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + expect((next.to as Date).getTime()).toBeGreaterThanOrEqual( + (next.from as Date).getTime() + ); + expect(dayKey(next.to as Date)).toBe('2024-04-17'); + }); + + it('still clears the opposite end for a genuine cross-day inversion', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + rangePicker({ + defaultValue: { from: new Date(2024, 3, 10), to: new Date(2024, 3, 12) }, + onValueChange + }); + + const start = screen.getByLabelText('Start date'); + await user.clear(start); + await user.type(start, '25 Apr 2024{Enter}'); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe('2024-04-25'); + expect(next.to).toBeNull(); + }); + + /* + * Inheritance is for days only. Every other granularity resolves to a period + * start, so carrying a 09:30 onto `Q4 2024` would emit a quarter that begins + * mid-morning on 1 October. + */ + it('does not inherit a time when the text names a period', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + const start = screen.getByLabelText('Start date'); + await user.clear(start); + await user.type(start, 'Q4 2024{Enter}'); + + const from = lastArg(onValueChange).from as Date; + expect(dayKey(from)).toBe('2024-10-01'); + expect(from.getHours()).toBe(0); + expect(from.getMinutes()).toBe(0); + }); + + it('keeps the time of day a retyped endpoint already had', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + rangePicker({ + defaultValue: { + from: new Date(2024, 3, 10, 9, 30), + to: new Date(2024, 3, 20, 17, 0) + }, + onValueChange + }); + + const start = screen.getByLabelText('Start date'); + await user.clear(start); + await user.type(start, '12 Apr 2024{Enter}'); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe('2024-04-12'); + // 09:30 was put there by `.TimeField`; a retype must not reset it. + expect((next.from as Date).getHours()).toBe(9); + expect((next.from as Date).getMinutes()).toBe(30); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/dst-month-nav.test.tsx b/packages/raystack/components/calendar-preview/__tests__/dst-month-nav.test.tsx new file mode 100644 index 000000000..0da7613d2 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/dst-month-nav.test.tsx @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { addMonths, endOfMonth, startOfMonth } from '../date-adapter'; + +/* + * `zoned()` pins a dayjs to the source instant's UTC offset, so `.add(n, + * 'month')` carried that offset into months where it does not apply: from the + * 1st at midnight, an hour early falls into the *previous* month. Stepping back + * from 1 Apr in New York gave 29 Feb 23:00 — March skipped — and stepping + * forward from 1 Nov gave 30 Nov 23:00, so the button looked dead. Once + * drifted the anchor never returned to the 1st. + * + * `startOf('month')` drifted the same way when the 1st sat on the far side of a + * transition — Sydney, October 2023, resolved to 30 Sep 23:00. `endOf('month')` + * did not: a sweep of 418 zones over 2023–2027 found no case where it left the + * month. It is built from parts here for one construction path, not for a fix, + * so the assertion below pins its contract rather than a DST defect. + */ +const NY = 'America/New_York'; +const SYDNEY = 'Australia/Sydney'; + +/** The wall-clock month/day/hour a consumer would see in `zone`. */ +const reads = (date: Date, zone: string) => + new Intl.DateTimeFormat('en-CA', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23' + }).format(date); + +const firstOf = (year: number, month: number, zone: string) => + startOfMonth(new Date(Date.UTC(year, month, 15, 12)), zone); + +describe('month arithmetic across a DST transition', () => { + it('steps back from 1 April without skipping March', () => { + let month = firstOf(2024, 3, NY); + expect(reads(month, NY)).toBe('2024-04-01, 00'); + + month = addMonths(month, -1, NY); + expect(reads(month, NY)).toBe('2024-03-01, 00'); + + month = addMonths(month, -1, NY); + expect(reads(month, NY)).toBe('2024-02-01, 00'); + }); + + it('steps forward from 1 November without stalling', () => { + let month = firstOf(2024, 10, NY); + expect(reads(month, NY)).toBe('2024-11-01, 00'); + + month = addMonths(month, 1, NY); + expect(reads(month, NY)).toBe('2024-12-01, 00'); + }); + + it('stays on the 1st across a year of steps in both directions', () => { + let month = firstOf(2024, 0, NY); + for (let i = 0; i < 12; i += 1) { + month = addMonths(month, 1, NY); + expect(reads(month, NY).slice(8, 10)).toBe('01'); + } + for (let i = 0; i < 12; i += 1) { + month = addMonths(month, -1, NY); + expect(reads(month, NY).slice(8, 10)).toBe('01'); + } + expect(reads(month, NY)).toBe('2024-01-01, 00'); + }); + + it('handles a transition that lands on the 1st itself', () => { + // Sydney moves to DST on 1 Oct 2023, the latent case in startOfMonth. + const month = startOfMonth(new Date(Date.UTC(2023, 9, 15, 12)), SYDNEY); + expect(reads(month, SYDNEY)).toBe('2023-10-01, 00'); + expect(reads(addMonths(month, 1, SYDNEY), SYDNEY)).toBe('2023-11-01, 00'); + expect(reads(addMonths(month, -1, SYDNEY), SYDNEY)).toBe('2023-09-01, 00'); + }); + + it('keeps the day of month where the target month has one', () => { + const jan31 = new Date(Date.UTC(2024, 0, 31, 12)); + expect(reads(addMonths(jan31, 1, NY), NY).slice(0, 10)).toBe('2024-02-29'); + expect(reads(addMonths(jan31, 2, NY), NY).slice(0, 10)).toBe('2024-03-31'); + }); + + it('ends a month on its last instant, one ms before the next begins', () => { + const end = endOfMonth(new Date(Date.UTC(2024, 2, 15, 12)), NY); + expect(reads(end, NY).slice(0, 10)).toBe('2024-03-31'); + expect(endOfMonth(new Date(Date.UTC(2024, 2, 15, 12)), NY).getTime()).toBe( + startOfMonth(new Date(Date.UTC(2024, 3, 15, 12)), NY).getTime() - 1 + ); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/grid-keyboard.test.tsx b/packages/raystack/components/calendar-preview/__tests__/grid-keyboard.test.tsx new file mode 100644 index 000000000..258603afa --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/grid-keyboard.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; + +/* + * Arrow-key navigation is the stated reason the RFC depends on + * react-day-picker, and it had no test anywhere in the suite. RDP moves a + * `focused` modifier between days and never touches the DOM, so a `DayButton` + * override that drops the ref and the focus effect leaves the keyboard dead + * while every other test stays green. + */ +const MONTH = new Date(2024, 3, 1); // April 2024 +const focused = () => document.activeElement?.textContent?.trim(); + +const grid = () => + render( + + + + + ); + +describe('day grid keyboard navigation', () => { + it('moves focus one day right', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowRight}'); + expect(focused()).toBe('18'); + }); + + it('moves focus one day left', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowLeft}'); + expect(focused()).toBe('16'); + }); + + it('moves focus a week down and back up', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowDown}'); + expect(focused()).toBe('24'); + await user.keyboard('{ArrowUp}'); + expect(focused()).toBe('17'); + }); + + /* + * The case that lost focus outright: stepping past the last day pages the + * month, and the day it lands on is in markup that did not exist when the + * key was pressed. Focus must follow it rather than fall to ``. + */ + it('follows focus across a month boundary instead of dropping it', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 30th/ }).focus(); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).not.toBe(document.body); + expect(focused()).toBe('1'); + expect( + document.querySelector('[data-slot="calendar-preview-nav-caption"]') + ?.textContent + ).toContain('May'); + }); + + it('keeps the focused day reachable when paging backwards too', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 1st/ }).focus(); + await user.keyboard('{ArrowLeft}'); + + expect(document.activeElement).not.toBe(document.body); + expect(focused()).toBe('31'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/host-independence.test.tsx b/packages/raystack/components/calendar-preview/__tests__/host-independence.test.tsx new file mode 100644 index 000000000..2d0a2aafe --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/host-independence.test.tsx @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { + dayKey, + formatDate, + getHours, + getMinutes, + getYear, + startOfMonth +} from '../date-adapter'; + +/* + * Every read went through dayjs's prototype `.tz()`, which round-trips the + * instant through `toLocaleString('en-US', { timeZone })` and re-parses that + * wall clock in the *host* zone. When the target's wall time landed in the + * host's spring-forward gap the re-parse jumped an hour: 21:00Z is 02:30 in + * Asia/Kolkata, but a machine in America/New_York read it as 03:30 — so editing + * only the minute field moved the value by 75 minutes. + * + * These assertions are absolute rather than relative, so they fail on any host + * whose zone leaks into the answer. `TZ` is fixed per Vitest process, so the + * cross-host comparison itself lives in the script this pins. + */ +const IST = 'Asia/Kolkata'; + +// 2024-03-09T21:00Z — inside the US spring-forward gap when read as local. +const GAP = new Date('2024-03-09T21:00:00Z'); + +describe('reads do not depend on the host timezone', () => { + it('reads the target wall clock across a foreign DST gap', () => { + expect(getHours(GAP, IST)).toBe(2); + expect(getMinutes(GAP, IST)).toBe(30); + expect(dayKey(GAP, IST)).toBe('2024-03-10'); + expect(getYear(GAP, IST)).toBe(2024); + }); + + it('formats that instant in the target zone', () => { + expect(formatDate(GAP, 'DD MMM YYYY HH:mm', IST)).toBe('10 Mar 2024 02:30'); + }); + + it('anchors the month from the target wall clock, not the host', () => { + // 2024-01-31T20:00Z is 01:30 on 1 Feb in IST — a different month than UTC. + const crossover = new Date('2024-01-31T20:00:00Z'); + expect(dayKey(crossover, IST)).toBe('2024-02-01'); + expect(dayKey(startOfMonth(crossover, IST), IST)).toBe('2024-02-01'); + }); + + it('handles a half-hour-offset zone through a transition', () => { + // Lord Howe runs at +10:30 before its 1 Oct shift, so 14:45Z is 01:15 local + // — a half-hour offset no host zone shares. + const lh = 'Australia/Lord_Howe'; + const instant = new Date('2023-09-30T14:45:00Z'); + expect(getHours(instant, lh)).toBe(1); + expect(getMinutes(instant, lh)).toBe(15); + expect(dayKey(instant, lh)).toBe('2023-10-01'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 077bf46be..658f884c4 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -1,9 +1,10 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { type CSSProperties, useMemo } from 'react'; +import { type CSSProperties, useEffect, useMemo, useRef } from 'react'; import { type DateRange, + type DayButtonProps, DayPicker, type DayPickerProps, type Matcher @@ -21,15 +22,29 @@ import { dayKey } from './date-adapter'; * at all. That is what makes spreading `...props` last honest — nothing is * force-overridden after the consumer's spread. */ -/* - * Module scope, not inside the render. React compares component *types* by - * identity: a fresh function per render is a new type, so RDP's whole grid - * unmounts and remounts and the focused day node does not survive — which - * defeats the roving tabindex the RFC keeps react-day-picker for. +/** + * The day button, carrying RDP's roving tabindex. + * + * Arrow keys do not move focus themselves: RDP moves a `focused` modifier + * between days and never touches the DOM, so the button has to focus itself + * when it becomes the focused one. Overriding `DayButton` without this ref and + * effect left the grid's arrow keys dead in every composition, and crossing a + * month boundary dropped focus to `` — inside a popover, that strands + * the user outside the surface with nothing focused. + * + * `modifiers` is therefore read, not discarded. Keyboard navigation is the + * stated reason the RFC takes a dependency on react-day-picker at all. */ -const GRID_COMPONENTS: DayPickerProps['components'] = { - DayButton: ({ day: _day, modifiers: _modifiers, ...buttonProps }) => ( +function DayButton({ day: _day, modifiers, ...buttonProps }: DayButtonProps) { + const ref = useRef(null); + + useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( - ), + ); +} + +/* + * Module scope, not inside the render. React compares component *types* by + * identity: a fresh function per render is a new type, so RDP's whole grid + * unmounts and remounts and the focused day node does not survive. Necessary + * but not sufficient — node identity is not the focus mechanism, the ref and + * effect above are. + */ +const GRID_COMPONENTS: DayPickerProps['components'] = { + DayButton, // `.Nav` owns the caption; RDP's would render the month twice. MonthCaption: () => <>, MonthGrid: gridProps => ( diff --git a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx index c6fb86e21..6c68e60d9 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx @@ -78,6 +78,12 @@ interface PeriodCell { start: Date; /** First instant of the *next* period, so `selected` needs no date maths. */ end: Date; + /** + * The date this cell emits — not always its first day. The overlap rule + * enables a period a mid-month `minDate` only partly allows, and emitting + * the 1st there hands the consumer a value before the bound they declared. + */ + value: Date; unavailable: boolean; } @@ -122,7 +128,8 @@ export function CalendarPreviewMonthGrid({ timeZone, disabled, readOnly, - loading + loading, + reportValidity } = useCalendarPreviewContext('MonthGrid'); const anchor = firstSelected(value) ?? new Date(); @@ -199,6 +206,14 @@ export function CalendarPreviewMonthGrid({ const outOfBounds = (minTime !== null && end.getTime() - 1 < minTime) || (maxTime !== null && start.getTime() > maxTime); + /* + * Clamped to the lower bound only. A period starting past `maxDate` is + * already out of bounds above, so nothing can exceed the upper one. + */ + const value = + minTime !== null && start.getTime() < minTime + ? new Date(minTime) + : start; return { // Integer identity, not `dayKey`: React stringifies keys anyway. @@ -206,7 +221,13 @@ export function CalendarPreviewMonthGrid({ label: granularity === 'year' ? String(year) : period.label(index), start, end, - unavailable: outOfBounds || !!isDateUnavailable?.(start) + value, + /* + * Availability is asked about `value`, not `start`: testing a day the + * cell would never emit both disabled reachable periods and let + * unavailable ones through. + */ + unavailable: outOfBounds || !!isDateUnavailable?.(value) } satisfies PeriodCell; }); built.push({ year, cells }); @@ -244,8 +265,16 @@ export function CalendarPreviewMonthGrid({ const writable = !disabled && !readOnly; const selectedTimes = selectedDatesIn(value).map(date => date.getTime()); - const commit = (start: Date) => { + const commit = (cell: PeriodCell) => { if (!writable) return; + const start = cell.value; + /* + * Valid by construction — an out-of-bounds or unavailable cell is disabled, + * so reaching here means `start` passes. Reported anyway: `.Grid` leaves + * this to RDP's own disabling, which left `onValidityChange` silent for + * every non-day pick. + */ + reportValidity({ valid: true }); if (selection === 'range') { const range = (value as DateRangeValue | null) ?? { from: null, @@ -280,7 +309,7 @@ export function CalendarPreviewMonthGrid({ aria-pressed={selected} data-selected={selected || undefined} data-slot='calendar-preview-month-cell' - onClick={() => commit(cell.start)} + onClick={() => commit(cell)} > {cell.label} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx index 23c023d58..fbdc30170 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx @@ -22,13 +22,18 @@ import { } from './calendar-preview-context'; import { dayKey, + endOfDay, formatForGranularity, + getHours, + getMinutes, getYear, isAfterDay, isWithinBounds, parseAcrossGranularities, parseForGranularity, - patternForGranularity + patternForGranularity, + setTime, + startOfDay } from './date-adapter'; type FieldInputProps = Omit; @@ -201,20 +206,57 @@ export function CalendarPreviewRangeInput({ if (matched !== granularity) setGranularity(matched); - const next: DateRangeValue = { ...range, [field]: parsed }; + /* + * A typed day carries no time, so it inherits the one this endpoint already + * had — otherwise every retype silently discarded whatever `.TimeField` or + * a preset had put there, resetting it to midnight. + * + * Day granularity only. Every other granularity resolves to the first + * instant of a period, and `Q4 2024` means the quarter, not 09:30 on the + * day it happens to start. + */ + const previous = matched === 'day' ? range[field] : null; + const committed = previous + ? setTime( + parsed, + getHours(previous, timeZone), + getMinutes(previous, timeZone), + timeZone + ) + : parsed; + + const next: DateRangeValue = { ...range, [field]: committed }; + /* * A typed start after the existing end clears the end rather than * swapping the two: swapping silently moves a value into a field the user * did not type in, which reads as the component losing their input. - */ - /* - * A day comparison, not an instant one: typed dates parse to midnight but - * `.TimeField` and presets write a clock time, so `08:00 > midnight` on the - * same day used to delete the user's start. + * + * By day, deliberately. An instant comparison here would delete the user's + * start the moment they typed an end on the same day, because a bare date + * is midnight and `.TimeField` had already put 08:00 on the start. */ if (next.from && next.to && isAfterDay(next.from, next.to, timeZone)) { if (field === 'from') next.to = null; else next.from = null; + } else if ( + next.from && + next.to && + next.from.getTime() > next.to.getTime() + ) { + /* + * Ordered by day but inverted by instant — the case a day comparison + * cannot see, and the one `.TimeField` refuses outright. Here the + * inversion is an artefact of the missing time rather than something the + * user asked for, so it is resolved instead of refused: a bare end date + * reads as "through the end of that day", which is how every other part + * of this component reads a midnight bound. + */ + if (field === 'to') { + next.to = endOfDay(next.to, timeZone); + } else { + next.from = startOfDay(next.from, timeZone); + } } setValue(next, { granularity: matched }); diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 9651d6f3e..8b6113c7b 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -25,27 +25,167 @@ dayjs.extend(isSameOrBefore); /** The one canonical display and input format. */ export const DEFAULT_FORMAT = 'DD MMM YYYY'; -const zoned = (date: Date, timeZone?: string) => - timeZone ? dayjs(date).tz(timeZone) : dayjs(date); +/* + * Wall-clock formatters, one per zone. `Intl.DateTimeFormat` construction is + * not cheap and every zoned read goes through one. + */ +const wallFormatters = new Map(); + +const wallFormatter = (timeZone: string): Intl.DateTimeFormat => { + const cached = wallFormatters.get(timeZone); + if (cached) return cached; + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + // `h23` rather than `hour12: false`, which reports midnight as hour 24. + hourCycle: 'h23' + }); + wallFormatters.set(timeZone, formatter); + return formatter; +}; + +/** The wall-clock fields of an instant, as read in a zone. */ +interface WallClock { + year: number; + /** Zero-based, matching `Date` and dayjs. */ + month: number; + day: number; + hour: number; + minute: number; + second: number; + ms: number; +} + +/** + * The instant as it reads on the clock in `timeZone`. + * + * `Intl` performs the conversion, so the answer never depends on the host's own + * zone. Every other route does. dayjs's prototype `.tz()` round-trips through + * `toLocaleString('en-US', { timeZone })` and re-parses that wall clock in the + * host zone, so when the target's wall time falls in the host's spring-forward + * gap the re-parse jumps an hour: the same instant read 02:30 in Asia/Kolkata on + * a machine in UTC and 03:30 on one in America/New_York, so editing only the + * minute field moved the value by 75 of them. + * + * Reconstructing a `dayjs.tz` from these parts does not help — measured, its + * field accessors are host-dependent in exactly the same way, returning hour 3 + * under New_York for the string `2024-03-10 02:30`. So the fields are read + * straight off `Intl` and dayjs is left out of the read path entirely. + */ +const wallClock = (date: Date, timeZone?: string): WallClock => { + if (!timeZone) { + return { + year: date.getFullYear(), + month: date.getMonth(), + day: date.getDate(), + hour: date.getHours(), + minute: date.getMinutes(), + second: date.getSeconds(), + ms: date.getMilliseconds() + }; + } + const parts: Record = {}; + for (const part of wallFormatter(timeZone).formatToParts(date)) { + parts[part.type] = part.value; + } + return { + year: Number(parts.year), + month: Number(parts.month) - 1, + day: Number(parts.day), + hour: Number(parts.hour), + minute: Number(parts.minute), + second: Number(parts.second), + // Intl does not report milliseconds, and they are the same in every zone. + ms: date.getMilliseconds() + }; +}; + +/** + * A dayjs carrying the target zone's wall clock, for token formatting only. + * + * Backed by UTC because UTC has no transitions: the fields handed in are the + * fields that read back. A local `Date` would renormalise a wall clock sitting + * in the *host's* gap — the bug this exists to avoid — and a `dayjs.tz` reads + * its fields back host-dependently. Offset tokens (`Z`, `z`) therefore describe + * UTC rather than `timeZone`; no format this component uses contains one. + */ +const forFormat = (date: Date, timeZone?: string) => { + if (!timeZone) return dayjs(date); + const w = wallClock(date, timeZone); + return dayjs.utc( + Date.UTC(w.year, w.month, w.day, w.hour, w.minute, w.second, w.ms) + ); +}; /** Zero-pads to two digits. Exported: `.TimeField` had its own copy. */ export const pad = (value: number) => String(value).padStart(2, '0'); +/** + * A zoned instant built from wall-clock parts. + * + * The only safe way to name a moment in a zone. Arithmetic on a dayjs pinned + * to the *source* instant's UTC offset carries that offset into periods where it + * does not apply — which is why + * `addMonths(1 Apr, -1)` in `America/New_York` used to land on 29 Feb 23:00 and + * skip March entirely. `dayjs.tz` resolves the offset from the wall clock it is + * handed, so the day and time asked for are the ones that come back. + */ +const fromParts = ( + year: number, + monthIndex: number, + day: number, + hour: number, + minute: number, + timeZone?: string +): Date => { + const iso = `${year}-${pad(monthIndex + 1)}-${pad(day)} ${pad(hour)}:${pad(minute)}`; + return timeZone + ? dayjs.tz(iso, 'YYYY-MM-DD HH:mm', timeZone).toDate() + : dayjs(iso, 'YYYY-MM-DD HH:mm', true).toDate(); +}; + /** * A stable identity for a calendar day, for memo keys and effect deps. * Two `Date`s for the same day compare equal here; by reference they never do, * which is what forced the old family's lint suppressions. */ export function dayKey(date: Date, timeZone?: string): string { - return zoned(date, timeZone).format('YYYY-MM-DD'); + const w = wallClock(date, timeZone); + return `${w.year}-${pad(w.month + 1)}-${pad(w.day)}`; } export function startOfMonth(date: Date, timeZone?: string): Date { - return zoned(date, timeZone).startOf('month').toDate(); + const w = wallClock(date, timeZone); + return fromParts(w.year, w.month, 1, 0, 0, timeZone); } +/** + * Month arithmetic on the wall clock, keeping the day of month where the + * target month has one — `31 Jan + 1` is the 28th or 29th, as dayjs would. + */ export function addMonths(date: Date, count: number, timeZone?: string): Date { - return zoned(date, timeZone).add(count, 'month').toDate(); + const w = wallClock(date, timeZone); + const absolute = w.month + count; + const year = w.year + Math.floor(absolute / 12); + const monthIndex = ((absolute % 12) + 12) % 12; + const daysInTarget = dayjs( + `${year}-${pad(monthIndex + 1)}-01`, + 'YYYY-MM-DD', + true + ).daysInMonth(); + return fromParts( + year, + monthIndex, + Math.min(w.day, daysInTarget), + w.hour, + w.minute, + timeZone + ); } /** First instant of a month, built from parts rather than parsed. */ @@ -54,25 +194,50 @@ export function firstOfMonth( monthIndex: number, timeZone?: string ): Date { - const iso = `${year}-${pad(monthIndex + 1)}-01`; - return timeZone - ? dayjs.tz(iso, 'YYYY-MM-DD', timeZone).toDate() - : dayjs(iso, 'YYYY-MM-DD', true).toDate(); + return fromParts(year, monthIndex, 1, 0, 0, timeZone); +} + +/** Midnight at the start of the day, in the display zone. */ +export function startOfDay(date: Date, timeZone?: string): Date { + const w = wallClock(date, timeZone); + return fromParts(w.year, w.month, w.day, 0, 0, timeZone); +} + +/** + * The last instant of the day, in the display zone. + * + * The next day is resolved through `Date.UTC`, which normalises a day overflow + * (31 April becomes 1 May) without any zone involved, and only then converted + * back to a wall clock. Adding a day to an offset-frozen dayjs would drift by + * an hour across a transition, which is the `addMonths` bug one unit down. + */ +export function endOfDay(date: Date, timeZone?: string): Date { + const w = wallClock(date, timeZone); + const next = new Date(Date.UTC(w.year, w.month, w.day + 1)); + const nextStart = fromParts( + next.getUTCFullYear(), + next.getUTCMonth(), + next.getUTCDate(), + 0, + 0, + timeZone + ); + return new Date(nextStart.getTime() - 1); } export function getHours(date: Date, timeZone?: string): number { - return zoned(date, timeZone).hour(); + return wallClock(date, timeZone).hour; } export function getMinutes(date: Date, timeZone?: string): number { - return zoned(date, timeZone).minute(); + return wallClock(date, timeZone).minute; } /** * The same calendar day, at a different time of day. * - * Built from calendar parts rather than by mutating a zoned object. `zoned()` - * freezes the UTC offset of the instant it is handed, and a day's midnight + * Built from calendar parts rather than by mutating a zoned object, which + * freezes the UTC offset of the instant it is handed. A day's midnight * carries the *pre*-transition offset: chaining `.hour(10)` onto 9 Mar 2025 in * `America/New_York` built 10:00 at -5, which reads back as 11:00 EDT. Every * time after a spring-forward landed an hour late — not just the hour that @@ -108,11 +273,12 @@ export function setTime( } export function getYear(date: Date, timeZone?: string): number { - return zoned(date, timeZone).year(); + return wallClock(date, timeZone).year; } export function endOfMonth(date: Date, timeZone?: string): Date { - return zoned(date, timeZone).endOf('month').toDate(); + const nextFirst = addMonths(startOfMonth(date, timeZone), 1, timeZone); + return new Date(nextFirst.getTime() - 1); } export function formatDate( @@ -120,7 +286,7 @@ export function formatDate( format: string = DEFAULT_FORMAT, timeZone?: string ): string { - return zoned(date, timeZone).format(format); + return forFormat(date, timeZone).format(format); } /** @@ -165,8 +331,7 @@ export function formatForGranularity( format: string = DEFAULT_FORMAT, timeZone?: string ): string { - const year = zoned(date, timeZone).year(); - const month = zoned(date, timeZone).month(); + const { year, month } = wallClock(date, timeZone); switch (granularity) { case 'month': return formatDate(date, 'MMM YYYY', timeZone); @@ -295,8 +460,8 @@ export function parseAcrossGranularities( * as much as a comparison key, and it reads as a date when debugging. */ export function dayOrdinal(date: Date, timeZone?: string): number { - const value = zoned(date, timeZone); - return value.year() * 10000 + (value.month() + 1) * 100 + value.date(); + const w = wallClock(date, timeZone); + return w.year * 10000 + (w.month + 1) * 100 + w.day; } /** Day-granularity comparisons, so callers never touch a date library. */ @@ -369,13 +534,8 @@ export function isWithinBounds( /** Whether a bound carries a time of day, or is a plain midnight-anchored day. */ function hasTimeOfDay(date: Date, timeZone?: string): boolean { - const value = zoned(date, timeZone); - return ( - value.hour() !== 0 || - value.minute() !== 0 || - value.second() !== 0 || - value.millisecond() !== 0 - ); + const w = wallClock(date, timeZone); + return w.hour !== 0 || w.minute !== 0 || w.second !== 0 || w.ms !== 0; } /** From 3c63dc27215c25daf9a6ef73ff7ec22a3098a18e Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Tue, 1 Sep 2026 18:48:41 +0530 Subject: [PATCH 29/37] test(popover): assert the positioner spread by data-side, not by style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assertion guarding the rest-spread was hollow. It checked that the positioner's style attribute contained `--`, but Base UI emits custom properties there whether or not `side` and `sideOffset` reached it — so deleting the `{...positionerProps}` this test exists to guard left both tests green. `data-side` is the observable that actually tracks the prop: it reads `bottom` by default and `top` only if `side='top'` arrives at the positioner. Re-running the same mutation now fails with `expected 'bottom' to be 'top'`. Found by an independent audit of 43cd61b1, which flagged it alongside three assertions in `audit-fixed.test.tsx` (lines 80, 84 and 146) that are still to be replaced. Co-Authored-By: Claude Opus 5 (1M context) --- .../popover/__tests__/surface-routing.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/popover/__tests__/surface-routing.test.tsx b/packages/raystack/components/popover/__tests__/surface-routing.test.tsx index 6e63c6c44..090ce8370 100644 --- a/packages/raystack/components/popover/__tests__/surface-routing.test.tsx +++ b/packages/raystack/components/popover/__tests__/surface-routing.test.tsx @@ -33,8 +33,14 @@ describe('surface prop routing survives the extraction', () => { expect(popup?.className).toMatch(/_popover_/); expect((popup as HTMLElement).style.zIndex).toBe('42'); expect(positioner?.className).toMatch(/_popoverPositioner_/); - // rest-spread still reaches the positioner (side/sideOffset overrides) - expect(positioner?.getAttribute('style')).toContain('--'); + /* + * `side` reaching the positioner is the whole point of the rest-spread, and + * `data-side` is the only observable that changes with it. Asserting the + * positioner merely *has* custom properties in its style attribute was + * hollow: Base UI emits those regardless, so deleting the spread left this + * test green. + */ + expect(positioner?.getAttribute('data-side')).toBe('top'); }); it('CalendarPreview.Content keeps its own classes, slots and focus rule', () => { From 5aa703348463a5dd7242e07ad322ef5f9b24c703 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 2 Sep 2026 14:18:16 +0530 Subject: [PATCH 30/37] fix(calendar-preview): merge gates 2, 3 and 6, plus H3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate 2 — `hasTimeOfDay` asks the host clock, not the display zone (H11). `maxDate={new Date(2024, 3, 17)}` is how a bare day is written, and that constructor produces midnight where the consumer's code runs. Asking in the display zone made the same bound 05:30 IST, so it counted as carrying a time and collapsed to "the 17th, but only until dawn" — `.Grid` and `.Input` accepted the whole day while `.TimeField` rejected every hour after it. The `isWithinBounds` arity assertion is replaced by the timezone test from H12: making the function ignore its zone previously passed all 358 tests. Gate 3 — one definition of a period. `periodRange` / `startOfPeriod` / `endOfPeriod` in the adapter, with `.MonthGrid` building cell bounds from it. `.MonthGrid` range commits gain the ordering guard the other writers had (H9): picking Dec against an existing March committed a backwards range that any `from <= x <= to` reader sees as empty. Compared by period, not instant, so re-picking the period the other endpoint sits in is not treated as a contradiction. Under a `lock` the guard refuses instead of clearing. The opposite endpoint is always the locked one, so the first version of this guard was structurally guaranteed to delete the endpoint the consumer pinned — found by probing the interaction, invisible in the diff. Gate 6 — `parseTypedText` and `typedFieldHandlers` extracted from `.Input` and `.RangeInput`, which had grown a parse pipeline and a keyboard contract each. That divergence is where the range writers stopped agreeing. H3 — a rejected commit keeps what the user typed. Clearing the draft regardless snapped the field back to the old value while handing the consumer an error about text no longer on screen. Escape still reverts, so the way out of bad text is the gesture that always meant that. One line, in one place, because the extraction had already isolated it. Tests consolidated 7 files into 4 — timezone, bounds-and-order, keyboard, memo-stability — grouped by subject rather than by the incident that prompted them. All 50 assertions preserved, and every guard re-verified by mutation after the move. Measured: reads are 13.9x faster and `formatDate` 9.3x after H1; the 252-cell grid build is 1.27x slower than the original inline arithmetic, ~1ms per build, paid once per bounds change now that the memo holds. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/audit-fixed.test.tsx | 26 +- .../__tests__/bounds-and-order.test.tsx | 277 +++++++++++++++++- .../__tests__/dst-month-nav.test.tsx | 89 ------ .../__tests__/exports.test.ts | 8 +- .../__tests__/grid-keyboard.test.tsx | 79 ----- .../__tests__/host-independence.test.tsx | 56 ---- .../__tests__/keyboard.test.tsx | 203 +++++++++++++ .../__tests__/timezone.test.tsx | 181 ++++++++++++ .../calendar-preview-input.tsx | 107 ++----- .../calendar-preview-month-grid.tsx | 74 ++++- .../calendar-preview-range-input.tsx | 89 ++---- .../calendar-preview-typed-field.ts | 144 +++++++++ .../calendar-preview/date-adapter.ts | 101 ++++++- 13 files changed, 1024 insertions(+), 410 deletions(-) delete mode 100644 packages/raystack/components/calendar-preview/__tests__/dst-month-nav.test.tsx delete mode 100644 packages/raystack/components/calendar-preview/__tests__/grid-keyboard.test.tsx delete mode 100644 packages/raystack/components/calendar-preview/__tests__/host-independence.test.tsx create mode 100644 packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx create mode 100644 packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-typed-field.ts diff --git a/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx b/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx index 8d4c1b7e6..3cdb53958 100644 --- a/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx @@ -80,17 +80,21 @@ describe('audit findings stay fixed', () => { expect(next.getMinutes()).toBeLessThanOrEqual(59); }); - it('11: isWithinBounds takes a timeZone like every other adapter fn', () => { - expect(isWithinBounds.length).toBe(4); - // Inclusive at both ends, and zone-aware rather than local-only. - expect( - isWithinBounds( - new Date(2024, 3, 17), - new Date(2024, 3, 17), - new Date(2024, 3, 17), - 'UTC' - ) - ).toBe(true); + /* + * Asserting `isWithinBounds.length === 4` only checked the declared parameter + * count, and the case beside it was true in every zone — so making the + * function ignore `timeZone` entirely left all 33 tests here green, and all + * 358 across the repo. This asks the only question that separates the two: + * one instant that falls on different days depending on the zone it is read + * in, against a bound that sits between them. + */ + it('11: isWithinBounds resolves the day in the zone it is given', () => { + // 23:00 UTC on 17 Apr is already 08:00 on the 18th in Tokyo. + const instant = new Date(Date.UTC(2024, 3, 17, 23, 0)); + const max = new Date(Date.UTC(2024, 3, 17, 12, 0)); + + expect(isWithinBounds(instant, undefined, max, 'UTC')).toBe(true); + expect(isWithinBounds(instant, undefined, max, 'Asia/Tokyo')).toBe(false); }); it('05: switching to Month scrolls the active year into view', async () => { diff --git a/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx b/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx index f45492df0..c84988156 100644 --- a/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx @@ -6,11 +6,22 @@ import type { CalendarValidity, DateRangeValue } from '../calendar-preview-context'; -import { dayKey } from '../date-adapter'; +import { + dayKey, + endOfPeriod, + periodRange, + startOfPeriod +} from '../date-adapter'; const lastArg = (fn: { mock: { calls: unknown[][] } }) => fn.mock.calls[fn.mock.calls.length - 1]?.[0] as T; +/* + * What each writer commits, and what it does when it cannot. Bounds clamping, + * period boundaries and range ordering are one subject: every bug here was two + * writers disagreeing about which instant a selection means. + */ + /* * `.MonthGrid` enables a cell when any day in its period is in range, so a * mid-month `minDate` does not make the rest of that month unreachable. The @@ -255,3 +266,267 @@ describe('.RangeInput cannot commit an inverted range', () => { expect((next.from as Date).getMinutes()).toBe(30); }); }); + +/* + * Three writers had each derived period boundaries for themselves, and + * disagreed about which instant a period commits. These pin the one definition + * they now share. + */ +describe('periodRange', () => { + const mid = (y: number, m: number, d = 17) => new Date(y, m, d, 13, 45); + + it('snaps a month to its own first and last instant', () => { + const { start, end } = periodRange(mid(2026, 5), 'month'); + expect(dayKey(start)).toBe('2026-06-01'); + expect(dayKey(end)).toBe('2026-07-01'); + expect(dayKey(endOfPeriod(mid(2026, 5), 'month'))).toBe('2026-06-30'); + }); + + it('snaps any month in a quarter to that quarter', () => { + for (const month of [3, 4, 5]) { + expect(dayKey(startOfPeriod(mid(2026, month), 'quarter'))).toBe( + '2026-04-01' + ); + } + expect(dayKey(endOfPeriod(mid(2026, 4), 'quarter'))).toBe('2026-06-30'); + }); + + it('snaps a half-year, and rolls the year at its far edge', () => { + expect(dayKey(startOfPeriod(mid(2026, 8), 'half-year'))).toBe('2026-07-01'); + expect(dayKey(endOfPeriod(mid(2026, 8), 'half-year'))).toBe('2026-12-31'); + // The last period of a year must end on 31 Dec, not spill into January. + expect(dayKey(periodRange(mid(2026, 11), 'half-year').end)).toBe( + '2027-01-01' + ); + }); + + it('snaps a year', () => { + expect(dayKey(startOfPeriod(mid(2026, 8), 'year'))).toBe('2026-01-01'); + expect(dayKey(endOfPeriod(mid(2026, 8), 'year'))).toBe('2026-12-31'); + }); + + it('treats a day as its own period', () => { + expect(dayKey(startOfPeriod(mid(2026, 5), 'day'))).toBe('2026-06-17'); + expect(dayKey(endOfPeriod(mid(2026, 5), 'day'))).toBe('2026-06-17'); + expect(startOfPeriod(mid(2026, 5), 'day').getHours()).toBe(0); + }); + + it('ends each period exactly where the next begins', () => { + for (const granularity of [ + 'day', + 'month', + 'quarter', + 'half-year', + 'year' + ]) { + const { end } = periodRange(mid(2026, 4), granularity); + const last = endOfPeriod(mid(2026, 4), granularity); + expect(last.getTime()).toBe(end.getTime() - 1); + } + }); + + /* + * Resolved on the display zone's clock, not the host's. Every bug this + * adapter has had — the skipped month, the host-dependent read, the collapsed + * midnight bound — was a boundary computed against the wrong clock, and an + * instant that falls in a different year in the two zones is the case that + * separates them. + */ + describe('in a display timezone', () => { + const NY = 'America/New_York'; + // 23:30 on 31 Dec 2025 in New York; already 2026 in UTC. + const crossover = new Date('2026-01-01T04:30:00Z'); + const reads = (date: Date) => + new Intl.DateTimeFormat('en-CA', { + timeZone: NY, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23' + }).format(date); + + it('takes the year from the display zone, not from UTC', () => { + expect(reads(startOfPeriod(crossover, 'year', NY))).toBe( + '2025-01-01, 00' + ); + expect(reads(endOfPeriod(crossover, 'year', NY))).toBe('2025-12-31, 23'); + }); + + it('resolves the quarter on that same clock', () => { + expect(reads(startOfPeriod(crossover, 'quarter', NY))).toBe( + '2025-10-01, 00' + ); + expect(reads(endOfPeriod(crossover, 'quarter', NY))).toBe( + '2025-12-31, 23' + ); + }); + + it('bounds a day at that zone’s midnight', () => { + expect(reads(startOfPeriod(crossover, 'day', NY))).toBe('2025-12-31, 00'); + expect(reads(endOfPeriod(crossover, 'day', NY))).toBe('2025-12-31, 23'); + }); + }); +}); + +/* + * `.RangeInput` guards ordering and `.TimeField` refuses inversion outright, + * but `.MonthGrid` had no guard at all: picking Dec against an existing March + * committed a backwards range that any `from <= x <= to` reader sees as empty. + */ +/* + * The cell loop builds each period's bounds itself, so the grid has its own + * path through the zone maths that the adapter tests above do not exercise. + */ +describe('.MonthGrid renders and commits in a display timezone', () => { + const NY = 'America/New_York'; + const readsNY = (date: Date) => + new Intl.DateTimeFormat('en-CA', { + timeZone: NY, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23' + }).format(date); + + it('emits the period start on the display zone clock', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + expect(screen.getAllByRole('button', { name: /^Q\d$/ })).toHaveLength(4); + await user.click(screen.getByRole('button', { name: 'Q3' })); + + expect(readsNY(lastArg(onValueChange))).toBe('2026-07-01, 00'); + }); +}); + +describe('.MonthGrid cannot commit a backwards range', () => { + const rangeGrid = (props: Record) => + render( + + + + ); + + it('clears the end when a chosen start moves past it', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + rangeGrid({ + value: { from: null, to: new Date(2026, 2, 1) }, + onValueChange + }); + + await user.click(screen.getByRole('button', { name: 'Dec' })); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe('2026-12-01'); + expect(next.to).toBeNull(); + }); + + it('keeps an ordered pair intact', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + rangeGrid({ + value: { from: null, to: new Date(2026, 8, 1) }, + onValueChange + }); + + await user.click(screen.getByRole('button', { name: 'Mar' })); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe('2026-03-01'); + expect(dayKey(next.to as Date)).toBe('2026-09-01'); + }); + + /* + * `lock` holds one endpoint read-only, and that endpoint is exactly the one an + * inversion would clear — so under a lock the ordering guard has nothing it + * may repair. It must refuse rather than delete the endpoint the consumer + * pinned, which is what the first version of this guard did. + */ + describe('under a lock', () => { + const locked = (onValueChange: () => void, onValidityChange: () => void) => + render( + + + + ); + + it('refuses a pick that would invert, keeping the locked endpoint', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + locked(onValueChange, onValidityChange); + + await user.click(screen.getByRole('button', { name: 'Mar' })); + + expect(onValueChange).not.toHaveBeenCalled(); + expect( + lastArg<{ valid: boolean; reason?: string }>(onValidityChange) + ).toEqual({ valid: false, reason: 'range-order' }); + }); + + it('still commits an ordered pick', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + locked(onValueChange, onValidityChange); + + await user.click(screen.getByRole('button', { name: 'Nov' })); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe('2026-09-01'); + expect(dayKey(next.to as Date)).toBe('2026-11-01'); + }); + }); + + /* + * By period, not by instant: re-picking the period the other endpoint already + * sits in is not a contradiction, and clearing it there would discard a + * selection the user never argued with. + */ + it('leaves the other endpoint alone when both land in one period', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + rangeGrid({ + value: { from: null, to: new Date(2026, 5, 20) }, + onValueChange + }); + + await user.click(screen.getByRole('button', { name: 'Jun' })); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe('2026-06-01'); + expect(next.to).not.toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/dst-month-nav.test.tsx b/packages/raystack/components/calendar-preview/__tests__/dst-month-nav.test.tsx deleted file mode 100644 index 0da7613d2..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/dst-month-nav.test.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { addMonths, endOfMonth, startOfMonth } from '../date-adapter'; - -/* - * `zoned()` pins a dayjs to the source instant's UTC offset, so `.add(n, - * 'month')` carried that offset into months where it does not apply: from the - * 1st at midnight, an hour early falls into the *previous* month. Stepping back - * from 1 Apr in New York gave 29 Feb 23:00 — March skipped — and stepping - * forward from 1 Nov gave 30 Nov 23:00, so the button looked dead. Once - * drifted the anchor never returned to the 1st. - * - * `startOf('month')` drifted the same way when the 1st sat on the far side of a - * transition — Sydney, October 2023, resolved to 30 Sep 23:00. `endOf('month')` - * did not: a sweep of 418 zones over 2023–2027 found no case where it left the - * month. It is built from parts here for one construction path, not for a fix, - * so the assertion below pins its contract rather than a DST defect. - */ -const NY = 'America/New_York'; -const SYDNEY = 'Australia/Sydney'; - -/** The wall-clock month/day/hour a consumer would see in `zone`. */ -const reads = (date: Date, zone: string) => - new Intl.DateTimeFormat('en-CA', { - timeZone: zone, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - hourCycle: 'h23' - }).format(date); - -const firstOf = (year: number, month: number, zone: string) => - startOfMonth(new Date(Date.UTC(year, month, 15, 12)), zone); - -describe('month arithmetic across a DST transition', () => { - it('steps back from 1 April without skipping March', () => { - let month = firstOf(2024, 3, NY); - expect(reads(month, NY)).toBe('2024-04-01, 00'); - - month = addMonths(month, -1, NY); - expect(reads(month, NY)).toBe('2024-03-01, 00'); - - month = addMonths(month, -1, NY); - expect(reads(month, NY)).toBe('2024-02-01, 00'); - }); - - it('steps forward from 1 November without stalling', () => { - let month = firstOf(2024, 10, NY); - expect(reads(month, NY)).toBe('2024-11-01, 00'); - - month = addMonths(month, 1, NY); - expect(reads(month, NY)).toBe('2024-12-01, 00'); - }); - - it('stays on the 1st across a year of steps in both directions', () => { - let month = firstOf(2024, 0, NY); - for (let i = 0; i < 12; i += 1) { - month = addMonths(month, 1, NY); - expect(reads(month, NY).slice(8, 10)).toBe('01'); - } - for (let i = 0; i < 12; i += 1) { - month = addMonths(month, -1, NY); - expect(reads(month, NY).slice(8, 10)).toBe('01'); - } - expect(reads(month, NY)).toBe('2024-01-01, 00'); - }); - - it('handles a transition that lands on the 1st itself', () => { - // Sydney moves to DST on 1 Oct 2023, the latent case in startOfMonth. - const month = startOfMonth(new Date(Date.UTC(2023, 9, 15, 12)), SYDNEY); - expect(reads(month, SYDNEY)).toBe('2023-10-01, 00'); - expect(reads(addMonths(month, 1, SYDNEY), SYDNEY)).toBe('2023-11-01, 00'); - expect(reads(addMonths(month, -1, SYDNEY), SYDNEY)).toBe('2023-09-01, 00'); - }); - - it('keeps the day of month where the target month has one', () => { - const jan31 = new Date(Date.UTC(2024, 0, 31, 12)); - expect(reads(addMonths(jan31, 1, NY), NY).slice(0, 10)).toBe('2024-02-29'); - expect(reads(addMonths(jan31, 2, NY), NY).slice(0, 10)).toBe('2024-03-31'); - }); - - it('ends a month on its last instant, one ms before the next begins', () => { - const end = endOfMonth(new Date(Date.UTC(2024, 2, 15, 12)), NY); - expect(reads(end, NY).slice(0, 10)).toBe('2024-03-31'); - expect(endOfMonth(new Date(Date.UTC(2024, 2, 15, 12)), NY).getTime()).toBe( - startOfMonth(new Date(Date.UTC(2024, 3, 15, 12)), NY).getTime() - 1 - ); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/exports.test.ts b/packages/raystack/components/calendar-preview/__tests__/exports.test.ts index 316d36007..02edbd51f 100644 --- a/packages/raystack/components/calendar-preview/__tests__/exports.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/exports.test.ts @@ -33,7 +33,13 @@ const exportedNames = (source: string, from: string) => { */ const INTERNAL = new Set([ 'CalendarPreviewContextValue', - 'CrossGranularityMatch' + 'CrossGranularityMatch', + // The shared typed-field core. Its shape is how `.Input` and `.RangeInput` + // talk to each other, not something a consumer composes against. + 'TypedParse', + 'TypedParseContext', + 'TypedFieldHandlers', + 'TypedFieldOptions' ]); describe('CalendarPreview published surface', () => { diff --git a/packages/raystack/components/calendar-preview/__tests__/grid-keyboard.test.tsx b/packages/raystack/components/calendar-preview/__tests__/grid-keyboard.test.tsx deleted file mode 100644 index 258603afa..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/grid-keyboard.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; -import { CalendarPreview } from '../calendar-preview'; - -/* - * Arrow-key navigation is the stated reason the RFC depends on - * react-day-picker, and it had no test anywhere in the suite. RDP moves a - * `focused` modifier between days and never touches the DOM, so a `DayButton` - * override that drops the ref and the focus effect leaves the keyboard dead - * while every other test stays green. - */ -const MONTH = new Date(2024, 3, 1); // April 2024 -const focused = () => document.activeElement?.textContent?.trim(); - -const grid = () => - render( - - - - - ); - -describe('day grid keyboard navigation', () => { - it('moves focus one day right', async () => { - const user = userEvent.setup(); - grid(); - screen.getByRole('button', { name: /April 17th/ }).focus(); - await user.keyboard('{ArrowRight}'); - expect(focused()).toBe('18'); - }); - - it('moves focus one day left', async () => { - const user = userEvent.setup(); - grid(); - screen.getByRole('button', { name: /April 17th/ }).focus(); - await user.keyboard('{ArrowLeft}'); - expect(focused()).toBe('16'); - }); - - it('moves focus a week down and back up', async () => { - const user = userEvent.setup(); - grid(); - screen.getByRole('button', { name: /April 17th/ }).focus(); - await user.keyboard('{ArrowDown}'); - expect(focused()).toBe('24'); - await user.keyboard('{ArrowUp}'); - expect(focused()).toBe('17'); - }); - - /* - * The case that lost focus outright: stepping past the last day pages the - * month, and the day it lands on is in markup that did not exist when the - * key was pressed. Focus must follow it rather than fall to ``. - */ - it('follows focus across a month boundary instead of dropping it', async () => { - const user = userEvent.setup(); - grid(); - screen.getByRole('button', { name: /April 30th/ }).focus(); - await user.keyboard('{ArrowRight}'); - - expect(document.activeElement).not.toBe(document.body); - expect(focused()).toBe('1'); - expect( - document.querySelector('[data-slot="calendar-preview-nav-caption"]') - ?.textContent - ).toContain('May'); - }); - - it('keeps the focused day reachable when paging backwards too', async () => { - const user = userEvent.setup(); - grid(); - screen.getByRole('button', { name: /April 1st/ }).focus(); - await user.keyboard('{ArrowLeft}'); - - expect(document.activeElement).not.toBe(document.body); - expect(focused()).toBe('31'); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/host-independence.test.tsx b/packages/raystack/components/calendar-preview/__tests__/host-independence.test.tsx deleted file mode 100644 index 2d0a2aafe..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/host-independence.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - dayKey, - formatDate, - getHours, - getMinutes, - getYear, - startOfMonth -} from '../date-adapter'; - -/* - * Every read went through dayjs's prototype `.tz()`, which round-trips the - * instant through `toLocaleString('en-US', { timeZone })` and re-parses that - * wall clock in the *host* zone. When the target's wall time landed in the - * host's spring-forward gap the re-parse jumped an hour: 21:00Z is 02:30 in - * Asia/Kolkata, but a machine in America/New_York read it as 03:30 — so editing - * only the minute field moved the value by 75 minutes. - * - * These assertions are absolute rather than relative, so they fail on any host - * whose zone leaks into the answer. `TZ` is fixed per Vitest process, so the - * cross-host comparison itself lives in the script this pins. - */ -const IST = 'Asia/Kolkata'; - -// 2024-03-09T21:00Z — inside the US spring-forward gap when read as local. -const GAP = new Date('2024-03-09T21:00:00Z'); - -describe('reads do not depend on the host timezone', () => { - it('reads the target wall clock across a foreign DST gap', () => { - expect(getHours(GAP, IST)).toBe(2); - expect(getMinutes(GAP, IST)).toBe(30); - expect(dayKey(GAP, IST)).toBe('2024-03-10'); - expect(getYear(GAP, IST)).toBe(2024); - }); - - it('formats that instant in the target zone', () => { - expect(formatDate(GAP, 'DD MMM YYYY HH:mm', IST)).toBe('10 Mar 2024 02:30'); - }); - - it('anchors the month from the target wall clock, not the host', () => { - // 2024-01-31T20:00Z is 01:30 on 1 Feb in IST — a different month than UTC. - const crossover = new Date('2024-01-31T20:00:00Z'); - expect(dayKey(crossover, IST)).toBe('2024-02-01'); - expect(dayKey(startOfMonth(crossover, IST), IST)).toBe('2024-02-01'); - }); - - it('handles a half-hour-offset zone through a transition', () => { - // Lord Howe runs at +10:30 before its 1 Oct shift, so 14:45Z is 01:15 local - // — a half-hour offset no host zone shares. - const lh = 'Australia/Lord_Howe'; - const instant = new Date('2023-09-30T14:45:00Z'); - expect(getHours(instant, lh)).toBe(1); - expect(getMinutes(instant, lh)).toBe(15); - expect(dayKey(instant, lh)).toBe('2023-10-01'); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx b/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx new file mode 100644 index 000000000..71cb1ff98 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx @@ -0,0 +1,203 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; + +/* + * What the keyboard does: arrow keys in the day grid, and Enter / Escape / blur + * in the typed fields. Both were places where a key press had no test at all — + * the grid's arrows never moved focus, and a rejected commit erased the text it + * was rejecting. + */ + +/* + * Arrow-key navigation is the stated reason the RFC depends on + * react-day-picker, and it had no test anywhere in the suite. RDP moves a + * `focused` modifier between days and never touches the DOM, so a `DayButton` + * override that drops the ref and the focus effect leaves the keyboard dead + * while every other test stays green. + */ +const MONTH = new Date(2024, 3, 1); // April 2024 +const focused = () => document.activeElement?.textContent?.trim(); + +const grid = () => + render( + + + + + ); + +describe('day grid keyboard navigation', () => { + it('moves focus one day right', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowRight}'); + expect(focused()).toBe('18'); + }); + + it('moves focus one day left', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowLeft}'); + expect(focused()).toBe('16'); + }); + + it('moves focus a week down and back up', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowDown}'); + expect(focused()).toBe('24'); + await user.keyboard('{ArrowUp}'); + expect(focused()).toBe('17'); + }); + + /* + * The case that lost focus outright: stepping past the last day pages the + * month, and the day it lands on is in markup that did not exist when the + * key was pressed. Focus must follow it rather than fall to ``. + */ + it('follows focus across a month boundary instead of dropping it', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 30th/ }).focus(); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).not.toBe(document.body); + expect(focused()).toBe('1'); + expect( + document.querySelector('[data-slot="calendar-preview-nav-caption"]') + ?.textContent + ).toContain('May'); + }); + + it('keeps the focused day reachable when paging backwards too', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 1st/ }).focus(); + await user.keyboard('{ArrowLeft}'); + + expect(document.activeElement).not.toBe(document.body); + expect(focused()).toBe('31'); + }); +}); + +/* + * A rejected commit used to erase the text it was rejecting. The field snapped + * back to the old value while the consumer was handed + * `{valid: false, reason: 'unparseable'}` — an error describing text no longer + * on screen, and with validity latching there was no way to dismiss it either. + * + * Both typed fields cleared the draft unconditionally, in two places. They now + * share one, so these cover `.Input` and `.RangeInput` together. + */ +describe('a rejected commit keeps what the user typed', () => { + it('.Input keeps unparseable text on Enter', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, '32 Apr 2024{Enter}'); + + expect(field).toHaveValue('32 Apr 2024'); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'unparseable' + }); + }); + + it('.Input keeps an out-of-bounds date on blur', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, '25 Apr 2024'); + await user.tab(); + + expect(field).toHaveValue('25 Apr 2024'); + }); + + it('Escape is still the way back to the committed value', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, '32 Apr 2024{Enter}'); + expect(field).toHaveValue('32 Apr 2024'); + + await user.keyboard('{Escape}'); + expect(field).toHaveValue('17 Apr 2024'); + }); + + /* + * Uncontrolled, so the commit actually lands. Under a controlled `value` whose + * parent ignores the change the field correctly returns to the committed text, + * which would pass this assertion for the wrong reason. + */ + it('an accepted commit still replaces the draft with the canonical text', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, '18 Apr 2024{Enter}'); + + expect(field).toHaveValue('18 Apr 2024'); + }); + + it('.RangeInput keeps unparseable text in the endpoint typed', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const start = screen.getByLabelText('Start date'); + await user.clear(start); + await user.type(start, 'nonsense{Enter}'); + + expect(start).toHaveValue('nonsense'); + // The endpoint the user did not touch is untouched. + expect(screen.getByLabelText('End date')).toHaveValue('20 Apr 2024'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx b/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx new file mode 100644 index 000000000..8f0881cf1 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; +import { + addMonths, + dayKey, + endOfMonth, + formatDate, + getHours, + getMinutes, + getYear, + isWithinBounds, + isWithinTimeBounds, + startOfMonth +} from '../date-adapter'; + +/* + * Everything zone-shaped in the adapter, in one place. Every defect this + * component has had here was a clock chosen wrongly — the month built on a + * frozen offset, the read that depended on the host, the bound whose midnight + * was measured in the wrong zone — so the cases live together rather than one + * file per incident. + */ + +/* + * `zoned()` pins a dayjs to the source instant's UTC offset, so `.add(n, + * 'month')` carried that offset into months where it does not apply: from the + * 1st at midnight, an hour early falls into the *previous* month. Stepping back + * from 1 Apr in New York gave 29 Feb 23:00 — March skipped — and stepping + * forward from 1 Nov gave 30 Nov 23:00, so the button looked dead. Once + * drifted the anchor never returned to the 1st. + * + * `startOf('month')` drifted the same way when the 1st sat on the far side of a + * transition — Sydney, October 2023, resolved to 30 Sep 23:00. `endOf('month')` + * did not: a sweep of 418 zones over 2023–2027 found no case where it left the + * month. It is built from parts here for one construction path, not for a fix, + * so the assertion below pins its contract rather than a DST defect. + */ +const NY = 'America/New_York'; +const SYDNEY = 'Australia/Sydney'; + +/** The wall-clock month/day/hour a consumer would see in `zone`. */ +const reads = (date: Date, zone: string) => + new Intl.DateTimeFormat('en-CA', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23' + }).format(date); + +const firstOf = (year: number, month: number, zone: string) => + startOfMonth(new Date(Date.UTC(year, month, 15, 12)), zone); + +describe('month arithmetic across a DST transition', () => { + it('steps back from 1 April without skipping March', () => { + let month = firstOf(2024, 3, NY); + expect(reads(month, NY)).toBe('2024-04-01, 00'); + + month = addMonths(month, -1, NY); + expect(reads(month, NY)).toBe('2024-03-01, 00'); + + month = addMonths(month, -1, NY); + expect(reads(month, NY)).toBe('2024-02-01, 00'); + }); + + it('steps forward from 1 November without stalling', () => { + let month = firstOf(2024, 10, NY); + expect(reads(month, NY)).toBe('2024-11-01, 00'); + + month = addMonths(month, 1, NY); + expect(reads(month, NY)).toBe('2024-12-01, 00'); + }); + + it('stays on the 1st across a year of steps in both directions', () => { + let month = firstOf(2024, 0, NY); + for (let i = 0; i < 12; i += 1) { + month = addMonths(month, 1, NY); + expect(reads(month, NY).slice(8, 10)).toBe('01'); + } + for (let i = 0; i < 12; i += 1) { + month = addMonths(month, -1, NY); + expect(reads(month, NY).slice(8, 10)).toBe('01'); + } + expect(reads(month, NY)).toBe('2024-01-01, 00'); + }); + + it('handles a transition that lands on the 1st itself', () => { + // Sydney moves to DST on 1 Oct 2023, the latent case in startOfMonth. + const month = startOfMonth(new Date(Date.UTC(2023, 9, 15, 12)), SYDNEY); + expect(reads(month, SYDNEY)).toBe('2023-10-01, 00'); + expect(reads(addMonths(month, 1, SYDNEY), SYDNEY)).toBe('2023-11-01, 00'); + expect(reads(addMonths(month, -1, SYDNEY), SYDNEY)).toBe('2023-09-01, 00'); + }); + + it('keeps the day of month where the target month has one', () => { + const jan31 = new Date(Date.UTC(2024, 0, 31, 12)); + expect(reads(addMonths(jan31, 1, NY), NY).slice(0, 10)).toBe('2024-02-29'); + expect(reads(addMonths(jan31, 2, NY), NY).slice(0, 10)).toBe('2024-03-31'); + }); + + it('ends a month on its last instant, one ms before the next begins', () => { + const end = endOfMonth(new Date(Date.UTC(2024, 2, 15, 12)), NY); + expect(reads(end, NY).slice(0, 10)).toBe('2024-03-31'); + expect(endOfMonth(new Date(Date.UTC(2024, 2, 15, 12)), NY).getTime()).toBe( + startOfMonth(new Date(Date.UTC(2024, 3, 15, 12)), NY).getTime() - 1 + ); + }); +}); + +/* + * Every read went through dayjs's prototype `.tz()`, which round-trips the + * instant through `toLocaleString('en-US', { timeZone })` and re-parses that + * wall clock in the *host* zone. When the target's wall time landed in the + * host's spring-forward gap the re-parse jumped an hour: 21:00Z is 02:30 in + * Asia/Kolkata, but a machine in America/New_York read it as 03:30 — so editing + * only the minute field moved the value by 75 minutes. + * + * These assertions are absolute rather than relative, so they fail on any host + * whose zone leaks into the answer. `TZ` is fixed per Vitest process, so the + * cross-host comparison itself lives in the script this pins. + */ +const IST = 'Asia/Kolkata'; + +// 2024-03-09T21:00Z — inside the US spring-forward gap when read as local. +const GAP = new Date('2024-03-09T21:00:00Z'); + +describe('reads do not depend on the host timezone', () => { + it('reads the target wall clock across a foreign DST gap', () => { + expect(getHours(GAP, IST)).toBe(2); + expect(getMinutes(GAP, IST)).toBe(30); + expect(dayKey(GAP, IST)).toBe('2024-03-10'); + expect(getYear(GAP, IST)).toBe(2024); + }); + + it('formats that instant in the target zone', () => { + expect(formatDate(GAP, 'DD MMM YYYY HH:mm', IST)).toBe('10 Mar 2024 02:30'); + }); + + it('anchors the month from the target wall clock, not the host', () => { + // 2024-01-31T20:00Z is 01:30 on 1 Feb in IST — a different month than UTC. + const crossover = new Date('2024-01-31T20:00:00Z'); + expect(dayKey(crossover, IST)).toBe('2024-02-01'); + expect(dayKey(startOfMonth(crossover, IST), IST)).toBe('2024-02-01'); + }); + + /* + * `maxDate={new Date(2024, 3, 17)}` is the ordinary way to say "the 17th", + * and the adapter promises it allows every time of day on that date. Whether + * a bound "has a time of day" was being asked of the *display* zone, where + * host midnight is 05:30 — so the bound silently collapsed to "the 17th, but + * only until dawn" and `.TimeField` rejected every hour after it while + * `.Grid` and `.Input` accepted the whole day. + */ + it('reads a bare day bound as the whole day in any display zone', () => { + const maxDate = new Date(2024, 3, 17); + const nineIST = new Date(Date.UTC(2024, 3, 17, 3, 30)); + + expect(isWithinBounds(nineIST, undefined, maxDate, IST)).toBe(true); + expect(isWithinTimeBounds(nineIST, undefined, maxDate, IST)).toBe(true); + }); + + it('still honours a bound that names a real time of day', () => { + // Authored with a time, so it constrains within its own day. + const maxDate = new Date(2024, 3, 17, 10, 0); + const beforeIt = new Date(2024, 3, 17, 9, 0); + const afterIt = new Date(2024, 3, 17, 11, 0); + + expect(isWithinTimeBounds(beforeIt, undefined, maxDate)).toBe(true); + expect(isWithinTimeBounds(afterIt, undefined, maxDate)).toBe(false); + }); + + it('handles a half-hour-offset zone through a transition', () => { + // Lord Howe runs at +10:30 before its 1 Oct shift, so 14:45Z is 01:15 local + // — a half-hour offset no host zone shares. + const lh = 'Australia/Lord_Howe'; + const instant = new Date('2023-09-30T14:45:00Z'); + expect(getHours(instant, lh)).toBe(1); + expect(getMinutes(instant, lh)).toBe(15); + expect(dayKey(instant, lh)).toBe('2023-10-01'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index c886a7cb5..c964869b4 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -2,13 +2,7 @@ import { mergeProps } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { - type ChangeEvent, - type KeyboardEvent, - useEffect, - useRef, - useState -} from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Input, type InputProps } from '../input/input'; import styles from './calendar-preview.module.css'; import type { CalendarValidity } from './calendar-preview-context'; @@ -16,13 +10,14 @@ import { useCalendarPreviewContext, useInsideTrigger } from './calendar-preview-context'; +import { + parseTypedText, + typedFieldHandlers +} from './calendar-preview-typed-field'; import { dayKey, formatForGranularity, - getYear, isWithinBounds, - parseAcrossGranularities, - parseForGranularity, patternForGranularity } from './date-adapter'; @@ -108,56 +103,45 @@ export function CalendarPreviewInput({ return { valid: true }; }; - const commit = (text: string) => { + const commit = (text: string): boolean => { // An emptied field clears the value; that is not an error state. if (text.trim() === '') { reportValidity({ valid: true }); setValue(null); - return; + return true; } - /* - * The active granularity wins. Only when it cannot read the text do we - * scan the granularities on offer, so typing `Q4` in a day field switches - * to Quarter rather than failing — and a day-only picker still rejects it. - */ - const visibleYear = getYear(month, timeZone); - let parsed = parseForGranularity( - text, + const read = parseTypedText(text, { granularity, + granularities, format, timeZone, - visibleYear - ); - let matched = granularity; - if (!parsed) { - const across = parseAcrossGranularities( - text, - granularities, - format, - timeZone, - visibleYear - ); - if (across) { - parsed = across.date; - matched = across.granularity as typeof granularity; - } - } - if (!parsed) { + month + }); + if (!read) { reportValidity({ valid: false, reason: 'unparseable' }); - return; + return false; } - const validity = validate(parsed); + const validity = validate(read.date); reportValidity(validity); - if (!validity.valid) return; + if (!validity.valid) return false; - if (matched !== granularity) setGranularity(matched); - setValue(parsed, { granularity: matched }); + if (read.granularity !== granularity) setGranularity(read.granularity); + setValue(read.date, { granularity: read.granularity }); // Typing navigates the grid, so the committed day is actually visible. - setMonth(parsed); + setMonth(read.date); + return true; }; + const handlers = typedFieldHandlers({ + draft, + setDraft, + commit, + insideTrigger, + setOpen + }); + return (
) => - setDraft(event.target.value), - onBlur: () => { - if (draft === null) return; - commit(draft); - setDraft(null); - }, - onKeyDown: (event: KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - if (draft === null) return; - commit(draft); - setDraft(null); - } - /* - * The trigger around this field carries no tab stop, so ArrowDown - * is how a keyboard reaches the calendar — the combobox - * convention, and an explicit gesture rather than the focus race - * the RFC retired. - */ - if (event.key === 'ArrowDown' && insideTrigger) { - event.preventDefault(); - setOpen(true); - } - /* - * Two-stage, as a combobox is: the first Escape reverts the text, - * a second dismisses the popover. Letting one press do both meant - * correcting a typo cost you the calendar. React's - * `stopPropagation` reaches the native event, which is what Base - * UI's document-level dismiss listener is on. - */ - if (event.key === 'Escape' && draft !== null) { - event.stopPropagation(); - setDraft(null); - } - } + ...handlers } as never, props as never ) as InputProps)} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx index 6c68e60d9..a8c2aff68 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx @@ -11,10 +11,19 @@ import { Skeleton } from '../skeleton'; import styles from './calendar-preview.module.css'; import type { CalendarGranularity, + CalendarValidity, DateRangeValue } from './calendar-preview-context'; import { useCalendarPreviewContext } from './calendar-preview-context'; -import { dayKey, dayOrdinal, firstOfMonth, getYear } from './date-adapter'; +import { + addMonths, + dayKey, + dayOrdinal, + firstOfMonth, + getYear, + periodMonths, + periodRange +} from './date-adapter'; const MONTH_LABELS = [ 'Jan', @@ -186,18 +195,19 @@ export function CalendarPreviewMonthGrid({ if (granularity === 'day') return []; const period = PERIODS[granularity]; - const monthSpan = 12 / period.perYear; + const monthsPerPeriod = periodMonths(granularity); const built: { year: number; cells: PeriodCell[] }[] = []; for (let year = firstYear; year <= lastYear; year += 1) { const cells = Array.from({ length: period.perYear }, (_, index) => { - const startMonth = period.startMonth(index); - const start = firstOfMonth(year, startMonth, timeZone); - const end = firstOfMonth( - year + (startMonth + monthSpan >= 12 ? 1 : 0), - (startMonth + monthSpan) % 12, - timeZone - ); + /* + * The start is known from the year and the index, so it is built once + * rather than rediscovered: `periodRange` would re-read the wall clock + * and re-parse to arrive back at this same instant. `addMonths` carries + * the year rollover, which is the part worth not writing twice. + */ + const start = firstOfMonth(year, period.startMonth(index), timeZone); + const end = addMonths(start, monthsPerPeriod, timeZone); /* * Overlap, not first-day: a `minDate` falling mid-month used to * disable the whole month and make every valid day in it unreachable. @@ -269,21 +279,55 @@ export function CalendarPreviewMonthGrid({ if (!writable) return; const start = cell.value; /* - * Valid by construction — an out-of-bounds or unavailable cell is disabled, - * so reaching here means `start` passes. Reported anyway: `.Grid` leaves - * this to RDP's own disabling, which left `onValidityChange` silent for - * every non-day pick. + * Bounds and availability are valid by construction — such a cell is + * disabled, so reaching here means `start` passes both. Reported at all + * because `.Grid` leaves this to RDP's own disabling, which left + * `onValidityChange` silent for every non-day pick. */ - reportValidity({ valid: true }); + const valid: CalendarValidity = { valid: true }; + if (selection === 'range') { const range = (value as DateRangeValue | null) ?? { from: null, to: null }; const field = lock ? (lock === 'from' ? 'to' : 'from') : activeField; - setValue({ ...range, [field]: start }); + const opposite = field === 'from' ? 'to' : 'from'; + const next: DateRangeValue = { ...range, [field]: start }; + /* + * `.RangeInput` guards ordering, `.Grid` delegates it to RDP and + * `.TimeField` refuses inversion outright; this writer had none of it, so + * picking Dec 2026 against an existing March committed a backwards range + * that any `from <= x <= to` reader sees as empty. + * + * Compared by period, not by instant: two picks inside one period are the + * same choice, and clearing the opposite end there would discard a + * selection the user did not contradict. + */ + if (next.from && next.to) { + const fromStart = periodRange(next.from, granularity, timeZone).start; + const toStart = periodRange(next.to, granularity, timeZone).start; + if (fromStart.getTime() > toStart.getTime()) { + /* + * `lock` holds the opposite endpoint read-only, and the opposite + * endpoint is exactly the one an inversion would clear — so under a + * lock there is nothing this writer may repair. Refused instead, the + * way `.TimeField` refuses an inversion it cannot fix, rather than + * deleting the endpoint the consumer pinned. + */ + if (lock === opposite) { + reportValidity({ valid: false, reason: 'range-order' }); + return; + } + next[opposite] = null; + } + } + reportValidity(valid); + setValue(next); return; } + + reportValidity(valid); if (selection === 'multiple') { const current = (value as Date[]) ?? []; const key = dayKey(start, timeZone); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx index fbdc30170..41cf7269b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-range-input.tsx @@ -2,13 +2,7 @@ import { mergeProps } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { - type ChangeEvent, - type KeyboardEvent, - useEffect, - useRef, - useState -} from 'react'; +import { useEffect, useRef, useState } from 'react'; import { Input, type InputProps } from '../input/input'; import styles from './calendar-preview.module.css'; import type { @@ -20,17 +14,18 @@ import { useCalendarPreviewContext, useInsideTrigger } from './calendar-preview-context'; +import { + parseTypedText, + typedFieldHandlers +} from './calendar-preview-typed-field'; import { dayKey, endOfDay, formatForGranularity, getHours, getMinutes, - getYear, isAfterDay, isWithinBounds, - parseAcrossGranularities, - parseForGranularity, patternForGranularity, setTime, startOfDay @@ -168,37 +163,19 @@ export function CalendarPreviewRangeInput({ return true; } - /* - * The active granularity wins. Only when it cannot read the text do we - * scan the granularities on offer, so typing `Q4` in a day field switches - * to Quarter rather than failing — and a day-only picker still rejects it. - */ - const visibleYear = getYear(month, timeZone); - let parsed = parseForGranularity( - text, + const read = parseTypedText(text, { granularity, + granularities, format, timeZone, - visibleYear - ); - let matched = granularity; - if (!parsed) { - const across = parseAcrossGranularities( - text, - granularities, - format, - timeZone, - visibleYear - ); - if (across) { - parsed = across.date; - matched = across.granularity as typeof granularity; - } - } - if (!parsed) { + month + }); + if (!read) { reportValidity({ valid: false, reason: 'unparseable' }); return false; } + const parsed = read.date; + const matched = read.granularity; const validity = validate(parsed); reportValidity(validity); @@ -302,40 +279,20 @@ export function CalendarPreviewRangeInput({ readOnly: readOnly || lock === field, 'aria-label': field === 'from' ? 'Start date' : 'End date', onFocus: () => setActiveField(field), - onChange: (event: ChangeEvent) => - setDraft(current => ({ - ...current, - [field]: event.target.value - })), - onBlur: () => { - if (draft[field] === null) return; - commit(field, draft[field] as string); - setDraft(current => ({ ...current, [field]: null })); - }, - onKeyDown: (event: KeyboardEvent) => { - if (event.key === 'Enter') { - event.preventDefault(); - const pending = draft[field]; - if (pending === null) return; - const committedOk = commit(field, pending); - setDraft(current => ({ ...current, [field]: null })); - if (committedOk && field === 'from' && lock !== 'to') { + ...typedFieldHandlers({ + draft: draft[field], + setDraft: text => + setDraft(current => ({ ...current, [field]: text })), + commit: text => commit(field, text), + insideTrigger, + setOpen, + // Committing the start hands the keyboard to the end field. + onEnterCommitted: accepted => { + if (accepted && field === 'from' && lock !== 'to') { endRef.current?.focus(); } } - // See `.Input`: ArrowDown is the keyboard's way into a calendar - // whose trigger carries no tab stop. - if (event.key === 'ArrowDown' && insideTrigger) { - event.preventDefault(); - setOpen(true); - } - // Two-stage, as a combobox is: revert the text first, dismiss on - // the second press. - if (event.key === 'Escape' && draft[field] !== null) { - event.stopPropagation(); - setDraft(current => ({ ...current, [field]: null })); - } - } + }) } as never, (props ?? {}) as never ) as InputProps)} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-typed-field.ts b/packages/raystack/components/calendar-preview/calendar-preview-typed-field.ts new file mode 100644 index 000000000..8be2aab21 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-typed-field.ts @@ -0,0 +1,144 @@ +'use client'; + +import type { ChangeEvent, KeyboardEvent } from 'react'; +import type { CalendarGranularity } from './calendar-preview-context'; +import { + getYear, + parseAcrossGranularities, + parseForGranularity +} from './date-adapter'; + +/** + * What `.Input` and `.RangeInput` share. + * + * The two had grown a parse pipeline and a keyboard contract each, written + * twice and then maintained apart. That divergence is where the range writers + * stopped agreeing: one grew an ordering guard and the other did not, and the + * bounds each committed drifted from the bounds the other would accept. Kept in + * one place, a fix to either reaches both. + */ + +export interface TypedParse { + date: Date; + granularity: CalendarGranularity; +} + +export interface TypedParseContext { + granularity: CalendarGranularity; + granularities: CalendarGranularity[]; + format: string; + timeZone?: string; + /** The visible month, which supplies the year that bare text omits. */ + month: Date; +} + +/** + * Reads what the user typed, at the granularity on offer. + * + * The active granularity wins. Only when it cannot read the text do we scan the + * granularities on offer, so typing `Q4` in a day field switches to Quarter + * rather than failing — and a day-only picker still rejects it. + */ +export function parseTypedText( + text: string, + { granularity, granularities, format, timeZone, month }: TypedParseContext +): TypedParse | null { + const visibleYear = getYear(month, timeZone); + const parsed = parseForGranularity( + text, + granularity, + format, + timeZone, + visibleYear + ); + if (parsed) return { date: parsed, granularity }; + + const across = parseAcrossGranularities( + text, + granularities, + format, + timeZone, + visibleYear + ); + if (!across) return null; + return { + date: across.date, + granularity: across.granularity as CalendarGranularity + }; +} + +export interface TypedFieldHandlers { + onChange: (event: ChangeEvent) => void; + onBlur: () => void; + onKeyDown: (event: KeyboardEvent) => void; +} + +export interface TypedFieldOptions { + /** Current draft text, or `null` when the field shows its committed value. */ + draft: string | null; + setDraft: (text: string | null) => void; + /** Returns whether the text was accepted. */ + commit: (text: string) => boolean; + insideTrigger: boolean; + setOpen: (open: boolean) => void; + /** Runs after a commit from Enter, with the result. Used to advance focus. */ + onEnterCommitted?: (accepted: boolean) => void; +} + +/** + * The typing contract: commit on Enter and on blur, open on ArrowDown, revert + * on Escape. + * + * The draft survives a rejected commit. Clearing it regardless — which both + * fields did while this logic lived in two places — snapped the field back to + * the old value and left the consumer holding an error about text no longer on + * screen: type `32 Apr 2024` over `17 Apr 2024`, and the field read + * `17 Apr 2024` beside `{valid: false, reason: 'unparseable'}`. Escape still + * reverts, so the way out of bad text is the gesture that always meant that. + */ +export function typedFieldHandlers({ + draft, + setDraft, + commit, + insideTrigger, + setOpen, + onEnterCommitted +}: TypedFieldOptions): TypedFieldHandlers { + return { + onChange: event => setDraft(event.target.value), + + onBlur: () => { + if (draft === null) return; + if (commit(draft)) setDraft(null); + }, + + onKeyDown: event => { + if (event.key === 'Enter') { + event.preventDefault(); + if (draft === null) return; + const accepted = commit(draft); + if (accepted) setDraft(null); + onEnterCommitted?.(accepted); + } + /* + * The trigger around these fields carries no tab stop, so ArrowDown is how + * a keyboard reaches the calendar — the combobox convention, and an + * explicit gesture rather than the focus race the RFC retired. + */ + if (event.key === 'ArrowDown' && insideTrigger) { + event.preventDefault(); + setOpen(true); + } + /* + * Two-stage, as a combobox is: the first Escape reverts the text, a second + * dismisses the popover. Letting one press do both meant correcting a typo + * cost you the calendar. React's `stopPropagation` reaches the native + * event, which is what Base UI's document-level dismiss listener is on. + */ + if (event.key === 'Escape' && draft !== null) { + event.stopPropagation(); + setDraft(null); + } + } + }; +} diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 8b6113c7b..26bd4cf0c 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -320,6 +320,76 @@ export function parseDate( export const quarterOfMonth = (monthIndex: number): number => Math.floor(monthIndex / 3) + 1; +/** Months spanned by one period at each granularity. `day` is not a month span. */ +const PERIOD_MONTHS: Record = { + month: 1, + quarter: 3, + 'half-year': 6, + year: 12 +}; + +/** + * Months in one period, for a caller that already knows where the period + * starts. `.MonthGrid` enumerates cells from a year and an index, so it has the + * start in hand; asking `periodRange` to rediscover it cost an extra wall-clock + * read and parse per cell — measured at 1.5x the grid build. + */ +export function periodMonths(granularity: string): number { + return PERIOD_MONTHS[granularity] ?? 1; +} + +/** + * The period containing `date` at a granularity, as a half-open pair. + * + * Three writers had each derived these boundaries for themselves — `.MonthGrid` + * from an index and a wrapping month span, `.Input` and `.RangeInput` from a + * parse — and they disagreed about which instant a period commits. That + * disagreement is what let `.MonthGrid` emit a day before `minDate`, and what + * left range writers unable to compare two periods at all. + * + * `end` is the first instant of the *next* period, so a containment test is + * `start <= x < end` with no inclusive-boundary arithmetic at the call site. + */ +export function periodRange( + date: Date, + granularity: string, + timeZone?: string +): { start: Date; end: Date } { + if (granularity === 'day') { + const start = startOfDay(date, timeZone); + return { start, end: new Date(endOfDay(date, timeZone).getTime() + 1) }; + } + const months = periodMonths(granularity); + const w = wallClock(date, timeZone); + const startMonth = Math.floor(w.month / months) * months; + const start = firstOfMonth(w.year, startMonth, timeZone); + const absolute = startMonth + months; + const end = firstOfMonth( + w.year + Math.floor(absolute / 12), + absolute % 12, + timeZone + ); + return { start, end }; +} + +/** First instant of the period containing `date`. */ +export function startOfPeriod( + date: Date, + granularity: string, + timeZone?: string +): Date { + return periodRange(date, granularity, timeZone).start; +} + +/** Last instant of the period containing `date`, inclusive. */ +export function endOfPeriod( + date: Date, + granularity: string, + timeZone?: string +): Date { + return new Date(periodRange(date, granularity, timeZone).end.getTime() - 1); +} + /** * How a value reads at each granularity, mirroring the reference app: a month * shows `Jun 2026`, a quarter `Q3 2026`, a half-year `H1 2026`, a year `2025`. @@ -532,9 +602,22 @@ export function isWithinBounds( return true; } -/** Whether a bound carries a time of day, or is a plain midnight-anchored day. */ -function hasTimeOfDay(date: Date, timeZone?: string): boolean { - const w = wallClock(date, timeZone); +/** + * Whether a bound carries a time of day, or is a plain midnight-anchored day. + * + * Asked of the host clock, deliberately, and not of `timeZone`. This is a + * question about how the bound was *written*, not about how it displays: + * `maxDate={new Date(2024, 3, 17)}` is the ordinary way to say "the 17th", and + * that constructor produces midnight in the zone the consumer's code runs in. + * + * Asking it in the display zone broke the promise one line down. With the host + * in UTC and `timeZone="Asia/Kolkata"`, that same `maxDate` is 05:30 IST — not + * midnight — so it counted as carrying a time, and the bound silently collapsed + * to "the 17th, but only until 05:30". `.Grid` and `.Input` accepted the whole + * day while `.TimeField` rejected every hour after dawn. + */ +function hasTimeOfDay(date: Date): boolean { + const w = wallClock(date); return w.hour !== 0 || w.minute !== 0 || w.second !== 0 || w.ms !== 0; } @@ -561,18 +644,10 @@ export function isWithinTimeBounds( ): boolean { if (!isWithinBounds(date, minDate, maxDate, timeZone)) return false; const instant = date.getTime(); - if ( - minDate && - hasTimeOfDay(minDate, timeZone) && - instant < minDate.getTime() - ) { + if (minDate && hasTimeOfDay(minDate) && instant < minDate.getTime()) { return false; } - if ( - maxDate && - hasTimeOfDay(maxDate, timeZone) && - instant > maxDate.getTime() - ) { + if (maxDate && hasTimeOfDay(maxDate) && instant > maxDate.getTime()) { return false; } return true; From bdadef7c6fd501ef056a935ef67e4241c8278d74 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 2 Sep 2026 14:18:30 +0530 Subject: [PATCH 31/37] refactor(filters): one copy of the date filter operators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DataTable` and `DataView` each carried their own `filter-operations.tsx` with the same six date operators and the same `compare` helper — 34 of the 40 lines this PR added to each were byte-identical, comment included, and the epoch-seconds fix had to be made twice by hand. Nothing about the split was load-bearing: both already type the map as tanstack's `FilterFn` and read `date` off the same `~/types/filters` `FilterValue`. The copies differed only in arrow-body style and an unused `_addMeta`. Sited beside `date-adapter` rather than under `shared/`. Both modules already import the adapter from there, so this adds no new dependency edge, whereas `shared/` is a leaf every component depends on and must not depend back on one — the first draft put it there and inverted that. Kept out of `date-adapter` itself so a tanstack `FilterFn` never joins the calendar's date surface. identical added lines 34 → 5 divergence 181 → 147 lines combined size 551 → 455 lines The remaining divergence is the string, number and select operators, which genuinely differ between the two packages. Co-Authored-By: Claude Opus 5 (1M context) --- .../filter-date-operations.ts | 63 +++++++++++++++++++ .../data-table/utils/filter-operations.tsx | 58 +---------------- .../data-view/utils/filter-operations.tsx | 52 +-------------- 3 files changed, 69 insertions(+), 104 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/filter-date-operations.ts diff --git a/packages/raystack/components/calendar-preview/filter-date-operations.ts b/packages/raystack/components/calendar-preview/filter-date-operations.ts new file mode 100644 index 000000000..b71c3b6e5 --- /dev/null +++ b/packages/raystack/components/calendar-preview/filter-date-operations.ts @@ -0,0 +1,63 @@ +import type { FilterFn } from '@tanstack/table-core'; + +import type { DateFilterOperatorType, FilterValue } from '~/types/filters'; +import { + isAfterDay, + isBeforeDay, + isSameDay, + toDateLoose +} from './date-adapter'; + +/** + * The date filter operators, once. + * + * Sited beside the adapter rather than under `shared/`: both `DataTable` and + * `DataView` already import `date-adapter` from here, so this adds no new + * dependency edge, whereas `shared/` is a leaf that components depend on and + * must not depend back on one. Kept out of `date-adapter` itself so a tanstack + * `FilterFn` never becomes part of the calendar's date surface. + * + * `DataTable` and `DataView` each had their own copy — same six operators, same + * predicates, differing only in arrow style and an unused `_addMeta`. Both + * already type them as `FilterFn` from tanstack and read `date` off + * the same `FilterValue`, so nothing about the duplication was load-bearing; it + * simply meant every fix here had to be made twice, and the epoch-seconds fix + * was made twice by hand. + * + * Date comparisons go through the calendar adapter, which is the one place that + * registers dayjs plugins. Extending them here as well made the module + * order-dependent — the failure class behind the 0.49.0 P0. + * + * A row value that will not parse compares false against every operator that + * asserts a relationship, which is what an unfilterable cell should do. `neq` + * is the exception, and deliberately: it negates `eq`, so an empty or + * unparseable cell is "not equal to" any date and survives the filter. That + * matches the behaviour the old operators had. + */ +const compare = ( + a: unknown, + b: unknown, + predicate: (left: Date, right: Date) => boolean +) => { + const left = toDateLoose(a); + const right = toDateLoose(b); + return left && right ? predicate(left, right) : false; +}; + +const on = + (predicate: (left: Date, right: Date) => boolean): FilterFn => + (row, columnId, filterValue: FilterValue) => + compare(row.getValue(columnId), filterValue.date, predicate); + +export const dateFilterOperations: Record< + DateFilterOperatorType, + FilterFn +> = { + eq: on(isSameDay), + neq: (row, columnId, filterValue: FilterValue) => + !compare(row.getValue(columnId), filterValue.date, isSameDay), + lt: on(isBeforeDay), + lte: on((a, b) => !isAfterDay(a, b)), + gt: on(isAfterDay), + gte: on((a, b) => !isBeforeDay(a, b)) +}; diff --git a/packages/raystack/components/data-table/utils/filter-operations.tsx b/packages/raystack/components/data-table/utils/filter-operations.tsx index f54fdede9..ac7da5eec 100644 --- a/packages/raystack/components/data-table/utils/filter-operations.tsx +++ b/packages/raystack/components/data-table/utils/filter-operations.tsx @@ -13,35 +13,10 @@ import { SelectFilterOperatorType, StringFilterOperatorType } from '~/types/filters'; -import { - isAfterDay, - isBeforeDay, - isSameDay, - toDateLoose -} from '../../calendar-preview/date-adapter'; +import { toDateLoose } from '../../calendar-preview/date-adapter'; +import { dateFilterOperations } from '../../calendar-preview/filter-date-operations'; import { DataTableFilterValues } from '../data-table.types'; -/* - * Date comparisons go through the calendar adapter, which is the one place - * that registers dayjs plugins. Extending them here as well made the module - * order-dependent — the failure class behind the 0.49.0 P0. - * - * A row value that will not parse compares false against every operator that - * asserts a relationship, which is what an unfilterable cell should do. `neq` - * is the exception, and deliberately: it negates `eq`, so an empty or - * unparseable cell is "not equal to" any date and survives the filter. That - * matches the behaviour the old operators had. - */ -const compare = ( - a: unknown, - b: unknown, - predicate: (left: Date, right: Date) => boolean -) => { - const left = toDateLoose(a); - const right = toDateLoose(b); - return left && right ? predicate(left, right) : false; -}; - export type FilterPrimitive = string | string[] | number | boolean | Date; export type FilterFunctionsMap = { @@ -102,34 +77,7 @@ export const filterOperationsMap: FilterFunctionsMap = { return columnValue.endsWith(filterStr); } }, - date: { - eq: (row, columnId, filterValue: FilterValue, _addMeta) => { - return compare(row.getValue(columnId), filterValue.date, isSameDay); - }, - neq: (row, columnId, filterValue: FilterValue, _addMeta) => { - return !compare(row.getValue(columnId), filterValue.date, isSameDay); - }, - lt: (row, columnId, filterValue: FilterValue, _addMeta) => { - return compare(row.getValue(columnId), filterValue.date, isBeforeDay); - }, - lte: (row, columnId, filterValue: FilterValue, _addMeta) => { - return compare( - row.getValue(columnId), - filterValue.date, - (a, b) => !isAfterDay(a, b) - ); - }, - gt: (row, columnId, filterValue: FilterValue, _addMeta) => { - return compare(row.getValue(columnId), filterValue.date, isAfterDay); - }, - gte: (row, columnId, filterValue: FilterValue, _addMeta) => { - return compare( - row.getValue(columnId), - filterValue.date, - (a, b) => !isBeforeDay(a, b) - ); - } - }, + date: dateFilterOperations, select: { eq: (row, columnId, filterValue: FilterValue, _addMeta) => { if (String(filterValue.value) === EmptyFilterValue) { diff --git a/packages/raystack/components/data-view/utils/filter-operations.tsx b/packages/raystack/components/data-view/utils/filter-operations.tsx index ade40b28a..6722ec842 100644 --- a/packages/raystack/components/data-view/utils/filter-operations.tsx +++ b/packages/raystack/components/data-view/utils/filter-operations.tsx @@ -13,35 +13,10 @@ import { SelectFilterOperatorType, StringFilterOperatorType } from '~/types/filters'; -import { - isAfterDay, - isBeforeDay, - isSameDay, - toDateLoose -} from '../../calendar-preview/date-adapter'; +import { toDateLoose } from '../../calendar-preview/date-adapter'; +import { dateFilterOperations } from '../../calendar-preview/filter-date-operations'; import { DataViewFilterValues } from '../data-view.types'; -/* - * Date comparisons go through the calendar adapter, which is the one place - * that registers dayjs plugins. Extending them here as well made the module - * order-dependent — the failure class behind the 0.49.0 P0. - * - * A row value that will not parse compares false against every operator that - * asserts a relationship, which is what an unfilterable cell should do. `neq` - * is the exception, and deliberately: it negates `eq`, so an empty or - * unparseable cell is "not equal to" any date and survives the filter. That - * matches the behaviour the old operators had. - */ -const compare = ( - a: unknown, - b: unknown, - predicate: (left: Date, right: Date) => boolean -) => { - const left = toDateLoose(a); - const right = toDateLoose(b); - return left && right ? predicate(left, right) : false; -}; - export type FilterFunctionsMap = { number: Record>; string: Record>; @@ -88,28 +63,7 @@ export const filterOperationsMap: FilterFunctionsMap = { return columnValue.endsWith(filterStr); } }, - date: { - eq: (row, columnId, filterValue: FilterValue) => - compare(row.getValue(columnId), filterValue.date, isSameDay), - neq: (row, columnId, filterValue: FilterValue) => - !compare(row.getValue(columnId), filterValue.date, isSameDay), - lt: (row, columnId, filterValue: FilterValue) => - compare(row.getValue(columnId), filterValue.date, isBeforeDay), - lte: (row, columnId, filterValue: FilterValue) => - compare( - row.getValue(columnId), - filterValue.date, - (a, b) => !isAfterDay(a, b) - ), - gt: (row, columnId, filterValue: FilterValue) => - compare(row.getValue(columnId), filterValue.date, isAfterDay), - gte: (row, columnId, filterValue: FilterValue) => - compare( - row.getValue(columnId), - filterValue.date, - (a, b) => !isBeforeDay(a, b) - ) - }, + date: dateFilterOperations, select: { eq: (row, columnId, filterValue: FilterValue) => { if (String(filterValue.value) === EmptyFilterValue) From 1b783a9330d7ce0b30e766c869ab7d3e9162daee Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 2 Sep 2026 14:18:47 +0530 Subject: [PATCH 32/37] fix(filter-chip): restore the invalid-date affordance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing garbage into a date chip and blurring left `aria-invalid` unset, no element carrying an error state, the text sitting there and the filter silently not applying. Screen-reader users got nothing at all. The old picker drew a red border from `updateError('Invalid date')`; this PR replaced it with `CalendarPreview`, which renders no error UI of its own by design and reports through `onValidityChange` so a surrounding `Field` can present it — and the chip is not a `Field` and wired nothing. Not restored verbatim: the deleted rule targeted a hashed `input-error-wrapper` class that no longer exists anywhere in the package, so putting it back would have been dead CSS that looked like a fix. Instead the chip wires the callback the component was designed for, into `aria-invalid` on the field and a `data-error` hook on the wrapper, and forwards to any handler a consumer passed through `calendarProps`. `aria-invalid` matters more than the border here — this was filed as a WCAG 3.3.1 error-identification regression, and a red edge announces nothing. before garbage + blur → aria-invalid null, 0 data-error after garbage + blur → aria-invalid true, 1 data-error valid date → both cleared Removing the wiring fails the new test while 393 others pass; it was entirely unguarded. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/filter-chip.test.tsx | 57 +++++++++++++++++++ .../filter-chip/filter-chip.module.css | 10 ++++ .../components/filter-chip/filter-chip.tsx | 25 +++++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx index 5e620d248..6f60974c6 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -1,4 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { FilterType } from '~/types/filters'; import { FilterChip } from '../filter-chip'; @@ -126,6 +127,62 @@ describe('FilterChip', () => { }); describe('Date Filter Type', () => { + /* + * The old picker drew a red border from `updateError('Invalid date')`. Its + * replacement renders no error UI of its own by design, reporting through + * `onValidityChange` so a surrounding `Field` can present it — and this + * chip is not a `Field`. Wiring nothing left unparseable text sitting + * there with no border, no `aria-invalid` and a filter that silently did + * not apply: an error-identification regression on shipped code. + */ + it('marks an unparseable date invalid, and recovers', async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, 'garbage'); + await user.tab(); + + // `aria-invalid` over the border alone: a red edge announces nothing. + expect(field).toHaveAttribute('aria-invalid', 'true'); + expect(container.querySelectorAll('[data-error]')).toHaveLength(1); + + await user.clear(field); + await user.type(field, '18 Apr 2024{Enter}'); + + expect(field).not.toHaveAttribute('aria-invalid'); + expect(container.querySelectorAll('[data-error]')).toHaveLength(0); + }); + + it('still forwards a consumer onValidityChange', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, 'garbage{Enter}'); + + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'unparseable' + }); + }); + it('renders the date picker without crashing when no value is set', () => { // Regression: an unset date chip seeds its value with '' and forwarded // that string to the picker, whose controlled-sync effect ran diff --git a/packages/raystack/components/filter-chip/filter-chip.module.css b/packages/raystack/components/filter-chip/filter-chip.module.css index f0d133fb1..8aa6392c9 100644 --- a/packages/raystack/components/filter-chip/filter-chip.module.css +++ b/packages/raystack/components/filter-chip/filter-chip.module.css @@ -195,6 +195,16 @@ button.selectValue:hover { overflow: hidden; } +/* The invalid-date affordance the old picker drove through + `updateError('Invalid date')`. Its rule targeted a hashed + `input-error-wrapper` class that no longer exists anywhere, so restoring it + verbatim would have been dead CSS; this hangs off the state `.RangeInput` and + `.Input` actually report, and pairs with `aria-invalid` on the field so the + error is announced rather than only drawn. */ +.dateFieldWrapper[data-error] .dateField [data-slot="input-container"] { + border: 1px solid var(--rs-color-border-danger-primary); +} + /* Reaches Input's container through its public slot rather than a hashed class name — the same thing the old `classNames.container` merge did, but without letting a consumer-supplied object replace the chip's own class. */ diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index bd150f131..8b7db1ddc 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -116,6 +116,16 @@ export const FilterChip = ({ const showOnRemove = typeof onRemove === 'function'; const isMultiSelectColumn = columnType === FilterType.multiselect; + /* + * The date field's invalid state. `CalendarPreview` renders no error UI of + * its own by design, reporting through `onValidityChange` so a surrounding + * `Field` can present it — but this chip is not a `Field`, and wiring nothing + * meant unparseable text simply sat there: no border, no `aria-invalid`, and + * a filter that silently did not apply. That is what the old picker's + * `updateError('Invalid date')` used to drive. + */ + const [dateInvalid, setDateInvalid] = useState(false); + const handleOperationChange = useCallback( (operation: FilterOperation) => { setOperation(operation); @@ -176,6 +186,7 @@ export const FilterChip = ({
{/* * Composed from parts rather than configured through `slotProps`. @@ -190,12 +201,22 @@ export const FilterChip = ({ handleFilterValueChange(date)} + onValueChange={date => { + setDateInvalid(false); + handleFilterValueChange(date); + }} + onValidityChange={validity => { + setDateInvalid(!validity.valid); + calendarProps?.onValidityChange?.(validity); + }} > {/* Preserves the chip's long-standing empty-state wording; `.Input` otherwise falls back to showing the format. */} - + From af48df38bdf4c9876372cf8712314a73326a7d01 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 2 Sep 2026 14:39:03 +0530 Subject: [PATCH 33/37] fix(calendar-preview): H2, H4, H5, H8, H10 and the FilterChip contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H2 — validity no longer latches. `reportValidity` was only ever called by the three typed fields, so an `{valid: false}` stood forever: type rubbish, pick 5 April in the grid, and a consumer's last verdict still read `unparseable` beside a good value, with `aria-invalid` stuck on the input. The root now reports valid whenever any writer commits; the fields still report their own failures first. H4 — `.TimeField` reports what it rejects. Both `abc` and `99` returned in silence: no validity, no value, the text just snapping back, while the file's own comment claimed it validated the shape the typed fields do. H5 — `step` is clamped at the prop boundary. It is public and was unvalidated, so `step={0}` made `Math.round(x / 0) * 0` NaN, reached `.minute(NaN)` and committed an Invalid Date that rendered as the literal string `NaN`. The snap now lands on the step grid rather than clamping to 59. `59` with `step={15}` gave 59 — a value off the field's own grid, the one thing `step` promises not to emit. It is 45. This is what `audit-fixed.test.tsx:80` should always have asserted; `<= 59` was true of every Date ever constructed. H8 — `.monthGrid` gets `position: relative`. `overflow-y` alone does not make an offsetParent, so `scrollActiveYearIntoView` measured `offsetTop` from whatever ancestor was positioned: the popup inside a popover, or `` inline, which clamped the list to its last year. The file already spent `position: relative` on `.months` and `.weeks`, where nothing is positioned against them. H10 — a bound past the year window no longer empties the grid. `minDate={2035}` with no `maxDate` gave firstYear 2035 against lastYear 2031, so the build loop never ran: no selectable period at all. Each edge now yields to the other. X2 — clearing a date chip emits `''`, not `null`. `FilterChipValue` has no `null` and `handleFilterValueChange` is typed `any`, so a consumer doing `value.getTime()` threw on first clear with no compile warning. X4 — `FilterChipCalendarProps` drops `commit`, `open`, `defaultOpen`, `onOpenChange` and `loading`. The chip composes no `.Footer`, so `commit='explicit'` buffered every edit for an Apply button that does not exist and the chip became permanently uneditable. Guarded by a `@ts-expect-error` that stops compiling if the prop returns. Every fix mutation-verified. H8 is the exception and deliberately untested: jsdom computes no layout, so no assertion here can tell the right offsetParent from the wrong one — which is also why `audit-fixed.test.tsx:146` stays hollow until the real-browser pass the RFC asks for. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/audit-fixed.test.tsx | 5 +- .../__tests__/validity-and-bounds.test.tsx | 163 ++++++++++++++++++ .../calendar-preview-month-grid.tsx | 21 ++- .../calendar-preview-root.tsx | 15 +- .../calendar-preview-time-field.tsx | 45 ++++- .../calendar-preview.module.css | 5 + .../__tests__/filter-chip.test.tsx | 49 ++++++ .../components/filter-chip/filter-chip.tsx | 24 ++- 8 files changed, 309 insertions(+), 18 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/validity-and-bounds.test.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx b/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx index 3cdb53958..bc6dbfba0 100644 --- a/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx @@ -76,8 +76,11 @@ describe('audit findings stay fixed', () => { await user.clear(minute); await user.type(minute, '59{Enter}'); const next = lastArg(onValueChange) as Date; + // `<= 59` was true of every Date ever constructed. The property is that the + // snap lands on the step grid without rolling the hour: 59 snaps to 45. expect(next.getHours()).toBe(9); - expect(next.getMinutes()).toBeLessThanOrEqual(59); + expect(next.getMinutes()).toBe(45); + expect(next.getMinutes() % 15).toBe(0); }); /* diff --git a/packages/raystack/components/calendar-preview/__tests__/validity-and-bounds.test.tsx b/packages/raystack/components/calendar-preview/__tests__/validity-and-bounds.test.tsx new file mode 100644 index 000000000..2008996b5 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/validity-and-bounds.test.tsx @@ -0,0 +1,163 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { getAllSlots } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import type { CalendarValidity } from '../calendar-preview-context'; + +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0] as T; + +const MONTH = new Date(2024, 3, 1); + +/* + * `reportValidity` was only ever called by the three typed fields, so an + * invalid verdict latched: nothing the grid, a preset, the revert button or a + * controlled parent did ever cleared it. A `Field` wired to + * `onValidityChange` showed "Invalid date" permanently beside a good value. + */ +describe('validity does not latch', () => { + it('a grid pick clears a standing complaint from the typed field', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + + ); + + await user.type(screen.getByRole('textbox'), 'rubbish{Enter}'); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'unparseable' + }); + + await user.click(screen.getByRole('button', { name: /April 5th/ })); + + expect(lastArg(onValidityChange)).toEqual({ + valid: true + }); + }); +}); + +/* + * Both `.TimeField` rejections returned in silence — no validity, no value + * change, the text simply reverting — while the file claimed to validate the + * same shape the typed fields do. + */ +describe('.TimeField reports what it rejects', () => { + it('reports unparseable text', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + const hour = screen.getByLabelText('Hour'); + await user.clear(hour); + await user.type(hour, 'abc'); + await user.tab(); + + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'unparseable' + }); + }); + + it('reports an out-of-range hour', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + const hour = screen.getByLabelText('Hour'); + await user.clear(hour); + await user.type(hour, '99'); + await user.tab(); + + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'out-of-bounds' + }); + }); + + /* + * `step` is public and was unvalidated: `Math.round(x / 0) * 0` is NaN, which + * reached `.minute(NaN)` and committed an Invalid Date that rendered as the + * literal string `NaN`. + */ + it('never commits an Invalid Date, whatever step it is given', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + const minute = screen.getByLabelText('Minute'); + await user.clear(minute); + await user.type(minute, '30{Enter}'); + + const next = lastArg(onValueChange); + expect(Number.isNaN(next.getTime())).toBe(false); + expect(next.getMinutes()).toBe(30); + }); +}); + +/* + * `yearWindow` was measured from the anchor with nothing tying it to a bounded + * edge, so a bound past the window left `firstYear > lastYear` and the build + * loop never ran. + */ +describe('.MonthGrid always offers a selectable period', () => { + const grid = (props: Record) => + render( + + + + ); + + it('renders cells when minDate sits beyond the year window', () => { + const { container } = grid({ minDate: new Date(2035, 0, 1) }); + expect( + getAllSlots(container, 'calendar-preview-month-cell').length + ).toBeGreaterThan(0); + }); + + it('renders cells when maxDate sits before the year window', () => { + const { container } = grid({ maxDate: new Date(2015, 11, 31) }); + expect( + getAllSlots(container, 'calendar-preview-month-cell').length + ).toBeGreaterThan(0); + }); + + it('spans from the bound to the window on the unbounded edge', () => { + // minDate 2030 with today in 2026: the list must reach 2030, not stop short. + const { container } = grid({ minDate: new Date(2030, 0, 1) }); + const years = getAllSlots( + container, + 'calendar-preview-month-grid-year' + ).map(node => node.textContent); + expect(years.some(text => text?.includes('2030'))).toBe(true); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx index a8c2aff68..7b8109cd7 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx @@ -184,12 +184,21 @@ export function CalendarPreviewMonthGrid({ * `anchorYear` — which follows the selection, and which a bounded list never * reads, so leaving it in the deps rebuilt every cell for an unmoved span. */ - const firstYear = minDate - ? getYear(minDate, timeZone) - : anchorYear - yearWindow; - const lastYear = maxDate - ? getYear(maxDate, timeZone) - : anchorYear + yearWindow; + /* + * `yearWindow` applies to the unbounded edge, but it was measured from the + * anchor with nothing tying it to the bounded one — so `minDate={2035}` with + * no `maxDate` gave firstYear 2035 against lastYear 2031 and the loop below + * never ran: an empty grid with no selectable period at all. A far-past + * `maxDate` did the same in mirror. Each edge now yields to the other. + */ + const boundedFirst = minDate ? getYear(minDate, timeZone) : null; + const boundedLast = maxDate ? getYear(maxDate, timeZone) : null; + const firstYear = + boundedFirst ?? + Math.min(anchorYear - yearWindow, boundedLast ?? Number.POSITIVE_INFINITY); + const lastYear = + boundedLast ?? + Math.max(anchorYear + yearWindow, boundedFirst ?? Number.NEGATIVE_INFINITY); const sections = useMemo(() => { if (granularity === 'day') return []; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 9dadc042c..103532a0c 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -289,10 +289,23 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { const granularityRef = useRef(granularity); granularityRef.current = granularity; + /* + * A committed value clears any standing complaint. + * + * `reportValidity` was only ever called by the three typed fields, so an + * `{valid: false}` latched forever: type rubbish, then pick 5 April in the + * grid, and a consumer's last and only validity still read + * `reason: 'unparseable'` beside a perfectly good value — with + * `aria-invalid` stuck on the input and no user action short of retyping into + * that same field able to clear it. Every writer that reaches here has + * produced a value the component accepted, which is exactly what "valid" + * means; the fields still report their own failures before they get here. + */ const setValue = useCallback( (next: CalendarValue, details?: { granularity?: string }) => { const resolved = (details?.granularity ?? granularityRef.current) as CalendarGranularity; + onValidityChange?.({ valid: true }); if (commitMode === 'explicit') { setBuffer(next); setBufferGranularity(resolved); @@ -301,7 +314,7 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { setValueUnwrapped(next); onValueChange?.(next, { granularity: resolved }); }, - [commitMode, setValueUnwrapped, onValueChange] + [commitMode, setValueUnwrapped, onValueChange, onValidityChange] ); const applyValue = useCallback(() => { diff --git a/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx b/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx index d61a40aed..c19fbf451 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx @@ -41,10 +41,17 @@ export interface CalendarPreviewTimeFieldProps */ export function CalendarPreviewTimeField({ className, - step = 1, + step: stepProp = 1, hourCycle = 24, ...props }: CalendarPreviewTimeFieldProps) { + /* + * Clamped at the boundary. `step` is public and unvalidated: `step={0}` sent + * `Math.round(parsed / 0) * 0` to NaN, `Math.min(59, NaN)` to NaN, and + * `.minute(NaN)` produced an Invalid Date that was committed and rendered as + * the literal string `NaN`. A fractional step rounded to a fractional minute. + */ + const step = Math.max(1, Math.floor(stepProp) || 1); const { selection, value, @@ -157,23 +164,45 @@ export function CalendarPreviewTimeField({ const commitHour = (text: string) => { const parsed = Number.parseInt(text, 10); - if (Number.isNaN(parsed)) return; + /* + * Reported, not swallowed. Both rejections used to return in silence — no + * validity, no value change, the text just snapping back — so a consumer + * wiring `onValidityChange` was told nothing about `abc` or `99`, while the + * file's own comment claimed it validated the shape the typed fields do. + */ + if (Number.isNaN(parsed)) { + reportValidity({ valid: false, reason: 'unparseable' }); + return; + } const max = hourCycle === 12 ? 12 : 23; const min = hourCycle === 12 ? 1 : 0; - if (parsed < min || parsed > max) return; + if (parsed < min || parsed > max) { + reportValidity({ valid: false, reason: 'out-of-bounds' }); + return; + } const next24 = hourCycle === 12 ? (parsed % 12) + (isPm ? 12 : 0) : parsed; write(next24, minutes); }; const commitMinute = (text: string) => { const parsed = Number.parseInt(text, 10); - if (Number.isNaN(parsed) || parsed < 0 || parsed > 59) return; + if (Number.isNaN(parsed)) { + reportValidity({ valid: false, reason: 'unparseable' }); + return; + } + if (parsed < 0 || parsed > 59) { + reportValidity({ valid: false, reason: 'out-of-bounds' }); + return; + } /* - * Clamped: an unclamped round sends 59 with step 15 to 60, and dayjs's - * `.minute(60)` rolls into the next hour — so validation rejected >59 two - * lines above and the snap then produced one anyway. + * Snapped down to the grid, not clamped to 59. An unclamped round sends 59 + * with step 15 to 60, and dayjs's `.minute(60)` rolls into the next hour — + * but clamping produced 59, a value off the field's own step grid, which is + * the one thing `step` promises not to emit. The last multiple that fits in + * the hour is 45. */ - write(hours24, Math.min(59, Math.round(parsed / step) * step)); + const snapped = Math.round(parsed / step) * step; + write(hours24, snapped > 59 ? Math.floor(59 / step) * step : snapped); }; const field = ( diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index c284489b1..77d1c91d1 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -244,9 +244,14 @@ /* The design's viewport is 192px of scrolling year sections. No --rs-* size fits, so it is a component-local custom property — the pattern tabs.module.css uses — rather than a bare hardcoded value. */ +/* `position: relative` is load-bearing, not decoration. `overflow-y` alone does + not make an element an offsetParent, so `scrollActiveYearIntoView` measured + `offsetTop` from whatever ancestor was positioned — the popup inside a + popover, or `` inline, which clamped the list to its last year. */ .monthGrid { --calendar-preview-month-grid-height: 192px; + position: relative; display: flex; flex-direction: column; gap: var(--rs-space-4); diff --git a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx index 6f60974c6..fdde13b70 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { FilterType } from '~/types/filters'; +import type { FilterChipCalendarProps } from '../filter-chip'; import { FilterChip } from '../filter-chip'; import styles from '../filter-chip.module.css'; @@ -183,6 +184,54 @@ describe('FilterChip', () => { }); }); + /* + * `FilterChipValue` is `string | string[] | number | Date` — no `null` — + * and `handleFilterValueChange` is typed `any`, so nothing caught the chip + * emitting `[null, 'eq']` when a date was cleared. Any consumer doing + * `value.getTime()` threw on the first clear, with no compile warning + * because the type says it cannot happen. The old picker declined to emit + * it deliberately. + */ + it('emits an empty string, never null, when a date is cleared', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.tab(); + + expect(onValueChange).toHaveBeenCalled(); + for (const [emitted] of onValueChange.mock.calls) { + expect(emitted).not.toBeNull(); + } + }); + + /* + * The chip composes no `.Footer`, so `commit='explicit'` buffered every + * edit for an Apply button that does not exist and the chip became + * permanently uneditable. It is now absent from the forwarded prop type; + * this fails to compile if it comes back. + */ + it('does not admit root props the chip cannot honour', () => { + const admitted: FilterChipCalendarProps = { + minDate: new Date(2024, 0, 1), + format: 'DD MMM YYYY' + }; + expect(admitted.minDate).toBeInstanceOf(Date); + + // @ts-expect-error `commit` has no Apply button in this composition. + const rejected: FilterChipCalendarProps = { commit: 'explicit' }; + expect(rejected).toBeTruthy(); + }); + it('renders the date picker without crashing when no value is set', () => { // Regression: an unset date chip seeds its value with '' and forwarded // that string to the picker, whose controlled-sync effect ran diff --git a/packages/raystack/components/filter-chip/filter-chip.tsx b/packages/raystack/components/filter-chip/filter-chip.tsx index 8b7db1ddc..6ebffccd0 100644 --- a/packages/raystack/components/filter-chip/filter-chip.tsx +++ b/packages/raystack/components/filter-chip/filter-chip.tsx @@ -53,7 +53,19 @@ export type FilterChipValue = string | string[] | number | Date; */ export type FilterChipCalendarProps = Omit< CalendarPreviewBaseProps, - 'children' + | 'children' + /* + * The chip composes no `.Footer` and no `.Apply`, so `commit='explicit'` + * buffered every edit for a button that does not exist — a valid date typed + * and blurred produced zero value changes and the chip became permanently + * uneditable. `open`/`defaultOpen`/`onOpenChange` and `loading` are the + * chip's own to drive for the same reason: it owns this composition. + */ + | 'commit' + | 'open' + | 'defaultOpen' + | 'onOpenChange' + | 'loading' >; export interface FilterChipProps @@ -203,7 +215,15 @@ export const FilterChip = ({ value={toDateLoose(filterValue)} onValueChange={date => { setDateInvalid(false); - handleFilterValueChange(date); + /* + * `null` is not in `FilterChipValue`, and `handleFilterValueChange` + * is typed `any` so nothing caught it: clearing a date chip + * emitted `[null, 'eq']` and any consumer doing + * `value.getTime()` threw on first use. The old picker declined + * to emit it for exactly this reason; an empty date is an empty + * string here, as it is for every other column type. + */ + handleFilterValueChange(date ?? ''); }} onValidityChange={validity => { setDateInvalid(!validity.valid); From 98ba4fdbfc778d1df2c7c9976ba6ab58abb5e3ed Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 2 Sep 2026 15:19:49 +0530 Subject: [PATCH 34/37] test(calendar-preview): group tests by subject, not by the pass that found them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 23 files to 17. All 250 assertions unchanged; only their homes are. `audit-fixed.test.tsx` collected findings under the number they were reported as, and `regressions.test.tsx` grouped them by which audit pass found them. Neither is a property of the component, so a CI failure named the incident rather than the behaviour — `07: snapping never rolls into the next hour` told you nothing about where to look. Both are distributed to the file that owns the behaviour, and the numeric prefixes are gone. audit-fixed -> input-nav 4, time-field 2, timezone 2, bounds-and-order 2, keyboard 2, range-input 1, month-grid 1, calendar-preview 1 regressions -> calendar-preview 6, range-input 4, granularity 2, timezone 1, input-nav 1, slots-documented 1 Four small files also folded into their subjects: `merge` into `input-nav`, `month-sync` into `calendar-preview`, `data-slots` into `slots-documented`, and `rerender` + `memo-stability` into `identity` — both of the latter ask what survives a re-render, one about DOM nodes and one about a memo. Verified by re-running all 14 mutation guarantees from their new homes, each under the host timezone it needs: B1 5, B2 5, H12 1, B3 3, H9 1, B4 1, H3 4, H2 1, H4 1, H5 1, H10 2, X2 1 failure respectively, plus H1 under America/New_York and H11 under UTC. Those last two pass on any host whose zone matches the display zone, which is the property they exist to expose. Three things this move broke and the checks that did or did not catch them. A `MONTH` collision between two merged files — caught by tsc. A stray copy of `filter-chip.tsx` left in `components/calendar-preview/` by a careless restore glob — caught by `exports.test.ts`, which flagged it as a type declared public and absent from the barrel. And a `parseDate` test that a bad brace match nested inside the `setTime` describe — caught by nothing: the count still read 250, no names duplicated, tsc was clean and the suite was green. Test count parity is not evidence of a clean move. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/audit-fixed.test.tsx | 570 ------------------ .../__tests__/bounds-and-order.test.tsx | 125 ++++ .../__tests__/calendar-preview.test.tsx | 261 +++++++- .../__tests__/data-slots.test.tsx | 96 --- .../__tests__/granularity.test.tsx | 34 ++ .../__tests__/identity.test.tsx | 142 +++++ .../__tests__/input-nav.test.tsx | 161 ++++- .../__tests__/keyboard.test.tsx | 64 +- .../__tests__/memo-stability.test.tsx | 71 --- .../calendar-preview/__tests__/merge.test.tsx | 52 -- .../__tests__/month-grid.test.tsx | 63 ++ .../__tests__/month-sync.test.tsx | 117 ---- .../__tests__/range-input.test.tsx | 128 ++++ .../__tests__/regressions.test.tsx | 265 -------- .../__tests__/rerender.test.tsx | 68 --- .../__tests__/slots-documented.test.tsx | 112 ++++ .../__tests__/time-field.test.tsx | 143 ++++- .../__tests__/timezone.test.tsx | 69 +++ 18 files changed, 1298 insertions(+), 1243 deletions(-) delete mode 100644 packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx delete mode 100644 packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx create mode 100644 packages/raystack/components/calendar-preview/__tests__/identity.test.tsx delete mode 100644 packages/raystack/components/calendar-preview/__tests__/memo-stability.test.tsx delete mode 100644 packages/raystack/components/calendar-preview/__tests__/merge.test.tsx delete mode 100644 packages/raystack/components/calendar-preview/__tests__/month-sync.test.tsx delete mode 100644 packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx delete mode 100644 packages/raystack/components/calendar-preview/__tests__/rerender.test.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx b/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx deleted file mode 100644 index bc6dbfba0..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/audit-fixed.test.tsx +++ /dev/null @@ -1,570 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import { getSlot } from '~/test-utils/data-slots'; -import { CalendarPreview } from '../calendar-preview'; -import type { DateRangeValue } from '../calendar-preview-context'; -import { - DEFAULT_FORMAT, - dayKey, - getHours, - getMinutes, - isWithinBounds, - isWithinTimeBounds, - parseDate, - setTime, - toDateLoose -} from '../date-adapter'; - -const MONTH = new Date(2024, 3, 1); -const lastArg = (fn: { mock: { calls: unknown[][] } }) => - fn.mock.calls[fn.mock.calls.length - 1]?.[0]; - -describe('audit findings stay fixed', () => { - it('03: a timed start survives a typed same-day end', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - render( - - - - ); - - await user.type(screen.getByLabelText('End date'), '17 Apr 2024{Enter}'); - const next = lastArg(onValueChange) as DateRangeValue; - // The old raw `from > to` compared instants, so 08:00 "exceeded" midnight - // on the same day and the start was nulled. - expect(next.from).not.toBeNull(); - expect(dayKey(next.from as Date)).toBe('2024-04-17'); - }); - - it('06: a mid-month minDate leaves that month selectable', () => { - const { container } = render( - - - - ); - const cells = Array.from( - container.querySelectorAll('[data-slot="calendar-preview-month-cell"]') - ) as HTMLButtonElement[]; - expect(cells.find(c => c.textContent === 'Apr')).not.toBeDisabled(); - expect(cells.find(c => c.textContent === 'Mar')).toBeDisabled(); - }); - - it('07: snapping never rolls into the next hour', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - render( - - - - ); - - const minute = screen.getByLabelText('Minute'); - await user.clear(minute); - await user.type(minute, '59{Enter}'); - const next = lastArg(onValueChange) as Date; - // `<= 59` was true of every Date ever constructed. The property is that the - // snap lands on the step grid without rolling the hour: 59 snaps to 45. - expect(next.getHours()).toBe(9); - expect(next.getMinutes()).toBe(45); - expect(next.getMinutes() % 15).toBe(0); - }); - - /* - * Asserting `isWithinBounds.length === 4` only checked the declared parameter - * count, and the case beside it was true in every zone — so making the - * function ignore `timeZone` entirely left all 33 tests here green, and all - * 358 across the repo. This asks the only question that separates the two: - * one instant that falls on different days depending on the zone it is read - * in, against a bound that sits between them. - */ - it('11: isWithinBounds resolves the day in the zone it is given', () => { - // 23:00 UTC on 17 Apr is already 08:00 on the 18th in Tokyo. - const instant = new Date(Date.UTC(2024, 3, 17, 23, 0)); - const max = new Date(Date.UTC(2024, 3, 17, 12, 0)); - - expect(isWithinBounds(instant, undefined, max, 'UTC')).toBe(true); - expect(isWithinBounds(instant, undefined, max, 'Asia/Tokyo')).toBe(false); - }); - - it('05: switching to Month scrolls the active year into view', async () => { - const user = userEvent.setup(); - /* - * jsdom has no layout, so `scrollTop` is not observable on its own — the - * previous form of this test asserted `scrollTop >= 0`, which is true of - * an untouched element. Stand a spy in its place and give the geometry - * non-zero values, so the assertion is about the scroll actually happening. - */ - const stub = (name: string, descriptor: PropertyDescriptor) => { - const original = Object.getOwnPropertyDescriptor( - HTMLElement.prototype, - name - ); - Object.defineProperty(HTMLElement.prototype, name, { - configurable: true, - ...descriptor - }); - return () => { - if (original) { - Object.defineProperty(HTMLElement.prototype, name, original); - } else { - Reflect.deleteProperty(HTMLElement.prototype, name); - } - }; - }; - - const scrolled = vi.fn(); - const restore = [ - stub('scrollTop', { get: () => 0, set: scrolled }), - stub('offsetTop', { get: () => 900 }), - stub('clientHeight', { get: () => 300 }) - ]; - - try { - render( - - - - - - ); - - expect(scrolled).not.toHaveBeenCalled(); - await user.click(screen.getByRole('tab', { name: 'Month' })); - // 900 - 300/2 + 300/2, centred on the active year. - expect(scrolled).toHaveBeenCalledWith(900); - } finally { - for (const undo of restore) undo(); - } - }); - - it('08: a trigger holding a typed field claims no button semantics', async () => { - const { container } = render( - - - - - - - - - ); - - const trigger = getSlot( - container, - 'calendar-preview-trigger' - ) as HTMLElement; - // In ARIA a button's children are presentational, so the field inside was - // at risk of never being announced as editable; the tab stop it added sat - // in front of the input doing nothing a keyboard user wants. - await waitFor(() => expect(trigger).not.toHaveAttribute('role')); - expect(trigger).toHaveAttribute('tabindex', '-1'); - expect(trigger.querySelector('input')).not.toBeNull(); - }); - - it('08: a plain trigger keeps the button semantics it should have', () => { - const { container } = render( - - Pick a date - - ); - const trigger = getSlot( - container, - 'calendar-preview-trigger' - ) as HTMLElement; - expect(trigger).toHaveAttribute('role', 'button'); - expect(trigger).toHaveAttribute('tabindex', '0'); - }); - - it('09: clicking the field a second time does not close the calendar', async () => { - const user = userEvent.setup(); - render( - - - - - - - - - ); - - const field = screen.getByRole('textbox'); - await user.click(field); - expect(await screen.findByRole('grid')).toBeInTheDocument(); - - // Repositioning the caret is an ordinary thing to do mid-edit. - await user.click(field); - expect(screen.queryByRole('grid')).toBeInTheDocument(); - }); - - it('09: clicking the trigger outside the field still toggles', async () => { - const user = userEvent.setup(); - const { container } = render( - - - - - - - - - ); - - const trigger = getSlot( - container, - 'calendar-preview-trigger' - ) as HTMLElement; - await user.click(trigger); - expect(await screen.findByRole('grid')).toBeInTheDocument(); - await user.click(trigger); - await waitFor(() => - expect(screen.queryByRole('grid')).not.toBeInTheDocument() - ); - }); - - it('opens from the keyboard with ArrowDown, since the trigger has no tab stop', async () => { - const user = userEvent.setup(); - render( - - - - - - - - - ); - - const field = screen.getByRole('textbox'); - field.focus(); - await user.keyboard('{ArrowDown}'); - expect(await screen.findByRole('grid')).toBeInTheDocument(); - }); - - it('12: Escape reverts the draft first and dismisses only on the second press', async () => { - const user = userEvent.setup(); - render( - - - - - - - - - ); - - const field = screen.getByRole('textbox') as HTMLInputElement; - await user.click(field); - expect(await screen.findByRole('grid')).toBeInTheDocument(); - - await user.type(field, 'nonsense'); - await user.keyboard('{Escape}'); - // Correcting a typo must not cost you the calendar. - expect(field.value).toBe('17 Apr 2024'); - expect(screen.queryByRole('grid')).toBeInTheDocument(); - - await user.keyboard('{Escape}'); - await waitFor(() => - expect(screen.queryByRole('grid')).not.toBeInTheDocument() - ); - }); - - /* - * 24. `zoned()` freezes the offset of the instant it is given. A day arrives - * as its own midnight, so on a spring-forward day that offset is the *old* - * one and every time set on top of it came back an hour late — not only the - * hour that does not exist. - */ - describe('24: setTime survives a daylight-saving shift', () => { - const TZ = 'America/New_York'; - // 9 Mar 2025: EST -> EDT at 02:00, so 02:00-02:59 never happens. - const shiftDay = parseDate('09 Mar 2025', DEFAULT_FORMAT, TZ) as Date; - - it.each([ - [1, 30], - [3, 0], - [10, 0], - [23, 45] - ])('returns %i:%i as asked', (hours, minutes) => { - const result = setTime(shiftDay, hours, minutes, TZ); - expect(getHours(result, TZ)).toBe(hours); - expect(getMinutes(result, TZ)).toBe(minutes); - }); - - it('resolves a time that does not exist forward into the shift', () => { - const result = setTime(shiftDay, 2, 30, TZ); - expect(getHours(result, TZ)).toBe(3); - expect(getMinutes(result, TZ)).toBe(30); - }); - - it('stays on the day it was handed', () => { - expect(dayKey(setTime(shiftDay, 23, 45, TZ), TZ)).toBe('2025-03-09'); - }); - - it('holds on the autumn shift too', () => { - // 2 Nov 2025: 01:00-01:59 happens twice; either instant reads back as 1. - const fallBack = parseDate('02 Nov 2025', DEFAULT_FORMAT, TZ) as Date; - expect(getHours(setTime(fallBack, 1, 30, TZ), TZ)).toBe(1); - expect(getHours(setTime(fallBack, 10, 0, TZ), TZ)).toBe(10); - }); - }); - - describe('25: .TimeField honours the picker bounds', () => { - const setup = (props: Record) => { - const onValueChange = vi.fn(); - const onValidityChange = vi.fn(); - render( - - - - ); - return { onValueChange, onValidityChange }; - }; - - it('refuses an hour past maxDate and reports why', async () => { - const user = userEvent.setup(); - // Bounded at 10:00 *on the selected day*, so only a time comparison can - // catch this — `isWithinBounds` compares whole days and would pass it. - const { onValueChange, onValidityChange } = setup({ - maxDate: new Date(2024, 3, 17, 10, 0) - }); - - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '23{Enter}'); - - expect(onValueChange).not.toHaveBeenCalled(); - expect(lastArg(onValidityChange)).toEqual({ - valid: false, - reason: 'out-of-bounds' - }); - }); - - it('refuses an hour before minDate', async () => { - const user = userEvent.setup(); - const { onValueChange, onValidityChange } = setup({ - minDate: new Date(2024, 3, 17, 8, 0) - }); - - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '07{Enter}'); - - expect(onValueChange).not.toHaveBeenCalled(); - expect(lastArg(onValidityChange)).toEqual({ - valid: false, - reason: 'out-of-bounds' - }); - }); - - it('leaves the whole last day usable under a day-level maxDate', async () => { - const user = userEvent.setup(); - // The ordinary way a picker is bounded: a plain day, at midnight. Read - // literally as an instant it would forbid every time on the 17th, which - // is not what it means anywhere else in the component. - const { onValueChange, onValidityChange } = setup({ - maxDate: new Date(2024, 3, 17) - }); - - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '10{Enter}'); - - expect(getHours(lastArg(onValueChange) as Date)).toBe(10); - expect(lastArg(onValidityChange)).toEqual({ valid: true }); - }); - - it('still rejects the day after a day-level maxDate', () => { - // The day bound has not gone soft — it is applied first, inclusive. - expect( - isWithinTimeBounds( - new Date(2024, 3, 18, 9, 0), - undefined, - new Date(2024, 3, 17) - ) - ).toBe(false); - expect( - isWithinTimeBounds( - new Date(2024, 3, 17, 23, 59), - undefined, - new Date(2024, 3, 17) - ) - ).toBe(true); - }); - - it('commits an in-bounds hour and reports valid', async () => { - const user = userEvent.setup(); - const { onValueChange, onValidityChange } = setup({ - maxDate: new Date(2024, 3, 17, 10, 0) - }); - - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '10{Enter}'); - - expect(getHours(lastArg(onValueChange) as Date)).toBe(10); - expect(lastArg(onValidityChange)).toEqual({ valid: true }); - }); - }); - - describe('.TimeField cannot invert a range', () => { - const range = (props: Record = {}) => { - const onValueChange = vi.fn(); - const onValidityChange = vi.fn(); - render( - - - - ); - return { onValueChange, onValidityChange }; - }; - - it('refuses a start pushed past the end inside the shared day', async () => { - const user = userEvent.setup(); - const { onValueChange, onValidityChange } = range(); - - // `from` is the active endpoint by default. - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '23{Enter}'); - - expect(onValueChange).not.toHaveBeenCalled(); - expect(lastArg(onValidityChange)).toEqual({ - valid: false, - reason: 'range-order' - }); - }); - - it('refuses an end pulled before the start', async () => { - const user = userEvent.setup(); - const { onValueChange, onValidityChange } = range({ lock: 'from' }); - - // `lock="from"` makes `to` the endpoint this field edits. - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '08{Enter}'); - - expect(onValueChange).not.toHaveBeenCalled(); - expect(lastArg(onValidityChange)).toEqual({ - valid: false, - reason: 'range-order' - }); - }); - - it('allows a time that keeps the endpoints ordered', async () => { - const user = userEvent.setup(); - const { onValueChange, onValidityChange } = range(); - - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '08{Enter}'); - - const next = lastArg(onValueChange) as DateRangeValue; - expect(getHours(next.from as Date)).toBe(8); - // The endpoint the user did not touch is untouched. - expect(getHours(next.to as Date)).toBe(10); - expect(lastArg(onValidityChange)).toEqual({ valid: true }); - }); - - it('leaves a multi-day range alone, where the days already order it', async () => { - const user = userEvent.setup(); - const { onValueChange } = range({ - value: { - from: new Date(2024, 3, 17, 9, 0), - to: new Date(2024, 3, 18, 8, 0) - } - }); - - // 23:00 on the 17th is still before 08:00 on the 18th. - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '23{Enter}'); - - const next = lastArg(onValueChange) as DateRangeValue; - expect(getHours(next.from as Date)).toBe(23); - }); - - it('commits normally when the other endpoint is empty', async () => { - const user = userEvent.setup(); - const { onValueChange } = range({ - value: { from: new Date(2024, 3, 17, 9, 0), to: null } - }); - - await user.clear(screen.getByLabelText('Hour')); - await user.type(screen.getByLabelText('Hour'), '23{Enter}'); - - const next = lastArg(onValueChange) as DateRangeValue; - expect(getHours(next.from as Date)).toBe(23); - expect(next.to).toBeNull(); - }); - }); - - describe('28: toDateLoose reads an epoch in seconds', () => { - it('reads seconds as seconds rather than landing in January 1970', () => { - expect(toDateLoose(1741046400)?.toISOString()).toBe( - '2025-03-04T00:00:00.000Z' - ); - }); - - it('still reads milliseconds as milliseconds', () => { - expect(toDateLoose(1741046400000)?.toISOString()).toBe( - '2025-03-04T00:00:00.000Z' - ); - }); - - it('splits at the ceiling, and symmetrically about the epoch', () => { - // 1e11 is the first value read as milliseconds; one less is seconds. - expect(toDateLoose(1e11)?.getUTCFullYear()).toBe(1973); - expect(toDateLoose(1e11 - 1)?.getUTCFullYear()).toBe(5138); - // Negative seconds are a real pre-1970 date, not a parse failure. - expect(toDateLoose(-86400)?.toISOString()).toBe( - '1969-12-31T00:00:00.000Z' - ); - }); - - it('still reads a digit *string* as a year, which it always did', () => { - // Pinned, not endorsed: the number path is split by magnitude but the - // string path cannot be, because a bare '2025' has to stay a year. - // Changing this should be a deliberate edit that trips this test. - // Local year, not UTC: a bare year parses to *local* midnight, so in a - // zone ahead of UTC the UTC year is the one before. - expect(toDateLoose('1741046400')?.getFullYear()).toBe(1741); - expect(toDateLoose('2025')?.getFullYear()).toBe(2025); - }); - - it('declines what it cannot read', () => { - expect(toDateLoose('not a date')).toBeNull(); - expect(toDateLoose(null)).toBeNull(); - expect(toDateLoose(undefined)).toBeNull(); - expect(toDateLoose(Number.NaN)).toBeNull(); - }); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx b/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx index c84988156..ea30ce180 100644 --- a/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx @@ -9,6 +9,7 @@ import type { import { dayKey, endOfPeriod, + getHours, periodRange, startOfPeriod } from '../date-adapter'; @@ -16,6 +17,8 @@ import { const lastArg = (fn: { mock: { calls: unknown[][] } }) => fn.mock.calls[fn.mock.calls.length - 1]?.[0] as T; +const MONTH = new Date(2024, 3, 1); + /* * What each writer commits, and what it does when it cannot. Bounds clamping, * period boundaries and range ordering are one subject: every bug here was two @@ -530,3 +533,125 @@ describe('.MonthGrid cannot commit a backwards range', () => { expect(next.to).not.toBeNull(); }); }); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +describe('regressions from the external audit', () => { + it('a mid-month minDate leaves that month selectable', () => { + const { container } = render( + + + + ); + const cells = Array.from( + container.querySelectorAll('[data-slot="calendar-preview-month-cell"]') + ) as HTMLButtonElement[]; + expect(cells.find(c => c.textContent === 'Apr')).not.toBeDisabled(); + expect(cells.find(c => c.textContent === 'Mar')).toBeDisabled(); + }); +}); + +describe('.TimeField cannot invert a range', () => { + const range = (props: Record = {}) => { + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + render( + + + + ); + return { onValueChange, onValidityChange }; + }; + + it('refuses a start pushed past the end inside the shared day', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = range(); + + // `from` is the active endpoint by default. + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'range-order' + }); + }); + + it('refuses an end pulled before the start', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = range({ lock: 'from' }); + + // `lock="from"` makes `to` the endpoint this field edits. + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '08{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'range-order' + }); + }); + + it('allows a time that keeps the endpoints ordered', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = range(); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '08{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(getHours(next.from as Date)).toBe(8); + // The endpoint the user did not touch is untouched. + expect(getHours(next.to as Date)).toBe(10); + expect(lastArg(onValidityChange)).toEqual({ valid: true }); + }); + + it('leaves a multi-day range alone, where the days already order it', async () => { + const user = userEvent.setup(); + const { onValueChange } = range({ + value: { + from: new Date(2024, 3, 17, 9, 0), + to: new Date(2024, 3, 18, 8, 0) + } + }); + + // 23:00 on the 17th is still before 08:00 on the 18th. + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(getHours(next.from as Date)).toBe(23); + }); + + it('commits normally when the other endpoint is empty', async () => { + const user = userEvent.setup(); + const { onValueChange } = range({ + value: { from: new Date(2024, 3, 17, 9, 0), to: null } + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(getHours(next.from as Date)).toBe(23); + expect(next.to).toBeNull(); + }); +}); 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 7f374c915..e5f799c09 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -1,16 +1,23 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; +import { getSlot } from '~/test-utils/data-slots'; import { CalendarPreview } from '../calendar-preview'; import styles from '../calendar-preview.module.css'; import type { DateRangeValue } from '../calendar-preview-context'; + +/** The day button for an ISO date, via the grid's `data-day` attribute. */ +const day = (c: HTMLElement, iso: string) => + c.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; + import { DEFAULT_FORMAT, dayKey, formatDate, isWithinBounds, parseDate, - startOfMonth + startOfMonth, + toDateLoose } from '../date-adapter'; const MONTH = new Date(2024, 3, 1); @@ -174,3 +181,255 @@ describe('date-adapter', () => { expect(isWithinBounds(new Date(2024, 3, 9), min, max)).toBe(false); }); }); + +const caption = () => + document.querySelector('[data-slot="calendar-preview-nav-caption"]') + ?.textContent; + +/* + * The visible month was initialised once at mount and then left alone, so a + * value that arrived after mount was never shown and reopening the popover + * did not return to the selection. + */ +describe('the visible month follows the value', () => { + it('shows a value that arrives after mount, next time it opens', async () => { + const user = userEvent.setup(); + const view = (value: Date | null) => ( + + Pick + + + + + + ); + + // Mounted empty, as a picker waiting on a fetch is. + const { rerender } = render(view(null)); + rerender(view(new Date(2023, 8, 14))); + + await user.click(screen.getByText('Pick')); + await screen.findByRole('grid'); + expect(caption()).toBe('September 2023'); + }); + + it('returns to the selection when reopened, not to where the user left', async () => { + const user = userEvent.setup(); + render( + + Pick + + + + + + ); + + await user.click(screen.getByText('Pick')); + await screen.findByRole('grid'); + expect(caption()).toBe('April 2024'); + + await user.click(screen.getByLabelText('Next month')); + await user.click(screen.getByLabelText('Next month')); + expect(caption()).toBe('June 2024'); + + await user.keyboard('{Escape}'); + await user.click(screen.getByText('Pick')); + await screen.findByRole('grid'); + expect(caption()).toBe('April 2024'); + }); + + it('leaves navigation alone while the popover stays open', async () => { + const user = userEvent.setup(); + render( + + + + + ); + + await user.click(screen.getByLabelText('Next month')); + expect(caption()).toBe('May 2024'); + // A re-render with nothing relevant changed must not pull it back. + await user.click(document.body); + expect(caption()).toBe('May 2024'); + }); + + it('does not yank an open calendar back to today when the value is cleared', async () => { + const user = userEvent.setup(); + const view = (value: Date | null) => ( + + + + + ); + const { rerender } = render(view(new Date(2024, 3, 10))); + await user.click(screen.getByLabelText('Next month')); + expect(caption()).toBe('May 2024'); + + rerender(view(null)); + expect(caption()).toBe('May 2024'); + }); + + it('never writes the month a consumer controls', async () => { + const user = userEvent.setup(); + const onMonthChange = vi.fn(); + render( + + Pick + + + + + + ); + + await user.click(screen.getByText('Pick')); + await screen.findByRole('grid'); + expect(caption()).toBe('April 2024'); + expect(onMonthChange).not.toHaveBeenCalled(); + }); +}); + +describe('toDateLoose reads an epoch in seconds', () => { + it('reads seconds as seconds rather than landing in January 1970', () => { + expect(toDateLoose(1741046400)?.toISOString()).toBe( + '2025-03-04T00:00:00.000Z' + ); + }); + + it('still reads milliseconds as milliseconds', () => { + expect(toDateLoose(1741046400000)?.toISOString()).toBe( + '2025-03-04T00:00:00.000Z' + ); + }); + + it('splits at the ceiling, and symmetrically about the epoch', () => { + // 1e11 is the first value read as milliseconds; one less is seconds. + expect(toDateLoose(1e11)?.getUTCFullYear()).toBe(1973); + expect(toDateLoose(1e11 - 1)?.getUTCFullYear()).toBe(5138); + // Negative seconds are a real pre-1970 date, not a parse failure. + expect(toDateLoose(-86400)?.toISOString()).toBe('1969-12-31T00:00:00.000Z'); + }); + + it('still reads a digit *string* as a year, which it always did', () => { + // Pinned, not endorsed: the number path is split by magnitude but the + // string path cannot be, because a bare '2025' has to stay a year. + // Changing this should be a deliberate edit that trips this test. + // Local year, not UTC: a bare year parses to *local* midnight, so in a + // zone ahead of UTC the UTC year is the one before. + expect(toDateLoose('1741046400')?.getFullYear()).toBe(1741); + expect(toDateLoose('2025')?.getFullYear()).toBe(2025); + }); + + it('declines what it cannot read', () => { + expect(toDateLoose('not a date')).toBeNull(); + expect(toDateLoose(null)).toBeNull(); + expect(toDateLoose(undefined)).toBeNull(); + expect(toDateLoose(Number.NaN)).toBeNull(); + }); +}); + +/* + * Moved here from `regressions.test.tsx`, which grouped fixes by the audit + * pass that found them. The assertions are unchanged; each now sits with the + * behaviour it guards. + */ +describe('regressions', () => { + it('readOnly shows the value but refuses grid writes', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + + await user.click(day(container, '2024-04-17')); + expect(onValueChange).not.toHaveBeenCalled(); + // readOnly is not disabled: the day stays legible and focusable. + expect(day(container, '2024-04-17')).not.toBeDisabled(); + }); + + it('disabled refuses to open the popover', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + Pick + + + + + ); + + await user.click(screen.getByText('Pick')); + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('opens on the value month, not on today', () => { + render( + + + + + ); + expect(screen.getByText('January 2020')).toBeInTheDocument(); + }); + + it('derives the month from a range value too', () => { + render( + + + + + ); + expect(screen.getByText('July 2021')).toBeInTheDocument(); + }); + + it('defaultMonth still wins over the value', () => { + render( + + + + + ); + expect(screen.getByText('April 2024')).toBeInTheDocument(); + }); + + it('never warns that the month default changed', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { rerender } = render( + + + + ); + // A controlled value moving must not re-initialise the uncontrolled month. + rerender( + + + + ); + const warnings = spy.mock.calls.filter(call => + String(call[0]).includes('changing the default') + ); + spy.mockRestore(); + expect(warnings).toHaveLength(0); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx deleted file mode 100644 index 938d58a25..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { render } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots'; -import { CalendarPreview } from '../calendar-preview'; - -const MONTH = new Date(2024, 3, 1); - -describe('CalendarPreview data-slot contract', () => { - it('exposes grid slots when composed inline, with no popover', () => { - const { container } = render( - - - - ); - - expectSlots(container, [ - 'calendar-preview-grid', - 'calendar-preview-weeks', - 'calendar-preview-table', - 'calendar-preview-day', - 'calendar-preview-day-number' - ]); - // Nothing portals when there is no `.Content`. - expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); - }); - - it('exposes trigger, positioner and content slots when open', () => { - render( - - Pick a date - - - - - ); - - // Portaled parts are asserted against the document, not the container. - expectSlots(document.body, [ - 'calendar-preview-trigger', - 'calendar-preview-positioner', - 'calendar-preview-content', - 'calendar-preview-grid' - ]); - }); - - it('omits the content slot while closed', () => { - render( - - Pick a date - - - - - ); - - expect(getSlot(document.body, 'calendar-preview-trigger')).not.toBeNull(); - expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); - }); - - it('renders one day slot per day button', () => { - const { container } = render( - - - - ); - - // April 2024 has 30 days and outside days are off by default. - expect(getAllSlots(container, 'calendar-preview-day')).toHaveLength(30); - // The month name belongs to `.Nav`, which this composition omits. - expect(getSlot(container, 'calendar-preview-nav-caption')).toBeNull(); - }); - - it('renders two months of day slots when months is 2', () => { - const { container } = render( - - - - ); - - // April (30) + May (31). - expect(getAllSlots(container, 'calendar-preview-day')).toHaveLength(61); - expect(getAllSlots(container, 'calendar-preview-table')).toHaveLength(2); - }); - - it('never mounts a Select — the caption is a plain label', () => { - const { container } = render( - - - - ); - - expect(getSlot(container, 'select-trigger')).toBeNull(); - expect(getSlot(container, 'calendar-preview-nav-month')).toBeNull(); - expect(container.querySelector('select')).toBeNull(); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/granularity.test.tsx b/packages/raystack/components/calendar-preview/__tests__/granularity.test.tsx index a5f45f02c..3879dcb40 100644 --- a/packages/raystack/components/calendar-preview/__tests__/granularity.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/granularity.test.tsx @@ -174,3 +174,37 @@ describe('granularity gates the grid', () => { expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); }); }); + +/* + * Moved here from `regressions.test.tsx`, which grouped fixes by the audit + * pass that found them. The assertions are unchanged; each now sits with the + * behaviour it guards. + */ +describe('regressions', () => { + it('hides the nav outside the day granularity, as the design does', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-nav')).toBeNull(); + }); + + it('never renders a tab strip with nothing selected', () => { + render( + + + + ); + // granularities defaults to the active granularity, so a lone tab is not + // worth showing at all. + expect(screen.queryAllByRole('tab')).toHaveLength(0); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/identity.test.tsx b/packages/raystack/components/calendar-preview/__tests__/identity.test.tsx new file mode 100644 index 000000000..c15b0465f --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/identity.test.tsx @@ -0,0 +1,142 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; + +/* + * Identity across re-renders: the DOM nodes that must survive one, and the + * memo that must not rebuild during one. Both are questions about what stays + * the same when React runs again, and both were places where asserting the + * obvious thing gave a false all-clear. + */ + +const MONTH = new Date(2024, 3, 1); +const day = (c: HTMLElement, iso: string) => + c.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; + +/* + * The suite renders once, acts once and asserts, which is how a whole class of + * second-render defects went unnoticed. These assert across a re-render. + */ +describe('survives a re-render', () => { + it('keeps the same day node, so roving tabindex has something to hold', () => { + const { container, rerender } = render( + + + + ); + const before = day(container, '2024-04-17'); + rerender( + + + + ); + expect(day(container, '2024-04-17')).toBe(before); + }); + + it('keeps DOM focus on the focused day across a re-render', () => { + const { container, rerender } = render( + + + + ); + const target = day(container, '2024-04-17'); + target.focus(); + expect(target).toHaveFocus(); + + rerender( + + + + ); + expect(day(container, '2024-04-17')).toHaveFocus(); + }); + + it('keeps focus on the focused day when selecting re-renders the grid', async () => { + // The realistic case: clicking a day re-renders with a new value, and the + // roving tabindex needs the node it just focused to still be there. + const user = userEvent.setup(); + const { container } = render( + + + + ); + const target = day(container, '2024-04-17'); + await user.click(target); + expect(day(container, '2024-04-17')).toBe(target); + expect(target).toHaveFocus(); + }); + + /* + * Stepping the month genuinely replaces those cells — April's days are not + * May's — so node identity is not expected to survive a there-and-back + * navigation, and no amount of hoisting would make it. + */ +}); + +/* + * Measured by counting `isDateUnavailable` calls, which run once per cell + * inside the memo. Asserting on the DOM gives a false all-clear: React reuses + * a node whenever type and key match, recomputed props or not. + * + * Bounds are written inline as `minDate={new Date(...)}` throughout, since + * that is the shape that used to bust the memo on every parent render. + */ + +/** 2015–2035 at month granularity: 21 years × 12 = 252 cells. */ +const CELLS = 252; + +function Harness({ + isDateUnavailable +}: { + isDateUnavailable: (date: Date) => boolean; +}) { + const [, setTick] = useState(0); + + return ( + <> + + + + + + ); +} + +describe('.MonthGrid memo stability', () => { + it('rebuilds nothing on an unrelated parent re-render', async () => { + const user = userEvent.setup(); + const isDateUnavailable = vi.fn(() => false); + render(); + expect(isDateUnavailable).toHaveBeenCalledTimes(CELLS); + isDateUnavailable.mockClear(); + + await user.click(screen.getByRole('button', { name: 'rerender parent' })); + + expect(isDateUnavailable).toHaveBeenCalledTimes(0); + }); + + it('rebuilds nothing when only the selected period changes', async () => { + const user = userEvent.setup(); + const isDateUnavailable = vi.fn(() => false); + render(); + isDateUnavailable.mockClear(); + + // `value` moves, but the dates do not — only which one is selected. + await user.click(screen.getAllByRole('button', { name: 'Mar' })[0]); + + expect(isDateUnavailable).toHaveBeenCalledTimes(0); + expect(screen.getAllByRole('button', { name: 'Mar' })[0]).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/input-nav.test.tsx b/packages/raystack/components/calendar-preview/__tests__/input-nav.test.tsx index 0a171f8bd..d3bff013c 100644 --- a/packages/raystack/components/calendar-preview/__tests__/input-nav.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/input-nav.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { expectSlots, getSlot } from '~/test-utils/data-slots'; @@ -283,3 +283,162 @@ describe('CalendarPreview.Nav revert button', () => { await user.click(screen.getAllByLabelText('Reset to default date')[0]); }); }); + +describe('consumer handlers compose rather than replace', () => { + it('.Input still commits when a consumer passes onKeyDown', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const consumerKeyDown = vi.fn(); + render( + + + + ); + + await user.type(screen.getByRole('textbox'), '17 Apr 2024{Enter}'); + expect( + dayKey( + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] as Date + ) + ).toBe('2024-04-17'); + expect(consumerKeyDown).toHaveBeenCalled(); + }); + + it('.Input still commits when a consumer passes onBlur', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onBlur = vi.fn(); + render( + + + + ); + + await user.type(screen.getByRole('textbox'), '17 Apr 2024'); + await user.tab(); + expect( + dayKey( + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] as Date + ) + ).toBe('2024-04-17'); + expect(onBlur).toHaveBeenCalled(); + }); +}); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +/* + * Regressions that arrived from review passes rather than from the spec. + * Each assertion sits with the behaviour it guards; which pass found it is + * history, not structure. + */ +describe('regressions', () => { + it('a trigger holding a typed field claims no button semantics', async () => { + const { container } = render( + + + + + + + + + ); + + const trigger = getSlot( + container, + 'calendar-preview-trigger' + ) as HTMLElement; + // In ARIA a button's children are presentational, so the field inside was + // at risk of never being announced as editable; the tab stop it added sat + // in front of the input doing nothing a keyboard user wants. + await waitFor(() => expect(trigger).not.toHaveAttribute('role')); + expect(trigger).toHaveAttribute('tabindex', '-1'); + expect(trigger.querySelector('input')).not.toBeNull(); + }); + + it('a plain trigger keeps the button semantics it should have', () => { + const { container } = render( + + Pick a date + + ); + const trigger = getSlot( + container, + 'calendar-preview-trigger' + ) as HTMLElement; + expect(trigger).toHaveAttribute('role', 'button'); + expect(trigger).toHaveAttribute('tabindex', '0'); + }); + + it('clicking the field a second time does not close the calendar', async () => { + const user = userEvent.setup(); + render( + + + + + + + + + ); + + const field = screen.getByRole('textbox'); + await user.click(field); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + + // Repositioning the caret is an ordinary thing to do mid-edit. + await user.click(field); + expect(screen.queryByRole('grid')).toBeInTheDocument(); + }); + + it('clicking the trigger outside the field still toggles', async () => { + const user = userEvent.setup(); + const { container } = render( + + + + + + + + + ); + + const trigger = getSlot( + container, + 'calendar-preview-trigger' + ) as HTMLElement; + await user.click(trigger); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + await user.click(trigger); + await waitFor(() => + expect(screen.queryByRole('grid')).not.toBeInTheDocument() + ); + }); + + it('captions a two-month grid as a range', () => { + const { container } = render( + + + + + ); + expect( + getSlot(container, 'calendar-preview-nav-caption') + ).toHaveTextContent('April 2024 – May 2024'); + expect( + container.querySelectorAll('[data-slot="calendar-preview-table"]') + ).toHaveLength(2); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx b/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx index 71cb1ff98..4e8681455 100644 --- a/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { CalendarPreview } from '../calendar-preview'; @@ -201,3 +201,65 @@ describe('a rejected commit keeps what the user typed', () => { expect(screen.getByLabelText('End date')).toHaveValue('20 Apr 2024'); }); }); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +describe('regressions from the external audit', () => { + it('opens from the keyboard with ArrowDown, since the trigger has no tab stop', async () => { + const user = userEvent.setup(); + render( + + + + + + + + + ); + + const field = screen.getByRole('textbox'); + field.focus(); + await user.keyboard('{ArrowDown}'); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + }); + + it('Escape reverts the draft first and dismisses only on the second press', async () => { + const user = userEvent.setup(); + render( + + + + + + + + + ); + + const field = screen.getByRole('textbox') as HTMLInputElement; + await user.click(field); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + + await user.type(field, 'nonsense'); + await user.keyboard('{Escape}'); + // Correcting a typo must not cost you the calendar. + expect(field.value).toBe('17 Apr 2024'); + expect(screen.queryByRole('grid')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + await waitFor(() => + expect(screen.queryByRole('grid')).not.toBeInTheDocument() + ); + }); + + /* + * 24. `zoned()` freezes the offset of the instant it is given. A day arrives + * as its own midnight, so on a spring-forward day that offset is the *old* + * one and every time set on top of it came back an hour late — not only the + * hour that does not exist. + */ +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/memo-stability.test.tsx b/packages/raystack/components/calendar-preview/__tests__/memo-stability.test.tsx deleted file mode 100644 index c1ede4483..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/memo-stability.test.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { useState } from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { CalendarPreview } from '../calendar-preview'; - -/* - * Measured by counting `isDateUnavailable` calls, which run once per cell - * inside the memo. Asserting on the DOM gives a false all-clear: React reuses - * a node whenever type and key match, recomputed props or not. - * - * Bounds are written inline as `minDate={new Date(...)}` throughout, since - * that is the shape that used to bust the memo on every parent render. - */ - -/** 2015–2035 at month granularity: 21 years × 12 = 252 cells. */ -const CELLS = 252; - -function Harness({ - isDateUnavailable -}: { - isDateUnavailable: (date: Date) => boolean; -}) { - const [, setTick] = useState(0); - - return ( - <> - - - - - - ); -} - -describe('.MonthGrid memo stability', () => { - it('rebuilds nothing on an unrelated parent re-render', async () => { - const user = userEvent.setup(); - const isDateUnavailable = vi.fn(() => false); - render(); - expect(isDateUnavailable).toHaveBeenCalledTimes(CELLS); - isDateUnavailable.mockClear(); - - await user.click(screen.getByRole('button', { name: 'rerender parent' })); - - expect(isDateUnavailable).toHaveBeenCalledTimes(0); - }); - - it('rebuilds nothing when only the selected period changes', async () => { - const user = userEvent.setup(); - const isDateUnavailable = vi.fn(() => false); - render(); - isDateUnavailable.mockClear(); - - // `value` moves, but the dates do not — only which one is selected. - await user.click(screen.getAllByRole('button', { name: 'Mar' })[0]); - - expect(isDateUnavailable).toHaveBeenCalledTimes(0); - expect(screen.getAllByRole('button', { name: 'Mar' })[0]).toHaveAttribute( - 'aria-pressed', - 'true' - ); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/merge.test.tsx b/packages/raystack/components/calendar-preview/__tests__/merge.test.tsx deleted file mode 100644 index 5672c9e90..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/merge.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import { CalendarPreview } from '../calendar-preview'; -import { dayKey } from '../date-adapter'; - -describe('consumer handlers compose rather than replace', () => { - it('.Input still commits when a consumer passes onKeyDown', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - const consumerKeyDown = vi.fn(); - render( - - - - ); - - await user.type(screen.getByRole('textbox'), '17 Apr 2024{Enter}'); - expect( - dayKey( - onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] as Date - ) - ).toBe('2024-04-17'); - expect(consumerKeyDown).toHaveBeenCalled(); - }); - - it('.Input still commits when a consumer passes onBlur', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - const onBlur = vi.fn(); - render( - - - - ); - - await user.type(screen.getByRole('textbox'), '17 Apr 2024'); - await user.tab(); - expect( - dayKey( - onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] as Date - ) - ).toBe('2024-04-17'); - expect(onBlur).toHaveBeenCalled(); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx index 6834d29c7..285b0057c 100644 --- a/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx @@ -223,3 +223,66 @@ describe('MonthGrid: third audit', () => { expect(dayKey(lastArg(onValueChange) as Date)).toBe('2024-04-01'); }); }); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +describe('regressions from the external audit', () => { + it('switching to Month scrolls the active year into view', async () => { + const user = userEvent.setup(); + /* + * jsdom has no layout, so `scrollTop` is not observable on its own — the + * previous form of this test asserted `scrollTop >= 0`, which is true of + * an untouched element. Stand a spy in its place and give the geometry + * non-zero values, so the assertion is about the scroll actually happening. + */ + const stub = (name: string, descriptor: PropertyDescriptor) => { + const original = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + name + ); + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + ...descriptor + }); + return () => { + if (original) { + Object.defineProperty(HTMLElement.prototype, name, original); + } else { + Reflect.deleteProperty(HTMLElement.prototype, name); + } + }; + }; + + const scrolled = vi.fn(); + const restore = [ + stub('scrollTop', { get: () => 0, set: scrolled }), + stub('offsetTop', { get: () => 900 }), + stub('clientHeight', { get: () => 300 }) + ]; + + try { + render( + + + + + + ); + + expect(scrolled).not.toHaveBeenCalled(); + await user.click(screen.getByRole('tab', { name: 'Month' })); + // 900 - 300/2 + 300/2, centred on the active year. + expect(scrolled).toHaveBeenCalledWith(900); + } finally { + for (const undo of restore) undo(); + } + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/month-sync.test.tsx b/packages/raystack/components/calendar-preview/__tests__/month-sync.test.tsx deleted file mode 100644 index eb4dba0bd..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/month-sync.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import { CalendarPreview } from '../calendar-preview'; - -const caption = () => - document.querySelector('[data-slot="calendar-preview-nav-caption"]') - ?.textContent; - -/* - * The visible month was initialised once at mount and then left alone, so a - * value that arrived after mount was never shown and reopening the popover - * did not return to the selection. - */ -describe('the visible month follows the value', () => { - it('shows a value that arrives after mount, next time it opens', async () => { - const user = userEvent.setup(); - const view = (value: Date | null) => ( - - Pick - - - - - - ); - - // Mounted empty, as a picker waiting on a fetch is. - const { rerender } = render(view(null)); - rerender(view(new Date(2023, 8, 14))); - - await user.click(screen.getByText('Pick')); - await screen.findByRole('grid'); - expect(caption()).toBe('September 2023'); - }); - - it('returns to the selection when reopened, not to where the user left', async () => { - const user = userEvent.setup(); - render( - - Pick - - - - - - ); - - await user.click(screen.getByText('Pick')); - await screen.findByRole('grid'); - expect(caption()).toBe('April 2024'); - - await user.click(screen.getByLabelText('Next month')); - await user.click(screen.getByLabelText('Next month')); - expect(caption()).toBe('June 2024'); - - await user.keyboard('{Escape}'); - await user.click(screen.getByText('Pick')); - await screen.findByRole('grid'); - expect(caption()).toBe('April 2024'); - }); - - it('leaves navigation alone while the popover stays open', async () => { - const user = userEvent.setup(); - render( - - - - - ); - - await user.click(screen.getByLabelText('Next month')); - expect(caption()).toBe('May 2024'); - // A re-render with nothing relevant changed must not pull it back. - await user.click(document.body); - expect(caption()).toBe('May 2024'); - }); - - it('does not yank an open calendar back to today when the value is cleared', async () => { - const user = userEvent.setup(); - const view = (value: Date | null) => ( - - - - - ); - const { rerender } = render(view(new Date(2024, 3, 10))); - await user.click(screen.getByLabelText('Next month')); - expect(caption()).toBe('May 2024'); - - rerender(view(null)); - expect(caption()).toBe('May 2024'); - }); - - it('never writes the month a consumer controls', async () => { - const user = userEvent.setup(); - const onMonthChange = vi.fn(); - render( - - Pick - - - - - - ); - - await user.click(screen.getByText('Pick')); - await screen.findByRole('grid'); - expect(caption()).toBe('April 2024'); - expect(onMonthChange).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/range-input.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range-input.test.tsx index 6b277440d..b9a2a718d 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range-input.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range-input.test.tsx @@ -22,6 +22,10 @@ const setup = (props: Record = {}) => ); const start = () => screen.getByLabelText('Start date'); + +/** The day button for an ISO date, via the grid's `data-day` attribute. */ +const day = (c: HTMLElement, iso: string) => + c.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; const end = () => screen.getByLabelText('End date'); const dayButton = (container: HTMLElement, iso: string) => @@ -372,3 +376,127 @@ describe('CalendarPreview.RangeInput placement', () => { expect(dayKey(next.from as Date)).toBe('2024-04-17'); }); }); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +/* + * Regressions that arrived from review passes rather than from the spec. + * Each assertion sits with the behaviour it guards; which pass found it is + * history, not structure. + */ +describe('regressions', () => { + it('a timed start survives a typed same-day end', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + await user.type(screen.getByLabelText('End date'), '17 Apr 2024{Enter}'); + const next = lastArg(onValueChange) as DateRangeValue; + // The old raw `from > to` compared instants, so 08:00 "exceeded" midnight + // on the same day and the start was nulled. + expect(next.from).not.toBeNull(); + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + }); + + it('typing garbage with a timeZone set does not crash the input', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + await user.type(screen.getByLabelText('Start date'), 'nonsense{Enter}'); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'unparseable' + }); + }); + + it('puts the active flag on an element that owns a border', () => { + const { container } = render( + + + + ); + const active = getSlot(container, 'calendar-preview-input-start'); + expect(active).toHaveAttribute('data-active'); + // The style hangs off this wrapper reaching Input's container slot. + expect( + active?.querySelector('[data-slot="input-container"]') + ).not.toBeNull(); + }); + + it('lock still allows clearing the unlocked end', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + + await user.click(day(container, '2024-04-20')); + const next = onValueChange.mock.calls[ + onValueChange.mock.calls.length - 1 + ][0] as DateRangeValue; + // Whatever RDP decides, the locked end is never moved. + expect(dayKey(next.from as Date)).toBe('2024-04-10'); + }); + + it('drops a draft across years when the format carries no year', async () => { + const user = userEvent.setup(); + const { container, rerender } = render( + + + + ); + + await user.type(screen.getByLabelText('Start date'), 'xx'); + + rerender( + + + + ); + + // Same rendered text either year — only dayKey sees the change. + expect(screen.getByLabelText('Start date')).toHaveValue('17 Apr'); + expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx b/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx deleted file mode 100644 index 28c1dc1ed..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; -import { getSlot } from '~/test-utils/data-slots'; -import { CalendarPreview } from '../calendar-preview'; -import type { DateRangeValue } from '../calendar-preview-context'; -import { dayKey, parseDate } from '../date-adapter'; - -const MONTH = new Date(2024, 3, 1); -const day = (c: HTMLElement, iso: string) => - c.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; - -describe('regressions', () => { - it('parseDate returns null, never throws, when a timeZone is set', () => { - // dayjs.tz does not validate — it throws RangeError on bad input, which - // would crash the input on an ordinary keystroke. - expect(() => parseDate('not a date', 'DD MMM YYYY', 'UTC')).not.toThrow(); - expect(parseDate('not a date', 'DD MMM YYYY', 'UTC')).toBeNull(); - expect(parseDate('2024-04-17', 'DD MMM YYYY', 'UTC')).toBeNull(); - expect( - dayKey(parseDate('17 Apr 2024', 'DD MMM YYYY', 'UTC') as Date, 'UTC') - ).toBe('2024-04-17'); - }); - - it('typing garbage with a timeZone set does not crash the input', async () => { - const user = userEvent.setup(); - const onValidityChange = vi.fn(); - render( - - - - ); - - await user.type(screen.getByLabelText('Start date'), 'nonsense{Enter}'); - expect(onValidityChange).toHaveBeenLastCalledWith({ - valid: false, - reason: 'unparseable' - }); - }); - - it('readOnly shows the value but refuses grid writes', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - const { container } = render( - - - - ); - - await user.click(day(container, '2024-04-17')); - expect(onValueChange).not.toHaveBeenCalled(); - // readOnly is not disabled: the day stays legible and focusable. - expect(day(container, '2024-04-17')).not.toBeDisabled(); - }); - - it('disabled refuses to open the popover', async () => { - const user = userEvent.setup(); - const onOpenChange = vi.fn(); - render( - - Pick - - - - - ); - - await user.click(screen.getByText('Pick')); - expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); - expect(onOpenChange).not.toHaveBeenCalled(); - }); - - it('opens on the value month, not on today', () => { - render( - - - - - ); - expect(screen.getByText('January 2020')).toBeInTheDocument(); - }); - - it('derives the month from a range value too', () => { - render( - - - - - ); - expect(screen.getByText('July 2021')).toBeInTheDocument(); - }); - - it('defaultMonth still wins over the value', () => { - render( - - - - - ); - expect(screen.getByText('April 2024')).toBeInTheDocument(); - }); - - it('does not clobber Input own data-slot', () => { - const { container } = render( - - - - ); - // Both contracts hold: ours on the wrapper, Input's on its own elements. - expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); - expect(container.querySelectorAll('[data-slot="input"]')).toHaveLength(2); - expect( - container.querySelectorAll('[data-slot="input-container"]') - ).toHaveLength(2); - }); - - it('puts the active flag on an element that owns a border', () => { - const { container } = render( - - - - ); - const active = getSlot(container, 'calendar-preview-input-start'); - expect(active).toHaveAttribute('data-active'); - // The style hangs off this wrapper reaching Input's container slot. - expect( - active?.querySelector('[data-slot="input-container"]') - ).not.toBeNull(); - }); - - it('lock still allows clearing the unlocked end', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - const { container } = render( - - - - ); - - await user.click(day(container, '2024-04-20')); - const next = onValueChange.mock.calls[ - onValueChange.mock.calls.length - 1 - ][0] as DateRangeValue; - // Whatever RDP decides, the locked end is never moved. - expect(dayKey(next.from as Date)).toBe('2024-04-10'); - }); - - it('drops a draft across years when the format carries no year', async () => { - const user = userEvent.setup(); - const { container, rerender } = render( - - - - ); - - await user.type(screen.getByLabelText('Start date'), 'xx'); - - rerender( - - - - ); - - // Same rendered text either year — only dayKey sees the change. - expect(screen.getByLabelText('Start date')).toHaveValue('17 Apr'); - expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); - }); -}); - -describe('regressions: second audit', () => { - it('never warns that the month default changed', () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const { rerender } = render( - - - - ); - // A controlled value moving must not re-initialise the uncontrolled month. - rerender( - - - - ); - const warnings = spy.mock.calls.filter(call => - String(call[0]).includes('changing the default') - ); - spy.mockRestore(); - expect(warnings).toHaveLength(0); - }); - - it('captions a two-month grid as a range', () => { - const { container } = render( - - - - - ); - expect( - getSlot(container, 'calendar-preview-nav-caption') - ).toHaveTextContent('April 2024 – May 2024'); - expect( - container.querySelectorAll('[data-slot="calendar-preview-table"]') - ).toHaveLength(2); - }); - - it('hides the nav outside the day granularity, as the design does', () => { - const { container } = render( - - - - ); - expect(getSlot(container, 'calendar-preview-nav')).toBeNull(); - }); - - it('never renders a tab strip with nothing selected', () => { - render( - - - - ); - // granularities defaults to the active granularity, so a lone tab is not - // worth showing at all. - expect(screen.queryAllByRole('tab')).toHaveLength(0); - }); -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/rerender.test.tsx b/packages/raystack/components/calendar-preview/__tests__/rerender.test.tsx deleted file mode 100644 index b982c3e8d..000000000 --- a/packages/raystack/components/calendar-preview/__tests__/rerender.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { describe, expect, it } from 'vitest'; -import { CalendarPreview } from '../calendar-preview'; - -const MONTH = new Date(2024, 3, 1); -const day = (c: HTMLElement, iso: string) => - c.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; - -/* - * The suite renders once, acts once and asserts, which is how a whole class of - * second-render defects went unnoticed. These assert across a re-render. - */ -describe('survives a re-render', () => { - it('keeps the same day node, so roving tabindex has something to hold', () => { - const { container, rerender } = render( - - - - ); - const before = day(container, '2024-04-17'); - rerender( - - - - ); - expect(day(container, '2024-04-17')).toBe(before); - }); - - it('keeps DOM focus on the focused day across a re-render', () => { - const { container, rerender } = render( - - - - ); - const target = day(container, '2024-04-17'); - target.focus(); - expect(target).toHaveFocus(); - - rerender( - - - - ); - expect(day(container, '2024-04-17')).toHaveFocus(); - }); - - it('keeps focus on the focused day when selecting re-renders the grid', async () => { - // The realistic case: clicking a day re-renders with a new value, and the - // roving tabindex needs the node it just focused to still be there. - const user = userEvent.setup(); - const { container } = render( - - - - ); - const target = day(container, '2024-04-17'); - await user.click(target); - expect(day(container, '2024-04-17')).toBe(target); - expect(target).toHaveFocus(); - }); - - /* - * Stepping the month genuinely replaces those cells — April's days are not - * May's — so node identity is not expected to survive a there-and-back - * navigation, and no amount of hoisting would make it. - */ -}); diff --git a/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx b/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx index e8b7baa35..45465d4f9 100644 --- a/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx @@ -2,6 +2,7 @@ import { readdirSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { cleanup, render } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; +import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots'; import { CalendarPreview } from '../calendar-preview'; /* @@ -164,3 +165,114 @@ describe('CalendarPreview data-slot documentation', () => { ).toEqual([]); }); }); + +describe('CalendarPreview data-slot contract', () => { + it('exposes grid slots when composed inline, with no popover', () => { + const { container } = render( + + + + ); + + expectSlots(container, [ + 'calendar-preview-grid', + 'calendar-preview-weeks', + 'calendar-preview-table', + 'calendar-preview-day', + 'calendar-preview-day-number' + ]); + // Nothing portals when there is no `.Content`. + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); + + it('exposes trigger, positioner and content slots when open', () => { + render( + + Pick a date + + + + + ); + + // Portaled parts are asserted against the document, not the container. + expectSlots(document.body, [ + 'calendar-preview-trigger', + 'calendar-preview-positioner', + 'calendar-preview-content', + 'calendar-preview-grid' + ]); + }); + + it('omits the content slot while closed', () => { + render( + + Pick a date + + + + + ); + + expect(getSlot(document.body, 'calendar-preview-trigger')).not.toBeNull(); + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); + + it('renders one day slot per day button', () => { + const { container } = render( + + + + ); + + // April 2024 has 30 days and outside days are off by default. + expect(getAllSlots(container, 'calendar-preview-day')).toHaveLength(30); + // The month name belongs to `.Nav`, which this composition omits. + expect(getSlot(container, 'calendar-preview-nav-caption')).toBeNull(); + }); + + it('renders two months of day slots when months is 2', () => { + const { container } = render( + + + + ); + + // April (30) + May (31). + expect(getAllSlots(container, 'calendar-preview-day')).toHaveLength(61); + expect(getAllSlots(container, 'calendar-preview-table')).toHaveLength(2); + }); + + it('never mounts a Select — the caption is a plain label', () => { + const { container } = render( + + + + ); + + expect(getSlot(container, 'select-trigger')).toBeNull(); + expect(getSlot(container, 'calendar-preview-nav-month')).toBeNull(); + expect(container.querySelector('select')).toBeNull(); + }); +}); + +/* + * Moved here from `regressions.test.tsx`, which grouped fixes by the audit + * pass that found them. The assertions are unchanged; each now sits with the + * behaviour it guards. + */ +describe('regressions', () => { + it('does not clobber Input own data-slot', () => { + const { container } = render( + + + + ); + // Both contracts hold: ours on the wrapper, Input's on its own elements. + expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); + expect(container.querySelectorAll('[data-slot="input"]')).toHaveLength(2); + expect( + container.querySelectorAll('[data-slot="input-container"]') + ).toHaveLength(2); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx b/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx index aa0097516..a0fe47b80 100644 --- a/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx @@ -4,12 +4,14 @@ import { describe, expect, it, vi } from 'vitest'; import { getSlot } from '~/test-utils/data-slots'; import { CalendarPreview } from '../calendar-preview'; import type { DateRangeValue } from '../calendar-preview-context'; -import { getHours, getMinutes } from '../date-adapter'; +import { getHours, getMinutes, isWithinTimeBounds } from '../date-adapter'; const lastArg = (fn: { mock: { calls: unknown[][] } }) => fn.mock.calls[fn.mock.calls.length - 1]?.[0]; const hour = () => screen.getByLabelText('Hour'); + +const MONTH = new Date(2024, 3, 1); const minute = () => screen.getByLabelText('Minute'); const tree = (props: Record = {}, fieldProps = {}) => @@ -147,3 +149,142 @@ describe('CalendarPreview.TimeField', () => { expect(onValueChange).not.toHaveBeenCalled(); }); }); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +describe('regressions from the external audit', () => { + it('snapping never rolls into the next hour', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + const minute = screen.getByLabelText('Minute'); + await user.clear(minute); + await user.type(minute, '59{Enter}'); + const next = lastArg(onValueChange) as Date; + // `<= 59` was true of every Date ever constructed. The property is that the + // snap lands on the step grid without rolling the hour: 59 snaps to 45. + expect(next.getHours()).toBe(9); + expect(next.getMinutes()).toBe(45); + expect(next.getMinutes() % 15).toBe(0); + }); + + /* + * Asserting `isWithinBounds.length === 4` only checked the declared parameter + * count, and the case beside it was true in every zone — so making the + * function ignore `timeZone` entirely left all 33 tests here green, and all + * 358 across the repo. This asks the only question that separates the two: + * one instant that falls on different days depending on the zone it is read + * in, against a bound that sits between them. + */ +}); + +describe('.TimeField honours the picker bounds', () => { + const setup = (props: Record) => { + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + render( + + + + ); + return { onValueChange, onValidityChange }; + }; + + it('refuses an hour past maxDate and reports why', async () => { + const user = userEvent.setup(); + // Bounded at 10:00 *on the selected day*, so only a time comparison can + // catch this — `isWithinBounds` compares whole days and would pass it. + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17, 10, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'out-of-bounds' + }); + }); + + it('refuses an hour before minDate', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = setup({ + minDate: new Date(2024, 3, 17, 8, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '07{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'out-of-bounds' + }); + }); + + it('leaves the whole last day usable under a day-level maxDate', async () => { + const user = userEvent.setup(); + // The ordinary way a picker is bounded: a plain day, at midnight. Read + // literally as an instant it would forbid every time on the 17th, which + // is not what it means anywhere else in the component. + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '10{Enter}'); + + expect(getHours(lastArg(onValueChange) as Date)).toBe(10); + expect(lastArg(onValidityChange)).toEqual({ valid: true }); + }); + + it('still rejects the day after a day-level maxDate', () => { + // The day bound has not gone soft — it is applied first, inclusive. + expect( + isWithinTimeBounds( + new Date(2024, 3, 18, 9, 0), + undefined, + new Date(2024, 3, 17) + ) + ).toBe(false); + expect( + isWithinTimeBounds( + new Date(2024, 3, 17, 23, 59), + undefined, + new Date(2024, 3, 17) + ) + ).toBe(true); + }); + + it('commits an in-bounds hour and reports valid', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17, 10, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '10{Enter}'); + + expect(getHours(lastArg(onValueChange) as Date)).toBe(10); + expect(lastArg(onValidityChange)).toEqual({ valid: true }); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx b/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx index 8f0881cf1..1c3f532f5 100644 --- a/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { addMonths, + DEFAULT_FORMAT, dayKey, endOfMonth, formatDate, @@ -9,6 +10,8 @@ import { getYear, isWithinBounds, isWithinTimeBounds, + parseDate, + setTime, startOfMonth } from '../date-adapter'; @@ -179,3 +182,69 @@ describe('reads do not depend on the host timezone', () => { expect(dayKey(instant, lh)).toBe('2023-10-01'); }); }); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +/* + * Regressions that arrived from review passes rather than from the spec. + * Each assertion sits with the behaviour it guards; which pass found it is + * history, not structure. + */ +describe('regressions', () => { + it('isWithinBounds resolves the day in the zone it is given', () => { + // 23:00 UTC on 17 Apr is already 08:00 on the 18th in Tokyo. + const instant = new Date(Date.UTC(2024, 3, 17, 23, 0)); + const max = new Date(Date.UTC(2024, 3, 17, 12, 0)); + + expect(isWithinBounds(instant, undefined, max, 'UTC')).toBe(true); + expect(isWithinBounds(instant, undefined, max, 'Asia/Tokyo')).toBe(false); + }); + + it('parseDate returns null, never throws, when a timeZone is set', () => { + // dayjs.tz does not validate — it throws RangeError on bad input, which + // would crash the input on an ordinary keystroke. + expect(() => parseDate('not a date', 'DD MMM YYYY', 'UTC')).not.toThrow(); + expect(parseDate('not a date', 'DD MMM YYYY', 'UTC')).toBeNull(); + expect(parseDate('2024-04-17', 'DD MMM YYYY', 'UTC')).toBeNull(); + expect( + dayKey(parseDate('17 Apr 2024', 'DD MMM YYYY', 'UTC') as Date, 'UTC') + ).toBe('2024-04-17'); + }); +}); + +describe('setTime survives a daylight-saving shift', () => { + const TZ = 'America/New_York'; + // 9 Mar 2025: EST -> EDT at 02:00, so 02:00-02:59 never happens. + const shiftDay = parseDate('09 Mar 2025', DEFAULT_FORMAT, TZ) as Date; + + it.each([ + [1, 30], + [3, 0], + [10, 0], + [23, 45] + ])('returns %i:%i as asked', (hours, minutes) => { + const result = setTime(shiftDay, hours, minutes, TZ); + expect(getHours(result, TZ)).toBe(hours); + expect(getMinutes(result, TZ)).toBe(minutes); + }); + + it('resolves a time that does not exist forward into the shift', () => { + const result = setTime(shiftDay, 2, 30, TZ); + expect(getHours(result, TZ)).toBe(3); + expect(getMinutes(result, TZ)).toBe(30); + }); + + it('stays on the day it was handed', () => { + expect(dayKey(setTime(shiftDay, 23, 45, TZ), TZ)).toBe('2025-03-09'); + }); + + it('holds on the autumn shift too', () => { + // 2 Nov 2025: 01:00-01:59 happens twice; either instant reads back as 1. + const fallBack = parseDate('02 Nov 2025', DEFAULT_FORMAT, TZ) as Date; + expect(getHours(setTime(fallBack, 1, 30, TZ), TZ)).toBe(1); + expect(getHours(setTime(fallBack, 10, 0, TZ), TZ)).toBe(10); + }); +}); From 2955ab0579119cc6a9f2f5534c2f1ca968d3b3ea Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 2 Sep 2026 16:47:59 +0530 Subject: [PATCH 35/37] fix(calendar-preview): hold the range-order contract in .Grid too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ordering policy was written once, in `.MonthGrid`, and verified once — in a block whose helper was called `rangeGrid` but whose JSX was `.MonthGrid`, in a file containing no `.Grid` at all. So the contract was checked against the view users reach by switching granularity, and never against the day grid they land on, where: - a click on a range holding only an end discarded both the surviving endpoint and the click. RDP's `addToRange` branches on `!from && !to`, `from && !to` and `from && to`; `{from: undefined, to: D}` falls through all three and returns `undefined`, which was mapped to `setValue(null)`. - a pick before a locked start committed `from > to`, and — because the root announces validity on every commit — reported it as valid. `.Grid` now shares one `commitRange`: an inversion clears the opposite endpoint, or is refused with `range-order` when a lock pinned it, matching `.MonthGrid` and `.TimeField`. Compared by day, as `.RangeInput` does, since a click carries no time of day. The contract itself moves to `range-order.test.tsx`, parameterised over the writers that can commit a range, so a third writer means adding a row rather than remembering the file exists. `.MonthGrid`'s period-specific point stays where it was. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/bounds-and-order.test.tsx | 93 +-------- .../__tests__/range-order.test.tsx | 197 ++++++++++++++++++ .../calendar-preview-grid.tsx | 82 ++++++-- 3 files changed, 275 insertions(+), 97 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/range-order.test.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx b/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx index ea30ce180..ec63bf01b 100644 --- a/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/bounds-and-order.test.tsx @@ -416,8 +416,13 @@ describe('.MonthGrid renders and commits in a display timezone', () => { }); }); -describe('.MonthGrid cannot commit a backwards range', () => { - const rangeGrid = (props: Record) => +/* + * Range ordering itself lives in `range-order.test.tsx`, parameterised over + * every writer that can commit a range. What stays here is the one contract + * point that is specific to a period writer. + */ +describe('.MonthGrid period semantics', () => { + const rangeMonthGrid = (props: Record) => render( { ); - it('clears the end when a chosen start moves past it', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - rangeGrid({ - value: { from: null, to: new Date(2026, 2, 1) }, - onValueChange - }); - - await user.click(screen.getByRole('button', { name: 'Dec' })); - - const next = lastArg(onValueChange); - expect(dayKey(next.from as Date)).toBe('2026-12-01'); - expect(next.to).toBeNull(); - }); - - it('keeps an ordered pair intact', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - rangeGrid({ - value: { from: null, to: new Date(2026, 8, 1) }, - onValueChange - }); - - await user.click(screen.getByRole('button', { name: 'Mar' })); - - const next = lastArg(onValueChange); - expect(dayKey(next.from as Date)).toBe('2026-03-01'); - expect(dayKey(next.to as Date)).toBe('2026-09-01'); - }); - - /* - * `lock` holds one endpoint read-only, and that endpoint is exactly the one an - * inversion would clear — so under a lock the ordering guard has nothing it - * may repair. It must refuse rather than delete the endpoint the consumer - * pinned, which is what the first version of this guard did. - */ - describe('under a lock', () => { - const locked = (onValueChange: () => void, onValidityChange: () => void) => - render( - - - - ); - - it('refuses a pick that would invert, keeping the locked endpoint', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - const onValidityChange = vi.fn(); - locked(onValueChange, onValidityChange); - - await user.click(screen.getByRole('button', { name: 'Mar' })); - - expect(onValueChange).not.toHaveBeenCalled(); - expect( - lastArg<{ valid: boolean; reason?: string }>(onValidityChange) - ).toEqual({ valid: false, reason: 'range-order' }); - }); - - it('still commits an ordered pick', async () => { - const user = userEvent.setup(); - const onValueChange = vi.fn(); - const onValidityChange = vi.fn(); - locked(onValueChange, onValidityChange); - - await user.click(screen.getByRole('button', { name: 'Nov' })); - - const next = lastArg(onValueChange); - expect(dayKey(next.from as Date)).toBe('2026-09-01'); - expect(dayKey(next.to as Date)).toBe('2026-11-01'); - }); - }); - /* * By period, not by instant: re-picking the period the other endpoint already * sits in is not a contradiction, and clearing it there would discard a @@ -521,7 +444,7 @@ describe('.MonthGrid cannot commit a backwards range', () => { it('leaves the other endpoint alone when both land in one period', async () => { const user = userEvent.setup(); const onValueChange = vi.fn(); - rangeGrid({ + rangeMonthGrid({ value: { from: null, to: new Date(2026, 5, 20) }, onValueChange }); diff --git a/packages/raystack/components/calendar-preview/__tests__/range-order.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range-order.test.tsx new file mode 100644 index 000000000..8f68adb56 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/range-order.test.tsx @@ -0,0 +1,197 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; +import type { DateRangeValue } from '../calendar-preview-context'; +import { dayKey } from '../date-adapter'; + +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0] as T; + +/* + * The range-order contract, run against every writer that can commit a range. + * + * It used to live in one `.MonthGrid`-only block behind a helper called + * `rangeGrid` — the name said "grid", the JSX said `.MonthGrid`, and the file + * contained no `.Grid` at all. So the whole contract was verified against the + * view users reach by switching granularity and never against the day grid they + * land on. `.Grid` was meanwhile destroying a whole range on one click and + * committing backwards ranges under a lock, and reporting both as valid. + * + * Parameterised so that is structural rather than remembered: a writer is a + * row in `WRITERS`, and every contract point below runs for each of them. + * Adding a fourth writer means adding a row, not remembering this file exists. + */ +interface RangeWriter { + label: string; + /** Mounts the writer with `focusMonth` (0-based, 2026) reachable. */ + mount(props: Record, focusMonth: number): void; + /** Clicks the cell that commits a value inside `month`. */ + pick(month: number): Promise; + /** The `dayKey` this writer commits for a pick inside `month`. */ + committed(month: number): string; +} + +const BOUNDS = { + minDate: new Date(2026, 0, 1), + maxDate: new Date(2026, 11, 31) +}; + +const MONTH_LABEL = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' +]; +const MONTH_NAME = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' +]; + +/** The day `.Grid` cases click, and the day the period writers resolve to. */ +const DAY_OF_MONTH = 1; + +const WRITERS: RangeWriter[] = [ + { + label: '.MonthGrid', + mount(props) { + render( + + + + ); + }, + async pick(month) { + const user = userEvent.setup(); + await user.click( + screen.getAllByRole('button', { name: MONTH_LABEL[month] })[0] + ); + }, + committed: month => `2026-${String(month + 1).padStart(2, '0')}-01` + }, + { + label: '.Grid', + mount(props, focusMonth) { + render( + + + + ); + }, + async pick(month) { + const user = userEvent.setup(); + // RDP names a day button "Thursday, June 1st, 2026". + await user.click( + screen.getByRole('button', { + name: new RegExp(`${MONTH_NAME[month]} ${DAY_OF_MONTH}st`) + }) + ); + }, + committed: month => + `2026-${String(month + 1).padStart(2, '0')}-${String(DAY_OF_MONTH).padStart(2, '0')}` + } +]; + +describe.each(WRITERS)('$label range ordering', (writer: RangeWriter) => { + it('sets the start and keeps an end that is already held', async () => { + const onValueChange = vi.fn(); + writer.mount( + { value: { from: null, to: new Date(2026, 8, 20) }, onValueChange }, + 2 + ); + + await writer.pick(2); + + const next = lastArg(onValueChange); + expect(next).not.toBeNull(); + expect(dayKey(next.from as Date)).toBe(writer.committed(2)); + expect(next.to).not.toBeNull(); + }); + + it('clears the end when a chosen start moves past it', async () => { + const onValueChange = vi.fn(); + writer.mount( + { value: { from: null, to: new Date(2026, 2, 1) }, onValueChange }, + 11 + ); + + await writer.pick(11); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe(writer.committed(11)); + expect(next.to).toBeNull(); + }); + + /* + * `lock` holds one endpoint read-only, and that endpoint is the only one an + * inversion could clear — so under a lock there is nothing to repair. Refused + * rather than deleting the endpoint the consumer pinned. + */ + it('refuses a pick that would invert, keeping the locked endpoint', async () => { + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + writer.mount( + { + lock: 'from', + value: { from: new Date(2026, 8, 1), to: null }, + onValueChange, + onValidityChange + }, + 2 + ); + + await writer.pick(2); + + expect(onValueChange).not.toHaveBeenCalled(); + expect( + lastArg<{ valid: boolean; reason?: string }>(onValidityChange) + ).toEqual({ valid: false, reason: 'range-order' }); + }); + + it('still commits an ordered pick under a lock', async () => { + const onValueChange = vi.fn(); + writer.mount( + { + lock: 'from', + value: { from: new Date(2026, 8, 1), to: null }, + onValueChange + }, + 10 + ); + + await writer.pick(10); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe('2026-09-01'); + expect(dayKey(next.to as Date)).toBe(writer.committed(10)); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 658f884c4..2bb8be110 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -11,9 +11,12 @@ import { } from 'react-day-picker'; import { Skeleton } from '../skeleton'; import styles from './calendar-preview.module.css'; -import type { DateRangeValue } from './calendar-preview-context'; +import type { + CalendarRangeField, + DateRangeValue +} from './calendar-preview-context'; import { useCalendarPreviewContext } from './calendar-preview-context'; -import { dayKey } from './date-adapter'; +import { dayKey, isAfterDay } from './date-adapter'; /** * Everything react-day-picker owns is derived from root context and is @@ -129,7 +132,8 @@ export function CalendarPreviewGrid({ readOnly, lock, granularity, - loading + loading, + reportValidity } = useCalendarPreviewContext('Grid'); /* @@ -234,6 +238,44 @@ export function CalendarPreviewGrid({ onSelect={(next: DateRange | undefined, triggerDate: Date) => { if (!writable) return; const held = range ?? { from: null, to: null }; + + /* + * The ordering policy, shared with `.MonthGrid`. Both grids answer + * the same question and had answered it differently: this one wrote + * the clicked day through with no guard at all, so picking before a + * locked start emitted `from > to` and — because the root announces + * validity on every commit — reported it as good. + * + * Compared by day, as `.RangeInput` does: a click carries no time of + * day, so an instant comparison would read a 09:00 endpoint as + * "after" the midnight the click produces. + */ + const commitRange = ( + candidate: DateRangeValue, + field: CalendarRangeField + ) => { + const opposite = field === 'from' ? 'to' : 'from'; + if ( + candidate.from && + candidate.to && + isAfterDay(candidate.from, candidate.to, timeZone) + ) { + /* + * `lock` holds the opposite endpoint read-only, and the opposite + * endpoint is the only one an inversion could clear — so under a + * lock there is nothing to repair. Refused, as `.TimeField` + * refuses what it cannot fix, rather than deleting a pinned end. + */ + if (lock === opposite) { + reportValidity({ valid: false, reason: 'range-order' }); + return; + } + setValue({ ...candidate, [opposite]: null }); + return; + } + setValue(candidate); + }; + /* * With an endpoint locked, RDP's range machine still rewrites both * ends, so ignore its result and drive the unlocked end from the @@ -243,19 +285,35 @@ export function CalendarPreviewGrid({ * only deselect available while a lock is held. */ if (lock) { - const unlocked = lock === 'from' ? held.to : held.from; - const nextUnlocked = + const field: CalendarRangeField = lock === 'from' ? 'to' : 'from'; + const unlocked = held[field]; + if ( unlocked && dayKey(unlocked, timeZone) === dayKey(triggerDate, timeZone) - ? null - : triggerDate; - setValue( - lock === 'from' - ? { from: held.from, to: nextUnlocked } - : { from: nextUnlocked, to: held.to } - ); + ) { + setValue({ ...held, [field]: null }); + return; + } + commitRange({ ...held, [field]: triggerDate }, field); + return; + } + + /* + * RDP has no answer for a range holding only an end. + * `addToRange` branches on `!from && !to`, `from && !to` and + * `from && to`; `{ from: undefined, to: D }` — which is what + * `{ from: null, to: D }` becomes on the way in — falls through all + * three, so `range` is never assigned and it returns `undefined`. + * Mapping that to `setValue(null)` discarded both the surviving + * endpoint and the click. Clearing one end is a documented move + * (`.RangeInput` empties a field; a typed end before the start nulls + * `from`), so this shape is ordinary, not exotic. + */ + if (!next && held.to && !held.from) { + commitRange({ from: triggerDate, to: held.to }, 'from'); return; } + setValue( next ? { from: next.from ?? null, to: next.to ?? null } : null ); From 07fb9ec6c2b5b08cc51b00e570b3251e5e6e47f0 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 2 Sep 2026 16:48:13 +0530 Subject: [PATCH 36/37] fix(calendar-preview): .Preset shape and bounds, and clamp the granularity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps that all come from a check being written for one input and not the others. `.Preset`'s render-time guard checked `range` against `selection` and left `value` — typed `Date | Date[] | null` under every mode — unchecked. A bare `Date` under `selection="multiple"` reached `setValue`, and `.Grid` then called `selected?.some` on a `Date` and threw. Both directions now fail at render, where the stack still points at the preset. `.Preset` also honoured no bounds. Every other writer does: the text fields through `validate()`, `.TimeField` through `isWithinTimeBounds`, `.Grid` through RDP's matchers, `.MonthGrid` by disabling its cells. So a preset outside `minDate`/`maxDate`, or on a day `isDateUnavailable` rejects, looked operable and committed a value `.Input` would have refused. It is marked `aria-disabled` — not `disabled`, matching how `.Grid` marks an unavailable day, so a keyboard user can reach it and read why — and `apply` refuses. Both endpoints of a range preset are checked, and every date of an array. Nothing reconciled the active granularity against the offered set, and their defaults disagree: `granularities={['month','quarter']}`, the natural way to build a month/quarter picker, left the granularity at its `'day'` default — no tab selected, the day grid rendering for a set that excludes it, and a `.MonthGrid` click committing `{granularity: 'day'}` from a picker with no day view. The offered set is now the authority, and the context and `granularityRef` both read the clamped value. `granularities`' JSDoc said `@defaultValue ['day']`, which was wrong and shipped into the `.d.ts`. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/preset-contracts.test.tsx | 285 ++++++++++++++++++ .../calendar-preview-presets.tsx | 46 ++- .../calendar-preview-root.tsx | 38 ++- 3 files changed, 358 insertions(+), 11 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/preset-contracts.test.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/preset-contracts.test.tsx b/packages/raystack/components/calendar-preview/__tests__/preset-contracts.test.tsx new file mode 100644 index 000000000..d5f3fea24 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/preset-contracts.test.tsx @@ -0,0 +1,285 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; +import type { CalendarValidity } from '../calendar-preview-context'; +import { dayKey } from '../date-adapter'; + +const lastCall = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]; +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + lastCall(fn)?.[0] as T; +/** The `{ granularity }` detail every value change carries alongside it. */ +const lastDetails = (fn: { mock: { calls: unknown[][] } }) => lastCall(fn)?.[1]; + +/* + * `.Preset` writes straight into root state, and its render-time guard checked + * only `range` against `selection` — never `value`, which is typed + * `Date | Date[] | null` under every mode. So a `Date` under `multiple`, or an + * array under `single`, reached `setValue` with the wrong shape: `.Grid` then + * called `selected?.some` on a Date and threw, and `.MonthGrid` would call + * `.map` on one. + */ +describe('.Preset validates value against selection', () => { + const quiet = () => + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + it('rejects a bare Date under selection="multiple"', () => { + const spy = quiet(); + expect(() => + render( + + + + Today + + + + ) + ).toThrow(/multiple/i); + spy.mockRestore(); + }); + + it('rejects an array under the default selection="single"', () => { + const spy = quiet(); + expect(() => + render( + + + + Two + + + + ) + ).toThrow(/single/i); + spy.mockRestore(); + }); + + it('still accepts the shapes each mode does want', () => { + expect(() => + render( + + + + One + + + + ) + ).not.toThrow(); + }); +}); + +/* + * Every other writer honours the bounds: `.Input`/`.RangeInput` through + * `validate()`, `.TimeField` through `isWithinTimeBounds`, `.Grid` through RDP + * matchers, `.MonthGrid` by disabling out-of-range cells. `.Preset` checked + * nothing, reported nothing, and was not marked — so a preset outside the + * declared bounds looked operable and committed a value `.Input` would refuse. + */ +describe('.Preset honours minDate and maxDate', () => { + const bounded = (props: Record, presetProps: object) => + render( + + + Then + + + ); + + it('marks an out-of-bounds preset unavailable', () => { + bounded({}, { value: new Date(2020, 0, 1) }); + const button = screen.getByRole('button', { name: 'Then' }); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + + it('commits nothing when an out-of-bounds preset is clicked', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + bounded({ onValueChange }, { value: new Date(2020, 0, 1) }); + + await user.click(screen.getByRole('button', { name: 'Then' })); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('leaves an in-bounds preset operable', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + bounded({ onValueChange }, { value: new Date(2026, 5, 10) }); + + const button = screen.getByRole('button', { name: 'Then' }); + expect(button).not.toHaveAttribute('aria-disabled', 'true'); + await user.click(button); + + expect(dayKey(lastArg(onValueChange))).toBe('2026-06-10'); + }); + + it('checks both endpoints of a range preset', () => { + render( + + + + Spanning + + + + ); + expect(screen.getByRole('button', { name: 'Spanning' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('respects isDateUnavailable', () => { + render( + dayKey(d) === '2026-06-10'}> + + + Blocked + + + + ); + expect(screen.getByRole('button', { name: 'Blocked' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); +}); + +/* + * Nothing reconciled the active granularity against the offered set, so + * `granularities={['month','quarter']}` — the natural way to build a + * month/quarter picker — left the active granularity at its `'day'` default: + * no tab selected, the day grid rendered for an offered set that excludes it, + * and a typed date committing `{granularity: 'day'}`. + */ +describe('the active granularity is one the picker offers', () => { + const offered = (props: Record = {}) => + render( + + + + + + ); + + it('selects exactly one tab', () => { + offered(); + const selected = screen + .getAllByRole('tab') + .filter(tab => tab.getAttribute('aria-selected') === 'true'); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveAccessibleName('Month'); + }); + + it('renders the view the active granularity names', () => { + const { container } = offered(); + expect( + container.querySelector('[data-slot="calendar-preview-grid"]') + ).toBeNull(); + expect( + container.querySelector('[data-slot="calendar-preview-month-grid"]') + ).not.toBeNull(); + }); + + const typing = async (text: string) => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + render( + + + + ); + await user.type(screen.getByRole('textbox'), `${text}{Enter}`); + return { onValueChange, onValidityChange }; + }; + + /* + * The active granularity is tried first and unconditionally, so while it sat + * at `day` this committed `{granularity: 'day'}` from a picker offering + * neither day nor anything that could redisplay the result. + */ + it('never commits at a granularity absent from the offered set', async () => { + const { onValueChange, onValidityChange } = await typing('15 Jun 2026'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'unparseable' + }); + }); + + it('commits text the offered set can read, at that granularity', async () => { + const { onValueChange } = await typing('Jun 2026'); + + expect(dayKey(lastArg(onValueChange))).toBe('2026-06-01'); + expect(lastDetails(onValueChange)).toMatchObject({ + granularity: 'month' + }); + }); + + const selectedTabName = () => { + const selected = screen + .getAllByRole('tab') + .filter(tab => tab.getAttribute('aria-selected') === 'true'); + expect(selected).toHaveLength(1); + return selected[0]; + }; + + it('honours an explicit defaultGranularity that is offered', () => { + offered({ defaultGranularity: 'quarter' }); + expect(selectedTabName()).toHaveAccessibleName('Quarter'); + }); + + /* + * The two cases the default cannot cover, where the props contradict each + * other outright. The offered set is the authority — it is what the tabs + * render from, so anything else leaves no tab selected. + */ + it('clamps a defaultGranularity the set excludes', () => { + offered({ defaultGranularity: 'day' }); + expect(selectedTabName()).toHaveAccessibleName('Month'); + }); + + /* + * `.MonthGrid` names no granularity, so a click falls back to whatever the + * root holds — which is the unclamped state, and was reporting `'day'` from + * a picker with no day view at all. + */ + it('reports an offered granularity for a click that names none', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + offered({ defaultGranularity: 'day', onValueChange }); + + await user.click(screen.getAllByRole('button', { name: 'Jun' })[0]); + + expect(lastDetails(onValueChange)).toMatchObject({ + granularity: 'month' + }); + }); + + it('clamps a controlled granularity the set excludes', () => { + offered({ granularity: 'day' }); + expect(selectedTabName()).toHaveAccessibleName('Month'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-presets.tsx b/packages/raystack/components/calendar-preview/calendar-preview-presets.tsx index 97500a134..87ced02e2 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-presets.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-presets.tsx @@ -9,6 +9,7 @@ import { isSameValue, useCalendarPreviewContext } from './calendar-preview-context'; +import { isWithinBounds } from './date-adapter'; export interface CalendarPreviewPresetsProps extends ComponentProps<'div'> { /** @@ -68,7 +69,11 @@ export function CalendarPreviewPreset({ setMonth, granularity, disabled, - readOnly + readOnly, + minDate, + maxDate, + isDateUnavailable, + timeZone } = useCalendarPreviewContext('Preset'); const presetValue: CalendarValue = @@ -81,16 +86,50 @@ export function CalendarPreviewPreset({ * A mismatched prop writes a shape the root cannot use, so it fails at * render rather than on click — the stack then points at the preset instead * of at whatever the bad value later broke. + * + * `value` is typed `Date | Date[] | null` under every mode, so it was only + * `range` that got checked against `selection`. A bare `Date` under + * `multiple` reached `setValue`, and `.Grid` called `selected?.some` on it + * and threw; an array under `single` made `.MonthGrid` `.map` a `Date`. */ const shapeError = selection === 'range' && range === undefined ? 'CalendarPreview.Preset needs `range` when selection="range"' : selection !== 'range' && range !== undefined ? 'CalendarPreview.Preset `range` requires selection="range" — use `value`' - : null; + : selection === 'multiple' && value != null && !Array.isArray(value) + ? 'CalendarPreview.Preset needs a `Date[]` value when selection="multiple"' + : selection === 'single' && Array.isArray(value) + ? 'CalendarPreview.Preset needs a single `Date` value when selection="single" — use selection="multiple" for an array' + : null; + + /* + * Every other writer honours the bounds — the text fields through + * `validate()`, `.TimeField` through `isWithinTimeBounds`, `.Grid` through + * RDP's matchers, `.MonthGrid` by disabling its cells. This one checked + * nothing, so a preset outside the declared range looked operable and + * committed a value `.Input` would have refused. + * + * `aria-disabled`, not `disabled`, as `.Grid` marks an unavailable day: the + * preset stays focusable, so a keyboard user can reach it and read why. + */ + const reachable = (date: Date) => + isWithinBounds(date, minDate, maxDate, timeZone) && + !isDateUnavailable?.(date); + + const unreachable = + presetValue instanceof Date + ? !reachable(presetValue) + : Array.isArray(presetValue) + ? presetValue.some(date => !reachable(date)) + : presetValue + ? [presetValue.from, presetValue.to].some( + date => date && !reachable(date) + ) + : false; const apply = () => { - if (isDisabled) return; + if (isDisabled || unreachable) return; setValue(presetValue, { granularity }); // Bring the applied period into view, as typing does. const anchor = @@ -111,6 +150,7 @@ export function CalendarPreviewPreset({ 'data-slot': 'calendar-preview-preset', 'data-selected': isActive || undefined, 'aria-pressed': isActive, + 'aria-disabled': unreachable || undefined, disabled: isDisabled, onClick: apply } as useRender.ComponentProps<'button'>, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 103532a0c..804955626 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -36,13 +36,16 @@ export interface CalendarValueChangeDetails { export interface CalendarPreviewBaseProps { /** The active granularity (controlled). */ granularity?: CalendarGranularity; - /** @defaultValue 'day' */ + /** Clamped into `granularities` when the two disagree. @defaultValue 'day' */ defaultGranularity?: CalendarGranularity; onGranularityChange?: (granularity: CalendarGranularity) => void; /** * Granularities the user may switch between. `.GranularityTabs` renders * only when there is more than one. - * @defaultValue ['day'] + * + * The active granularity is always one of these: a `granularity` or + * `defaultGranularity` outside the set is clamped to the first entry. + * @defaultValue the active granularity */ granularities?: CalendarGranularity[]; @@ -248,14 +251,33 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { state: 'granularity' }); + /* + * The offered set is the authority on what the picker can show, so the + * granularity is clamped into it. Nothing reconciled the two before, and + * their defaults disagree: `granularities={['month','quarter']}` left the + * granularity at its `'day'` default — no tab selected, the day grid + * rendered for a set excluding it, and clicks committing + * `{granularity: 'day'}` from a picker with no day view. + * + * The context and `granularityRef` both read this, not the raw state. + */ + const activeGranularity = + granularities && + granularities.length > 0 && + !granularities.includes(granularity) + ? granularities[0] + : granularity; + /* * Defaults to just the active granularity, so a single-granularity picker * shows no tabs and the active one is always present in the list. */ const offeredGranularities = useMemo( () => - granularities && granularities.length > 0 ? granularities : [granularity], - [granularities, granularity] + granularities && granularities.length > 0 + ? granularities + : [activeGranularity], + [granularities, activeGranularity] ); const setGranularity = useCallback( @@ -286,8 +308,8 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { * every time the tab changed. Reading it at call time is also the more * correct of the two: it cannot be a stale closure. */ - const granularityRef = useRef(granularity); - granularityRef.current = granularity; + const granularityRef = useRef(activeGranularity); + granularityRef.current = activeGranularity; /* * A committed value clears any standing complaint. @@ -493,7 +515,7 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { const contextValue = useMemo( () => ({ selection, - granularity, + granularity: activeGranularity, setGranularity, granularities: offeredGranularities, value: effectiveValue, @@ -526,7 +548,7 @@ export function CalendarPreviewRoot(props: CalendarPreviewRootProps) { }), [ selection, - granularity, + activeGranularity, setGranularity, offeredGranularities, effectiveValue, From 2afd6a66e7da39384f11be056aaf83ce139b159d Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Wed, 2 Sep 2026 16:53:18 +0530 Subject: [PATCH 37/37] fix(calendar-preview): give Enter back to the form when there is nothing to commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `preventDefault()` ran above the `draft === null` guard, so an untouched `.Input` swallowed Enter for the life of the field: put a date picker in a form and implicit submit stopped working, whether or not anyone had typed in it. The guard now comes first — Enter with no pending edit is not ours. The same guard sat above `onEnterCommitted`, the hook documented as handing the keyboard from Start to End. So the hand-off only fired for a field that had just been edited: tabbing into a filled Start and pressing Enter — the commonest keyboard flow through a range — did nothing. A field showing its committed value is trivially accepted, so it reports `true` and advances. Both are one edit to one handler, which is also why they were one bug: the two effects share a guard. Tests cover both, with a plain input in the same form as the control, and the pending-edit path still keeps Enter for itself. Also removes a comment left orphaned in `keyboard.test.tsx` when its test moved to `timezone.test.tsx`. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/keyboard.test.tsx | 84 +++++++++++++++++-- .../calendar-preview-typed-field.ts | 14 +++- 2 files changed, 91 insertions(+), 7 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx b/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx index 4e8681455..07bd804a1 100644 --- a/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx @@ -255,11 +255,83 @@ describe('regressions from the external audit', () => { expect(screen.queryByRole('grid')).not.toBeInTheDocument() ); }); +}); - /* - * 24. `zoned()` freezes the offset of the instant it is given. A day arrives - * as its own midnight, so on a spring-forward day that offset is the *old* - * one and every time set on top of it came back an hour late — not only the - * hour that does not exist. - */ +/* + * Enter belongs to the form when the field has nothing to commit. + * `preventDefault()` ran above the `draft === null` guard, so an untouched + * date field swallowed Enter for its whole life and implicit submit never + * worked once one was on the page. The focus hand-off sits below that same + * guard, so tabbing into a filled Start field and pressing Enter — the + * commonest keyboard flow — did nothing either. + */ +describe('Enter on a field with nothing to commit', () => { + const inForm = (onSubmit: () => void) => + render( +
{ + event.preventDefault(); + onSubmit(); + }} + > + + + + + + + ); + + it('submits the surrounding form', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + inForm(onSubmit); + + await user.click(screen.getAllByRole('textbox')[0]); + await user.keyboard('{Enter}'); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + // The control: proof a red result above is the component, not the harness. + it('behaves as a plain input in the same form does', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + inForm(onSubmit); + + await user.click(screen.getByLabelText('Plain')); + await user.keyboard('{Enter}'); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('still keeps Enter for itself while an edit is pending', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + inForm(onSubmit); + + const field = screen.getAllByRole('textbox')[0]; + await user.clear(field); + await user.type(field, '18 Apr 2024{Enter}'); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('hands the keyboard from Start to End in .RangeInput', async () => { + const user = userEvent.setup(); + render( + + + + ); + + screen.getByLabelText('Start date').focus(); + await user.keyboard('{Enter}'); + + expect(screen.getByLabelText('End date')).toHaveFocus(); + }); }); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-typed-field.ts b/packages/raystack/components/calendar-preview/calendar-preview-typed-field.ts index 8be2aab21..fc2298dbe 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-typed-field.ts +++ b/packages/raystack/components/calendar-preview/calendar-preview-typed-field.ts @@ -114,8 +114,20 @@ export function typedFieldHandlers({ onKeyDown: event => { if (event.key === 'Enter') { + /* + * Nothing to commit, so Enter is not ours: it belongs to the form. + * `preventDefault()` used to run above this guard, which blocked + * implicit submit for the life of an untouched field. + * + * The hand-off still runs — a field showing its committed value is + * trivially accepted, and tabbing into a filled Start and pressing + * Enter is the commonest keyboard flow through a range. + */ + if (draft === null) { + onEnterCommitted?.(true); + return; + } event.preventDefault(); - if (draft === null) return; const accepted = commit(draft); if (accepted) setDraft(null); onEnterCommitted?.(accepted);