diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index ba98b172d..a6bca6c79 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -62,7 +62,7 @@ import { DemoProps } from './types'; export default function Demo(props: DemoProps) { const { data, - // `...Apsara` carries the 31 icons Apsara publishes, so none of those needs + // `...Apsara` carries the 32 icons Apsara publishes, so none of those needs // its own entry — and nothing below may repeat one of their keys, because a // later key shadows the spread. A demo that needs any other glyph names a // lucide component from the block above and sizes it at the call site, 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..89c7d7521 --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -0,0 +1,257 @@ +'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 presetDemo = { + type: 'code', + code: ` + + + Last 7 days + + + Last 30 days + + + This month + + + + +` +}; + +export const loadingDemo = { + type: 'code', + 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..b91ee63cb --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -0,0 +1,283 @@ +--- +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, + presetDemo, + loadingDemo, + 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. + +The table below flattens the root props for reading. The exported +`CalendarPreviewProps` is a discriminated union of `CalendarPreviewSingleProps`, +`CalendarPreviewRangeProps` and `CalendarPreviewMultipleProps`: `selection` +narrows `value`, `defaultValue` and `onValueChange` to a single shape, and +`lock` exists only on the range arm. Type a wrapper against one of those arms, +or against `CalendarPreviewBaseProps` for the props that do not vary by +selection — `Omit` over the union collapses it and loses the discriminant. + + + +### Trigger + +Anchors the popover. Renders a `div`, never a ` + + + + + ); +} + +describe('.MonthGrid memo stability', () => { + it('rebuilds nothing on an unrelated parent re-render', async () => { + const user = userEvent.setup(); + const isDateUnavailable = vi.fn(() => false); + render(); + expect(isDateUnavailable).toHaveBeenCalledTimes(CELLS); + isDateUnavailable.mockClear(); + + await user.click(screen.getByRole('button', { name: 'rerender parent' })); + + expect(isDateUnavailable).toHaveBeenCalledTimes(0); + }); + + it('rebuilds nothing when only the selected period changes', async () => { + const user = userEvent.setup(); + const isDateUnavailable = vi.fn(() => false); + render(); + isDateUnavailable.mockClear(); + + // `value` moves, but the dates do not — only which one is selected. + await user.click(screen.getAllByRole('button', { name: 'Mar' })[0]); + + expect(isDateUnavailable).toHaveBeenCalledTimes(0); + expect(screen.getAllByRole('button', { name: 'Mar' })[0]).toHaveAttribute( + 'aria-pressed', + 'true' + ); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/merge.test.tsx b/packages/raystack/components/calendar-preview/__tests__/merge.test.tsx new file mode 100644 index 000000000..5672c9e90 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/merge.test.tsx @@ -0,0 +1,52 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; +import { dayKey } from '../date-adapter'; + +describe('consumer handlers compose rather than replace', () => { + it('.Input still commits when a consumer passes onKeyDown', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const consumerKeyDown = vi.fn(); + render( + + + + ); + + await user.type(screen.getByRole('textbox'), '17 Apr 2024{Enter}'); + expect( + dayKey( + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] as Date + ) + ).toBe('2024-04-17'); + expect(consumerKeyDown).toHaveBeenCalled(); + }); + + it('.Input still commits when a consumer passes onBlur', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onBlur = vi.fn(); + render( + + + + ); + + await user.type(screen.getByRole('textbox'), '17 Apr 2024'); + await user.tab(); + expect( + dayKey( + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] as Date + ) + ).toBe('2024-04-17'); + expect(onBlur).toHaveBeenCalled(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx new file mode 100644 index 000000000..6834d29c7 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx @@ -0,0 +1,225 @@ +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(); + }); +}); + +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/__tests__/month-sync.test.tsx b/packages/raystack/components/calendar-preview/__tests__/month-sync.test.tsx new file mode 100644 index 000000000..eb4dba0bd --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/month-sync.test.tsx @@ -0,0 +1,117 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; + +const caption = () => + document.querySelector('[data-slot="calendar-preview-nav-caption"]') + ?.textContent; + +/* + * The visible month was initialised once at mount and then left alone, so a + * value that arrived after mount was never shown and reopening the popover + * did not return to the selection. + */ +describe('the visible month follows the value', () => { + it('shows a value that arrives after mount, next time it opens', async () => { + const user = userEvent.setup(); + const view = (value: Date | null) => ( + + Pick + + + + + + ); + + // Mounted empty, as a picker waiting on a fetch is. + const { rerender } = render(view(null)); + rerender(view(new Date(2023, 8, 14))); + + await user.click(screen.getByText('Pick')); + await screen.findByRole('grid'); + expect(caption()).toBe('September 2023'); + }); + + it('returns to the selection when reopened, not to where the user left', async () => { + const user = userEvent.setup(); + render( + + Pick + + + + + + ); + + await user.click(screen.getByText('Pick')); + await screen.findByRole('grid'); + expect(caption()).toBe('April 2024'); + + await user.click(screen.getByLabelText('Next month')); + await user.click(screen.getByLabelText('Next month')); + expect(caption()).toBe('June 2024'); + + await user.keyboard('{Escape}'); + await user.click(screen.getByText('Pick')); + await screen.findByRole('grid'); + expect(caption()).toBe('April 2024'); + }); + + it('leaves navigation alone while the popover stays open', async () => { + const user = userEvent.setup(); + render( + + + + + ); + + await user.click(screen.getByLabelText('Next month')); + expect(caption()).toBe('May 2024'); + // A re-render with nothing relevant changed must not pull it back. + await user.click(document.body); + expect(caption()).toBe('May 2024'); + }); + + it('does not yank an open calendar back to today when the value is cleared', async () => { + const user = userEvent.setup(); + const view = (value: Date | null) => ( + + + + + ); + const { rerender } = render(view(new Date(2024, 3, 10))); + await user.click(screen.getByLabelText('Next month')); + expect(caption()).toBe('May 2024'); + + rerender(view(null)); + expect(caption()).toBe('May 2024'); + }); + + it('never writes the month a consumer controls', async () => { + const user = userEvent.setup(); + const onMonthChange = vi.fn(); + render( + + Pick + + + + + + ); + + await user.click(screen.getByText('Pick')); + await screen.findByRole('grid'); + expect(caption()).toBe('April 2024'); + expect(onMonthChange).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/presets.test.tsx b/packages/raystack/components/calendar-preview/__tests__/presets.test.tsx new file mode 100644 index 000000000..7c03c8b12 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/presets.test.tsx @@ -0,0 +1,206 @@ +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 MONTH = new Date(2024, 3, 1); +const lastCall = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]; + +const LAST_7 = { from: new Date(2024, 3, 11), to: new Date(2024, 3, 17) }; + +describe('CalendarPreview.Presets', () => { + it('renders its slots and orientation', () => { + const { container } = render( + + + + Today + + + + ); + expect(getSlot(container, 'calendar-preview-presets')).toHaveAttribute( + 'data-orientation', + 'horizontal' + ); + expect(getAllSlots(container, 'calendar-preview-preset')).toHaveLength(1); + }); + + it('applies a single value and reports the granularity', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + A day + + + + ); + + await user.click(screen.getByRole('button', { name: 'A day' })); + const [value, details] = lastCall(onValueChange); + expect(dayKey(value as Date)).toBe('2024-04-17'); + expect(details).toEqual({ granularity: 'day' }); + }); + + it('applies a range', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + Last 7 days + + + + ); + + await user.click(screen.getByRole('button', { name: 'Last 7 days' })); + const next = lastCall(onValueChange)[0] as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-11'); + expect(dayKey(next.to as Date)).toBe('2024-04-17'); + }); + + it('marks itself pressed while the value matches', async () => { + const user = userEvent.setup(); + const { container } = render( + + + Last 7 + + + ); + + const preset = getSlot(container, 'calendar-preview-preset') as HTMLElement; + expect(preset).toHaveAttribute('aria-pressed', 'false'); + await user.click(preset); + expect(preset).toHaveAttribute('aria-pressed', 'true'); + }); + + it('brings the applied period into view', async () => { + const user = userEvent.setup(); + const onMonthChange = vi.fn(); + render( + + + + Far away + + + + + ); + + await user.click(screen.getByRole('button', { name: 'Far away' })); + expect(dayKey(lastCall(onMonthChange)[0] as Date)).toBe('2025-09-09'); + expect(screen.getByText('September 2025')).toBeInTheDocument(); + }); + + it('buffers under commit="explicit" like any other edit', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + A day + + + + + + + ); + + await user.click(screen.getByRole('button', { name: 'A day' })); + expect(onValueChange).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Apply' })); + expect(dayKey(lastCall(onValueChange)[0] as Date)).toBe('2024-04-17'); + }); + + it('refuses writes when disabled or readOnly', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = render( + + + + A day + + + + ); + + expect(getSlot(container, 'calendar-preview-preset')).toBeDisabled(); + await user.click(screen.getByRole('button', { name: 'A day' })); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('renders as another element through render', () => { + const { container } = render( + + + } + > + As a link + + + + ); + expect(getSlot(container, 'calendar-preview-preset')?.tagName).toBe('A'); + }); + + it('rejects a range preset on a single picker', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => + render( + + + + Wrong + + + + ) + ).toThrow('requires selection="range"'); + spy.mockRestore(); + }); + + it('rejects a value preset on a range picker', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => + render( + + + + Wrong + + + + ) + ).toThrow('needs `range`'); + spy.mockRestore(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/range-input.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range-input.test.tsx new file mode 100644 index 000000000..6b277440d --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/range-input.test.tsx @@ -0,0 +1,374 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +/** `.at()` is outside the package's TS lib target. */ +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0]; + +import { getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import type { DateRangeValue } from '../calendar-preview-context'; +import { dayKey } from '../date-adapter'; + +const MONTH = new Date(2024, 3, 1); + +const setup = (props: Record = {}) => + render( + + + + + ); + +const start = () => screen.getByLabelText('Start date'); +const end = () => screen.getByLabelText('End date'); + +const dayButton = (container: HTMLElement, iso: string) => + container.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; + +describe('CalendarPreview.RangeInput', () => { + it('renders both field slots', () => { + const { container } = setup(); + expect(getSlot(container, 'calendar-preview-range-inputs')).not.toBeNull(); + expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); + expect(getSlot(container, 'calendar-preview-input-end')).not.toBeNull(); + }); + + it('shows the committed value in the canonical format', () => { + setup({ + defaultValue: { from: new Date(2024, 3, 17), to: new Date(2024, 3, 20) } + }); + expect(start()).toHaveValue('17 Apr 2024'); + expect(end()).toHaveValue('20 Apr 2024'); + }); + + it('commits typed text on Enter and reports it', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + setup({ onValueChange }); + + await user.click(start()); + await user.type(start(), '17 Apr 2024{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + }); + + it('commits on blur as well as Enter', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + setup({ onValueChange }); + + await user.click(start()); + await user.type(start(), '17 Apr 2024'); + await user.tab(); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + }); + + it('advances focus to the end field on Enter, not while typing', async () => { + const user = userEvent.setup(); + setup(); + + await user.click(start()); + await user.type(start(), '17 Apr 2024'); + expect(start()).toHaveFocus(); + + await user.keyboard('{Enter}'); + expect(end()).toHaveFocus(); + }); + + it('reports unparseable text without changing the value', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const onValueChange = vi.fn(); + setup({ onValidityChange, onValueChange }); + + await user.click(start()); + await user.type(start(), 'not a date{Enter}'); + + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'unparseable' + }); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('reports an out-of-bounds date without changing the value', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const onValueChange = vi.fn(); + setup({ + minDate: new Date(2024, 3, 10), + onValidityChange, + onValueChange + }); + + await user.click(start()); + await user.type(start(), '02 Apr 2024{Enter}'); + + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'out-of-bounds' + }); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('treats an emptied field as clearing that endpoint, not an error', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + const onValueChange = vi.fn(); + setup({ + defaultValue: { from: new Date(2024, 3, 17), to: new Date(2024, 3, 20) }, + onValidityChange, + onValueChange + }); + + await user.clear(end()); + await user.tab(); + + expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true }); + const next = lastArg(onValueChange) as DateRangeValue; + expect(next.to).toBeNull(); + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + }); + + it('clears the end when a typed start moves past it', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + setup({ + defaultValue: { from: new Date(2024, 3, 10), to: new Date(2024, 3, 12) }, + onValueChange + }); + + await user.clear(start()); + await user.type(start(), '25 Apr 2024{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-25'); + expect(next.to).toBeNull(); + }); + + it('reverts the draft on Escape', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + setup({ + defaultValue: { from: new Date(2024, 3, 17), to: null }, + onValueChange + }); + + await user.clear(start()); + await user.type(start(), '01 Jan 2020'); + await user.keyboard('{Escape}'); + + expect(start()).toHaveValue('17 Apr 2024'); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('moves the visible month to a typed date', async () => { + const user = userEvent.setup(); + const onMonthChange = vi.fn(); + render( + + + + + + ); + + await user.click(start()); + await user.type(start(), '09 Sep 2025{Enter}'); + + expect(dayKey(lastArg(onMonthChange) as Date)).toBe('2025-09-09'); + expect(screen.getByText('September 2025')).toBeInTheDocument(); + }); + + it('drops the draft when the grid writes underneath it', async () => { + const user = userEvent.setup(); + const { container } = setup(); + + await user.click(start()); + await user.type(start(), '17 Ap'); + await user.click(dayButton(container, '2024-04-05')); + + expect(start()).toHaveValue('05 Apr 2024'); + }); + + it('tracks the active field from focus', async () => { + const user = userEvent.setup(); + const { container } = setup(); + + expect(getSlot(container, 'calendar-preview-input-start')).toHaveAttribute( + 'data-active' + ); + await user.click(end()); + expect(getSlot(container, 'calendar-preview-input-end')).toHaveAttribute( + 'data-active' + ); + }); +}); + +describe('CalendarPreview.RangeInput lock', () => { + it('holds the locked field read-only without disabling the picker', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = setup({ + lock: 'from', + defaultValue: { from: new Date(2024, 3, 10), to: null }, + onValueChange + }); + + expect(start()).toHaveAttribute('readonly'); + expect(end()).not.toHaveAttribute('readonly'); + // The grid stays live — this is the whole point of `lock`. + expect(dayButton(container, '2024-04-20')).not.toBeDisabled(); + + await user.click(dayButton(container, '2024-04-20')); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-10'); + expect(dayKey(next.to as Date)).toBe('2024-04-20'); + }); + + it('never makes the locked endpoint the active field', async () => { + const user = userEvent.setup(); + const { container } = setup({ lock: 'from' }); + + await user.click(start()); + expect( + getSlot(container, 'calendar-preview-input-start') + ).not.toHaveAttribute('data-active'); + expect(getSlot(container, 'calendar-preview-input-end')).toHaveAttribute( + 'data-active' + ); + }); + + it('holds the start when the end is locked', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = setup({ + lock: 'to', + defaultValue: { from: null, to: new Date(2024, 3, 25) }, + onValueChange + }); + + await user.click(dayButton(container, '2024-04-12')); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-12'); + expect(dayKey(next.to as Date)).toBe('2024-04-25'); + }); + + it('throws when used outside selection="range"', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => + render( + + + + ) + ).toThrow('requires selection="range"'); + spy.mockRestore(); + }); +}); + +/* + * The RFC puts `.RangeInput` under `.Trigger`; the Figma puts the typed field + * inside the popover surface instead. These tests pin what each placement + * actually costs, so the decision can be made on evidence. + */ +describe('CalendarPreview.RangeInput placement', () => { + it('works inside .Content, alongside the grid', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + Pick + + + + + + ); + + await user.type(await screen.findByLabelText('Start date'), '17 Apr 2024'); + await user.keyboard('{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + }); + + /* + * This assertion used to run the other way, and in doing so pinned a broken + * default in place: the popup took focus on open, keystrokes went to the + * grid, and Enter selected a day instead of committing the text — so every + * correct use had to pass `initialFocus={false}`. `.Content` now declines + * that focus by itself whenever a typed field is composed inside `.Trigger`. + */ + it('keeps focus in the field inside .Trigger, with no flag to pass', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + + + + + + ); + + const startField = screen.getByLabelText('Start date'); + await user.click(startField); + expect(startField).toHaveFocus(); + + await user.type(startField, '17 Apr 2024{Enter}'); + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + }); + + it('works inside .Trigger when .Content declines initial focus', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + + + + + + ); + + const startField = screen.getByLabelText('Start date'); + await user.click(startField); + expect(startField).toHaveFocus(); + + await user.type(startField, '17 Apr 2024'); + await user.keyboard('{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx b/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx new file mode 100644 index 000000000..28c1dc1ed --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/regressions.test.tsx @@ -0,0 +1,265 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import type { DateRangeValue } from '../calendar-preview-context'; +import { dayKey, parseDate } from '../date-adapter'; + +const MONTH = new Date(2024, 3, 1); +const day = (c: HTMLElement, iso: string) => + c.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; + +describe('regressions', () => { + it('parseDate returns null, never throws, when a timeZone is set', () => { + // dayjs.tz does not validate — it throws RangeError on bad input, which + // would crash the input on an ordinary keystroke. + expect(() => parseDate('not a date', 'DD MMM YYYY', 'UTC')).not.toThrow(); + expect(parseDate('not a date', 'DD MMM YYYY', 'UTC')).toBeNull(); + expect(parseDate('2024-04-17', 'DD MMM YYYY', 'UTC')).toBeNull(); + expect( + dayKey(parseDate('17 Apr 2024', 'DD MMM YYYY', 'UTC') as Date, 'UTC') + ).toBe('2024-04-17'); + }); + + it('typing garbage with a timeZone set does not crash the input', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + await user.type(screen.getByLabelText('Start date'), 'nonsense{Enter}'); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'unparseable' + }); + }); + + it('readOnly shows the value but refuses grid writes', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + + await user.click(day(container, '2024-04-17')); + expect(onValueChange).not.toHaveBeenCalled(); + // readOnly is not disabled: the day stays legible and focusable. + expect(day(container, '2024-04-17')).not.toBeDisabled(); + }); + + it('disabled refuses to open the popover', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render( + + Pick + + + + + ); + + await user.click(screen.getByText('Pick')); + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + expect(onOpenChange).not.toHaveBeenCalled(); + }); + + it('opens on the value month, not on today', () => { + render( + + + + + ); + expect(screen.getByText('January 2020')).toBeInTheDocument(); + }); + + it('derives the month from a range value too', () => { + render( + + + + + ); + expect(screen.getByText('July 2021')).toBeInTheDocument(); + }); + + it('defaultMonth still wins over the value', () => { + render( + + + + + ); + expect(screen.getByText('April 2024')).toBeInTheDocument(); + }); + + it('does not clobber Input own data-slot', () => { + const { container } = render( + + + + ); + // Both contracts hold: ours on the wrapper, Input's on its own elements. + expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); + expect(container.querySelectorAll('[data-slot="input"]')).toHaveLength(2); + expect( + container.querySelectorAll('[data-slot="input-container"]') + ).toHaveLength(2); + }); + + it('puts the active flag on an element that owns a border', () => { + const { container } = render( + + + + ); + const active = getSlot(container, 'calendar-preview-input-start'); + expect(active).toHaveAttribute('data-active'); + // The style hangs off this wrapper reaching Input's container slot. + expect( + active?.querySelector('[data-slot="input-container"]') + ).not.toBeNull(); + }); + + it('lock still allows clearing the unlocked end', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + + await user.click(day(container, '2024-04-20')); + const next = onValueChange.mock.calls[ + onValueChange.mock.calls.length - 1 + ][0] as DateRangeValue; + // Whatever RDP decides, the locked end is never moved. + expect(dayKey(next.from as Date)).toBe('2024-04-10'); + }); + + it('drops a draft across years when the format carries no year', async () => { + const user = userEvent.setup(); + const { container, rerender } = render( + + + + ); + + await user.type(screen.getByLabelText('Start date'), 'xx'); + + rerender( + + + + ); + + // Same rendered text either year — only dayKey sees the change. + expect(screen.getByLabelText('Start date')).toHaveValue('17 Apr'); + expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); + }); +}); + +describe('regressions: second audit', () => { + it('never warns that the month default changed', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const { rerender } = render( + + + + ); + // A controlled value moving must not re-initialise the uncontrolled month. + rerender( + + + + ); + const warnings = spy.mock.calls.filter(call => + String(call[0]).includes('changing the default') + ); + spy.mockRestore(); + expect(warnings).toHaveLength(0); + }); + + it('captions a two-month grid as a range', () => { + const { container } = render( + + + + + ); + expect( + getSlot(container, 'calendar-preview-nav-caption') + ).toHaveTextContent('April 2024 – May 2024'); + expect( + container.querySelectorAll('[data-slot="calendar-preview-table"]') + ).toHaveLength(2); + }); + + it('hides the nav outside the day granularity, as the design does', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-nav')).toBeNull(); + }); + + it('never renders a tab strip with nothing selected', () => { + render( + + + + ); + // granularities defaults to the active granularity, so a lone tab is not + // worth showing at all. + expect(screen.queryAllByRole('tab')).toHaveLength(0); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/rerender.test.tsx b/packages/raystack/components/calendar-preview/__tests__/rerender.test.tsx new file mode 100644 index 000000000..b982c3e8d --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/rerender.test.tsx @@ -0,0 +1,68 @@ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; + +const MONTH = new Date(2024, 3, 1); +const day = (c: HTMLElement, iso: string) => + c.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; + +/* + * The suite renders once, acts once and asserts, which is how a whole class of + * second-render defects went unnoticed. These assert across a re-render. + */ +describe('survives a re-render', () => { + it('keeps the same day node, so roving tabindex has something to hold', () => { + const { container, rerender } = render( + + + + ); + const before = day(container, '2024-04-17'); + rerender( + + + + ); + expect(day(container, '2024-04-17')).toBe(before); + }); + + it('keeps DOM focus on the focused day across a re-render', () => { + const { container, rerender } = render( + + + + ); + const target = day(container, '2024-04-17'); + target.focus(); + expect(target).toHaveFocus(); + + rerender( + + + + ); + expect(day(container, '2024-04-17')).toHaveFocus(); + }); + + it('keeps focus on the focused day when selecting re-renders the grid', async () => { + // The realistic case: clicking a day re-renders with a new value, and the + // roving tabindex needs the node it just focused to still be there. + const user = userEvent.setup(); + const { container } = render( + + + + ); + const target = day(container, '2024-04-17'); + await user.click(target); + expect(day(container, '2024-04-17')).toBe(target); + expect(target).toHaveFocus(); + }); + + /* + * Stepping the month genuinely replaces those cells — April's days are not + * May's — so node identity is not expected to survive a there-and-back + * navigation, and no amount of hoisting would make it. + */ +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx b/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx 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/__tests__/time-field.test.tsx b/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx new file mode 100644 index 000000000..aa0097516 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx @@ -0,0 +1,149 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import type { DateRangeValue } from '../calendar-preview-context'; +import { getHours, getMinutes } from '../date-adapter'; + +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0]; + +const hour = () => screen.getByLabelText('Hour'); +const minute = () => screen.getByLabelText('Minute'); + +const tree = (props: Record = {}, fieldProps = {}) => + render( + + + + ); + +describe('CalendarPreview.TimeField', () => { + it('renders its slot', () => { + const { container } = tree(); + expect(getSlot(container, 'calendar-preview-time-field')).not.toBeNull(); + }); + + it('is empty and disabled with no date selected', () => { + tree(); + expect(hour()).toHaveValue(''); + expect(hour()).toBeDisabled(); + expect(minute()).toBeDisabled(); + }); + + it('shows the selected time, zero-padded', () => { + tree({ value: new Date(2024, 3, 17, 9, 5) }); + expect(hour()).toHaveValue('09'); + expect(minute()).toHaveValue('05'); + }); + + it('writes the time back onto the same day', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ value: new Date(2024, 3, 17, 9, 5), onValueChange }); + + await user.clear(hour()); + await user.type(hour(), '14{Enter}'); + + const next = lastArg(onValueChange) as Date; + expect(getHours(next)).toBe(14); + expect(next.getDate()).toBe(17); + }); + + it('snaps minutes to the step', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ value: new Date(2024, 3, 17, 9, 0), onValueChange }, { step: 15 }); + + await user.clear(minute()); + await user.type(minute(), '20{Enter}'); + expect(getMinutes(lastArg(onValueChange) as Date)).toBe(15); + }); + + it('rejects out-of-range values without changing anything', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ value: new Date(2024, 3, 17, 9, 5), onValueChange }); + + await user.clear(hour()); + await user.type(hour(), '99{Enter}'); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('renders AM/PM only under a 12-hour cycle', () => { + const { container, unmount } = tree( + { value: new Date(2024, 3, 17, 15, 0) }, + { hourCycle: 12 } + ); + expect(getSlot(container, 'calendar-preview-meridiem')).not.toBeNull(); + expect(hour()).toHaveValue('03'); + expect(screen.getByRole('button', { name: 'PM' })).toHaveAttribute( + 'aria-pressed', + 'true' + ); + unmount(); + + const second = tree({ value: new Date(2024, 3, 17, 15, 0) }); + expect(getSlot(second.container, 'calendar-preview-meridiem')).toBeNull(); + expect(hour()).toHaveValue('15'); + }); + + it('flips meridiem without moving the hour hand', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree( + { value: new Date(2024, 3, 17, 15, 30), onValueChange }, + { hourCycle: 12 } + ); + + await user.click(screen.getByRole('button', { name: 'AM' })); + const next = lastArg(onValueChange) as Date; + expect(getHours(next)).toBe(3); + expect(getMinutes(next)).toBe(30); + }); + + it('edits the active endpoint of a range, honouring lock', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ + selection: 'range', + lock: 'from', + value: { + from: new Date(2024, 3, 10, 8, 0), + to: new Date(2024, 3, 20, 9, 0) + }, + onValueChange + }); + + // With `from` locked, the unlocked `to` is what this field edits. + expect(hour()).toHaveValue('09'); + await user.clear(hour()); + await user.type(hour(), '18{Enter}'); + + const next = lastArg(onValueChange) as DateRangeValue; + expect(getHours(next.to as Date)).toBe(18); + expect(getHours(next.from as Date)).toBe(8); + }); + + it('refuses writes when readOnly', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ value: new Date(2024, 3, 17, 9, 5), readOnly: true, onValueChange }); + + await user.type(hour(), '1{Enter}'); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('reverts a draft on Escape', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + tree({ value: new Date(2024, 3, 17, 9, 5), onValueChange }); + + await user.clear(hour()); + await user.type(hour(), '11'); + await user.keyboard('{Escape}'); + expect(hour()).toHaveValue('09'); + expect(onValueChange).not.toHaveBeenCalled(); + }); +}); 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..f8be92cea --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx @@ -0,0 +1,56 @@ +'use client'; + +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< + 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. + * + * `initialFocus` defaults to declining focus whenever a typed field is + * composed inside `.Trigger` — the RFC's headline shape, and the one + * `FilterChip` uses. Taking focus into the popup there sends the user's + * 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. + */ +export function CalendarPreviewContent({ + className, + initialFocus, + ...props +}: CalendarPreviewContentProps) { + const { triggerOwnsFocus } = useCalendarPreviewContext('Content'); + + return ( + + ); +} + +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..033a50909 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -0,0 +1,170 @@ +'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; + +/** Which endpoint of a range the next grid click writes to. */ +export type CalendarRangeField = 'from' | 'to'; + +export interface CalendarValidity { + valid: boolean; + /** + * `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 { + selection: CalendarSelection; + granularity: CalendarGranularity; + setGranularity: (granularity: CalendarGranularity) => void; + /** Switchable granularities. `.GranularityTabs` renders when >1. */ + granularities: CalendarGranularity[]; + value: Value; + /** + * `granularity` names the one that produced the value, for when it differs + * from the active one — typing `Q4` into a day field switches the tab and + * commits in the same breath, and the reported detail must be the new one, + * not the stale closure's. + */ + setValue: (value: Value, details?: { granularity?: string }) => void; + /** The visible month. Independent of selection, and owned by the root. */ + month: Date; + 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; + /** + * True when a `defaultValue` was given and the current value differs from + * it — the condition under which `.Nav` offers its revert button. + */ + canReset: boolean; + /** Restore `defaultValue`. A no-op when nothing was given to revert to. */ + resetValue: () => void; + /** 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 `.MonthGrid` or `.TimeField` write + * lands on, tracked from focus in `.RangeInput`. `.Grid` does not read it: + * react-day-picker's own range machine decides which end a day click moves, + * and it agrees with the focused field in the cases that matter. + */ + activeField: CalendarRangeField; + setActiveField: (field: CalendarRangeField) => void; + /** Range only. The endpoint held read-only in both the input and the grid. */ + lock?: CalendarRangeField; + reportValidity: (validity: CalendarValidity) => void; + minDate?: Date; + maxDate?: Date; + isDateUnavailable?: (date: Date) => boolean; + format: string; + timeZone?: string; + weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6; + /** + * Already folded into `disabled`, so no part needs to check both. Read it + * only to decide whether to render a skeleton in place of content. + */ + loading: boolean; + disabled: boolean; + readOnly: boolean; + /** + * True while a typed field is mounted inside `.Trigger`. Three things turn + * on it: the trigger stops claiming button semantics it must not have around + * a textbox, a click inside that field stops toggling an open popover, and + * `.Content` declines the initial focus it would otherwise steal from the + * field the user is typing into. + */ + triggerOwnsFocus: boolean; + /** + * Called by a typed field that finds itself inside `.Trigger`. Returns its + * own unregister, so it is used straight as an effect cleanup. + */ + registerTriggerField: () => () => void; +} + +/* + * 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; +} + +/* + * A second, deliberately tiny context, provided by `.Trigger` over its own + * subtree only. It answers one question the root cannot — *where* a typed + * field is composed, not merely that one exists — because `.Input` is equally + * valid inside `.Content`, where none of the trigger's adjustments apply. + */ +const CalendarPreviewTriggerScopeContext = createContext(false); + +export const CalendarPreviewTriggerScope = + CalendarPreviewTriggerScopeContext.Provider; + +/** True when the calling part is composed inside `.Trigger`. */ +export function useInsideTrigger(): boolean { + return useContext(CalendarPreviewTriggerScopeContext); +} + +/** + * Value equality across all three selection modes, compared on the exact + * instant so a time-of-day edit counts as a change. + */ +export function isSameValue(a: CalendarValue, b: CalendarValue): boolean { + if (a === b) return true; + if (a == null || b == null) return false; + if (a instanceof Date && b instanceof Date) + return a.getTime() === b.getTime(); + if (Array.isArray(a) && Array.isArray(b)) { + return ( + a.length === b.length && + a.every((item, index) => item.getTime() === b[index]?.getTime()) + ); + } + if (a instanceof Date || b instanceof Date || Array.isArray(a)) return false; + const left = a as DateRangeValue; + const right = b as DateRangeValue; + const same = (x: Date | null, y: Date | null) => + x === y || (!!x && !!y && x.getTime() === y.getTime()); + return same(left.from, right.from) && same(left.to, right.to); +} 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..5d372863f --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx @@ -0,0 +1,108 @@ +'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', + disabled, + onClick, + ...props +}: CalendarPreviewCancelProps) { + const { + cancelValue, + setOpen, + disabled: rootDisabled + } = useCalendarPreviewContext('Cancel'); + + return ( + + ); +} + +CalendarPreviewCancel.displayName = 'CalendarPreview.Cancel'; 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 new file mode 100644 index 000000000..658f884c4 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -0,0 +1,298 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { type CSSProperties, useEffect, useMemo, useRef } from 'react'; +import { + type DateRange, + type DayButtonProps, + DayPicker, + type DayPickerProps, + type Matcher +} from 'react-day-picker'; +import { Skeleton } from '../skeleton'; +import styles from './calendar-preview.module.css'; +import type { DateRangeValue } from './calendar-preview-context'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey } from './date-adapter'; + +/** + * 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. + */ +/** + * The day button, carrying RDP's roving tabindex. + * + * Arrow keys do not move focus themselves: RDP moves a `focused` modifier + * between days and never touches the DOM, so the button has to focus itself + * when it becomes the focused one. Overriding `DayButton` without this ref and + * effect left the grid's arrow keys dead in every composition, and crossing a + * month boundary dropped focus to `` — inside a popover, that strands + * the user outside the surface with nothing focused. + * + * `modifiers` is therefore read, not discarded. Keyboard navigation is the + * stated reason the RFC takes a dependency on react-day-picker at all. + */ +function DayButton({ day: _day, modifiers, ...buttonProps }: DayButtonProps) { + const ref = useRef(null); + + useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( + + ); +} + +/* + * Module scope, not inside the render. React compares component *types* by + * identity: a fresh function per render is a new type, so RDP's whole grid + * unmounts and remounts and the focused day node does not survive. Necessary + * but not sufficient — node identity is not the focus mechanism, the ref and + * effect above are. + */ +const GRID_COMPONENTS: DayPickerProps['components'] = { + DayButton, + // `.Nav` owns the caption; RDP's would render the month twice. + MonthCaption: () => <>, + MonthGrid: gridProps => ( +
+ + + ) +}; + +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, + '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, + readOnly, + lock, + granularity, + 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 (minTime !== null) matchers.push({ before: new Date(minTime) }); + if (maxTime !== null) matchers.push({ after: new Date(maxTime) }); + if (isDateUnavailable) matchers.push(isDateUnavailable); + return matchers; + }, [minTime, maxTime, 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 + * each shows itself for its own granularities. + */ + if (granularity !== 'day') return null; + + /* + * The grid is replaced outright rather than overlaid: the old family shimmered + * five rows over a live grid, which left the days underneath focusable. + */ + if (loading) { + return ( +
+ +
+ ); + } + + /* + * `readOnly` shows the value but refuses writes, so days stay legible and + * focusable rather than dimmed — that is what separates it from `disabled`. + */ + const writable = !disabled && !readOnly; + + /* + * 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: GRID_COMPONENTS, + classNames: mergedClassNames, + 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 ( + { + if (!writable) return; + const held = range ?? { from: null, to: null }; + /* + * With an endpoint locked, RDP's range machine still rewrites both + * ends, so ignore its result and drive the unlocked end from the + * clicked day alone. This is what closes the whole-picker-disable + * gate — "fix the start, pick the end" no longer means disabling + * the picker. Re-clicking the unlocked end clears it, which is the + * only deselect available while a lock is held. + */ + if (lock) { + const unlocked = lock === 'from' ? held.to : held.from; + const nextUnlocked = + unlocked && + dayKey(unlocked, timeZone) === dayKey(triggerDate, timeZone) + ? null + : triggerDate; + setValue( + lock === 'from' + ? { from: held.from, to: nextUnlocked } + : { from: nextUnlocked, to: held.to } + ); + return; + } + setValue( + next ? { from: next.from ?? null, to: next.to ?? null } : null + ); + }} + data-slot='calendar-preview-grid' + {...shared} + /> + ); + } + + if (selection === 'multiple') { + return ( + { + if (!writable) return; + setValue(next ?? []); + }} + data-slot='calendar-preview-grid' + {...shared} + /> + ); + } + + return ( + { + if (!writable) return; + setValue(next ?? null); + }} + data-slot='calendar-preview-grid' + {...shared} + /> + ); +} + +CalendarPreviewGrid.displayName = 'CalendarPreview.Grid'; 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..c886a7cb5 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -0,0 +1,223 @@ +'use client'; + +import { mergeProps } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +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 { CalendarValidity } from './calendar-preview-context'; +import { + useCalendarPreviewContext, + useInsideTrigger +} from './calendar-preview-context'; +import { + dayKey, + formatForGranularity, + getYear, + isWithinBounds, + parseAcrossGranularities, + parseForGranularity, + patternForGranularity +} 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, + granularity, + granularities, + setGranularity, + month, + value, + setValue, + setMonth, + reportValidity, + minDate, + maxDate, + isDateUnavailable, + format, + timeZone, + disabled, + readOnly, + setOpen, + registerTriggerField + } = useCalendarPreviewContext('Input'); + + /* + * Tell the root that the typed field is inside `.Trigger`, which is what + * lets the trigger drop its button role and `.Content` decline the focus it + * would otherwise steal from this field. Nothing happens when the field is + * composed inside `.Content` instead, where neither adjustment applies. + */ + const insideTrigger = useInsideTrigger(); + useEffect(() => { + if (!insideTrigger) return; + return registerTriggerField(); + }, [insideTrigger, registerTriggerField]); + + const committed = value + ? formatForGranularity(value, granularity, 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, timeZone)) { + 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; + } + + /* + * The active granularity wins. Only when it cannot read the text do we + * scan the granularities on offer, so typing `Q4` in a day field switches + * to Quarter rather than failing — and a day-only picker still rejects it. + */ + const visibleYear = getYear(month, timeZone); + let parsed = parseForGranularity( + text, + granularity, + format, + timeZone, + visibleYear + ); + let matched = granularity; + if (!parsed) { + const across = parseAcrossGranularities( + text, + granularities, + format, + timeZone, + visibleYear + ); + if (across) { + parsed = across.date; + matched = across.granularity as typeof granularity; + } + } + if (!parsed) { + reportValidity({ valid: false, reason: 'unparseable' }); + return; + } + + const validity = validate(parsed); + reportValidity(validity); + if (!validity.valid) return; + + if (matched !== granularity) setGranularity(matched); + setValue(parsed, { granularity: matched }); + // Typing navigates the grid, so the committed day is actually visible. + setMonth(parsed); + }; + + return ( +
+ {/* + * 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. + */} + ( + { + 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); + } + /* + * The trigger around this field carries no tab stop, so ArrowDown + * is how a keyboard reaches the calendar — the combobox + * convention, and an explicit gesture rather than the focus race + * the RFC retired. + */ + if (event.key === 'ArrowDown' && insideTrigger) { + event.preventDefault(); + setOpen(true); + } + /* + * Two-stage, as a combobox is: the first Escape reverts the text, + * a second dismisses the popover. Letting one press do both meant + * correcting a typo cost you the calendar. React's + * `stopPropagation` reaches the native event, which is what Base + * UI's document-level dismiss listener is on. + */ + if (event.key === 'Escape' && draft !== null) { + event.stopPropagation(); + setDraft(null); + } + } + } as never, + props as never + ) as InputProps)} + /> +
+ ); +} + +CalendarPreviewInput.displayName = 'CalendarPreview.Input'; 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..6c68e60d9 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx @@ -0,0 +1,383 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import { + type ComponentProps, + type CSSProperties, + useCallback, + useMemo +} from 'react'; +import { Skeleton } from '../skeleton'; +import styles from './calendar-preview.module.css'; +import type { + CalendarGranularity, + DateRangeValue +} from './calendar-preview-context'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey, dayOrdinal, firstOfMonth, getYear } 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>; + +/** + * 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: number; + label: string; + start: Date; + /** First instant of the *next* period, so `selected` needs no date maths. */ + end: Date; + /** + * The date this cell emits — not always its first day. The overlap rule + * enables a period a mid-month `minDate` only partly allows, and emitting + * the 1st there hands the consumer a value before the bound they declared. + */ + value: Date; + unavailable: boolean; +} + +export interface CalendarPreviewMonthGridProps + extends Omit, 'children'> { + /** + * 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; +} + +/** + * 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, + loading, + reportValidity + } = useCalendarPreviewContext('MonthGrid'); + + const anchor = firstSelected(value) ?? new Date(); + const anchorYear = getYear(anchor, timeZone); + + /* + * A callback ref rather than an effect. The effect form could not see the + * scroll container on mount — a child's ref attaches before its parent's, so + * `scrollRef.current` was still null — and a dependency array is the wrong + * shape for "the element to scroll to has changed". React runs this exactly + * when that element attaches: when the grid mounts, and again whenever the + * anchor year moves the ref to a different section. + * + * The container is read from the node rather than captured, both to survive + * that ordering and to keep the scroll scoped: an unqualified + * `scrollIntoView` inside a portal can move the page behind the popover. + * Every year element is a direct child of the scroll container. + */ + const scrollActiveYearIntoView = useCallback( + (node: HTMLDivElement | null) => { + const container = node?.parentElement; + if (!node || !container) return; + container.scrollTop = + node.offsetTop - container.clientHeight / 2 + node.clientHeight / 2; + }, + [] + ); + + /* + * 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 built: { year: number; cells: PeriodCell[] }[] = []; + for (let year = firstYear; year <= lastYear; year += 1) { + const cells = Array.from({ length: period.perYear }, (_, index) => { + const startMonth = period.startMonth(index); + const start = firstOfMonth(year, startMonth, timeZone); + const end = firstOfMonth( + year + (startMonth + monthSpan >= 12 ? 1 : 0), + (startMonth + monthSpan) % 12, + timeZone + ); + /* + * 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 outOfBounds = + (minTime !== null && end.getTime() - 1 < minTime) || + (maxTime !== null && start.getTime() > maxTime); + /* + * Clamped to the lower bound only. A period starting past `maxDate` is + * already out of bounds above, so nothing can exceed the upper one. + */ + const value = + minTime !== null && start.getTime() < minTime + ? new Date(minTime) + : start; + + return { + // Integer identity, not `dayKey`: React stringifies keys anyway. + key: dayOrdinal(start, timeZone), + label: granularity === 'year' ? String(year) : period.label(index), + start, + end, + value, + /* + * Availability is asked about `value`, not `start`: testing a day the + * cell would never emit both disabled reachable periods and let + * unavailable ones through. + */ + unavailable: outOfBounds || !!isDateUnavailable?.(value) + } satisfies PeriodCell; + }); + built.push({ year, cells }); + } + return built; + }, [ + granularity, + firstYear, + lastYear, + minTime, + maxTime, + isDateUnavailable, + timeZone + ]); + + if (granularity === 'day') return null; + + if (loading) { + return ( +
+ +
+ ); + } + + const period = PERIODS[granularity]; + const writable = !disabled && !readOnly; + const selectedTimes = selectedDatesIn(value).map(date => date.getTime()); + + const commit = (cell: PeriodCell) => { + if (!writable) return; + const start = cell.value; + /* + * Valid by construction — an out-of-bounds or unavailable cell is disabled, + * so reaching here means `start` passes. Reported anyway: `.Grid` leaves + * this to RDP's own disabling, which left `onValidityChange` silent for + * every non-day pick. + */ + reportValidity({ valid: true }); + if (selection === 'range') { + const range = (value as DateRangeValue | null) ?? { + from: null, + 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 = (cell: PeriodCell) => { + const selected = selectedTimes.some( + time => time >= cell.start.getTime() && time < cell.end.getTime() + ); + return ( + + ); + }; + + return ( +
+ {sections.map(({ year, cells }) => + period.grouped ? ( +
+
+ {year} +
+
+ {cells.map(renderCell)} +
+
+ ) : ( +
+ {cells.map(renderCell)} +
+ ) + )} +
+ ); +} + +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; +} + +/** + * A cell lights when a selected date falls anywhere inside its period, not + * only when it starts it. Picking 17 April in the day grid and switching to + * Month must not show an empty grid — that reads as lost state. Clicking the + * cell still rewrites the value to the period start. + */ +function selectedDatesIn(value: unknown): Date[] { + if (value instanceof Date) return [value]; + if (Array.isArray(value)) return value as Date[]; + if (!value) return []; + const range = value as DateRangeValue; + return [range.from, range.to].filter(Boolean) as Date[]; +} 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..4f4cc7787 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-nav.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon, UndoIcon } from '~/icons'; +import { IconButton } from '../icon-button'; +import { Skeleton } from '../skeleton'; +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; + /** + * 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; +} + +/** + * Caption, a revert button, and previous / next. **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 revert button appears only when the root was given a `defaultValue` and + * the current value differs from it; pressing it restores that default. It is + * absent otherwise rather than disabled, because a control that can never do + * anything is noise. + */ +export function CalendarPreviewNav({ + className, + align = 'start', + captionFormat = 'MMMM YYYY', + months = 1, + ...props +}: CalendarPreviewNavProps) { + const { + month, + setMonth, + minDate, + maxDate, + disabled, + readOnly, + timeZone, + granularity, + loading, + canReset, + resetValue + } = 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); + + /* + * 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 ( +
+ {loading ? ( + /* The slot goes on a wrapper: `Skeleton` does not spread unknown + props, so one passed to it is dropped rather than rendered. */ + + + + ) : ( + + {months > 1 + ? `${formatDate(month, captionFormat, timeZone)} – ${formatDate( + addMonths(month, months - 1, timeZone), + captionFormat, + timeZone + )}` + : formatDate(month, captionFormat, timeZone)} + + )} +
+ {canReset && ( + + + + )} + 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-presets.tsx b/packages/raystack/components/calendar-preview/calendar-preview-presets.tsx new file mode 100644 index 000000000..97500a134 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-presets.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import type { ComponentProps, ReactElement } from 'react'; +import styles from './calendar-preview.module.css'; +import type { CalendarValue, DateRangeValue } from './calendar-preview-context'; +import { + isSameValue, + useCalendarPreviewContext +} from './calendar-preview-context'; + +export interface CalendarPreviewPresetsProps extends ComponentProps<'div'> { + /** + * A column beside the grid, or a row above it. + * @defaultValue 'vertical' + */ + orientation?: 'vertical' | 'horizontal'; +} + +/** Holds `.Preset` buttons. Renders nothing of its own beyond the layout. */ +export function CalendarPreviewPresets({ + className, + orientation = 'vertical', + ...props +}: CalendarPreviewPresetsProps) { + return ( +
+ ); +} + +CalendarPreviewPresets.displayName = 'CalendarPreview.Presets'; + +export interface CalendarPreviewPresetProps + extends Omit, 'value'> { + /** The value this preset applies. Use for `single` and `multiple`. */ + value?: Date | Date[] | null; + /** The range this preset applies. Use for `selection="range"`. */ + range?: DateRangeValue; + /** Render as another element — an Apsara `Button`, say. */ + render?: ReactElement; +} + +/** + * One preset. Writes straight into root state, so it needs no callback of its + * own, and marks itself pressed while the current value matches it. + * + * It deliberately does not close the popover. Under `commit='explicit'` that + * would discard the very edit it just made, and for a range you want to see + * what was applied — compose `.Apply` or handle `onValueChange` to close. + */ +export function CalendarPreviewPreset({ + className, + value, + range, + render = + ); + })} +
+ )} + + ); +} + +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-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx new file mode 100644 index 000000000..587f398dd --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx @@ -0,0 +1,89 @@ +'use client'; + +import { Popover as PopoverPrimitive } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import styles from './calendar-preview.module.css'; +import { + CalendarPreviewTriggerScope, + useCalendarPreviewContext +} from './calendar-preview-context'; + +export interface CalendarPreviewTriggerProps + extends PopoverPrimitive.Trigger.Props {} + +/** + * Anchors the popover. Renders a `div`, not a `