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__/input-nav.test.tsx b/packages/raystack/components/calendar-preview/__tests__/input-nav.test.tsx new file mode 100644 index 000000000..d3bff013c --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/input-nav.test.tsx @@ -0,0 +1,444 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { expectSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import { dayKey } from '../date-adapter'; + +const MONTH = new Date(2024, 3, 1); +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0]; + +describe('CalendarPreview.Input', () => { + it('renders its slot without clobbering Input own', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-input')).not.toBeNull(); + expect(container.querySelectorAll('[data-slot="input"]')).toHaveLength(1); + }); + + it('shows the committed value and commits typed text', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + expect(field).toHaveValue('17 Apr 2024'); + + await user.clear(field); + await user.type(field, '20 Apr 2024{Enter}'); + expect(dayKey(lastArg(onValueChange) as Date)).toBe('2024-04-20'); + }); + + it('reports validity and never throws with a timeZone set', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + await user.type(screen.getByRole('textbox'), 'rubbish{Enter}'); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'unparseable' + }); + }); + + it('clears on empty, and reverts on Escape', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, 'x'); + await user.keyboard('{Escape}'); + expect(field).toHaveValue('17 Apr 2024'); + + await user.clear(field); + await user.keyboard('{Enter}'); + expect(lastArg(onValueChange)).toBeNull(); + }); + + it('throws when used with selection="range"', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + expect(() => + render( + + + + ) + ).toThrow('CalendarPreview.RangeInput for ranges'); + spy.mockRestore(); + }); +}); + +describe('CalendarPreview.Nav', () => { + it('renders caption and both buttons, and no Select', () => { + const { container } = render( + + + + + ); + expectSlots(container, [ + 'calendar-preview-nav', + 'calendar-preview-nav-caption', + 'calendar-preview-nav-previous', + 'calendar-preview-nav-next' + ]); + expect(container.querySelector('select')).toBeNull(); + expect(getSlot(container, 'select-trigger')).toBeNull(); + expect( + getSlot(container, 'calendar-preview-nav-caption') + ).toHaveTextContent('April 2024'); + }); + + it('steps the month and reports it', async () => { + const user = userEvent.setup(); + const onMonthChange = vi.fn(); + render( + + + + + ); + + await user.click(screen.getByLabelText('Next month')); + expect(dayKey(lastArg(onMonthChange) as Date)).toBe('2024-05-01'); + expect(screen.getByText('May 2024')).toBeInTheDocument(); + + await user.click(screen.getByLabelText('Previous month')); + await user.click(screen.getByLabelText('Previous month')); + expect(dayKey(lastArg(onMonthChange) as Date)).toBe('2024-03-01'); + }); + + it('offers a step whenever the target month holds any selectable day', () => { + // minDate mid-March: stepping back from April must stay available. + render( + + + + ); + expect(screen.getByLabelText('Previous month')).not.toBeDisabled(); + }); + + it('disables a step when the target month is wholly out of range', () => { + render( + + + + ); + expect(screen.getByLabelText('Previous month')).toBeDisabled(); + expect(screen.getByLabelText('Next month')).not.toBeDisabled(); + }); + + it('disables both steps when the picker is disabled', () => { + render( + + + + ); + expect(screen.getByLabelText('Previous month')).toBeDisabled(); + expect(screen.getByLabelText('Next month')).toBeDisabled(); + }); + + it('drives the grid it sits beside', async () => { + const user = userEvent.setup(); + const { container } = render( + + + + + ); + + await user.click(screen.getByLabelText('Next month')); + expect(container.querySelector('[data-day="2024-05-15"]')).not.toBeNull(); + expect(container.querySelector('[data-day="2024-04-15"]')).toBeNull(); + }); +}); + +describe('CalendarPreview.Nav revert button', () => { + const withDefault = (props: Record = {}) => + render( + + + + + ); + + it('is absent while the value still equals the default', () => { + const { container } = withDefault(); + expect(getSlot(container, 'calendar-preview-nav-undo')).toBeNull(); + }); + + it('is absent when no default was given at all', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-nav-undo')).toBeNull(); + }); + + it('appears once the selection differs from the default', async () => { + const user = userEvent.setup(); + const { container } = withDefault(); + expect(getSlot(container, 'calendar-preview-nav-undo')).toBeNull(); + + await user.click( + container.querySelector('[data-day="2024-04-17"] button') as HTMLElement + ); + expect(getSlot(container, 'calendar-preview-nav-undo')).not.toBeNull(); + }); + + it('restores the default value and then hides itself again', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = withDefault({ onValueChange }); + + await user.click( + container.querySelector('[data-day="2024-04-17"] button') as HTMLElement + ); + await user.click(screen.getByLabelText('Reset to default date')); + + expect(dayKey(lastArg(onValueChange) as Date)).toBe('2024-04-10'); + expect(getSlot(container, 'calendar-preview-nav-undo')).toBeNull(); + }); + + it('counts a time-of-day change as differing from the default', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-nav-undo')).not.toBeNull(); + }); + + it('works for a range default too', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-nav-undo')).not.toBeNull(); + }); + + it('is disabled rather than active when the picker is readOnly', async () => { + const user = userEvent.setup(); + const { container } = withDefault({ readOnly: true }); + // readOnly still lets the grid render, so reach the differing state via a + // controlled value instead. + expect(getSlot(container, 'calendar-preview-nav-undo')).toBeNull(); + + const { container: c2 } = render( + + + + ); + expect(getSlot(c2, 'calendar-preview-nav-undo')).toBeDisabled(); + await user.click(screen.getAllByLabelText('Reset to default date')[0]); + }); +}); + +describe('consumer handlers compose rather than replace', () => { + it('.Input still commits when a consumer passes onKeyDown', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const consumerKeyDown = vi.fn(); + render( + + + + ); + + await user.type(screen.getByRole('textbox'), '17 Apr 2024{Enter}'); + expect( + dayKey( + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] as Date + ) + ).toBe('2024-04-17'); + expect(consumerKeyDown).toHaveBeenCalled(); + }); + + it('.Input still commits when a consumer passes onBlur', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onBlur = vi.fn(); + render( + + + + ); + + await user.type(screen.getByRole('textbox'), '17 Apr 2024'); + await user.tab(); + expect( + dayKey( + onValueChange.mock.calls[onValueChange.mock.calls.length - 1][0] as Date + ) + ).toBe('2024-04-17'); + expect(onBlur).toHaveBeenCalled(); + }); +}); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +/* + * Regressions that arrived from review passes rather than from the spec. + * Each assertion sits with the behaviour it guards; which pass found it is + * history, not structure. + */ +describe('regressions', () => { + it('a trigger holding a typed field claims no button semantics', async () => { + const { container } = render( + + + + + + + + + ); + + const trigger = getSlot( + container, + 'calendar-preview-trigger' + ) as HTMLElement; + // In ARIA a button's children are presentational, so the field inside was + // at risk of never being announced as editable; the tab stop it added sat + // in front of the input doing nothing a keyboard user wants. + await waitFor(() => expect(trigger).not.toHaveAttribute('role')); + expect(trigger).toHaveAttribute('tabindex', '-1'); + expect(trigger.querySelector('input')).not.toBeNull(); + }); + + it('a plain trigger keeps the button semantics it should have', () => { + const { container } = render( + + Pick a date + + ); + const trigger = getSlot( + container, + 'calendar-preview-trigger' + ) as HTMLElement; + expect(trigger).toHaveAttribute('role', 'button'); + expect(trigger).toHaveAttribute('tabindex', '0'); + }); + + it('clicking the field a second time does not close the calendar', async () => { + const user = userEvent.setup(); + render( + + + + + + + + + ); + + const field = screen.getByRole('textbox'); + await user.click(field); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + + // Repositioning the caret is an ordinary thing to do mid-edit. + await user.click(field); + expect(screen.queryByRole('grid')).toBeInTheDocument(); + }); + + it('clicking the trigger outside the field still toggles', async () => { + const user = userEvent.setup(); + const { container } = render( + + + + + + + + + ); + + const trigger = getSlot( + container, + 'calendar-preview-trigger' + ) as HTMLElement; + await user.click(trigger); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + await user.click(trigger); + await waitFor(() => + expect(screen.queryByRole('grid')).not.toBeInTheDocument() + ); + }); + + it('captions a two-month grid as a range', () => { + const { container } = render( + + + + + ); + expect( + getSlot(container, 'calendar-preview-nav-caption') + ).toHaveTextContent('April 2024 – May 2024'); + expect( + container.querySelectorAll('[data-slot="calendar-preview-table"]') + ).toHaveLength(2); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx b/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx new file mode 100644 index 000000000..07bd804a1 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/keyboard.test.tsx @@ -0,0 +1,337 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; + +/* + * What the keyboard does: arrow keys in the day grid, and Enter / Escape / blur + * in the typed fields. Both were places where a key press had no test at all — + * the grid's arrows never moved focus, and a rejected commit erased the text it + * was rejecting. + */ + +/* + * Arrow-key navigation is the stated reason the RFC depends on + * react-day-picker, and it had no test anywhere in the suite. RDP moves a + * `focused` modifier between days and never touches the DOM, so a `DayButton` + * override that drops the ref and the focus effect leaves the keyboard dead + * while every other test stays green. + */ +const MONTH = new Date(2024, 3, 1); // April 2024 +const focused = () => document.activeElement?.textContent?.trim(); + +const grid = () => + render( + + + + + ); + +describe('day grid keyboard navigation', () => { + it('moves focus one day right', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowRight}'); + expect(focused()).toBe('18'); + }); + + it('moves focus one day left', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowLeft}'); + expect(focused()).toBe('16'); + }); + + it('moves focus a week down and back up', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 17th/ }).focus(); + await user.keyboard('{ArrowDown}'); + expect(focused()).toBe('24'); + await user.keyboard('{ArrowUp}'); + expect(focused()).toBe('17'); + }); + + /* + * The case that lost focus outright: stepping past the last day pages the + * month, and the day it lands on is in markup that did not exist when the + * key was pressed. Focus must follow it rather than fall to ``. + */ + it('follows focus across a month boundary instead of dropping it', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 30th/ }).focus(); + await user.keyboard('{ArrowRight}'); + + expect(document.activeElement).not.toBe(document.body); + expect(focused()).toBe('1'); + expect( + document.querySelector('[data-slot="calendar-preview-nav-caption"]') + ?.textContent + ).toContain('May'); + }); + + it('keeps the focused day reachable when paging backwards too', async () => { + const user = userEvent.setup(); + grid(); + screen.getByRole('button', { name: /April 1st/ }).focus(); + await user.keyboard('{ArrowLeft}'); + + expect(document.activeElement).not.toBe(document.body); + expect(focused()).toBe('31'); + }); +}); + +/* + * A rejected commit used to erase the text it was rejecting. The field snapped + * back to the old value while the consumer was handed + * `{valid: false, reason: 'unparseable'}` — an error describing text no longer + * on screen, and with validity latching there was no way to dismiss it either. + * + * Both typed fields cleared the draft unconditionally, in two places. They now + * share one, so these cover `.Input` and `.RangeInput` together. + */ +describe('a rejected commit keeps what the user typed', () => { + it('.Input keeps unparseable text on Enter', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, '32 Apr 2024{Enter}'); + + expect(field).toHaveValue('32 Apr 2024'); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'unparseable' + }); + }); + + it('.Input keeps an out-of-bounds date on blur', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, '25 Apr 2024'); + await user.tab(); + + expect(field).toHaveValue('25 Apr 2024'); + }); + + it('Escape is still the way back to the committed value', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, '32 Apr 2024{Enter}'); + expect(field).toHaveValue('32 Apr 2024'); + + await user.keyboard('{Escape}'); + expect(field).toHaveValue('17 Apr 2024'); + }); + + /* + * Uncontrolled, so the commit actually lands. Under a controlled `value` whose + * parent ignores the change the field correctly returns to the committed text, + * which would pass this assertion for the wrong reason. + */ + it('an accepted commit still replaces the draft with the canonical text', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const field = screen.getByRole('textbox'); + await user.clear(field); + await user.type(field, '18 Apr 2024{Enter}'); + + expect(field).toHaveValue('18 Apr 2024'); + }); + + it('.RangeInput keeps unparseable text in the endpoint typed', async () => { + const user = userEvent.setup(); + render( + + + + ); + + const start = screen.getByLabelText('Start date'); + await user.clear(start); + await user.type(start, 'nonsense{Enter}'); + + expect(start).toHaveValue('nonsense'); + // The endpoint the user did not touch is untouched. + expect(screen.getByLabelText('End date')).toHaveValue('20 Apr 2024'); + }); +}); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +describe('regressions from the external audit', () => { + it('opens from the keyboard with ArrowDown, since the trigger has no tab stop', async () => { + const user = userEvent.setup(); + render( + + + + + + + + + ); + + const field = screen.getByRole('textbox'); + field.focus(); + await user.keyboard('{ArrowDown}'); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + }); + + it('Escape reverts the draft first and dismisses only on the second press', async () => { + const user = userEvent.setup(); + render( + + + + + + + + + ); + + const field = screen.getByRole('textbox') as HTMLInputElement; + await user.click(field); + expect(await screen.findByRole('grid')).toBeInTheDocument(); + + await user.type(field, 'nonsense'); + await user.keyboard('{Escape}'); + // Correcting a typo must not cost you the calendar. + expect(field.value).toBe('17 Apr 2024'); + expect(screen.queryByRole('grid')).toBeInTheDocument(); + + await user.keyboard('{Escape}'); + await waitFor(() => + expect(screen.queryByRole('grid')).not.toBeInTheDocument() + ); + }); +}); + +/* + * Enter belongs to the form when the field has nothing to commit. + * `preventDefault()` ran above the `draft === null` guard, so an untouched + * date field swallowed Enter for its whole life and implicit submit never + * worked once one was on the page. The focus hand-off sits below that same + * guard, so tabbing into a filled Start field and pressing Enter — the + * commonest keyboard flow — did nothing either. + */ +describe('Enter on a field with nothing to commit', () => { + const inForm = (onSubmit: () => void) => + render( +
{ + event.preventDefault(); + onSubmit(); + }} + > + + + + + +
+ ); + + it('submits the surrounding form', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + inForm(onSubmit); + + await user.click(screen.getAllByRole('textbox')[0]); + await user.keyboard('{Enter}'); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + // The control: proof a red result above is the component, not the harness. + it('behaves as a plain input in the same form does', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + inForm(onSubmit); + + await user.click(screen.getByLabelText('Plain')); + await user.keyboard('{Enter}'); + + expect(onSubmit).toHaveBeenCalledTimes(1); + }); + + it('still keeps Enter for itself while an edit is pending', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + inForm(onSubmit); + + const field = screen.getAllByRole('textbox')[0]; + await user.clear(field); + await user.type(field, '18 Apr 2024{Enter}'); + + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it('hands the keyboard from Start to End in .RangeInput', async () => { + const user = userEvent.setup(); + render( + + + + ); + + screen.getByLabelText('Start date').focus(); + await user.keyboard('{Enter}'); + + expect(screen.getByLabelText('End date')).toHaveFocus(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/loading.test.tsx b/packages/raystack/components/calendar-preview/__tests__/loading.test.tsx new file mode 100644 index 000000000..d5811cd55 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/loading.test.tsx @@ -0,0 +1,108 @@ +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'; + +const MONTH = new Date(2024, 3, 1); + +const full = (props: Record = {}) => + render( + + + + + + A preset + + + + + + + + + + ); + +describe('CalendarPreview loading', () => { + it('replaces the caption and the grid with a shimmer', () => { + const { container } = full({ loading: true }); + expect(getAllSlots(container, 'calendar-preview-skeleton')).toHaveLength(2); + expect(getSlot(container, 'calendar-preview-nav-caption')).toBeNull(); + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + expect(getSlot(container, 'calendar-preview-day')).toBeNull(); + }); + + it('replaces the grid outright rather than overlaying it', () => { + // The old family shimmered five rows over a live grid, leaving the days + // underneath focusable. + const { container } = full({ loading: true }); + expect(container.querySelectorAll('[data-day]')).toHaveLength(0); + expect(getSlot(container, 'calendar-preview-skeleton')).toHaveAttribute( + 'aria-busy', + 'true' + ); + }); + + it('disables every control, not just the grid', () => { + const { container } = full({ loading: true }); + expect(container.querySelector('input')).toBeDisabled(); + for (const tab of screen.getAllByRole('tab')) { + expect(tab).toHaveAttribute('aria-disabled', 'true'); + } + expect(getSlot(container, 'calendar-preview-preset')).toBeDisabled(); + expect(screen.getByLabelText('Previous month')).toBeDisabled(); + expect(screen.getByLabelText('Next month')).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + }); + + it('accepts no writes while loading', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + full({ loading: true, onValueChange }); + await user.click(screen.getByRole('button', { name: 'A preset' })); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('shimmers the month grid too', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-skeleton')).not.toBeNull(); + expect(getSlot(container, 'calendar-preview-month-cell')).toBeNull(); + }); + + it('restores everything when loading clears', () => { + const { container, rerender } = full({ loading: true }); + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + + rerender( + + + + + ); + expect(getSlot(container, 'calendar-preview-grid')).not.toBeNull(); + expect(getSlot(container, 'calendar-preview-nav-caption')).not.toBeNull(); + expect(getAllSlots(container, 'calendar-preview-skeleton')).toHaveLength(0); + }); + + it('leaves an explicit disabled untouched when not loading', () => { + const { container } = full({ disabled: true }); + expect(getAllSlots(container, 'calendar-preview-skeleton')).toHaveLength(0); + expect(container.querySelector('input')).toBeDisabled(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx new file mode 100644 index 000000000..285b0057c --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/month-grid.test.tsx @@ -0,0 +1,288 @@ +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'); + }); +}); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +describe('regressions from the external audit', () => { + it('switching to Month scrolls the active year into view', async () => { + const user = userEvent.setup(); + /* + * jsdom has no layout, so `scrollTop` is not observable on its own — the + * previous form of this test asserted `scrollTop >= 0`, which is true of + * an untouched element. Stand a spy in its place and give the geometry + * non-zero values, so the assertion is about the scroll actually happening. + */ + const stub = (name: string, descriptor: PropertyDescriptor) => { + const original = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + name + ); + Object.defineProperty(HTMLElement.prototype, name, { + configurable: true, + ...descriptor + }); + return () => { + if (original) { + Object.defineProperty(HTMLElement.prototype, name, original); + } else { + Reflect.deleteProperty(HTMLElement.prototype, name); + } + }; + }; + + const scrolled = vi.fn(); + const restore = [ + stub('scrollTop', { get: () => 0, set: scrolled }), + stub('offsetTop', { get: () => 900 }), + stub('clientHeight', { get: () => 300 }) + ]; + + try { + render( + + + + + + ); + + expect(scrolled).not.toHaveBeenCalled(); + await user.click(screen.getByRole('tab', { name: 'Month' })); + // 900 - 300/2 + 300/2, centred on the active year. + expect(scrolled).toHaveBeenCalledWith(900); + } finally { + for (const undo of restore) undo(); + } + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/preset-contracts.test.tsx b/packages/raystack/components/calendar-preview/__tests__/preset-contracts.test.tsx new file mode 100644 index 000000000..d5f3fea24 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/preset-contracts.test.tsx @@ -0,0 +1,285 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; +import type { CalendarValidity } from '../calendar-preview-context'; +import { dayKey } from '../date-adapter'; + +const lastCall = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]; +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + lastCall(fn)?.[0] as T; +/** The `{ granularity }` detail every value change carries alongside it. */ +const lastDetails = (fn: { mock: { calls: unknown[][] } }) => lastCall(fn)?.[1]; + +/* + * `.Preset` writes straight into root state, and its render-time guard checked + * only `range` against `selection` — never `value`, which is typed + * `Date | Date[] | null` under every mode. So a `Date` under `multiple`, or an + * array under `single`, reached `setValue` with the wrong shape: `.Grid` then + * called `selected?.some` on a Date and threw, and `.MonthGrid` would call + * `.map` on one. + */ +describe('.Preset validates value against selection', () => { + const quiet = () => + vi.spyOn(console, 'error').mockImplementation(() => undefined); + + it('rejects a bare Date under selection="multiple"', () => { + const spy = quiet(); + expect(() => + render( + + + + Today + + + + ) + ).toThrow(/multiple/i); + spy.mockRestore(); + }); + + it('rejects an array under the default selection="single"', () => { + const spy = quiet(); + expect(() => + render( + + + + Two + + + + ) + ).toThrow(/single/i); + spy.mockRestore(); + }); + + it('still accepts the shapes each mode does want', () => { + expect(() => + render( + + + + One + + + + ) + ).not.toThrow(); + }); +}); + +/* + * Every other writer honours the bounds: `.Input`/`.RangeInput` through + * `validate()`, `.TimeField` through `isWithinTimeBounds`, `.Grid` through RDP + * matchers, `.MonthGrid` by disabling out-of-range cells. `.Preset` checked + * nothing, reported nothing, and was not marked — so a preset outside the + * declared bounds looked operable and committed a value `.Input` would refuse. + */ +describe('.Preset honours minDate and maxDate', () => { + const bounded = (props: Record, presetProps: object) => + render( + + + Then + + + ); + + it('marks an out-of-bounds preset unavailable', () => { + bounded({}, { value: new Date(2020, 0, 1) }); + const button = screen.getByRole('button', { name: 'Then' }); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + + it('commits nothing when an out-of-bounds preset is clicked', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + bounded({ onValueChange }, { value: new Date(2020, 0, 1) }); + + await user.click(screen.getByRole('button', { name: 'Then' })); + + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('leaves an in-bounds preset operable', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + bounded({ onValueChange }, { value: new Date(2026, 5, 10) }); + + const button = screen.getByRole('button', { name: 'Then' }); + expect(button).not.toHaveAttribute('aria-disabled', 'true'); + await user.click(button); + + expect(dayKey(lastArg(onValueChange))).toBe('2026-06-10'); + }); + + it('checks both endpoints of a range preset', () => { + render( + + + + Spanning + + + + ); + expect(screen.getByRole('button', { name: 'Spanning' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('respects isDateUnavailable', () => { + render( + dayKey(d) === '2026-06-10'}> + + + Blocked + + + + ); + expect(screen.getByRole('button', { name: 'Blocked' })).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); +}); + +/* + * Nothing reconciled the active granularity against the offered set, so + * `granularities={['month','quarter']}` — the natural way to build a + * month/quarter picker — left the active granularity at its `'day'` default: + * no tab selected, the day grid rendered for an offered set that excludes it, + * and a typed date committing `{granularity: 'day'}`. + */ +describe('the active granularity is one the picker offers', () => { + const offered = (props: Record = {}) => + render( + + + + + + ); + + it('selects exactly one tab', () => { + offered(); + const selected = screen + .getAllByRole('tab') + .filter(tab => tab.getAttribute('aria-selected') === 'true'); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveAccessibleName('Month'); + }); + + it('renders the view the active granularity names', () => { + const { container } = offered(); + expect( + container.querySelector('[data-slot="calendar-preview-grid"]') + ).toBeNull(); + expect( + container.querySelector('[data-slot="calendar-preview-month-grid"]') + ).not.toBeNull(); + }); + + const typing = async (text: string) => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + render( + + + + ); + await user.type(screen.getByRole('textbox'), `${text}{Enter}`); + return { onValueChange, onValidityChange }; + }; + + /* + * The active granularity is tried first and unconditionally, so while it sat + * at `day` this committed `{granularity: 'day'}` from a picker offering + * neither day nor anything that could redisplay the result. + */ + it('never commits at a granularity absent from the offered set', async () => { + const { onValueChange, onValidityChange } = await typing('15 Jun 2026'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'unparseable' + }); + }); + + it('commits text the offered set can read, at that granularity', async () => { + const { onValueChange } = await typing('Jun 2026'); + + expect(dayKey(lastArg(onValueChange))).toBe('2026-06-01'); + expect(lastDetails(onValueChange)).toMatchObject({ + granularity: 'month' + }); + }); + + const selectedTabName = () => { + const selected = screen + .getAllByRole('tab') + .filter(tab => tab.getAttribute('aria-selected') === 'true'); + expect(selected).toHaveLength(1); + return selected[0]; + }; + + it('honours an explicit defaultGranularity that is offered', () => { + offered({ defaultGranularity: 'quarter' }); + expect(selectedTabName()).toHaveAccessibleName('Quarter'); + }); + + /* + * The two cases the default cannot cover, where the props contradict each + * other outright. The offered set is the authority — it is what the tabs + * render from, so anything else leaves no tab selected. + */ + it('clamps a defaultGranularity the set excludes', () => { + offered({ defaultGranularity: 'day' }); + expect(selectedTabName()).toHaveAccessibleName('Month'); + }); + + /* + * `.MonthGrid` names no granularity, so a click falls back to whatever the + * root holds — which is the unclamped state, and was reporting `'day'` from + * a picker with no day view at all. + */ + it('reports an offered granularity for a click that names none', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + offered({ defaultGranularity: 'day', onValueChange }); + + await user.click(screen.getAllByRole('button', { name: 'Jun' })[0]); + + expect(lastDetails(onValueChange)).toMatchObject({ + granularity: 'month' + }); + }); + + it('clamps a controlled granularity the set excludes', () => { + offered({ granularity: 'day' }); + expect(selectedTabName()).toHaveAccessibleName('Month'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__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..b9a2a718d --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/range-input.test.tsx @@ -0,0 +1,502 @@ +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'); + +/** The day button for an ISO date, via the grid's `data-day` attribute. */ +const day = (c: HTMLElement, iso: string) => + c.querySelector(`[data-day="${iso}"] button`) as HTMLButtonElement; +const end = () => screen.getByLabelText('End date'); + +const dayButton = (container: HTMLElement, iso: string) => + 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'); + }); +}); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +/* + * Regressions that arrived from review passes rather than from the spec. + * Each assertion sits with the behaviour it guards; which pass found it is + * history, not structure. + */ +describe('regressions', () => { + it('a timed start survives a typed same-day end', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + await user.type(screen.getByLabelText('End date'), '17 Apr 2024{Enter}'); + const next = lastArg(onValueChange) as DateRangeValue; + // The old raw `from > to` compared instants, so 08:00 "exceeded" midnight + // on the same day and the start was nulled. + expect(next.from).not.toBeNull(); + expect(dayKey(next.from as Date)).toBe('2024-04-17'); + }); + + it('typing garbage with a timeZone set does not crash the input', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + await user.type(screen.getByLabelText('Start date'), 'nonsense{Enter}'); + expect(onValidityChange).toHaveBeenLastCalledWith({ + valid: false, + reason: 'unparseable' + }); + }); + + it('puts the active flag on an element that owns a border', () => { + const { container } = render( + + + + ); + const active = getSlot(container, 'calendar-preview-input-start'); + expect(active).toHaveAttribute('data-active'); + // The style hangs off this wrapper reaching Input's container slot. + expect( + active?.querySelector('[data-slot="input-container"]') + ).not.toBeNull(); + }); + + it('lock still allows clearing the unlocked end', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + + await user.click(day(container, '2024-04-20')); + const next = onValueChange.mock.calls[ + onValueChange.mock.calls.length - 1 + ][0] as DateRangeValue; + // Whatever RDP decides, the locked end is never moved. + expect(dayKey(next.from as Date)).toBe('2024-04-10'); + }); + + it('drops a draft across years when the format carries no year', async () => { + const user = userEvent.setup(); + const { container, rerender } = render( + + + + ); + + await user.type(screen.getByLabelText('Start date'), 'xx'); + + rerender( + + + + ); + + // Same rendered text either year — only dayKey sees the change. + expect(screen.getByLabelText('Start date')).toHaveValue('17 Apr'); + expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/range-order.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range-order.test.tsx new file mode 100644 index 000000000..8f68adb56 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/range-order.test.tsx @@ -0,0 +1,197 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { CalendarPreview } from '../calendar-preview'; +import type { DateRangeValue } from '../calendar-preview-context'; +import { dayKey } from '../date-adapter'; + +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0] as T; + +/* + * The range-order contract, run against every writer that can commit a range. + * + * It used to live in one `.MonthGrid`-only block behind a helper called + * `rangeGrid` — the name said "grid", the JSX said `.MonthGrid`, and the file + * contained no `.Grid` at all. So the whole contract was verified against the + * view users reach by switching granularity and never against the day grid they + * land on. `.Grid` was meanwhile destroying a whole range on one click and + * committing backwards ranges under a lock, and reporting both as valid. + * + * Parameterised so that is structural rather than remembered: a writer is a + * row in `WRITERS`, and every contract point below runs for each of them. + * Adding a fourth writer means adding a row, not remembering this file exists. + */ +interface RangeWriter { + label: string; + /** Mounts the writer with `focusMonth` (0-based, 2026) reachable. */ + mount(props: Record, focusMonth: number): void; + /** Clicks the cell that commits a value inside `month`. */ + pick(month: number): Promise; + /** The `dayKey` this writer commits for a pick inside `month`. */ + committed(month: number): string; +} + +const BOUNDS = { + minDate: new Date(2026, 0, 1), + maxDate: new Date(2026, 11, 31) +}; + +const MONTH_LABEL = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' +]; +const MONTH_NAME = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' +]; + +/** The day `.Grid` cases click, and the day the period writers resolve to. */ +const DAY_OF_MONTH = 1; + +const WRITERS: RangeWriter[] = [ + { + label: '.MonthGrid', + mount(props) { + render( + + + + ); + }, + async pick(month) { + const user = userEvent.setup(); + await user.click( + screen.getAllByRole('button', { name: MONTH_LABEL[month] })[0] + ); + }, + committed: month => `2026-${String(month + 1).padStart(2, '0')}-01` + }, + { + label: '.Grid', + mount(props, focusMonth) { + render( + + + + ); + }, + async pick(month) { + const user = userEvent.setup(); + // RDP names a day button "Thursday, June 1st, 2026". + await user.click( + screen.getByRole('button', { + name: new RegExp(`${MONTH_NAME[month]} ${DAY_OF_MONTH}st`) + }) + ); + }, + committed: month => + `2026-${String(month + 1).padStart(2, '0')}-${String(DAY_OF_MONTH).padStart(2, '0')}` + } +]; + +describe.each(WRITERS)('$label range ordering', (writer: RangeWriter) => { + it('sets the start and keeps an end that is already held', async () => { + const onValueChange = vi.fn(); + writer.mount( + { value: { from: null, to: new Date(2026, 8, 20) }, onValueChange }, + 2 + ); + + await writer.pick(2); + + const next = lastArg(onValueChange); + expect(next).not.toBeNull(); + expect(dayKey(next.from as Date)).toBe(writer.committed(2)); + expect(next.to).not.toBeNull(); + }); + + it('clears the end when a chosen start moves past it', async () => { + const onValueChange = vi.fn(); + writer.mount( + { value: { from: null, to: new Date(2026, 2, 1) }, onValueChange }, + 11 + ); + + await writer.pick(11); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe(writer.committed(11)); + expect(next.to).toBeNull(); + }); + + /* + * `lock` holds one endpoint read-only, and that endpoint is the only one an + * inversion could clear — so under a lock there is nothing to repair. Refused + * rather than deleting the endpoint the consumer pinned. + */ + it('refuses a pick that would invert, keeping the locked endpoint', async () => { + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + writer.mount( + { + lock: 'from', + value: { from: new Date(2026, 8, 1), to: null }, + onValueChange, + onValidityChange + }, + 2 + ); + + await writer.pick(2); + + expect(onValueChange).not.toHaveBeenCalled(); + expect( + lastArg<{ valid: boolean; reason?: string }>(onValidityChange) + ).toEqual({ valid: false, reason: 'range-order' }); + }); + + it('still commits an ordered pick under a lock', async () => { + const onValueChange = vi.fn(); + writer.mount( + { + lock: 'from', + value: { from: new Date(2026, 8, 1), to: null }, + onValueChange + }, + 10 + ); + + await writer.pick(10); + + const next = lastArg(onValueChange); + expect(dayKey(next.from as Date)).toBe('2026-09-01'); + expect(dayKey(next.to as Date)).toBe(writer.committed(10)); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx b/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx new file mode 100644 index 000000000..45465d4f9 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/slots-documented.test.tsx @@ -0,0 +1,278 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { cleanup, render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; + +/* + * `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([]); + }); +}); + +describe('CalendarPreview data-slot contract', () => { + it('exposes grid slots when composed inline, with no popover', () => { + const { container } = render( + + + + ); + + expectSlots(container, [ + 'calendar-preview-grid', + 'calendar-preview-weeks', + 'calendar-preview-table', + 'calendar-preview-day', + 'calendar-preview-day-number' + ]); + // Nothing portals when there is no `.Content`. + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); + + it('exposes trigger, positioner and content slots when open', () => { + render( + + Pick a date + + + + + ); + + // Portaled parts are asserted against the document, not the container. + expectSlots(document.body, [ + 'calendar-preview-trigger', + 'calendar-preview-positioner', + 'calendar-preview-content', + 'calendar-preview-grid' + ]); + }); + + it('omits the content slot while closed', () => { + render( + + Pick a date + + + + + ); + + expect(getSlot(document.body, 'calendar-preview-trigger')).not.toBeNull(); + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); + + it('renders one day slot per day button', () => { + const { container } = render( + + + + ); + + // April 2024 has 30 days and outside days are off by default. + expect(getAllSlots(container, 'calendar-preview-day')).toHaveLength(30); + // The month name belongs to `.Nav`, which this composition omits. + expect(getSlot(container, 'calendar-preview-nav-caption')).toBeNull(); + }); + + it('renders two months of day slots when months is 2', () => { + const { container } = render( + + + + ); + + // April (30) + May (31). + expect(getAllSlots(container, 'calendar-preview-day')).toHaveLength(61); + expect(getAllSlots(container, 'calendar-preview-table')).toHaveLength(2); + }); + + it('never mounts a Select — the caption is a plain label', () => { + const { container } = render( + + + + ); + + expect(getSlot(container, 'select-trigger')).toBeNull(); + expect(getSlot(container, 'calendar-preview-nav-month')).toBeNull(); + expect(container.querySelector('select')).toBeNull(); + }); +}); + +/* + * Moved here from `regressions.test.tsx`, which grouped fixes by the audit + * pass that found them. The assertions are unchanged; each now sits with the + * behaviour it guards. + */ +describe('regressions', () => { + it('does not clobber Input own data-slot', () => { + const { container } = render( + + + + ); + // Both contracts hold: ours on the wrapper, Input's on its own elements. + expect(getSlot(container, 'calendar-preview-input-start')).not.toBeNull(); + expect(container.querySelectorAll('[data-slot="input"]')).toHaveLength(2); + expect( + container.querySelectorAll('[data-slot="input-container"]') + ).toHaveLength(2); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx b/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx new file mode 100644 index 000000000..a0fe47b80 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/time-field.test.tsx @@ -0,0 +1,290 @@ +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, isWithinTimeBounds } from '../date-adapter'; + +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0]; + +const hour = () => screen.getByLabelText('Hour'); + +const MONTH = new Date(2024, 3, 1); +const minute = () => screen.getByLabelText('Minute'); + +const tree = (props: Record = {}, fieldProps = {}) => + 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(); + }); +}); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +describe('regressions from the external audit', () => { + it('snapping never rolls into the next hour', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + const minute = screen.getByLabelText('Minute'); + await user.clear(minute); + await user.type(minute, '59{Enter}'); + const next = lastArg(onValueChange) as Date; + // `<= 59` was true of every Date ever constructed. The property is that the + // snap lands on the step grid without rolling the hour: 59 snaps to 45. + expect(next.getHours()).toBe(9); + expect(next.getMinutes()).toBe(45); + expect(next.getMinutes() % 15).toBe(0); + }); + + /* + * Asserting `isWithinBounds.length === 4` only checked the declared parameter + * count, and the case beside it was true in every zone — so making the + * function ignore `timeZone` entirely left all 33 tests here green, and all + * 358 across the repo. This asks the only question that separates the two: + * one instant that falls on different days depending on the zone it is read + * in, against a bound that sits between them. + */ +}); + +describe('.TimeField honours the picker bounds', () => { + const setup = (props: Record) => { + const onValueChange = vi.fn(); + const onValidityChange = vi.fn(); + render( + + + + ); + return { onValueChange, onValidityChange }; + }; + + it('refuses an hour past maxDate and reports why', async () => { + const user = userEvent.setup(); + // Bounded at 10:00 *on the selected day*, so only a time comparison can + // catch this — `isWithinBounds` compares whole days and would pass it. + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17, 10, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '23{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'out-of-bounds' + }); + }); + + it('refuses an hour before minDate', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = setup({ + minDate: new Date(2024, 3, 17, 8, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '07{Enter}'); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'out-of-bounds' + }); + }); + + it('leaves the whole last day usable under a day-level maxDate', async () => { + const user = userEvent.setup(); + // The ordinary way a picker is bounded: a plain day, at midnight. Read + // literally as an instant it would forbid every time on the 17th, which + // is not what it means anywhere else in the component. + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '10{Enter}'); + + expect(getHours(lastArg(onValueChange) as Date)).toBe(10); + expect(lastArg(onValidityChange)).toEqual({ valid: true }); + }); + + it('still rejects the day after a day-level maxDate', () => { + // The day bound has not gone soft — it is applied first, inclusive. + expect( + isWithinTimeBounds( + new Date(2024, 3, 18, 9, 0), + undefined, + new Date(2024, 3, 17) + ) + ).toBe(false); + expect( + isWithinTimeBounds( + new Date(2024, 3, 17, 23, 59), + undefined, + new Date(2024, 3, 17) + ) + ).toBe(true); + }); + + it('commits an in-bounds hour and reports valid', async () => { + const user = userEvent.setup(); + const { onValueChange, onValidityChange } = setup({ + maxDate: new Date(2024, 3, 17, 10, 0) + }); + + await user.clear(screen.getByLabelText('Hour')); + await user.type(screen.getByLabelText('Hour'), '10{Enter}'); + + expect(getHours(lastArg(onValueChange) as Date)).toBe(10); + expect(lastArg(onValidityChange)).toEqual({ valid: true }); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx b/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx new file mode 100644 index 000000000..1c3f532f5 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/timezone.test.tsx @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'vitest'; +import { + addMonths, + DEFAULT_FORMAT, + dayKey, + endOfMonth, + formatDate, + getHours, + getMinutes, + getYear, + isWithinBounds, + isWithinTimeBounds, + parseDate, + setTime, + startOfMonth +} from '../date-adapter'; + +/* + * Everything zone-shaped in the adapter, in one place. Every defect this + * component has had here was a clock chosen wrongly — the month built on a + * frozen offset, the read that depended on the host, the bound whose midnight + * was measured in the wrong zone — so the cases live together rather than one + * file per incident. + */ + +/* + * `zoned()` pins a dayjs to the source instant's UTC offset, so `.add(n, + * 'month')` carried that offset into months where it does not apply: from the + * 1st at midnight, an hour early falls into the *previous* month. Stepping back + * from 1 Apr in New York gave 29 Feb 23:00 — March skipped — and stepping + * forward from 1 Nov gave 30 Nov 23:00, so the button looked dead. Once + * drifted the anchor never returned to the 1st. + * + * `startOf('month')` drifted the same way when the 1st sat on the far side of a + * transition — Sydney, October 2023, resolved to 30 Sep 23:00. `endOf('month')` + * did not: a sweep of 418 zones over 2023–2027 found no case where it left the + * month. It is built from parts here for one construction path, not for a fix, + * so the assertion below pins its contract rather than a DST defect. + */ +const NY = 'America/New_York'; +const SYDNEY = 'Australia/Sydney'; + +/** The wall-clock month/day/hour a consumer would see in `zone`. */ +const reads = (date: Date, zone: string) => + new Intl.DateTimeFormat('en-CA', { + timeZone: zone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hourCycle: 'h23' + }).format(date); + +const firstOf = (year: number, month: number, zone: string) => + startOfMonth(new Date(Date.UTC(year, month, 15, 12)), zone); + +describe('month arithmetic across a DST transition', () => { + it('steps back from 1 April without skipping March', () => { + let month = firstOf(2024, 3, NY); + expect(reads(month, NY)).toBe('2024-04-01, 00'); + + month = addMonths(month, -1, NY); + expect(reads(month, NY)).toBe('2024-03-01, 00'); + + month = addMonths(month, -1, NY); + expect(reads(month, NY)).toBe('2024-02-01, 00'); + }); + + it('steps forward from 1 November without stalling', () => { + let month = firstOf(2024, 10, NY); + expect(reads(month, NY)).toBe('2024-11-01, 00'); + + month = addMonths(month, 1, NY); + expect(reads(month, NY)).toBe('2024-12-01, 00'); + }); + + it('stays on the 1st across a year of steps in both directions', () => { + let month = firstOf(2024, 0, NY); + for (let i = 0; i < 12; i += 1) { + month = addMonths(month, 1, NY); + expect(reads(month, NY).slice(8, 10)).toBe('01'); + } + for (let i = 0; i < 12; i += 1) { + month = addMonths(month, -1, NY); + expect(reads(month, NY).slice(8, 10)).toBe('01'); + } + expect(reads(month, NY)).toBe('2024-01-01, 00'); + }); + + it('handles a transition that lands on the 1st itself', () => { + // Sydney moves to DST on 1 Oct 2023, the latent case in startOfMonth. + const month = startOfMonth(new Date(Date.UTC(2023, 9, 15, 12)), SYDNEY); + expect(reads(month, SYDNEY)).toBe('2023-10-01, 00'); + expect(reads(addMonths(month, 1, SYDNEY), SYDNEY)).toBe('2023-11-01, 00'); + expect(reads(addMonths(month, -1, SYDNEY), SYDNEY)).toBe('2023-09-01, 00'); + }); + + it('keeps the day of month where the target month has one', () => { + const jan31 = new Date(Date.UTC(2024, 0, 31, 12)); + expect(reads(addMonths(jan31, 1, NY), NY).slice(0, 10)).toBe('2024-02-29'); + expect(reads(addMonths(jan31, 2, NY), NY).slice(0, 10)).toBe('2024-03-31'); + }); + + it('ends a month on its last instant, one ms before the next begins', () => { + const end = endOfMonth(new Date(Date.UTC(2024, 2, 15, 12)), NY); + expect(reads(end, NY).slice(0, 10)).toBe('2024-03-31'); + expect(endOfMonth(new Date(Date.UTC(2024, 2, 15, 12)), NY).getTime()).toBe( + startOfMonth(new Date(Date.UTC(2024, 3, 15, 12)), NY).getTime() - 1 + ); + }); +}); + +/* + * Every read went through dayjs's prototype `.tz()`, which round-trips the + * instant through `toLocaleString('en-US', { timeZone })` and re-parses that + * wall clock in the *host* zone. When the target's wall time landed in the + * host's spring-forward gap the re-parse jumped an hour: 21:00Z is 02:30 in + * Asia/Kolkata, but a machine in America/New_York read it as 03:30 — so editing + * only the minute field moved the value by 75 minutes. + * + * These assertions are absolute rather than relative, so they fail on any host + * whose zone leaks into the answer. `TZ` is fixed per Vitest process, so the + * cross-host comparison itself lives in the script this pins. + */ +const IST = 'Asia/Kolkata'; + +// 2024-03-09T21:00Z — inside the US spring-forward gap when read as local. +const GAP = new Date('2024-03-09T21:00:00Z'); + +describe('reads do not depend on the host timezone', () => { + it('reads the target wall clock across a foreign DST gap', () => { + expect(getHours(GAP, IST)).toBe(2); + expect(getMinutes(GAP, IST)).toBe(30); + expect(dayKey(GAP, IST)).toBe('2024-03-10'); + expect(getYear(GAP, IST)).toBe(2024); + }); + + it('formats that instant in the target zone', () => { + expect(formatDate(GAP, 'DD MMM YYYY HH:mm', IST)).toBe('10 Mar 2024 02:30'); + }); + + it('anchors the month from the target wall clock, not the host', () => { + // 2024-01-31T20:00Z is 01:30 on 1 Feb in IST — a different month than UTC. + const crossover = new Date('2024-01-31T20:00:00Z'); + expect(dayKey(crossover, IST)).toBe('2024-02-01'); + expect(dayKey(startOfMonth(crossover, IST), IST)).toBe('2024-02-01'); + }); + + /* + * `maxDate={new Date(2024, 3, 17)}` is the ordinary way to say "the 17th", + * and the adapter promises it allows every time of day on that date. Whether + * a bound "has a time of day" was being asked of the *display* zone, where + * host midnight is 05:30 — so the bound silently collapsed to "the 17th, but + * only until dawn" and `.TimeField` rejected every hour after it while + * `.Grid` and `.Input` accepted the whole day. + */ + it('reads a bare day bound as the whole day in any display zone', () => { + const maxDate = new Date(2024, 3, 17); + const nineIST = new Date(Date.UTC(2024, 3, 17, 3, 30)); + + expect(isWithinBounds(nineIST, undefined, maxDate, IST)).toBe(true); + expect(isWithinTimeBounds(nineIST, undefined, maxDate, IST)).toBe(true); + }); + + it('still honours a bound that names a real time of day', () => { + // Authored with a time, so it constrains within its own day. + const maxDate = new Date(2024, 3, 17, 10, 0); + const beforeIt = new Date(2024, 3, 17, 9, 0); + const afterIt = new Date(2024, 3, 17, 11, 0); + + expect(isWithinTimeBounds(beforeIt, undefined, maxDate)).toBe(true); + expect(isWithinTimeBounds(afterIt, undefined, maxDate)).toBe(false); + }); + + it('handles a half-hour-offset zone through a transition', () => { + // Lord Howe runs at +10:30 before its 1 Oct shift, so 14:45Z is 01:15 local + // — a half-hour offset no host zone shares. + const lh = 'Australia/Lord_Howe'; + const instant = new Date('2023-09-30T14:45:00Z'); + expect(getHours(instant, lh)).toBe(1); + expect(getMinutes(instant, lh)).toBe(15); + expect(dayKey(instant, lh)).toBe('2023-10-01'); + }); +}); + +/* + * Moved here from `audit-fixed.test.tsx`, which collected findings by the + * number they were reported under. Each assertion is unchanged; only its home + * is, so a failure lands beside the behaviour it describes. + */ +/* + * Regressions that arrived from review passes rather than from the spec. + * Each assertion sits with the behaviour it guards; which pass found it is + * history, not structure. + */ +describe('regressions', () => { + it('isWithinBounds resolves the day in the zone it is given', () => { + // 23:00 UTC on 17 Apr is already 08:00 on the 18th in Tokyo. + const instant = new Date(Date.UTC(2024, 3, 17, 23, 0)); + const max = new Date(Date.UTC(2024, 3, 17, 12, 0)); + + expect(isWithinBounds(instant, undefined, max, 'UTC')).toBe(true); + expect(isWithinBounds(instant, undefined, max, 'Asia/Tokyo')).toBe(false); + }); + + it('parseDate returns null, never throws, when a timeZone is set', () => { + // dayjs.tz does not validate — it throws RangeError on bad input, which + // would crash the input on an ordinary keystroke. + expect(() => parseDate('not a date', 'DD MMM YYYY', 'UTC')).not.toThrow(); + expect(parseDate('not a date', 'DD MMM YYYY', 'UTC')).toBeNull(); + expect(parseDate('2024-04-17', 'DD MMM YYYY', 'UTC')).toBeNull(); + expect( + dayKey(parseDate('17 Apr 2024', 'DD MMM YYYY', 'UTC') as Date, 'UTC') + ).toBe('2024-04-17'); + }); +}); + +describe('setTime survives a daylight-saving shift', () => { + const TZ = 'America/New_York'; + // 9 Mar 2025: EST -> EDT at 02:00, so 02:00-02:59 never happens. + const shiftDay = parseDate('09 Mar 2025', DEFAULT_FORMAT, TZ) as Date; + + it.each([ + [1, 30], + [3, 0], + [10, 0], + [23, 45] + ])('returns %i:%i as asked', (hours, minutes) => { + const result = setTime(shiftDay, hours, minutes, TZ); + expect(getHours(result, TZ)).toBe(hours); + expect(getMinutes(result, TZ)).toBe(minutes); + }); + + it('resolves a time that does not exist forward into the shift', () => { + const result = setTime(shiftDay, 2, 30, TZ); + expect(getHours(result, TZ)).toBe(3); + expect(getMinutes(result, TZ)).toBe(30); + }); + + it('stays on the day it was handed', () => { + expect(dayKey(setTime(shiftDay, 23, 45, TZ), TZ)).toBe('2025-03-09'); + }); + + it('holds on the autumn shift too', () => { + // 2 Nov 2025: 01:00-01:59 happens twice; either instant reads back as 1. + const fallBack = parseDate('02 Nov 2025', DEFAULT_FORMAT, TZ) as Date; + expect(getHours(setTime(fallBack, 1, 30, TZ), TZ)).toBe(1); + expect(getHours(setTime(fallBack, 10, 0, TZ), TZ)).toBe(10); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/validity-and-bounds.test.tsx b/packages/raystack/components/calendar-preview/__tests__/validity-and-bounds.test.tsx new file mode 100644 index 000000000..2008996b5 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/validity-and-bounds.test.tsx @@ -0,0 +1,163 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { getAllSlots } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import type { CalendarValidity } from '../calendar-preview-context'; + +const lastArg = (fn: { mock: { calls: unknown[][] } }) => + fn.mock.calls[fn.mock.calls.length - 1]?.[0] as T; + +const MONTH = new Date(2024, 3, 1); + +/* + * `reportValidity` was only ever called by the three typed fields, so an + * invalid verdict latched: nothing the grid, a preset, the revert button or a + * controlled parent did ever cleared it. A `Field` wired to + * `onValidityChange` showed "Invalid date" permanently beside a good value. + */ +describe('validity does not latch', () => { + it('a grid pick clears a standing complaint from the typed field', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + + ); + + await user.type(screen.getByRole('textbox'), 'rubbish{Enter}'); + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'unparseable' + }); + + await user.click(screen.getByRole('button', { name: /April 5th/ })); + + expect(lastArg(onValidityChange)).toEqual({ + valid: true + }); + }); +}); + +/* + * Both `.TimeField` rejections returned in silence — no validity, no value + * change, the text simply reverting — while the file claimed to validate the + * same shape the typed fields do. + */ +describe('.TimeField reports what it rejects', () => { + it('reports unparseable text', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + const hour = screen.getByLabelText('Hour'); + await user.clear(hour); + await user.type(hour, 'abc'); + await user.tab(); + + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'unparseable' + }); + }); + + it('reports an out-of-range hour', async () => { + const user = userEvent.setup(); + const onValidityChange = vi.fn(); + render( + + + + ); + + const hour = screen.getByLabelText('Hour'); + await user.clear(hour); + await user.type(hour, '99'); + await user.tab(); + + expect(lastArg(onValidityChange)).toEqual({ + valid: false, + reason: 'out-of-bounds' + }); + }); + + /* + * `step` is public and was unvalidated: `Math.round(x / 0) * 0` is NaN, which + * reached `.minute(NaN)` and committed an Invalid Date that rendered as the + * literal string `NaN`. + */ + it('never commits an Invalid Date, whatever step it is given', async () => { + const user = userEvent.setup(); + const onValueChange = vi.fn(); + render( + + + + ); + + const minute = screen.getByLabelText('Minute'); + await user.clear(minute); + await user.type(minute, '30{Enter}'); + + const next = lastArg(onValueChange); + expect(Number.isNaN(next.getTime())).toBe(false); + expect(next.getMinutes()).toBe(30); + }); +}); + +/* + * `yearWindow` was measured from the anchor with nothing tying it to a bounded + * edge, so a bound past the window left `firstYear > lastYear` and the build + * loop never ran. + */ +describe('.MonthGrid always offers a selectable period', () => { + const grid = (props: Record) => + render( + + + + ); + + it('renders cells when minDate sits beyond the year window', () => { + const { container } = grid({ minDate: new Date(2035, 0, 1) }); + expect( + getAllSlots(container, 'calendar-preview-month-cell').length + ).toBeGreaterThan(0); + }); + + it('renders cells when maxDate sits before the year window', () => { + const { container } = grid({ maxDate: new Date(2015, 11, 31) }); + expect( + getAllSlots(container, 'calendar-preview-month-cell').length + ).toBeGreaterThan(0); + }); + + it('spans from the bound to the window on the unbounded edge', () => { + // minDate 2030 with today in 2026: the list must reach 2030, not stop short. + const { container } = grid({ minDate: new Date(2030, 0, 1) }); + const years = getAllSlots( + container, + 'calendar-preview-month-grid-year' + ).map(node => node.textContent); + expect(years.some(text => text?.includes('2030'))).toBe(true); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-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..2bb8be110 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -0,0 +1,356 @@ +'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 { + CalendarRangeField, + DateRangeValue +} from './calendar-preview-context'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey, isAfterDay } 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, + reportValidity + } = 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 }; + + /* + * The ordering policy, shared with `.MonthGrid`. Both grids answer + * the same question and had answered it differently: this one wrote + * the clicked day through with no guard at all, so picking before a + * locked start emitted `from > to` and — because the root announces + * validity on every commit — reported it as good. + * + * Compared by day, as `.RangeInput` does: a click carries no time of + * day, so an instant comparison would read a 09:00 endpoint as + * "after" the midnight the click produces. + */ + const commitRange = ( + candidate: DateRangeValue, + field: CalendarRangeField + ) => { + const opposite = field === 'from' ? 'to' : 'from'; + if ( + candidate.from && + candidate.to && + isAfterDay(candidate.from, candidate.to, timeZone) + ) { + /* + * `lock` holds the opposite endpoint read-only, and the opposite + * endpoint is the only one an inversion could clear — so under a + * lock there is nothing to repair. Refused, as `.TimeField` + * refuses what it cannot fix, rather than deleting a pinned end. + */ + if (lock === opposite) { + reportValidity({ valid: false, reason: 'range-order' }); + return; + } + setValue({ ...candidate, [opposite]: null }); + return; + } + setValue(candidate); + }; + + /* + * With an endpoint locked, RDP's range machine still rewrites both + * ends, so ignore its result and drive the unlocked end from the + * 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 field: CalendarRangeField = lock === 'from' ? 'to' : 'from'; + const unlocked = held[field]; + if ( + unlocked && + dayKey(unlocked, timeZone) === dayKey(triggerDate, timeZone) + ) { + setValue({ ...held, [field]: null }); + return; + } + commitRange({ ...held, [field]: triggerDate }, field); + return; + } + + /* + * RDP has no answer for a range holding only an end. + * `addToRange` branches on `!from && !to`, `from && !to` and + * `from && to`; `{ from: undefined, to: D }` — which is what + * `{ from: null, to: D }` becomes on the way in — falls through all + * three, so `range` is never assigned and it returns `undefined`. + * Mapping that to `setValue(null)` discarded both the surviving + * endpoint and the click. Clearing one end is a documented move + * (`.RangeInput` empties a field; a typed end before the start nulls + * `from`), so this shape is ordinary, not exotic. + */ + if (!next && held.to && !held.from) { + commitRange({ from: triggerDate, to: held.to }, 'from'); + return; + } + + setValue( + next ? { from: next.from ?? null, to: next.to ?? null } : null + ); + }} + 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..c964869b4 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { mergeProps } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { useEffect, useRef, useState } from 'react'; +import { Input, type InputProps } from '../input/input'; +import styles from './calendar-preview.module.css'; +import type { CalendarValidity } from './calendar-preview-context'; +import { + useCalendarPreviewContext, + useInsideTrigger +} from './calendar-preview-context'; +import { + parseTypedText, + typedFieldHandlers +} from './calendar-preview-typed-field'; +import { + dayKey, + formatForGranularity, + isWithinBounds, + 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): boolean => { + // An emptied field clears the value; that is not an error state. + if (text.trim() === '') { + reportValidity({ valid: true }); + setValue(null); + return true; + } + + const read = parseTypedText(text, { + granularity, + granularities, + format, + timeZone, + month + }); + if (!read) { + reportValidity({ valid: false, reason: 'unparseable' }); + return false; + } + + const validity = validate(read.date); + reportValidity(validity); + if (!validity.valid) return false; + + if (read.granularity !== granularity) setGranularity(read.granularity); + setValue(read.date, { granularity: read.granularity }); + // Typing navigates the grid, so the committed day is actually visible. + setMonth(read.date); + return true; + }; + + const handlers = typedFieldHandlers({ + draft, + setDraft, + commit, + insideTrigger, + setOpen + }); + + 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, + ...handlers + } 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..7b8109cd7 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-month-grid.tsx @@ -0,0 +1,436 @@ +'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, + CalendarValidity, + DateRangeValue +} from './calendar-preview-context'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { + addMonths, + dayKey, + dayOrdinal, + firstOfMonth, + getYear, + periodMonths, + periodRange +} 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. + */ + /* + * `yearWindow` applies to the unbounded edge, but it was measured from the + * anchor with nothing tying it to the bounded one — so `minDate={2035}` with + * no `maxDate` gave firstYear 2035 against lastYear 2031 and the loop below + * never ran: an empty grid with no selectable period at all. A far-past + * `maxDate` did the same in mirror. Each edge now yields to the other. + */ + const boundedFirst = minDate ? getYear(minDate, timeZone) : null; + const boundedLast = maxDate ? getYear(maxDate, timeZone) : null; + const firstYear = + boundedFirst ?? + Math.min(anchorYear - yearWindow, boundedLast ?? Number.POSITIVE_INFINITY); + const lastYear = + boundedLast ?? + Math.max(anchorYear + yearWindow, boundedFirst ?? Number.NEGATIVE_INFINITY); + + const sections = useMemo(() => { + if (granularity === 'day') return []; + + const period = PERIODS[granularity]; + const monthsPerPeriod = periodMonths(granularity); + + const built: { year: number; cells: PeriodCell[] }[] = []; + for (let year = firstYear; year <= lastYear; year += 1) { + const cells = Array.from({ length: period.perYear }, (_, index) => { + /* + * The start is known from the year and the index, so it is built once + * rather than rediscovered: `periodRange` would re-read the wall clock + * and re-parse to arrive back at this same instant. `addMonths` carries + * the year rollover, which is the part worth not writing twice. + */ + const start = firstOfMonth(year, period.startMonth(index), timeZone); + const end = addMonths(start, monthsPerPeriod, timeZone); + /* + * Overlap, not first-day: a `minDate` falling mid-month used to + * disable the whole month and make every valid day in it unreachable. + * `.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; + /* + * Bounds and availability are valid by construction — such a cell is + * disabled, so reaching here means `start` passes both. Reported at all + * because `.Grid` leaves this to RDP's own disabling, which left + * `onValidityChange` silent for every non-day pick. + */ + const valid: CalendarValidity = { valid: true }; + + if (selection === 'range') { + const range = (value as DateRangeValue | null) ?? { + from: null, + to: null + }; + const field = lock ? (lock === 'from' ? 'to' : 'from') : activeField; + const opposite = field === 'from' ? 'to' : 'from'; + const next: DateRangeValue = { ...range, [field]: start }; + /* + * `.RangeInput` guards ordering, `.Grid` delegates it to RDP and + * `.TimeField` refuses inversion outright; this writer had none of it, so + * picking Dec 2026 against an existing March committed a backwards range + * that any `from <= x <= to` reader sees as empty. + * + * Compared by period, not by instant: two picks inside one period are the + * same choice, and clearing the opposite end there would discard a + * selection the user did not contradict. + */ + if (next.from && next.to) { + const fromStart = periodRange(next.from, granularity, timeZone).start; + const toStart = periodRange(next.to, granularity, timeZone).start; + if (fromStart.getTime() > toStart.getTime()) { + /* + * `lock` holds the opposite endpoint read-only, and the opposite + * endpoint is exactly the one an inversion would clear — so under a + * lock there is nothing this writer may repair. Refused instead, the + * way `.TimeField` refuses an inversion it cannot fix, rather than + * deleting the endpoint the consumer pinned. + */ + if (lock === opposite) { + reportValidity({ valid: false, reason: 'range-order' }); + return; + } + next[opposite] = null; + } + } + reportValidity(valid); + setValue(next); + return; + } + + reportValidity(valid); + if (selection === 'multiple') { + const current = (value as Date[]) ?? []; + const key = dayKey(start, timeZone); + 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..87ced02e2 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-presets.tsx @@ -0,0 +1,167 @@ +'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'; +import { isWithinBounds } from './date-adapter'; + +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 `