From 9449ba586f295527e6d17346ae74fbbfbb663bb2 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sun, 30 Aug 2026 04:34:24 +0530 Subject: [PATCH 01/27] =?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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] 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/27] =?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/27] =?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';