diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index 0aaeafe6c..559818bc8 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..a6cc10a03 --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -0,0 +1,289 @@ +'use client'; + +import type { ComponentPropsType } from '@/components/demo/types'; +import { getPropsString } from '@/lib/utils'; + +/* The grid props drive the playground rather than the root's, because they + are what visibly changes: the root's state props need a value to show. */ +export const getCode = (props: ComponentPropsType) => { + return ` + + + + + + + + + `; +}; + +export const playground = { + type: 'playground', + controls: { + fixedWeeks: { + type: 'checkbox', + defaultValue: true + }, + showOutsideDays: { + type: 'checkbox', + defaultValue: false + }, + showWeekNumber: { + type: 'checkbox', + defaultValue: false + }, + loading: { + type: 'checkbox', + defaultValue: false + }, + weekStartsOn: { + type: 'select', + options: [0, 1, 2, 3, 4, 5, 6], + defaultValue: 0 + } + }, + getCode +}; + +export const preview = { + type: 'code', + tabs: [ + { + name: 'Inline', + code: ` + + ` + }, + { + name: 'Two months', + code: ` + + ` + }, + { + name: 'Month + year', + code: ` + + + + + + + + + ` + } + ] +}; + +export const compositionDemo = { + type: 'code', + tabs: [ + { + name: 'Default header', + code: ` + + ` + }, + { + name: 'Custom caption', + code: ` + + + Delivery date + + + + + + + ` + }, + { + name: 'With footer', + code: ` + + Dates are inclusive + ` + }, + { + name: 'Node footer', + code: ` + + + + Beta + Times are UTC + + + ` + } + ] +}; + +export const resetDemo = { + type: 'code', + tabs: [ + { + name: 'Reset', + code: ` + + ` + }, + { + name: 'Nothing to restore', + code: ` + + ` + }, + { + name: 'Clear the selection', + code: ` + + ` + }, + { + name: 'No defaultDate', + code: ` + + ` + } + ] +}; + +export const boundsDemo = { + type: 'code', + tabs: [ + { + name: 'Min date', + code: ` + + ` + }, + { + name: 'Min and max', + code: ` + + ` + }, + { + name: 'Unavailable days', + code: ` date.getDay() === 0 || date.getDay() === 6} + > + + ` + }, + { + name: 'Read only', + code: ` + + ` + } + ] +}; + +export const gridDemo = { + type: 'code', + tabs: [ + { + name: 'Outside days', + code: ` + + + + + ` + }, + { + name: 'Week numbers', + code: ` + + + + + ` + }, + { + name: 'Monday first', + code: ` + + + + + ` + }, + { + name: 'Loading', + code: ` + + + + + ` + } + ] +}; + +export const dateInfoDemo = { + type: 'code', + tabs: [ + { + name: 'Date info', + code: ` + + + + date.getDate() % 7 === 0 ? ( + $ + ) : null + } + /> + + ` + }, + { + name: 'Tooltips', + code: ` + + + + date.getDay() === 0 ? 'Weekend rate applies' : null + } + /> + + ` + } + ] +}; 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..30abdc064 --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -0,0 +1,290 @@ +--- +title: Calendar Preview +description: A subcomposed calendar that owns its selection and view state. +source: packages/raystack/components/calendar-preview +--- + +import { + preview, + playground, + compositionDemo, + resetDemo, + boundsDemo, + gridDemo, + dateInfoDemo, +} from "./demo.ts"; + + + + + Later RFC 005 phases may still reshape props, slots and `useCalendar()`'s + return, so pin an exact version if you adopt it now. `Calendar` and + `DatePicker` remain the supported choice. + + +## Playground + + + +## Anatomy + +Every part renders its own default, so composition is opt-in depth: + +```tsx +import { CalendarPreview } from '@raystack/apsara' + + + + +``` + +Expanded, the day view is a header and a grid: + +```tsx + + + + + + + + + + + + +``` + +`.Grid` renders the day cells itself and takes no children. `.Day` and `.Weekday` are +not written inside it — they are overrides, passed through `components`: + +```tsx + +``` + +Children override the content a part computes from context, so +`Q3 2024` replaces the month label. + +## API Reference + +### CalendarPreview + +The root. Owns the selected value and the visible month, provides both to every part, and renders a column that hugs its content. Also takes `render`, `className` and `ref`. + + + +### CalendarPreview.Days + +The day view — a header and a grid. Hugs its content rather than reserving a fixed height. + + + +### CalendarPreview.Caption + +The month label above the grid, and optionally the trigger for the month and year scroller. + + + +### CalendarPreview.Grid + +The day grid. Layout and per-day data live here rather than on the root, so a calendar with two grids can configure them independently. + + + +### CalendarPreview.Header + +The row above the grid. Composes `.Caption`, `.Reset`, `.PrevMonth` and `.NextMonth` when given no children. Takes `render`, `className` and `ref`. + + + +### CalendarPreview.PrevMonth / CalendarPreview.NextMonth + +Step the view one month. Never disabled by `minDate` or `maxDate` — bounds limit selection, not navigation. + + + +### CalendarPreview.Reset + +Restores `defaultDate`, reporting `reason: 'reset'`. Rendered whenever `defaultDate` +is set, and disabled once the value already equals it — it stays mounted rather than +disappearing, so activating it does not send focus to the page body or shift the nav +buttons sideways. It carries `data-restored` while there is nothing to restore. + + + +### CalendarPreview.Footer + +The row below the calendar. A bare string is wrapped in `Text`; anything else renders as given. + +It needs no container of its own: the root renders a column that hugs its content, so `.Days` and `.Footer` stack whatever the surrounding layout does. + + + +### useCalendar + +Reads the enclosing root's state, for building parts the library does not ship. Deliberately narrow: + +```tsx +import { useCalendar } from '@raystack/apsara' + +const { value, setValue, scale, month, setMonth, isDateUnavailable } = useCalendar() +``` + +Calling it outside a `CalendarPreview` throws, naming the part that asked. `scale` is +read-only for now — the setter arrives with the scale switcher in a later phase. + +`setValue(null)` clears the selection and reports `reason: 'clear'`, carrying the day +that was cleared as `details.toDate()`. + + + + +### Slots + +Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): + +| Slot | Element | +|------|---------| +| `calendar-preview` | The root, a column wrapping the parts | +| `calendar-preview-days` | The day view surface | +| `calendar-preview-header` | The header row, single-month layout | +| `calendar-preview-month-header` | One month's header, when several months are shown | +| `calendar-preview-caption` | The month label, single-month layout | +| `calendar-preview-month-header-caption` | One month's label, when several months are shown | +| `calendar-preview-caption-positioner` | The scroller's positioning wrapper | +| `calendar-preview-caption-popup` | The month and year scroller (when `dropdown` is open) | +| `calendar-preview-caption-months` | The month column of the scroller | +| `calendar-preview-caption-month` | One month in the scroller | +| `calendar-preview-caption-years` | The year column of the scroller | +| `calendar-preview-caption-year` | One year in the scroller | +| `calendar-preview-reset` | The reset button | +| `calendar-preview-prev-month` | The previous-month button | +| `calendar-preview-next-month` | The next-month button | +| `calendar-preview-grid` | The grid root | +| `calendar-preview-weeks` | Wrapper around the table and its skeleton | +| `calendar-preview-table` | The `` that holds the days | +| `calendar-preview-skeleton` | The loading skeleton shown over the grid | +| `calendar-preview-weekday` | One weekday heading | +| `calendar-preview-week-number` | One week-number cell (when `showWeekNumber`) | +| `calendar-preview-week-number-header` | The week-number column heading | +| `calendar-preview-day-trigger` | The tooltip trigger wrapping each day button | +| `calendar-preview-day` | The ` + + ); + const footer = getSlot(container, 'calendar-preview-footer'); + expect(within(footer as HTMLElement).getByRole('button')).toHaveTextContent( + 'Pick a preset' + ); + expect(getSlot(container, 'calendar-preview-footer-text')).toBeNull(); + }); +}); + +describe('CalendarPreview part contract', () => { + /* Every part takes `render`, `className`, `ref` and carries a `data-slot`, + with the consumer's props spread last. */ + const parts: Array<[string, string, React.ReactNode]> = [ + [ + 'Days', + 'calendar-preview-days', + + ], + [ + 'Header', + 'calendar-preview-header', + + ], + [ + 'PrevMonth', + 'calendar-preview-prev-month', + + ], + [ + 'NextMonth', + 'calendar-preview-next-month', + + ], + [ + 'Caption', + 'calendar-preview-caption', + + ], + [ + 'Grid', + 'calendar-preview-grid', + + ], + [ + 'Footer', + 'calendar-preview-footer', + + ] + ]; + + it.each( + parts + )('%s carries its slot and spreads props last', (_name, slot, element) => { + const { container } = renderCalendar(element); + const node = getSlot(container, slot); + expect(node).toBeInTheDocument(); + expect(node).toHaveClass('mine'); + expect(node).toHaveAttribute('data-mine', 'true'); + }); + + it('carries its own slot on the root, and spreads props last', () => { + const { container } = render( + + + + ); + const root = getSlot(container, 'calendar-preview'); + expect(root).toBeInTheDocument(); + expect(root).toHaveClass('mine'); + expect(root).toHaveAttribute('data-mine', 'true'); + expect(root).toHaveAttribute('data-scale', 'day'); + }); + + /* The root is a box, not a bare provider: without one, `.Days` and `.Footer` + inherit the surrounding layout and sit side by side in a flex row. */ + it('contains the day view and the footer rather than emitting them loose', () => { + const { container } = render( + + + Dates are inclusive + + ); + const root = getSlot(container, 'calendar-preview') as HTMLElement; + expect(getSlot(root, 'calendar-preview-days')?.parentElement).toBe(root); + expect(getSlot(root, 'calendar-preview-footer')?.parentElement).toBe(root); + }); + + it('renders .Reset with its slot and the consumer props', () => { + const { container } = renderCalendar( + , + { defaultDate: new Date(2026, 7, 20) } + ); + const node = getSlot(container, 'calendar-preview-reset'); + expect(node).toHaveClass('mine'); + expect(node).toHaveAttribute('data-mine', 'true'); + }); + + it('lets render replace the element each part produces', () => { + const { container } = renderCalendar( + }> + }> + } /> + + + ); + expect(getSlot(container, 'calendar-preview-days')?.tagName).toBe( + 'SECTION' + ); + expect(getSlot(container, 'calendar-preview-header')?.tagName).toBe('NAV'); + expect(getSlot(container, 'calendar-preview-caption')?.tagName).toBe('H2'); + }); + + it('forwards ref to the element each part produces', () => { + const days = { current: null as HTMLDivElement | null }; + const header = { current: null as HTMLDivElement | null }; + const grid = { current: null as HTMLDivElement | null }; + renderCalendar( + + + + + ); + expect(days.current).toHaveAttribute('data-slot', 'calendar-preview-days'); + expect(header.current).toHaveAttribute( + 'data-slot', + 'calendar-preview-header' + ); + expect(grid.current).toHaveAttribute('data-slot', 'calendar-preview-grid'); + }); +}); + +describe('CalendarPreview part boundaries', () => { + it('names the part when a cell is used outside a grid', () => { + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + expect(() => + render( + + + + ) + ).toThrow('CalendarPreview.Day must be used within '); + error.mockRestore(); + }); + + it('runs a consumer onClick alongside the reset', () => { + const onClick = vi.fn(); + const onValueChange = vi.fn(); + const { container } = renderCalendar( + , + { defaultDate: new Date(2026, 7, 20), onValueChange } + ); + fireEvent.click( + getSlot(container, 'calendar-preview-reset') as HTMLElement + ); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledTimes(1); + }); + + it('leaves the reset inert while the calendar is disabled', () => { + const { container } = renderCalendar(, { + defaultDate: new Date(2026, 7, 20), + disabled: true + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeDisabled(); + }); + + it('scrolls the active row of the caption scroller into view', () => { + const scrollIntoView = vi.fn(); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + value: scrollIntoView, + writable: true, + configurable: true + }); + const { container } = renderCalendar( + + + + + + ); + openCaption(container); + /* One per column — the active month and the active year. */ + expect(scrollIntoView).toHaveBeenCalledTimes(2); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' }); + Reflect.deleteProperty(Element.prototype, 'scrollIntoView'); + }); +}); + +describe('CalendarPreview public surface', () => { + /* The scope boundary for this phase, asserted rather than described: the + popover, the input and the period views land in later PRs, and a part + appearing here early would be public API shipped by accident. */ + /* `displayName` is an own property of the root function `Object.assign` + writes the parts onto, so it is not one of them. */ + const partNames = Object.keys(CalendarPreviewFromBarrel).filter( + key => key !== 'displayName' + ); + + it('exports exactly the parts this phase builds', () => { + expect(partNames.sort()).toEqual( + [ + 'Caption', + 'Day', + 'Days', + 'Footer', + 'Grid', + 'Header', + 'NextMonth', + 'PrevMonth', + 'Reset', + 'Weekday' + ].sort() + ); + }); + + it('gives every part a displayName', () => { + expect(CalendarPreviewFromBarrel.displayName).toBe('CalendarPreview'); + for (const name of partNames) { + const part = CalendarPreviewFromBarrel[ + name as keyof typeof CalendarPreviewFromBarrel + ] as { displayName?: string }; + expect(part.displayName, `${name} has no displayName`).toBe( + `CalendarPreview.${name}` + ); + } + }); +}); + +describe('inertness', () => { + /* The guard lives in the root's `setValue`, so every path inherits it -- + the grid's own click handler no longer carries one. */ + function Setter({ day }: { day: Date }) { + const { setValue } = useCalendar(); + return ( + + ); + } + + it.each([ + 'readOnly', + 'disabled' + ] as const)('ignores useCalendar().setValue when %s', flag => { + const onValueChange = vi.fn(); + const { container } = renderCalendar( + <> + + + , + { [flag]: true, onValueChange } + ); + + fireEvent.click(screen.getByText('set')); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(dayCell(container, '20')).not.toHaveAttribute('data-selected'); + }); + + /* `.Reset` carries its own `disabled` attribute, so the button is inert + before the root guard is reached. Both layers are asserted: the attribute + here, and the guard underneath it by the `setValue` cases above -- which + is the path a custom reset part would take. */ + it.each([ + 'readOnly', + 'disabled' + ] as const)('renders .Reset disabled and commits nothing when %s', flag => { + const onValueChange = vi.fn(); + const { container } = renderCalendar( + <> + + + + + , + { + [flag]: true, + defaultDate: new Date(2026, 7, 9), + defaultValue: new Date(2026, 7, 20), + onValueChange + } + ); + + const reset = getSlot(container, 'calendar-preview-reset'); + expect(reset).toBeInTheDocument(); + expect(reset).toBeDisabled(); + + fireEvent.click(reset as HTMLElement); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('still commits when neither flag is set', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar( + <> + + + , + { onValueChange } + ); + + fireEvent.click(screen.getByText('set')); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(dayCell(container, '20')).toHaveAttribute('data-selected'); + }); +}); + +describe('CalendarPreview.Grid children', () => { + /* `.Day` and `.Weekday` are `components` overrides, not children. A stray + child used to ride `...props` into the day-picker root and blank the + grid, which is what the docs Anatomy snippet told readers to write. */ + it('renders the day grid even when given children', () => { + const { container } = renderCalendar( + + HIJACKED + + ); + + expect(container).not.toHaveTextContent('HIJACKED'); + expect(getAllSlots(container, 'calendar-preview-day').length).toBe(31); + expect(dayCell(container, '15')).toHaveAttribute('data-today'); + }); +}); + +describe('change reasons', () => { + function Clearer() { + const { setValue } = useCalendar(); + return ( + + ); + } + + it("reports a restore as 'reset', not as a pick", () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar( + <> + + + + + , + { + defaultDate: new Date(2026, 7, 20), + defaultValue: new Date(2026, 7, 10), + onValueChange + } + ); + + fireEvent.click( + getSlot(container, 'calendar-preview-reset') as HTMLElement + ); + + expect(onValueChange.mock.calls[0][1].reason).toBe('reset'); + }); + + it("reports a click as 'select'", () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { onValueChange }); + + fireEvent.click(dayCell(container, '20')); + + expect(onValueChange.mock.calls[0][1].reason).toBe('select'); + }); + + /* `toDate()` is documented as "the day acted on -- never null, even when + `value` is". Clearing through the hook used to report today, a day + nobody touched. */ + it("reports a hook clear as 'clear', carrying the day cleared", () => { + const onValueChange = vi.fn(); + renderCalendar( + <> + + + , + { defaultValue: new Date(2026, 7, 10), onValueChange } + ); + + fireEvent.click(screen.getByText('clear')); + + const [value, details] = onValueChange.mock.calls[0]; + expect(value).toBeNull(); + expect(details.reason).toBe('clear'); + expect(details.toDate()).toEqual(new Date(2026, 7, 10)); + }); +}); + +describe('keyboard navigation', () => { + /* `index.mdx` claims "Arrow keys move between days" and nothing tested it. + The behaviour is react-day-picker's roving tabindex, which is exactly why + it is worth pinning: it is the contract the grid boundary is buying. */ + function focused(): string | null { + const active = document.activeElement; + return ( + active?.querySelector('[data-slot="calendar-preview-day-number"]') + ?.textContent ?? null + ); + } + + /* `.focus()` alone sets `document.activeElement` but does not reach React's + `onFocus` under jsdom, so react-day-picker never registers a focus target + and every arrow key is a no-op. Both are needed. */ + function focusDay(container: HTMLElement, day: string): HTMLElement { + const cell = dayCell(container, day); + cell.focus(); + fireEvent.focus(cell); + return cell; + } + + it.each([ + ['ArrowRight', '16'], + ['ArrowLeft', '14'], + ['ArrowDown', '22'], + ['ArrowUp', '8'] + ])('moves the focused day on %s', (key, expected) => { + const { container } = renderCalendar(); + const start = focusDay(container, '15'); + + fireEvent.keyDown(start, { key }); + + expect(focused()).toBe(expected); + }); + + /* August 2026 starts on a Saturday, so the 19th is a Wednesday: Home lands + on Sunday the 16th and End on Saturday the 22nd. */ + it('moves to the start and end of the week on Home and End', () => { + const { container } = renderCalendar(); + const start = focusDay(container, '19'); + + fireEvent.keyDown(start, { key: 'Home' }); + expect(focused()).toBe('16'); + + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' }); + expect(focused()).toBe('22'); + }); + + it('commits the focused day on Enter', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { onValueChange }); + const day = focusDay(container, '20'); + + fireEvent.keyDown(day, { key: 'Enter' }); + fireEvent.click(day); + + expect(onValueChange).toHaveBeenCalled(); + expect(dayCell(container, '20')).toHaveAttribute('data-selected'); + }); + + it('keeps exactly one day in the tab order', () => { + const { container } = renderCalendar(); + const tabbable = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.getAttribute('tabindex') === '0' + ); + + expect(tabbable).toHaveLength(1); + }); + + /* Read-only stays navigable -- that is the whole difference from disabled, + and the reason the days carry `aria-disabled` rather than `disabled`. */ + it('still moves between days when readOnly', () => { + const { container } = renderCalendar(undefined, { readOnly: true }); + const start = focusDay(container, '15'); + + fireEvent.keyDown(start, { key: 'ArrowRight' }); + + expect(focused()).toBe('16'); + /* Focus really moved, rather than only the draft marker. */ + expect(document.activeElement).toBe(dayCell(container, '16')); + expect(dayCell(container, '15')).toHaveAttribute('aria-disabled', 'true'); + expect(dayCell(container, '15')).not.toBeDisabled(); + }); +}); + +describe('the month the calendar opens on', () => { + /* `defaultMonth`'s documented default is "the month of `value`, else + `today`", and every branch of that chain is exercised here -- the render + helper passes `defaultMonth` explicitly, so none of it was covered. */ + function caption(container: HTMLElement): string { + return getSlot(container, 'calendar-preview-caption')?.textContent ?? ''; + } + + it('prefers an explicit defaultMonth over everything else', () => { + const { container } = render( + + + + ); + expect(caption(container)).toContain('Mar 2027'); + }); + + it('falls back to the month of a controlled value', () => { + const { container } = render( + + + + ); + expect(caption(container)).toContain('Mar 2027'); + }); + + it('falls back to the month of an uncontrolled defaultValue', () => { + const { container } = render( + + + + ); + expect(caption(container)).toContain('Mar 2027'); + }); + + it('falls back to today when there is no value at all', () => { + const { container } = render( + + + + ); + expect(caption(container)).toContain('Aug 2026'); + }); +}); + +describe('timeZone', () => { + /* + * RFC 005 pins the contract: `timeZone` is forwarded to the grid and this + * family does no conversion of its own. `Date` props are therefore + * instants, and the calendar shows the day each instant falls on in the + * given zone -- so a `defaultMonth` built from local fields can land in the + * neighbouring month at a far offset. These tests pin that, and pin the + * change details, which used to be keyed in the ambient zone instead. + */ + const NOON_UTC = new Date(Date.UTC(2026, 7, 15, 12)); + const AUGUST_UTC = new Date(Date.UTC(2026, 7, 1, 12)); + + it.each([ + ['Pacific/Kiritimati', '16'], + ['Pacific/Niue', '15'], + ['UTC', '15'] + ])('marks today by the day the instant falls on in %s', (zone, day) => { + const { container } = renderCalendar(, { + today: NOON_UTC, + defaultMonth: AUGUST_UTC, + timeZone: zone + }); + + expect(dayCell(container, day)).toHaveAttribute('data-today'); + }); + + it.each([ + 'Pacific/Kiritimati', + 'Pacific/Niue', + 'UTC' + ])('opens on the month the instant falls in, in %s', zone => { + const { container } = renderCalendar(, { + today: NOON_UTC, + defaultMonth: AUGUST_UTC, + timeZone: zone + }); + + const caption = getSlot(container, 'calendar-preview-caption'); + expect(caption?.textContent).toContain('Aug 2026'); + }); + + /* The click path keys correctly even unzoned, because RDP hands back a + date already shifted into the zone. The path that needs the fix is a + commit of a plain instant, where nothing has shifted it yet. */ + function Setter({ day }: { day: Date }) { + const { setValue } = useCalendar(); + return ( + + ); + } + + it.each([ + ['Pacific/Kiritimati', '2026-08-21'], + ['Pacific/Niue', '2026-08-20'], + ['UTC', '2026-08-20'] + ])('keys the change period in the calendar zone, not the ambient one (%s)', (zone, expected) => { + const onValueChange = vi.fn(); + renderCalendar( + <> + + + , + { + today: NOON_UTC, + defaultMonth: AUGUST_UTC, + timeZone: zone, + onValueChange + } + ); + + fireEvent.click(screen.getByText('set')); + + const [, details] = onValueChange.mock.calls[0]; + expect(details.period.start).toBe(expected); + expect(details.period.end).toBe(expected); + }); +}); + +describe('defaultFormatValue', () => { + it('formats a day as DD/MM/YYYY', () => { + expect(defaultFormatValue(new Date(2027, 4, 20), 'day')).toBe('20/05/2027'); + }); + + it('formats the coarser scales by their own shorthand', () => { + const value = { date: '2026-08-31', scale: 'month' as const }; + expect(defaultFormatValue(value, 'month')).toBe('Aug 2026'); + expect(defaultFormatValue(value, 'quarter')).toBe('Q3 2026'); + expect(defaultFormatValue(value, 'halfYear')).toBe('H2 2026'); + expect(defaultFormatValue(value, 'year')).toBe('2026'); + expect( + defaultFormatValue({ date: '2026-02-01', scale: 'day' }, 'halfYear') + ).toBe('H1 2026'); + }); +}); + +describe('useCalendar', () => { + function Probe() { + const { value, setValue, month, setMonth, scale, isDateUnavailable } = + useCalendar(); + return ( +
+ {value ? value.getDate() : 'none'} + {month.getMonth()} + {scale} + + {String(isDateUnavailable(new Date(2026, 7, 1)))} + + + + +
+ ); + } + + it('reads the value, the view month, the scale and the predicate', () => { + render( + + + + ); + expect(screen.getByTestId('value')).toHaveTextContent('none'); + expect(screen.getByTestId('month')).toHaveTextContent('7'); + expect(screen.getByTestId('scale')).toHaveTextContent('day'); + expect(screen.getByTestId('blocked')).toHaveTextContent('true'); + }); + + it('commits through the same state the parts use', () => { + function Harness() { + const [value, setValue] = useState(null); + return ( + + + + + ); + } + const { container } = render(); + + fireEvent.click(screen.getByText('set')); + expect(screen.getByTestId('value')).toHaveTextContent('20'); + expect(dayCell(container, '20')).toHaveAttribute('data-selected'); + + fireEvent.click(screen.getByText('move')); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'Oct 2026' + ); + + fireEvent.click(screen.getByText('clear')); + expect(screen.getByTestId('value')).toHaveTextContent('none'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx new file mode 100644 index 000000000..a10cb933d --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx @@ -0,0 +1,361 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; + +const TODAY = new Date(2026, 7, 15); +const AUGUST = new Date(2026, 7, 1); + +function renderCalendar(ui?: React.ReactNode, props = {}) { + return render( + + {ui ?? } + + ); +} + +describe('CalendarPreview data-slot contract', () => { + it('exposes a slot for every element the default day view renders', () => { + const { container } = renderCalendar(); + expectSlots(container, [ + 'calendar-preview', + 'calendar-preview-days', + 'calendar-preview-header', + 'calendar-preview-prev-month', + 'calendar-preview-caption', + 'calendar-preview-next-month', + 'calendar-preview-grid', + 'calendar-preview-weeks', + 'calendar-preview-table', + 'calendar-preview-skeleton', + 'calendar-preview-weekday', + 'calendar-preview-day-trigger', + 'calendar-preview-day', + 'calendar-preview-day-number' + ]); + }); + + it('renders exactly the documented slots, and no others', () => { + const { container } = renderCalendar(); + const rendered = new Set( + Array.from(container.querySelectorAll('[data-slot]')) + .map(element => element.getAttribute('data-slot') ?? '') + .filter(name => name.startsWith('calendar-preview')) + ); + /* Fails on a typo or an undocumented addition as loudly as on a rename, + which is the point: slot names are semver-covered public API. */ + expect([...rendered].sort()).toEqual( + [ + 'calendar-preview', + 'calendar-preview-day', + 'calendar-preview-day-number', + 'calendar-preview-day-trigger', + 'calendar-preview-days', + 'calendar-preview-caption', + 'calendar-preview-grid', + 'calendar-preview-header', + 'calendar-preview-next-month', + 'calendar-preview-prev-month', + 'calendar-preview-skeleton', + 'calendar-preview-table', + 'calendar-preview-weekday', + 'calendar-preview-weeks' + ].sort() + ); + }); + + /* The inventory above renders the default grid, so a slot that only appears + under a prop cannot be caught by it. `showWeekNumber` shipped with two + elements carrying an empty `data-slot` for exactly that reason. */ + it('renders exactly the documented slots with the optional columns on', () => { + const { container } = renderCalendar( + + + + ); + const rendered = new Set( + Array.from(container.querySelectorAll('[data-slot]')) + .map(element => element.getAttribute('data-slot') ?? '') + .filter(name => name.startsWith('calendar-preview')) + ); + + expect([...rendered].sort()).toEqual( + [ + 'calendar-preview', + 'calendar-preview-day', + 'calendar-preview-day-number', + 'calendar-preview-day-trigger', + 'calendar-preview-days', + 'calendar-preview-grid', + 'calendar-preview-skeleton', + 'calendar-preview-table', + 'calendar-preview-week-number', + 'calendar-preview-week-number-header', + 'calendar-preview-weekday', + 'calendar-preview-weeks' + ].sort() + ); + }); + + /* No element may ship a blank slot: `[data-slot]` is the styling and + testing contract, and an empty one silently opts out of it. */ + it('never renders an empty data-slot', () => { + const { container } = renderCalendar( + + + + ); + + const blank = Array.from(container.querySelectorAll('[data-slot]')).filter( + element => element.getAttribute('data-slot')?.trim() === '' + ); + + expect(blank).toHaveLength(0); + }); + + it('exposes a slot for every element the two-month day view renders', () => { + const { container } = renderCalendar( + + ); + expectSlots(container, [ + 'calendar-preview', + 'calendar-preview-days', + 'calendar-preview-month-header', + 'calendar-preview-prev-month', + 'calendar-preview-month-header-caption', + 'calendar-preview-next-month', + 'calendar-preview-grid', + 'calendar-preview-table', + 'calendar-preview-weekday', + 'calendar-preview-day' + ]); + /* The single-month header is the one slot this layout must not render — + each month captions itself instead. */ + expect(getSlot(container, 'calendar-preview-header')).toBeNull(); + }); + + it('exposes the reset slot only when there is something to restore', () => { + const { container } = renderCalendar(undefined, { + defaultDate: new Date(2026, 7, 10), + defaultValue: new Date(2026, 7, 20) + }); + expect(getSlot(container, 'calendar-preview-reset')).not.toBeNull(); + }); + + it('exposes the footer slots when a footer is mounted', () => { + const { container } = renderCalendar( + <> + + Dates are inclusive + + ); + expectSlots(container, [ + 'calendar-preview-footer', + 'calendar-preview-footer-text' + ]); + }); + + it('exposes the day-info slot only where dateInfo returns something', () => { + const { container } = renderCalendar( + + (date.getDate() === 15 ? 'INFO' : null)} + /> + + ); + expect(getAllSlots(container, 'calendar-preview-day-info')).toHaveLength(1); + }); + + it('omits the day-info slot when no dateInfo is given', () => { + const { container } = renderCalendar(); + expect(getSlot(container, 'calendar-preview-day-info')).toBeNull(); + }); + + it('exposes the tooltip slot on hover when tooltips are enabled', async () => { + renderCalendar( + + (date.getDate() === 15 ? 'Fifteenth' : null)} + /> + + ); + const user = userEvent.setup(); + const day = screen.getByText('15').closest('button'); + await user.hover(day as HTMLButtonElement); + expect(await screen.findByText('Fifteenth')).toBeInTheDocument(); + expect( + getSlot(document.body, 'calendar-preview-day-tooltip') + ).not.toBeNull(); + }); + + it('exposes the caption scroller slots once it is opened', () => { + const { container } = renderCalendar( + + + + + + ); + const caption = getSlot(container, 'calendar-preview-caption'); + fireEvent.pointerDown(caption as HTMLElement); + fireEvent.click(caption as HTMLElement); + expectSlots(document.body, [ + 'calendar-preview-caption-positioner', + 'calendar-preview-caption-popup', + 'calendar-preview-caption-months', + 'calendar-preview-caption-month', + 'calendar-preview-caption-years', + 'calendar-preview-caption-year' + ]); + }); +}); + +describe('CalendarPreview state attributes', () => { + it('marks the day view with its scale and its inert states', () => { + const { container } = renderCalendar(undefined, { + disabled: true, + readOnly: true + }); + const days = getSlot(container, 'calendar-preview-days'); + expect(days).toHaveAttribute('data-scale', 'day'); + expect(days).toHaveAttribute('data-disabled', 'true'); + expect(days).toHaveAttribute('data-readonly', 'true'); + }); + + it('marks the day view busy while its grid is loading', () => { + const { container } = renderCalendar( + + + + + ); + expect(getSlot(container, 'calendar-preview-days')).toHaveAttribute( + 'data-busy', + 'true' + ); + expect(getSlot(container, 'calendar-preview-skeleton')).toHaveAttribute( + 'data-visible', + 'true' + ); + expect(getSlot(container, 'calendar-preview-table')).toHaveAttribute( + 'aria-busy', + 'true' + ); + }); + + it('carries the scale on the caption and on every cell', () => { + const { container } = renderCalendar(); + expect(getSlot(container, 'calendar-preview-caption')).toHaveAttribute( + 'data-scale', + 'day' + ); + for (const cell of getAllSlots(container, 'calendar-preview-day')) { + expect(cell).toHaveAttribute('data-scale', 'day'); + } + }); + + it('marks the selected cell, and only that one', () => { + const { container } = renderCalendar(undefined, { + defaultValue: new Date(2026, 7, 20) + }); + const selected = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-selected') + ); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveTextContent('20'); + }); + + it("marks today's cell", () => { + const { container } = renderCalendar(); + const today = getAllSlots(container, 'calendar-preview-day').filter(cell => + cell.hasAttribute('data-today') + ); + expect(today).toHaveLength(1); + expect(today[0]).toHaveTextContent('15'); + }); + + it('marks unavailable cells and leaves the rest unmarked', () => { + const { container } = renderCalendar(undefined, { + minDate: new Date(2026, 7, 10) + }); + const cells = getAllSlots(container, 'calendar-preview-day'); + const unavailable = cells.filter(cell => + cell.hasAttribute('data-unavailable') + ); + expect(unavailable.length).toBeGreaterThan(0); + expect(unavailable.length).toBeLessThan(cells.length); + expect(unavailable[unavailable.length - 1]).toHaveTextContent('9'); + }); + + it('renders no outside days by default', () => { + const { container } = renderCalendar(); + /* August 2026 starts on a Saturday, so a grid that showed outside days + would open with five of them. Reference A leaves those cells blank. */ + const outside = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-outside') + ); + expect(outside).toHaveLength(0); + }); + + it('marks the days that fall outside the displayed month when asked', () => { + const { container } = renderCalendar( + + + + ); + const outside = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-outside') + ); + expect(outside.length).toBeGreaterThan(0); + expect(outside[0]).not.toHaveAttribute('data-today'); + }); + + it('marks the focused cell as the draft until it is committed', () => { + const { container } = renderCalendar(); + expect( + getAllSlots(container, 'calendar-preview-day').filter(cell => + cell.hasAttribute('data-draft') + ) + ).toHaveLength(0); + + const day = screen.getByText('20').closest('button'); + fireEvent.focus(day as HTMLButtonElement); + + const drafted = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-draft') + ); + expect(drafted).toHaveLength(1); + expect(drafted[0]).toHaveTextContent('20'); + expect(drafted[0]).not.toHaveAttribute('data-selected'); + }); + + it('marks the active row in each caption column', () => { + const { container } = renderCalendar( + + + + + + ); + const caption = getSlot(container, 'calendar-preview-caption'); + expect(caption).toHaveAttribute('data-dropdown', 'true'); + fireEvent.pointerDown(caption as HTMLElement); + fireEvent.click(caption as HTMLElement); + + const activeMonth = getAllSlots( + document.body, + 'calendar-preview-caption-month' + ).filter(option => option.hasAttribute('data-active')); + expect(activeMonth).toHaveLength(1); + expect(activeMonth[0]).toHaveTextContent('Aug'); + + const activeYear = getAllSlots( + document.body, + 'calendar-preview-caption-year' + ).filter(option => option.hasAttribute('data-active')); + expect(activeYear).toHaveLength(1); + expect(activeYear[0]).toHaveTextContent('2026'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts index 5df3b3e0b..a461c0a60 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -7,10 +7,16 @@ import { endOfQuarterKey, endOfYearKey, epoch, + formatCaptionLabel, + formatDayLabel, + formatMonthLabel, isDayKey, monthFromName, monthOf, + monthShortNames, + monthStart, parseKey, + shiftMonths, startOfMonthKey, startOfQuarterKey, startOfYearKey, @@ -35,10 +41,12 @@ describe('dayKey', () => { expect(dayKey(instant, 'America/New_York')).toBe('2026-08-31'); }); - /* `new Date(0, 0, 1)` means 1900, so the far years are built by setFullYear. */ + /* `Date.UTC(0, 0, 1)` means 1900, so the far years are set explicitly. Built + in UTC, not local: at +14 a local-midnight year 10000 is year 9999 in UTC, + and the five-digit case below then has nothing to throw about. */ const atYear = (year: number): Date => { - const date = new Date(2000, 0, 1); - date.setFullYear(year, 0, 1); + const date = new Date(Date.UTC(2000, 0, 1)); + date.setUTCFullYear(year, 0, 1); return date; }; @@ -187,3 +195,70 @@ describe('monthFromName', () => { expect(monthFromName(name)).toBeNull(); }); }); + +describe('shiftMonths', () => { + it('moves whole months and lands on the first', () => { + expect(dayKey(shiftMonths(new Date(2026, 7, 15), 1))).toBe('2026-09-01'); + expect(dayKey(shiftMonths(new Date(2026, 7, 15), -1))).toBe('2026-07-01'); + expect(dayKey(shiftMonths(new Date(2026, 7, 15), 0))).toBe('2026-08-01'); + }); + + it('crosses a year boundary in both directions', () => { + expect(dayKey(shiftMonths(new Date(2026, 11, 10), 1))).toBe('2027-01-01'); + expect(dayKey(shiftMonths(new Date(2026, 0, 10), -1))).toBe('2025-12-01'); + }); + + /* Stepping from the 31st would otherwise clamp to the 28th and stay there. */ + it('does not drift when stepping repeatedly from a long month', () => { + let month = new Date(2026, 0, 31); + for (let step = 0; step < 3; step += 1) month = shiftMonths(month, 1); + expect(dayKey(month)).toBe('2026-04-01'); + }); +}); + +describe('monthStart', () => { + it('builds the first of a month from a 0-indexed month', () => { + expect(dayKey(monthStart(2026, 0))).toBe('2026-01-01'); + expect(dayKey(monthStart(2026, 11))).toBe('2026-12-01'); + }); +}); + +describe('label formatters', () => { + it('formats a day as DD/MM/YYYY', () => { + expect(formatDayLabel(new Date(2027, 4, 20))).toBe('20/05/2027'); + expect(formatDayLabel(new Date(2027, 0, 5))).toBe('05/01/2027'); + }); + + it('formats a month in short form', () => { + expect(formatMonthLabel(new Date(2027, 4, 20))).toBe('May 2027'); + expect(formatMonthLabel(new Date(2027, 8, 1))).toBe('Sep 2027'); + }); + + it('formats a caption with the month abbreviated', () => { + expect(formatCaptionLabel(new Date(2027, 8, 1))).toBe('Sep 2027'); + }); + + it('reads the labels in an explicit zone', () => { + const instant = new Date(Date.UTC(2026, 7, 31, 20, 0)); + expect(formatDayLabel(instant, 'Asia/Tokyo')).toBe('01/09/2026'); + expect(formatMonthLabel(instant, 'Asia/Tokyo')).toBe('Sep 2026'); + expect(formatCaptionLabel(instant, 'UTC')).toBe('Aug 2026'); + }); +}); + +describe('monthShortNames', () => { + it('lists twelve abbreviations, January first', () => { + const names = monthShortNames(); + expect(names).toHaveLength(12); + expect(names[0]).toBe('Jan'); + expect(names[11]).toBe('Dec'); + }); + + /* The caption's month column shows these, so the parser has to take them + back — the same contract the full names carry. */ + it('round-trips through monthFromName', () => { + monthShortNames().forEach((name, index) => { + expect(monthFromName(name)).toBe(index + 1); + }); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/scale.test.ts b/packages/raystack/components/calendar-preview/__tests__/scale.test.ts index 134e1d837..5cd4a7d01 100644 --- a/packages/raystack/components/calendar-preview/__tests__/scale.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/scale.test.ts @@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest'; import { anchorOf, + type CalendarPreviewScale, convertScale, isAvailable, isScale, periodOf, - SCALES, - type Scale + SCALES } from '../lib/scale'; const LEADING = false; @@ -148,14 +148,14 @@ describe('convertScale — every direction', () => { * cell is the period of the target scale containing that anchor, read at the * stated edge. */ - const leading: Record = { + const leading: Record = { day: '2026-08-15', month: '2026-08-01', quarter: '2026-07-01', halfYear: '2026-07-01', year: '2026-01-01' }; - const trailing: Record = { + const trailing: Record = { day: '2026-08-15', month: '2026-08-31', quarter: '2026-09-30', diff --git a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx new file mode 100644 index 000000000..7d841e979 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { + mergeProps, + Popover as PopoverPrimitive, + useRender +} from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ReactNode, useEffect, useRef } from 'react'; +import styles from './calendar-preview.module.css'; +import { + useCalendarPreviewContext, + useCalendarPreviewDaysContext +} from './calendar-preview-context'; +import { + formatCaptionLabel, + monthShortNames, + monthStart, + shiftMonths +} from './date-adapter'; + +/* Two elements, so two prop shapes: a plain caption is a `span`, one that + opens the scroller is a `button`. */ +export type CalendarPreviewCaptionProps = + | ({ dropdown?: false } & useRender.ComponentProps<'span'>) + | ({ dropdown: true } & useRender.ComponentProps<'button'>); + +/** + * The label above the grid. Children replace it entirely, so + * `Q3 2026` works. + * + * With `dropdown` it opens our own month and year scroller. No `Select` may be + * mounted here — one is what makes the popover dismissal loop return. Picking + * moves the view; it never selects a value. + */ +export function CalendarPreviewCaption(props: CalendarPreviewCaptionProps) { + return props.dropdown ? ( + + ) : ( + + ); +} + +CalendarPreviewCaption.displayName = 'CalendarPreview.Caption'; + +function useCaptionLabel(): ReactNode { + const { month, timeZone } = useCalendarPreviewContext( + 'CalendarPreview.Caption' + ); + const days = useCalendarPreviewDaysContext(); + const count = days?.numberOfMonths ?? 1; + if (count <= 1) return formatCaptionLabel(month, timeZone); + const last = shiftMonths(month, count - 1); + return `${formatCaptionLabel(month, timeZone)} – ${formatCaptionLabel(last, timeZone)}`; +} + +function CaptionLabel({ + dropdown: _dropdown, + className, + children, + render, + ref, + ...props +}: { dropdown?: false } & useRender.ComponentProps<'span'>) { + const { scale } = useCalendarPreviewContext('CalendarPreview.Caption'); + const label = useCaptionLabel(); + + return useRender({ + defaultTagName: 'span', + ref, + render, + props: mergeProps<'span'>( + { + className: cx(styles.caption, className), + 'data-slot': 'calendar-preview-caption', + 'data-scale': scale, + children: children ?? label + } as useRender.ComponentProps<'span'>, + props + ) + }); +} + +function CaptionDropdown({ + dropdown: _dropdown, + className, + children, + render, + ref, + ...props +}: { dropdown: true } & useRender.ComponentProps<'button'>) { + const { month, setMonth, yearRange, scale, disabled } = + useCalendarPreviewContext('CalendarPreview.Caption'); + const label = useCaptionLabel(); + + const activeMonth = month.getMonth(); + const activeYear = month.getFullYear(); + const years: number[] = []; + for (let year = yearRange.from; year <= yearRange.to; year += 1) { + years.push(year); + } + + return ( + + + {children ?? label} + + + + + ({ + key: name, + text: name, + active: index === activeMonth, + onSelect: () => setMonth(monthStart(activeYear, index)) + }))} + /> + ({ + key: String(year), + text: String(year), + active: year === activeYear, + onSelect: () => setMonth(monthStart(year, activeMonth)) + }))} + /> + + + + + ); +} + +interface CaptionOption { + key: string; + text: string; + active: boolean; + onSelect: () => void; +} + +function CaptionColumn({ + slot, + optionSlot, + label, + options +}: { + slot: string; + optionSlot: string; + label: string; + options: CaptionOption[]; +}) { + const activeRef = useRef(null); + + /* A twenty-year column otherwise opens scrolled to the wrong end. Optional + call: jsdom does not implement scrollIntoView. */ + useEffect(() => { + activeRef.current?.scrollIntoView?.({ block: 'center' }); + }, []); + + return ( +
+ {options.map(option => ( + + ))} +
+ ); +} 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..7dceef2a3 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { createContext, type ReactNode, useContext } from 'react'; +import type { DayKey } from './date-adapter'; +import type { CalendarPreviewScale } from './lib/scale'; + +/** What caused a value to change. */ +export type CalendarPreviewChangeReason = + | 'select' + | 'input' + | 'clear' + | 'reset' + | 'scale'; + +export interface CalendarPreviewChangeDetails { + /** What caused the change. */ + reason: CalendarPreviewChangeReason; + /** Both edges, month-end correct. At day scale they are the same day. */ + period: { start: DayKey; end: DayKey }; + /** + * The day acted on — never null, even when `value` is, so a clear still says + * which cell the user clicked. + */ + toDate: () => Date; +} + +/* Generic so a later phase's scale-aware arms carry a `CalendarPreviewScaleValue` without a + second context: stored as `unknown`, cast once at the hook boundary. */ +export interface CalendarPreviewContextValue { + value: Value; + /** `occasion` is the day acted on, which a cleared `value` cannot carry. */ + setValue: ( + value: Value, + reason: CalendarPreviewChangeReason, + occasion: Date + ) => void; + /** Read even when `value` is controlled. */ + defaultDate: Date | null | undefined; + /** A value reset — it never moves the view. */ + reset: () => void; + month: Date; + /** Never clamped by `minDate` / `maxDate`. */ + setMonth: (month: Date) => void; + yearRange: { from: number; to: number }; + scale: CalendarPreviewScale; + setScale: (scale: CalendarPreviewScale) => void; + isDateUnavailable: (date: Date) => boolean; + today: Date; + timeZone: string | undefined; + clearable: boolean; + disabled: boolean; + readOnly: boolean; +} + +const CalendarPreviewContext = + createContext | null>(null); + +export function CalendarPreviewProvider({ + value, + children +}: { + value: CalendarPreviewContextValue; + children: ReactNode; +}) { + return ( + {children} + ); +} + +/* `part` is the caller's display name, so the throw points at the element the + author wrote rather than at this file. */ +export function useCalendarPreviewContext( + part: string +): CalendarPreviewContextValue { + const context = useContext(CalendarPreviewContext); + if (!context) { + throw new Error(`${part} must be used within `); + } + return context as CalendarPreviewContextValue; +} + +/* `.Days` owns this rather than the root, so two day views in one tree cannot + disable each other's navigation. */ +export interface CalendarPreviewDaysContextValue { + numberOfMonths: number; + busy: boolean; + setBusy: (busy: boolean) => void; +} + +const CalendarPreviewDaysContext = + createContext(null); + +export function CalendarPreviewDaysProvider({ + value, + children +}: { + value: CalendarPreviewDaysContextValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useCalendarPreviewDaysContext(): CalendarPreviewDaysContextValue | null { + return useContext(CalendarPreviewDaysContext); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx new file mode 100644 index 000000000..8d8664ccf --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { useMemo, useState } from 'react'; +import styles from './calendar-preview.module.css'; +import { + type CalendarPreviewDaysContextValue, + CalendarPreviewDaysProvider, + useCalendarPreviewContext +} from './calendar-preview-context'; +import { CalendarPreviewGrid } from './calendar-preview-grid'; +import { CalendarPreviewHeader } from './calendar-preview-header'; + +export interface CalendarPreviewDaysProps + extends useRender.ComponentProps<'div'> { + /** + * How many months the grid shows side by side. + * @defaultValue 1 + */ + numberOfMonths?: number; +} + +/* Owns what the header and grid share, so two day views in one tree cannot + disable each other's navigation. */ +export function CalendarPreviewDays({ + numberOfMonths = 1, + className, + children, + render, + ref, + ...props +}: CalendarPreviewDaysProps) { + const { disabled, readOnly, scale } = useCalendarPreviewContext( + 'CalendarPreview.Days' + ); + const [busy, setBusy] = useState(false); + + const context = useMemo( + () => ({ numberOfMonths, busy, setBusy }), + [numberOfMonths, busy] + ); + + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.days, className), + 'data-slot': 'calendar-preview-days', + 'data-scale': scale, + 'data-disabled': disabled || undefined, + 'data-readonly': readOnly || undefined, + 'data-busy': busy || undefined, + /* Several months caption themselves inside the grid, so a `.Header` + here would be a second, redundant row. */ + children: children ?? ( + <> + {numberOfMonths <= 1 && } + + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); + + return ( + + {element} + + ); +} + +CalendarPreviewDays.displayName = 'CalendarPreview.Days'; 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..da6af402b --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { Flex } from '../flex'; +import { Text } from '../text'; +import styles from './calendar-preview.module.css'; + +export type CalendarPreviewFooterProps = ComponentProps; + +/* A bare string is wrapped in `Text` so the common case needs no knowledge of + the type scale; anything else renders as given. */ +export function CalendarPreviewFooter({ + className, + children, + ...props +}: CalendarPreviewFooterProps) { + return ( + + {typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} + + ); +} + +CalendarPreviewFooter.displayName = 'CalendarPreview.Footer'; 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..c7233f799 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -0,0 +1,532 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; +import { cx } from 'class-variance-authority'; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef +} from 'react'; +import { + type CustomComponents, + type DayButtonProps, + DayPicker, + type DayPickerProps, + type MonthCaptionProps, + type MonthGridProps, + type RootProps, + type WeekdayProps, + type WeekNumberHeaderProps, + type WeekNumberProps +} from 'react-day-picker'; +import { Skeleton } from '../skeleton'; +import { Tooltip } from '../tooltip'; +import styles from './calendar-preview.module.css'; +import { + useCalendarPreviewContext, + useCalendarPreviewDaysContext +} from './calendar-preview-context'; +import { + CalendarPreviewNextMonth, + CalendarPreviewPrevMonth +} from './calendar-preview-header'; +import { formatCaptionLabel, formatWeekdayLabel } from './date-adapter'; + +/* The only file that may import react-day-picker. It runs with + `hideNavigation` and `captionLayout='label'` so it never mounts a `Select`, + and the selection props come from root context rather than from + `CalendarPreviewGridProps` — which is what lets `...props` stay last. */ +/* Split in two on purpose. Every day button and its tooltip wrapper consume + the day-facing half, so it is memoized — an unstable value there re-renders + all 42 cells per month on any grid render. The root half carries + `rootProps`, a fresh rest-spread every render that cannot be memoized + without going stale; it has exactly one consumer, so its instability costs + one element instead of 42. */ +interface GridContextValue { + dateInfo?: (date: Date) => ReactNode; + tooltipMessages?: (date: Date) => ReactNode; + showTooltip: boolean; + loading: boolean; +} + +interface GridRootContextValue { + rootRender: useRender.ComponentProps<'div'>['render']; + rootRef: useRender.ComponentProps<'div'>['ref']; + rootProps: useRender.ComponentProps<'div'>; +} + +const GridContext = createContext(null); +const GridRootContext = createContext(null); + +function useGridContext(part: string): GridContextValue { + const context = useContext(GridContext); + if (!context) { + throw new Error(`${part} must be used within `); + } + return context; +} + +function useGridRootContext(part: string): GridRootContextValue { + const context = useContext(GridRootContext); + if (!context) { + throw new Error(`${part} must be used within `); + } + return context; +} + +export interface CalendarPreviewGridProps + extends useRender.ComponentProps<'div'> { + /** + * Always render six week rows, so the grid height never jumps between a + * 4-, 5- and 6-row month. + * + * On by default. Phases 3-4 put this calendar in a popover, where a grid + * that changes height on navigation resizes the surface under the user's + * cursor. Opt out with `fixedWeeks={false}` where the calendar is inline + * and the trailing blank row is not wanted. + * + * @defaultValue true + */ + fixedWeeks?: boolean; + /** + * Render the days either side of the month. + * + * Off, unlike the current `DatePicker`: reference A ends every grid on the + * last day of its month and leaves the leading cells blank. The cells are + * still rendered, so the week rows keep their shape — they are just empty. + * + * @defaultValue false + */ + showOutsideDays?: boolean; + /** Render a week-number column. */ + showWeekNumber?: boolean; + /** First day of the week, 0 (Sunday) to 6. */ + weekStartsOn?: DayPickerProps['weekStartsOn']; + /** Extra day modifiers, passed through to react-day-picker. */ + modifiers?: DayPickerProps['modifiers']; + /** Override react-day-picker's component slots. */ + components?: Partial; + /** + * Extra content for a day, rendered above the date number. + * + * A function, not a record: the record form keyed cells by a formatted + * string and silently missed every day once a `timeZone` shifted the key. + */ + dateInfo?: (date: Date) => ReactNode; + /** Whether day tooltips are shown at all. @defaultValue false */ + showTooltip?: boolean; + /** The tooltip for a day, or nothing. A function, for the same reason. */ + tooltipMessages?: (date: Date) => ReactNode; + /** Cover the grid with a skeleton and stop navigation. */ + loading?: boolean; +} + +export function CalendarPreviewGrid({ + fixedWeeks = true, + showOutsideDays = false, + showWeekNumber, + weekStartsOn, + modifiers, + components, + dateInfo, + showTooltip = false, + tooltipMessages, + loading = false, + className, + render, + ref, + /* Swallowed, not forwarded: `.Grid` renders the day cells from context, and + `props` is spread last into the day-picker root, so a stray child would + win over them and blank the calendar. `.Day` / `.Weekday` are `components` + overrides, not children. */ + children: _children, + ...props +}: CalendarPreviewGridProps) { + const { + value, + setValue, + month, + setMonth, + isDateUnavailable, + today, + timeZone, + clearable, + disabled, + readOnly + } = useCalendarPreviewContext('CalendarPreview.Grid'); + const days = useCalendarPreviewDaysContext(); + const setBusy = days?.setBusy; + + /* The header is a sibling, so loading has to reach their common parent for + navigation to go inert with it. */ + useEffect(() => { + if (!setBusy) return; + setBusy(loading); + return () => setBusy(false); + }, [loading, setBusy]); + + /* `dateInfo` and `tooltipMessages` are functions, so a consumer passing + inline arrows still invalidates this every render — which is why the docs + ask for them to be memoized at the call site. */ + const gridContext = useMemo( + () => ({ dateInfo, tooltipMessages, showTooltip, loading }), + [dateInfo, tooltipMessages, showTooltip, loading] + ); + + const gridRootContext: GridRootContextValue = { + rootRender: render, + rootRef: ref, + rootProps: props + }; + + const months = days?.numberOfMonths ?? 1; + + /* Several months have no single header to caption them, so each month + captions itself and `.Days` renders no `.Header` above. */ + const slots = useMemo( + () => ({ + Root: CalendarPreviewGridRoot, + MonthGrid: CalendarPreviewWeeks, + DayButton: CalendarPreviewDay, + Weekday: CalendarPreviewWeekday, + WeekNumber: CalendarPreviewWeekNumber, + WeekNumberHeader: CalendarPreviewWeekNumberHeader, + ...(months > 1 ? { MonthCaption: CalendarPreviewMonthCaption } : {}), + ...components + }), + [components, months] + ); + + /* No `readOnly` / `disabled` check here — the root's `setValue` owns it, so + every path in and out of the calendar inherits the same guard. */ + const handleSelect = useCallback( + (selected: Date | undefined, triggerDate: Date) => { + setValue(selected ?? null, selected ? 'select' : 'clear', triggerDate); + }, + [setValue] + ); + + /* `mode`, `required`, `selected` and `onSelect` stay on the elements below: + RDP discriminates its union on the literal `required`, which a `boolean` + cannot narrow, so both arms are written out rather than cast away. */ + const base = { + month, + onMonthChange: setMonth, + timeZone, + today, + hideNavigation: true, + captionLayout: 'label', + numberOfMonths: months, + formatters: GRID_FORMATTERS, + disabled: disabled ? true : isDateUnavailable, + fixedWeeks, + showOutsideDays, + showWeekNumber, + weekStartsOn, + modifiers, + components: slots, + className: cx(styles.grid, className), + 'data-slot': 'calendar-preview-grid', + 'data-readonly': readOnly || undefined, + classNames: GRID_CLASS_NAMES + } satisfies Omit & { + 'data-slot': string; + 'data-readonly'?: true; + }; + + return ( + + + {clearable ? ( + + ) : ( + + )} + + + ); +} + +CalendarPreviewGrid.displayName = 'CalendarPreview.Grid'; + +/* `` forwards only `className`, `style` and `data-*` to its root, + so `render`, `ref` and the consumer's props have to land here instead. */ +function CalendarPreviewGridRoot({ rootRef, ...rootProps }: RootProps) { + const { + rootRender, + rootRef: ref, + rootProps: extra + } = useGridRootContext('CalendarPreview.Grid'); + return useRender({ + defaultTagName: 'div', + ref, + render: rootRender, + props: mergeProps<'div'>(rootProps, extra) + }); +} + +function CalendarPreviewWeeks(props: MonthGridProps) { + const { loading } = useGridContext('CalendarPreview.Grid'); + const { readOnly } = useCalendarPreviewContext('CalendarPreview.Grid'); + return ( +
+
+ + + ); +} + +/* Three fixed grid columns rather than spacer elements: the empty nav track is + still reserved when a month carries no button, so every caption centres on + its own grid instead of drifting toward the buttonless side. */ +function CalendarPreviewMonthCaption({ + calendarMonth, + displayIndex, + /* The class react-day-picker passes here hides the caption, which is what + the single-month layout wants and this header must not be. */ + className: _className, + ...props +}: MonthCaptionProps) { + const { timeZone } = useCalendarPreviewContext('CalendarPreview.Grid'); + const days = useCalendarPreviewDaysContext(); + + return ( +
+ {displayIndex === 0 && ( + + )} + + {formatCaptionLabel(calendarMonth.date, timeZone)} + + {displayIndex === (days?.numberOfMonths ?? 1) - 1 && ( + + )} +
+ ); +} + +export interface CalendarPreviewDayProps + extends DayButtonProps, + Pick, 'render' | 'ref'> {} + +/* At day scale the draft is the roving-focus cell — arrowed to, not entered. + PR 5's scale-switch draft writes the same attribute. */ +export function CalendarPreviewDay({ + day, + modifiers, + className, + children, + render, + ref, + ...props +}: CalendarPreviewDayProps) { + const { scale, readOnly } = useCalendarPreviewContext('CalendarPreview.Day'); + const { dateInfo, tooltipMessages, showTooltip } = useGridContext( + 'CalendarPreview.Day' + ); + + /* Replacing react-day-picker's `DayButton` also replaces the effect it uses + to move DOM focus, which lives on that component rather than in the + library's keyboard handler. Without this, an arrow key moves RDP's focus + target and the `data-draft` marker while focus stays put — so the next + Enter commits the day the user navigated away from. */ + const buttonRef = useRef(null); + const mergedRef = useMergedRefs(buttonRef, ref); + + useEffect(() => { + if (modifiers.focused) buttonRef.current?.focus(); + }, [modifiers.focused]); + + const info = dateInfo?.(day.date); + const message = showTooltip ? tooltipMessages?.(day.date) : null; + + const button = useRender({ + defaultTagName: 'button', + ref: mergedRef, + render, + props: mergeProps<'button'>( + { + type: 'button', + className: cx( + styles['day-button'], + info != null && styles['day-button-with-info'], + className + ), + 'data-slot': 'calendar-preview-day', + 'data-scale': scale, + 'data-selected': modifiers.selected || undefined, + 'data-draft': (modifiers.focused && !modifiers.selected) || undefined, + 'data-unavailable': modifiers.disabled || undefined, + 'data-today': modifiers.today || undefined, + 'data-outside': day.outside || undefined, + children: ( + <> + {info != null && ( + + {info} + + )} + + {children} + + + ) + } as useRender.ComponentProps<'button'>, + props, + /* After the spread, unlike everything else here. RDP sets + `aria-disabled` per day and passes `undefined` for an available one, + which would erase this — and `readOnly` is root state that has to win + over a per-day value. It is not `disabled`: a read-only grid stays + focusable and arrow-navigable, which is the whole difference. */ + readOnly + ? ({ 'aria-disabled': true } as useRender.ComponentProps<'button'>) + : {} + ) + }); + + /* The wrapper is unconditional. Two reasons, both measured: a disabled + button fires no pointer events, so hanging the trigger on the day itself + hid exactly the tooltip a blocked day needs; and returning `button` bare + when there is no message changes the element type at that position, which + tears down the DOM node and drops focus the moment `showTooltip` or a + per-day message flips. */ + return ( + + + } + > + {button} + + {message != null && ( + + {message} + + )} + + ); +} + +CalendarPreviewDay.displayName = 'CalendarPreview.Day'; + +export interface CalendarPreviewWeekdayProps + extends WeekdayProps, + Pick, 'render' | 'ref'> {} + +export function CalendarPreviewWeekday({ + className, + render, + ref, + ...props +}: CalendarPreviewWeekdayProps) { + return useRender({ + defaultTagName: 'th', + ref, + render, + props: mergeProps<'th'>( + { + className: cx(styles.weekday, className), + 'data-slot': 'calendar-preview-weekday' + } as useRender.ComponentProps<'th'>, + props + ) + }); +} + +CalendarPreviewWeekday.displayName = 'CalendarPreview.Weekday'; + +/* `showWeekNumber` renders these two, and RFC 005 asks for a `data-slot` on + every rendered element. Overridden only to carry the slot — the classes + already arrive through `GRID_CLASS_NAMES`. */ +function CalendarPreviewWeekNumber({ week: _week, ...props }: WeekNumberProps) { + return
; +} + +function CalendarPreviewWeekNumberHeader(props: WeekNumberHeaderProps) { + return ; +} + +/* Three-letter names from the adapter rather than react-day-picker's + two-letter default, because the frames spell them `Sun Mon Tue`. + + English only, and deliberately so for now: there is no `locale` prop on this + family yet, so every `format()` call falls back to date-fns' `en-US`, and + the nav, reset and caption labels are hardcoded literals besides. Adding + `locale` means threading it through `date-adapter` and adding a label bag + for the literals — a change to make once, not one to half-make here. */ +const GRID_FORMATTERS: DayPickerProps['formatters'] = { + formatWeekdayName: date => formatWeekdayLabel(date) +}; + +/* month_caption is hidden, not removed: `.Header` owns the visible caption, + and RDP still labels each table through it. */ +const GRID_CLASS_NAMES: DayPickerProps['classNames'] = { + months: styles.months, + month: styles.month, + month_caption: styles['month-caption'], + caption_label: styles['caption-label'], + weeks: styles.weeks, + week: styles.week, + weekdays: styles.weekdays, + day: styles.day, + day_button: styles['day-button'], + weekday: styles.weekday, + today: styles.today, + outside: styles.outside, + disabled: styles.disabled, + selected: styles.selected, + hidden: styles.hidden, + week_number: styles['week-number'], + week_number_header: styles['week-number-header'] +}; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-header.tsx b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx new file mode 100644 index 000000000..15f00da58 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import type { ReactNode } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '~/icons'; +import { IconButton } from '../icon-button'; +import styles from './calendar-preview.module.css'; +import { CalendarPreviewCaption } from './calendar-preview-caption'; +import { + useCalendarPreviewContext, + useCalendarPreviewDaysContext +} from './calendar-preview-context'; +import { CalendarPreviewReset } from './calendar-preview-reset'; +import { shiftMonths } from './date-adapter'; + +export type CalendarPreviewHeaderProps = useRender.ComponentProps<'div'>; + +/* Single-month only, and source order is tab order, so the row needs no CSS + reordering. Several months caption themselves inside `.Grid`. */ +export function CalendarPreviewHeader({ + className, + children, + render, + ref, + ...props +}: CalendarPreviewHeaderProps) { + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.header, className), + 'data-slot': 'calendar-preview-header', + children: children ?? ( + <> + + + + + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); + + return element; +} + +CalendarPreviewHeader.displayName = 'CalendarPreview.Header'; + +export type CalendarPreviewNavProps = useRender.ComponentProps<'button'>; + +/* Never disabled by `minDate`: bounds limit selection, not navigation. */ +export function CalendarPreviewPrevMonth(props: CalendarPreviewNavProps) { + return ( + } + /> + ); +} + +CalendarPreviewPrevMonth.displayName = 'CalendarPreview.PrevMonth'; + +export function CalendarPreviewNextMonth(props: CalendarPreviewNavProps) { + return ( + } + /> + ); +} + +CalendarPreviewNextMonth.displayName = 'CalendarPreview.NextMonth'; + +interface NavButtonProps extends CalendarPreviewNavProps { + delta: number; + slot: string; + label: string; + icon: ReactNode; +} + +function CalendarPreviewNavButton({ + delta, + slot, + label, + icon, + className, + children, + render, + ref, + ...props +}: NavButtonProps) { + const { month, setMonth, disabled } = useCalendarPreviewContext( + 'CalendarPreview.Header' + ); + const days = useCalendarPreviewDaysContext(); + const inert = disabled || (days?.busy ?? false); + + return useRender({ + defaultTagName: 'button', + ref, + render: render ?? , + props: mergeProps<'button'>( + { + type: 'button', + className: cx(styles['nav-button'], className), + 'data-slot': slot, + 'aria-label': label, + disabled: inert, + onClick: () => setMonth(shiftMonths(month, delta)), + children: children ?? icon + } as useRender.ComponentProps<'button'>, + props + ) + }); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx new file mode 100644 index 000000000..a994fbb67 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { UndoIcon } from '~/icons'; +import { IconButton } from '../icon-button'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey } from './date-adapter'; + +export type CalendarPreviewResetProps = ComponentProps; + +/** + * Restores `defaultDate`, or clears the selection when it is `null`. A value + * reset, not a view reset — it leaves the + * visible month alone. Keyed off `defaultDate` rather than `defaultValue` so + * it still shows under a controlled `value`. + * + * With nothing to restore it stays mounted and disabled rather than + * unmounting: unmounting the focused element sends focus to ``, which + * strands a keyboard user mid-calendar, and removing a `flex: none` child + * from the header re-flows both nav buttons sideways every time the value + * crosses the default. + */ +export function CalendarPreviewReset({ + className, + children, + onClick, + ...props +}: CalendarPreviewResetProps) { + const { value, defaultDate, reset, disabled, readOnly, timeZone } = + useCalendarPreviewContext('CalendarPreview.Reset'); + + /* No `defaultDate` means the part has no job at all, which is a different + thing from having nothing to restore right now — `null` is a default. */ + if (defaultDate === undefined) return null; + + const restored = + defaultDate === null + ? value == null + : value != null && + dayKey(value, timeZone) === dayKey(defaultDate, timeZone); + + return ( + { + onClick?.(event); + reset(); + }} + {...props} + > + {children ?? } + + ); +} + +CalendarPreviewReset.displayName = 'CalendarPreview.Reset'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx new file mode 100644 index 000000000..d1bbe89cb --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -0,0 +1,324 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { useControlled } from '@base-ui/utils/useControlled'; +import { cx } from 'class-variance-authority'; +import { useCallback, useMemo } from 'react'; +import styles from './calendar-preview.module.css'; +import { + type CalendarPreviewChangeDetails, + type CalendarPreviewChangeReason, + type CalendarPreviewContextValue, + CalendarPreviewProvider +} from './calendar-preview-context'; +import { + dayKey, + formatDayLabel, + formatMonthLabel, + monthOf, + parseKey, + yearOf +} from './date-adapter'; +import { + type CalendarPreviewScale, + type CalendarPreviewScaleValue, + periodOf +} from './lib/scale'; + +const DEFAULT_YEAR_SPAN = 10; + +/* `defaultValue` is omitted because `HTMLAttributes` already declares it as a + form value, which is not what it means here. */ +export interface CalendarPreviewProps + extends Omit, 'defaultValue'> { + /** The selected day (controlled). */ + value?: Date | null; + /** The initially selected day (uncontrolled). */ + defaultValue?: Date | null; + /** Called when a day is committed or cleared. */ + onValueChange?: ( + value: Date | null, + details: CalendarPreviewChangeDetails + ) => void; + + /** The first month the grid displays (controlled). */ + month?: Date; + /** + * The month the grid opens on. + * @defaultValue the month of `value`, else `today` + */ + defaultMonth?: Date; + /** Called when the view moves. */ + onMonthChange?: (month: Date) => void; + /** + * The years the caption's year column offers. + * @defaultValue ten years either side of `today`, widened to cover any bound + */ + yearRange?: { from: number; to: number }; + + /** Earliest selectable day, inclusive. Never clamps navigation. */ + minDate?: Date; + /** Latest selectable day, inclusive. Never clamps navigation. */ + maxDate?: Date; + /** Reject individual days. Applied on top of `minDate` / `maxDate`. */ + isDateUnavailable?: (date: Date) => boolean; + + /** + * The day `.Reset` restores. Read even when `value` is controlled, which + * `defaultValue` is not — otherwise a controlled consumer never sees + * `.Reset`. + * + * `null` is a default of *nothing selected*, so `.Reset` clears. Omitting + * the prop is different: the part then has no job and does not render. + */ + defaultDate?: Date | null; + + /** + * The zone the grid reads days in. Forwarded to the grid; this family does + * no conversion of its own (RFC 005). + * + * Every `Date` prop and every `Date` handed back is therefore an **instant**, + * not a calendar day, and the calendar shows the day that instant falls on + * in this zone. At a far offset that is not the day the local fields spell: + * with `timeZone="Pacific/Niue"`, a `defaultMonth` of `new Date(2026, 7, 1)` + * is 31 July there, and the grid opens on July. Build `Date`s for a zoned + * calendar from a known instant — `new Date(Date.UTC(…))` — rather than from + * local calendar fields. + * + * `onValueChange` receives whatever the grid produced, which is a `TZDate` + * when this is set. It is a `Date` subclass carrying the same instant, so + * `getTime()` and comparisons are unaffected; only its field getters read in + * this zone. + */ + timeZone?: string; + /** + * Today, injectable so a calendar renders deterministically in tests. + * @defaultValue `new Date()` + */ + today?: Date; + /** + * Whether clicking the selected day deselects it. + * @defaultValue true + */ + clearable?: boolean; + /** + * Whether the whole calendar is inert and every day is disabled. + * @defaultValue false + */ + disabled?: boolean; + /** + * Whether the value can be read and navigated but not changed. + * @defaultValue false + */ + readOnly?: boolean; +} + +/* The formatter phase 5's value-rendering parts will use. Not yet reachable + from a prop: a `formatValue` override ships with the part that calls it, so + the two arrive together rather than the prop shipping inert. */ +export function defaultFormatValue( + value: Date | CalendarPreviewScaleValue, + scale: CalendarPreviewScale +): string { + const date = value instanceof Date ? value : parseKey(value.date); + if (scale === 'day') return formatDayLabel(date); + if (scale === 'month') return formatMonthLabel(date); + + const key = dayKey(date); + const year = yearOf(key); + if (scale === 'year') return String(year); + const month = monthOf(key); + if (scale === 'quarter') return `Q${Math.floor((month - 1) / 3) + 1} ${year}`; + return `H${month <= 6 ? 1 : 2} ${year}`; +} + +export function CalendarPreviewRoot({ + value: valueProp, + defaultValue = null, + onValueChange, + month: monthProp, + defaultMonth, + onMonthChange, + yearRange: yearRangeProp, + minDate, + maxDate, + isDateUnavailable: isDateUnavailableProp, + defaultDate, + timeZone, + today: todayProp, + clearable = true, + disabled = false, + readOnly = false, + className, + children, + render, + ref, + ...props +}: CalendarPreviewProps) { + const today = useMemo(() => todayProp ?? new Date(), [todayProp]); + + const [value, setValueUnwrapped] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: 'CalendarPreview', + state: 'value' + }); + + const [month, setMonthUnwrapped] = useControlled({ + controlled: monthProp, + /* `valueProp` before `defaultValue`: `defaultValue` is forced to null the + moment `value` is controlled, so reading it alone opened a controlled + calendar on today's month with the selection off-screen — against this + prop's own documented default. */ + default: defaultMonth ?? valueProp ?? defaultValue ?? today, + name: 'CalendarPreview', + state: 'month' + }); + + /* Uncontrolled until the scale switcher lands in PR 5. The state lives here + now so the parts and `useCalendar()` read it from one place either way. */ + const [scale, setScaleUnwrapped] = useControlled({ + controlled: undefined, + default: 'day', + name: 'CalendarPreview', + state: 'scale' + }); + + const setMonth = useCallback( + (next: Date) => { + setMonthUnwrapped(next); + onMonthChange?.(next); + }, + [setMonthUnwrapped, onMonthChange] + ); + + /* The inertness guard lives here rather than in the grid's click handler: + `useCalendar().setValue` and `reset()` reach this same function, and a + guard further out would leave both of them able to write to a calendar + the consumer asked to be read-only. */ + const setValue = useCallback( + ( + next: Date | null, + reason: CalendarPreviewChangeReason, + occasion: Date + ) => { + if (readOnly || disabled) return; + setValueUnwrapped(next); + onValueChange?.(next, { + reason, + period: periodOf(occasion, scale, timeZone), + toDate: () => occasion + }); + }, + [setValueUnwrapped, onValueChange, scale, timeZone, readOnly, disabled] + ); + + const setScale = useCallback( + (next: CalendarPreviewScale) => setScaleUnwrapped(next), + [setScaleUnwrapped] + ); + + /* `'reset'`, not `'select'`: restoring the default is not a pick, and a + consumer that logs or validates on selection needs to tell them apart. */ + const reset = useCallback(() => { + if (defaultDate === undefined) return; + /* A `null` default clears, and reports the day it cleared: `'reset'` would + claim a day was restored when none was. */ + if (defaultDate === null) { + if (value == null) return; + setValue(null, 'clear', value); + return; + } + setValue(defaultDate, 'reset', defaultDate); + }, [defaultDate, value, setValue]); + + /* Day-keys, not instants: a `minDate` carrying a time of day still leaves + its own day selectable, which the current family gets wrong. */ + const isDateUnavailable = useCallback( + (date: Date) => { + const key = dayKey(date, timeZone); + if (minDate && key < dayKey(minDate, timeZone)) return true; + if (maxDate && key > dayKey(maxDate, timeZone)) return true; + return isDateUnavailableProp?.(date) ?? false; + }, + [minDate, maxDate, isDateUnavailableProp, timeZone] + ); + + /* A year the user can never scroll to is a trap, so the span stretches to + cover the bounds even though bounds never clamp navigation. */ + const yearRange = useMemo(() => { + if (yearRangeProp) return yearRangeProp; + const base = today.getFullYear(); + const years = [base - DEFAULT_YEAR_SPAN, base + DEFAULT_YEAR_SPAN]; + if (minDate) years.push(minDate.getFullYear()); + if (maxDate) years.push(maxDate.getFullYear()); + return { from: Math.min(...years), to: Math.max(...years) }; + }, [yearRangeProp, today, minDate, maxDate]); + + const context = useMemo>( + () => ({ + value, + setValue, + defaultDate, + reset, + month, + setMonth, + yearRange, + scale, + setScale, + isDateUnavailable, + today, + timeZone, + clearable, + disabled, + readOnly + }), + [ + value, + setValue, + defaultDate, + reset, + month, + setMonth, + yearRange, + scale, + setScale, + isDateUnavailable, + today, + timeZone, + clearable, + disabled, + readOnly + ] + ); + + /* A real element, not a bare provider: `.Days` and `.Footer` are in-flow + siblings, and without a box of their own they inherit whatever the + surrounding layout does — sitting side by side inside a flex row. */ + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.root, className), + 'data-slot': 'calendar-preview', + 'data-scale': scale, + 'data-disabled': disabled || undefined, + 'data-readonly': readOnly || undefined, + children + } as useRender.ComponentProps<'div'>, + props + ) + }); + + return ( + } + > + {element} + + ); +} + +CalendarPreviewRoot.displayName = 'CalendarPreview'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css new file mode 100644 index 000000000..bff0e7482 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -0,0 +1,456 @@ +/* Hugs its content and stacks its parts, so `.Days` and `.Footer` sit one + above the other whatever the surrounding layout does. */ +.root { + display: flex; + flex-direction: column; + width: fit-content; +} + +/* The day view hugs its content — no reserved height, so the surface around it + can size itself instead of being padded out to a fixed number. */ +.days { + display: flex; + flex-direction: column; + width: fit-content; + padding: var(--rs-space-3); + border-radius: var(--rs-radius-4); + background: var(--rs-color-background-base-primary); + color: var(--rs-color-foreground-base-primary); +} + +.days[data-disabled] { + pointer-events: none; +} + +/* Inset by the gap a weekday label leaves inside its 40px cell. Aligning the + header to the column box instead would sit the caption visibly left of + "Sun", because the label is centred in the cell rather than flush to it. */ +.header { + display: flex; + align-items: center; + gap: var(--rs-space-2); + min-height: var(--rs-space-9); + margin-bottom: var(--rs-space-3); + padding-inline: var(--rs-space-3); +} + +/* The week-number column is a gutter, not a date column, so the caption starts + past it — aligned with Sunday rather than with the grid's outer edge. The + header cannot read `showWeekNumber`, which is a `.Grid` prop, so it asks the + rendered grid instead. */ +.days:has(.week-number-header) .header { + padding-inline-start: calc(var(--rs-space-10) + var(--rs-space-3)); +} + +.nav-button { + flex: none; + color: var(--rs-color-foreground-base-primary); +} + +.nav-button:disabled { + color: var(--rs-color-foreground-base-tertiary); + cursor: not-allowed; +} + +/* Takes the space left of the buttons, so the caption sits against the start + edge and the reset and two nav buttons group at the end — the single-month + header in reference A. Source order already matches, so nothing reorders. */ +.caption { + flex: 1; + text-align: start; + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); + user-select: none; + -webkit-user-select: none; +} + +/* The caption that opens the scroller is a filled chip, so the affordance + reads without an adjacent glyph. `flex: none` undoes `.caption`'s stretch — + the chip hugs its label rather than running to the nav buttons. */ +.caption-trigger { + display: inline-flex; + flex: none; + margin-inline-end: auto; + align-items: center; + justify-content: center; + gap: var(--rs-space-1); + padding: var(--rs-space-1) var(--rs-space-3); + border: none; + border-radius: var(--rs-radius-2); + background: var(--rs-color-background-neutral-secondary); + color: inherit; + font: inherit; + cursor: pointer; +} + +.caption-trigger:hover:not(:disabled) { + background: var(--rs-color-background-neutral-secondary-hover); +} + +.caption-trigger:focus-visible { + outline: var(--rs-focus-ring); + outline-offset: var(--rs-focus-ring-offset-inset); +} + +.caption-trigger:disabled { + color: var(--rs-color-foreground-base-tertiary); + cursor: not-allowed; +} + +.caption-positioner { + z-index: 1; +} + +/* Our own scroller, not a Select: two plain columns of buttons in a popup we + own, so nothing here portals a listbox the surrounding popover has to + recognise as inside itself. */ +.caption-popup { + display: flex; + gap: var(--rs-space-2); + padding: var(--rs-space-2); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-4); + background: var(--rs-color-background-base-primary); + box-shadow: var(--rs-shadow-lifted); +} + +.caption-column { + display: flex; + flex-direction: column; + gap: var(--rs-space-1); + overflow-y: auto; + /* Six rows of the day-cell height; taller lists scroll. */ + max-height: calc(var(--rs-space-10) * 6); +} + +.caption-option { + flex: none; + padding: var(--rs-space-2) var(--rs-space-3); + border: none; + border-radius: var(--rs-radius-2); + background: transparent; + color: var(--rs-color-foreground-base-primary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.caption-option:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.caption-option:focus-visible { + outline: var(--rs-focus-ring); + outline-offset: var(--rs-focus-ring-offset-inset); +} + +/* Grey, not accent: the scroller marks which month and year are in view, which + is a different thing from the selected day the grid fills in accent. */ +.caption-option[data-active] { + background: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + +.reset { + flex: none; +} + +/* Both nav tracks stay reserved whether or not this month draws a button, so + the caption centres on its grid rather than on the remaining space. The + track width is the size-3 IconButton the nav renders. */ +.month-header { + display: grid; + grid-template-columns: var(--rs-space-6) 1fr var(--rs-space-6); + align-items: center; + gap: var(--rs-space-2); + min-height: var(--rs-space-9); + margin-bottom: var(--rs-space-3); + padding-inline: var(--rs-space-3); +} + +.month-header-prev { + grid-column: 1; +} + +.month-header-caption { + grid-column: 2; + text-align: center; +} + +.month-header-next { + grid-column: 3; +} + +.grid { + position: relative; +} + +.months { + display: flex; + gap: var(--rs-space-4); +} + +.month { + display: flex; + flex-direction: column; +} + +/* `.Header` owns the visible caption. This one stays in the tree because + react-day-picker points each grid's accessible name at the month, and a + removed node would take that name with it. */ +.month-caption { + position: absolute; + /* A hairline box, not a spacing value — the space scale starts at 2px. */ + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.caption-label { + font: inherit; +} + +.weeks { + position: relative; +} + +/* The user-agent's 2px border-spacing would ring the grid, leaving the header + two pixels wider than the columns it sits above. */ +.weeks table { + border-spacing: 0; +} + +.week, +.weekdays { + display: flex; +} + +/* Cells size to the border box and drop the user-agent's table-cell padding, + so a heading and the days under it are the same 40px column. Content-box + would make the bordered day cell 4px wider than its heading, and the two + rows would drift a column apart by Saturday. */ +.weekday, +.day, +.week-number, +.week-number-header { + box-sizing: border-box; + padding: 0; +} + +.weekday { + display: flex; + align-items: center; + justify-content: center; + width: var(--rs-space-10); + height: var(--rs-space-10); + color: var(--rs-color-foreground-base-secondary); + text-align: center; + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.week-number, +.week-number-header { + display: flex; + align-items: center; + justify-content: center; + width: var(--rs-space-10); + height: var(--rs-space-10); + color: var(--rs-color-foreground-base-tertiary); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.day { + width: var(--rs-space-10); + height: var(--rs-space-10); + margin-bottom: var(--rs-space-1); + border: 1px solid transparent; + border-radius: var(--rs-radius-5); + background-color: var(--rs-color-background-base-primary); + color: var(--rs-color-foreground-base-primary); + text-align: center; + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.day:hover:not(.disabled):not(.outside):not(.selected) { + border-color: var(--rs-color-border-accent-emphasis-hover); + background-color: transparent; +} + +.selected { + background: var(--rs-color-background-accent-emphasis); +} + +.selected .day-button { + color: var(--rs-color-foreground-base-emphasis); +} + +.selected .day-button:active { + background-color: var(--rs-color-background-accent-emphasis-hover); +} + +.outside { + color: var(--rs-color-foreground-base-tertiary); +} + +.disabled { + opacity: 0.5; +} + +.hidden { + visibility: hidden; +} + +/* Sits between the cell and the button, so it has to pass the cell's box + through: `.day-button` inherits its radius and sizes against it. */ +.day-trigger { + display: block; + width: 100%; + height: 100%; + border-radius: inherit; +} + +.day-button { + position: relative; + display: grid; + place-content: center; + width: 100%; + height: 100%; + padding: unset; + border: none; + border-radius: inherit; + background: inherit; + color: inherit; + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + cursor: pointer; +} + +.day-button:not([data-unavailable]):not([data-outside]):not( + [data-selected] + ):active { + background-color: var(--rs-color-background-base-primary-hover); +} + +.day-button[data-unavailable] { + cursor: not-allowed; +} + +/* The same border hover paints, so the two rings match. An outline on the + button cannot: its radius grows outward and misses the arc by a pixel. */ +.day:has(.day-button:focus-visible) { + border-color: var(--rs-color-border-accent-emphasis); +} + +.day-button:focus-visible { + outline: none; +} + +/* Today's dot sits under the number, and rides up when a day carries info. */ +.day-button[data-today]::after { + content: ""; + position: absolute; + bottom: var(--rs-space-2); + left: 50%; + transform: translateX(-50%); + width: var(--rs-space-2); + height: var(--rs-space-2); + border-radius: var(--rs-radius-full); + background-color: var(--rs-color-background-accent-emphasis); +} + +.day-button[data-today][data-selected]::after { + background-color: var(--rs-color-foreground-base-emphasis); +} + +.day-button-with-info[data-today]::after { + bottom: var(--rs-space-1); +} + +.day-info { + position: absolute; + top: calc(-1 * var(--rs-space-1)); + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 100%; + pointer-events: none; +} + +.day-button[data-selected] .day-info, +.day-button[data-selected] .day-info * { + color: var(--rs-color-foreground-base-emphasis); +} + +.day-number { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.skeleton { + position: absolute; + inset: 0; + /* Solid backing so the grid underneath doesn't ghost through mid-fade. */ + background: var(--rs-color-background-base-primary); + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.skeleton[data-visible] { + opacity: 1; + visibility: visible; + /* Block clicks on the day grid underneath while loading. */ + pointer-events: auto; +} + +.skeleton-rows { + display: flex; + flex-direction: column; + gap: var(--rs-space-6); + padding-top: var(--rs-space-6); +} + +@media (prefers-reduced-motion: no-preference) { + .skeleton { + /* Exiting: fade opacity, then flip visibility after the fade. */ + transition: + opacity var(--rs-duration-fast) var(--rs-ease-out), + visibility 0s linear var(--rs-duration-fast); + } + + .skeleton[data-visible] { + /* Entering: visibility flips immediately, opacity fades in. */ + transition: opacity var(--rs-duration-fast) var(--rs-ease-out); + } +} + +.footer { + padding: var(--rs-space-3); + margin-top: var(--rs-space-2); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx new file mode 100644 index 000000000..fec4ab37d --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { CalendarPreviewCaption } from './calendar-preview-caption'; +import { CalendarPreviewDays } from './calendar-preview-days'; +import { CalendarPreviewFooter } from './calendar-preview-footer'; +import { + CalendarPreviewDay, + CalendarPreviewGrid, + CalendarPreviewWeekday +} from './calendar-preview-grid'; +import { + CalendarPreviewHeader, + CalendarPreviewNextMonth, + CalendarPreviewPrevMonth +} from './calendar-preview-header'; +import { CalendarPreviewReset } from './calendar-preview-reset'; +import { CalendarPreviewRoot } from './calendar-preview-root'; + +export const CalendarPreview = Object.assign(CalendarPreviewRoot, { + Days: CalendarPreviewDays, + Header: CalendarPreviewHeader, + PrevMonth: CalendarPreviewPrevMonth, + NextMonth: CalendarPreviewNextMonth, + Caption: CalendarPreviewCaption, + Reset: CalendarPreviewReset, + Grid: CalendarPreviewGrid, + Day: CalendarPreviewDay, + Weekday: CalendarPreviewWeekday, + Footer: CalendarPreviewFooter +}); diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 65fd88534..6c2962e15 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -1,26 +1,9 @@ -/* - * The one module in `calendar-preview/` allowed to call a date library. - * - * Everything else — `lib/scale.ts`, `lib/parse.ts` and, from phase 1, the parts - * — goes through this surface. Two reasons, both from RFC 005: - * - * 1. `dayjs.extend()` is import-order dependent. A module that formats a - * quarter works or throws depending on whether some *other* module has - * already run its `extend()`. date-fns has no plugin registry, so the - * failure class disappears — but only while a single file owns the - * imports. Adding a date-library import elsewhere in `calendar-preview/` - * re-opens it. - * 2. The library stays swappable. Base UI ships `./internals/temporal` with - * date-fns and Luxon adapters; adopting it later is an edit to this file - * and nothing else. - * - * Day values are timeless. The canonical form is a `DayKey` — `'YYYY-MM-DD'`, - * no time, no zone — and it is what crosses every boundary in `lib/`. Two - * day-keys compare correctly with `<`, `>` and `===`, so ordering a day - * against a bound needs no library call and cannot drift by a timezone. - */ +/* The only module in `calendar-preview/` that may import a date library. + Importing one elsewhere re-opens the import-order failure `dayjs.extend()` + caused, and costs the swappability the RFC keeps for Temporal. */ import { TZDate } from '@date-fns/tz'; import { + addMonths, endOfMonth, endOfQuarter, endOfYear, @@ -32,12 +15,8 @@ import { startOfYear } from 'date-fns'; -/** - * A timeless calendar day, `'YYYY-MM-DD'`. - * - * Lexicographic order is chronological order, which is why `lib/` compares - * these as strings rather than converting back to `Date`. - */ +/* Lexicographic order is chronological order, so `lib/` orders days as + strings — no library call, and no drift by timezone. */ export type DayKey = string; /* `uuuu`, not `yyyy`: `yyyy` is year-of-era, so JS year 0 formats as `'0001'` @@ -45,52 +24,33 @@ export type DayKey = string; const DAY_KEY_FORMAT = 'uuuu-MM-dd'; const DAY_KEY_SHAPE = /^\d{4}-\d{2}-\d{2}$/; -/* A fixed reference for `parse`; every token in DAY_KEY_FORMAT is supplied by - * the input, so no field is ever inherited from it. */ +/* Every token in DAY_KEY_FORMAT comes from the input, so no field is ever + inherited from this reference. */ const PARSE_REFERENCE = new Date(2000, 0, 1); -/** - * The calendar day `date` falls on, as a `DayKey`. - * - * With no `timeZone` the day is read from the date's own calendar fields — the - * day a user in the ambient zone sees. Pass `timeZone` to read the day in that - * zone instead; this is the call that keeps a grid rendered at `timeZone` from - * keying its cells one day off, which is the shape of the current family's - * tooltip/`dateInfo` bug. - */ +/* Passing `timeZone` is what keeps a grid rendered in that zone from keying + its cells a day off — the current family's tooltip/`dateInfo` bug. */ export function dayKey(date: Date, timeZone?: string): DayKey { - const calendarDate = timeZone ? new TZDate(date, timeZone) : date; - const key = format(calendarDate, DAY_KEY_FORMAT); + const key = format(zoned(date, timeZone), DAY_KEY_FORMAT); if (!DAY_KEY_SHAPE.test(key)) { throw new RangeError(`Day is outside the supported range: ${key}`); } return key; } -/** - * The instant `date` represents, in milliseconds. - * - * For ordering two *days*, compare their `dayKey`s instead — an epoch carries a - * time-of-day and a zone offset, and two Dates on the same calendar day can - * order either way. - */ +/* Not for ordering two days: an epoch carries a time and an offset, so two + Dates on the same calendar day can order either way. Compare dayKeys. */ export function epoch(date: Date): number { return date.getTime(); } -/** Whether `value` is a well-formed, real calendar day. `'2027-02-29'` is not. */ +/** Whether `value` is a real calendar day. `'2027-02-29'` is not. */ export function isDayKey(value: string): boolean { return DAY_KEY_SHAPE.test(value) && isValid(parseStrict(value)); } -/** - * A `DayKey` back to a `Date` at local midnight. - * - * Throws on anything that is not a real calendar day, including a well-shaped - * one that does not exist (`'2027-02-29'`). Callers handling typed input should - * gate on {@link isDayKey}, or build keys with {@link dayKeyFromParts}, rather - * than catching. - */ +/* Throws rather than returning null: callers handling typed input gate on + isDayKey or build with dayKeyFromParts, so a throw here is a real bug. */ export function parseKey(key: DayKey): Date { if (!DAY_KEY_SHAPE.test(key)) { throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(key)}`); @@ -102,18 +62,8 @@ export function parseKey(key: DayKey): Date { return date; } -/** - * A `DayKey` from calendar parts, or `null` when they name no real day. - * - * `month` is 1-12. This is the entry point for parsed user input: it validates - * against the actual calendar, so 31 April and 29 February in a common year are - * rejected rather than rolled forward the way a `Date` constructor would. - * - * The accepted year range is exactly what a four-digit key can hold, so this - * and {@link isDayKey} always agree. Rejecting a *two-digit* year is a shape - * question and belongs to whatever matches the input — `lib/parse.ts` pins the - * year at four digits before it gets here. - */ +/* `month` is 1-12. Validates against the real calendar, so 31 April is + rejected rather than rolled forward the way `new Date` would. */ export function dayKeyFromParts( year: number, month: number, @@ -125,37 +75,30 @@ export function dayKeyFromParts( return isDayKey(key) ? key : null; } -/** The first day of the month containing `key`. */ export function startOfMonthKey(key: DayKey): DayKey { return dayKey(startOfMonth(parseKey(key))); } -/** The last day of the month containing `key` — leap-correct by construction. */ export function endOfMonthKey(key: DayKey): DayKey { return dayKey(endOfMonth(parseKey(key))); } -/** The first day of the calendar quarter containing `key`. */ export function startOfQuarterKey(key: DayKey): DayKey { return dayKey(startOfQuarter(parseKey(key))); } -/** The last day of the calendar quarter containing `key`. */ export function endOfQuarterKey(key: DayKey): DayKey { return dayKey(endOfQuarter(parseKey(key))); } -/** The first day of the year containing `key`. */ export function startOfYearKey(key: DayKey): DayKey { return dayKey(startOfYear(parseKey(key))); } -/** The last day of the year containing `key`. */ export function endOfYearKey(key: DayKey): DayKey { return dayKey(endOfYear(parseKey(key))); } -/** The calendar year of `key`. */ export function yearOf(key: DayKey): number { return Number(key.slice(0, 4)); } @@ -165,14 +108,7 @@ export function monthOf(key: DayKey): number { return Number(key.slice(5, 7)); } -/** - * The month number (1-12) a written month name denotes, or `null`. - * - * Accepts the full and three-letter forms, case-insensitively — `'September'`, - * `'Sep'`, `'sep'`. The names come from date-fns' default locale, which is - * `en-US`; a localized picker will pass a locale through here rather than - * growing a second lookup somewhere else. - */ +/* Accepts both the full and three-letter forms. */ export function monthFromName(name: string): number | null { for (const pattern of ['MMMM', 'MMM']) { const date = parse(name, pattern, PARSE_REFERENCE); @@ -181,6 +117,51 @@ export function monthFromName(name: string): number | null { return null; } +/* Normalising to the first stops repeated navigation drifting: stepping on + from 31 January would clamp to the 28th and stay there. */ +export function shiftMonths(date: Date, delta: number): Date { + return addMonths(startOfMonth(date), delta); +} + +/** The first day of a calendar month. `monthIndex` is 0-11, as on `Date`. */ +export function monthStart(year: number, monthIndex: number): Date { + return new Date(year, monthIndex, 1); +} + +/* Day-first, matching what `lib/parse.ts` accepts, so a rendered value can be + typed straight back in. */ +export function formatDayLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'dd/MM/yyyy'); +} + +export function formatMonthLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'MMM yyyy'); +} + +/* Identical to formatMonthLabel today, kept separate because they answer + different questions: what the grid shows, versus what a value means. */ +export function formatCaptionLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'MMM yyyy'); +} + +/* Three letters, against react-day-picker's two-letter default — the frames + spell them `Sun Mon Tue`. */ +export function formatWeekdayLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'EEE'); +} + +/* Same locale as monthFromName parses, so the caption's month column and the + input parser cannot disagree about a name. */ +export function monthShortNames(): string[] { + return MONTH_INDEXES.map(index => format(new Date(2001, index, 1), 'MMM')); +} + +const MONTH_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + +function zoned(date: Date, timeZone?: string): Date { + return timeZone ? new TZDate(date, timeZone) : date; +} + function parseStrict(value: string): Date { return parse(value, DAY_KEY_FORMAT, PARSE_REFERENCE); } diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx new file mode 100644 index 000000000..a42f123e6 --- /dev/null +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -0,0 +1,24 @@ +export { CalendarPreview } from './calendar-preview'; +export type { CalendarPreviewCaptionProps } from './calendar-preview-caption'; +export type { + CalendarPreviewChangeDetails, + CalendarPreviewChangeReason +} from './calendar-preview-context'; +export type { CalendarPreviewDaysProps } from './calendar-preview-days'; +export type { CalendarPreviewFooterProps } from './calendar-preview-footer'; +export type { + CalendarPreviewDayProps, + CalendarPreviewGridProps, + CalendarPreviewWeekdayProps +} from './calendar-preview-grid'; +export type { + CalendarPreviewHeaderProps, + CalendarPreviewNavProps +} from './calendar-preview-header'; +export type { CalendarPreviewResetProps } from './calendar-preview-reset'; +export type { CalendarPreviewProps } from './calendar-preview-root'; +export type { + CalendarPreviewScale, + CalendarPreviewScaleValue +} from './lib/scale'; +export { type UseCalendarReturn, useCalendar } from './use-calendar'; diff --git a/packages/raystack/components/calendar-preview/lib/parse.ts b/packages/raystack/components/calendar-preview/lib/parse.ts index cd4cfb70b..a8b4e2f91 100644 --- a/packages/raystack/components/calendar-preview/lib/parse.ts +++ b/packages/raystack/components/calendar-preview/lib/parse.ts @@ -1,21 +1,14 @@ /* - * Turning a typed string into a `ScaleValue` — pure functions, no React, no UI. + * Turning a typed string into a `CalendarPreviewScaleValue` — pure functions, no React, no UI. * - * A scale-aware input accepts more than one shape of date, because the scales - * are what the user is choosing between: `20/05/2027` is a day, `Q4` is a - * quarter, `2025` is a year. Parsing therefore decides both the date *and* the - * scale, and the two are returned together for the same reason the committed - * value carries its scale. - * - * Recognition is deliberately narrow. Every accepted shape is pinned by a + * Recognition is deliberately narrow: every accepted shape is pinned by a * regular expression before any date maths runs, so a near-miss is rejected - * rather than coerced: the failure mode this replaces is dayjs' - * `customParseFormat`, which is lenient enough to read `20/05/27` as the year - * 27. Anything not listed below returns `null` and the caller keeps the field's - * previous value. + * rather than coerced. The failure mode this replaces is dayjs' + * `customParseFormat`, lenient enough to read `20/05/27` as the year 27. + * Anything unrecognised returns `null` and the caller keeps its previous value. */ import { dayKeyFromParts, isDayKey, monthFromName } from '../date-adapter'; -import { anchorOf, periodOf, type ScaleValue } from './scale'; +import { anchorOf, type CalendarPreviewScaleValue, periodOf } from './scale'; export interface ParseScaleInputOptions { /** @@ -57,13 +50,10 @@ const YEAR = /^(\d{4})$/; * | `H1 2026`, `H1` | `halfYear` | H1 is Jan-Jun, H2 is Jul-Dec | * | `2025` | `year` | exactly four digits | * - * **Year inference.** A bare `Q4`, `H1` or `May` resolves inside the *reference - * year* — the calendar year of `referenceDate`, which defaults to now. The rule - * never rolls forward: `Q1` typed in December 2026 is Q1 **2026**, not Q1 2027. - * A "next occurrence" rule would make the same typed string mean different - * years depending on the day it was typed — `Q1` would change meaning across - * midnight on 31 December, and a stored value would not agree with the string - * that produced it after a reload. A user who means another year types it. + * **Year inference.** A bare `Q4`, `H1` or `May` resolves inside the calendar + * year of `referenceDate`, and never rolls forward: `Q1` typed in December + * 2026 is Q1 **2026**. Rolling forward would make the same string mean + * different years either side of midnight on 31 December. * * The returned date is the period's edge under `trailing`, matching what * clicking that period in the calendar would commit — so typing `Q4 2026` and @@ -76,7 +66,7 @@ const YEAR = /^(\d{4})$/; export function parseScaleInput( input: string, options: ParseScaleInputOptions = {} -): ScaleValue | null { +): CalendarPreviewScaleValue | null { const { referenceDate, trailing = false } = options; const text = input.trim().replace(/\s+/g, ' '); if (text === '') return null; @@ -122,24 +112,19 @@ export function parseScaleInput( return null; } -/** The explicit year when the input carried one, else the reference year. */ function yearFrom(matched: string | undefined, reference?: Date): number { if (matched !== undefined) return Number(matched); return (reference ?? new Date()).getFullYear(); } -/** - * The value for the period of `scale` that starts in `year`-`month`. - * - * `month` is the period's first month, so the first of it always exists and - * always lands inside the period — the edge maths is then `scale.ts`'s. - */ +/* `month` is the period's first month, so the first of it always exists and + always lands inside the period — the edge maths is then `scale.ts`'s. */ function at( year: number, month: number, - scale: ScaleValue['scale'], + scale: CalendarPreviewScaleValue['scale'], trailing: boolean -): ScaleValue | null { +): CalendarPreviewScaleValue | null { const inside = dayKeyFromParts(year, month, 1); if (inside === null) return null; return { date: anchorOf(periodOf(inside, scale), trailing), scale }; diff --git a/packages/raystack/components/calendar-preview/lib/scale.ts b/packages/raystack/components/calendar-preview/lib/scale.ts index 4dfb23d0a..4702a1e64 100644 --- a/packages/raystack/components/calendar-preview/lib/scale.ts +++ b/packages/raystack/components/calendar-preview/lib/scale.ts @@ -19,7 +19,12 @@ import { } from '../date-adapter'; /** The granularities a value can be selected at. */ -export type Scale = 'day' | 'month' | 'quarter' | 'halfYear' | 'year'; +export type CalendarPreviewScale = + | 'day' + | 'month' + | 'quarter' + | 'halfYear' + | 'year'; /** * A committed selection: a concrete day, plus what that day *means*. @@ -28,9 +33,9 @@ export type Scale = 'day' | 'month' | 'quarter' | 'halfYear' | 'year'; * `{ date: '2026-08-31', scale: 'month' }` still reads back as August 2026 with * no calendar mounted — see RFC 005, "The value carries its scale". */ -export interface ScaleValue { +export interface CalendarPreviewScaleValue { date: DayKey; - scale: Scale; + scale: CalendarPreviewScale; } /** The inclusive day span a period covers. */ @@ -40,7 +45,7 @@ export interface Period { } /** Every scale, finest first. */ -export const SCALES: readonly Scale[] = [ +export const SCALES: readonly CalendarPreviewScale[] = [ 'day', 'month', 'quarter', @@ -48,8 +53,7 @@ export const SCALES: readonly Scale[] = [ 'year' ]; -/** Whether `value` is one of the five scales. */ -export function isScale(value: string): value is Scale { +export function isScale(value: string): value is CalendarPreviewScale { return (SCALES as readonly string[]).includes(value); } @@ -59,8 +63,12 @@ export function isScale(value: string): value is Scale { * `halfYear` is ours to derive — no date library has it. H1 is January to June, * H2 is July to December. */ -export function periodOf(date: Date | DayKey, scale: Scale): Period { - const key = toKey(date); +export function periodOf( + date: Date | DayKey, + scale: CalendarPreviewScale, + timeZone?: string +): Period { + const key = toKey(date, timeZone); switch (scale) { case 'day': return { start: key, end: key }; @@ -94,25 +102,23 @@ export function anchorOf(period: Period, trailing: boolean): DayKey { } /** - * Re-read a value at a different scale. - * - * One rule, every direction: take the value's date as the anchor, find the - * period of the target scale that contains it, and emit that period's edge per - * `trailing`. + * Re-read a value at a different scale: take its date as the anchor, find the + * period of the target scale containing it, emit that period's edge. * * Converting outward is lossy and does not undo. `2026-08-15` at `'day'` - * becomes `2026-01-01` at `'year'` when leading, and converting that back to - * `'day'` yields `2026-01-01`, not the original — the anchor is all that - * survives. Converting to the scale a value already carries is idempotent for - * any value sitting on its own period's edge, which is what every function - * here emits. + * becomes `2026-01-01` at `'year'` when leading, and back at `'day'` stays + * `2026-01-01` — the anchor is all that survives. */ export function convertScale( - value: ScaleValue, - to: Scale, - trailing: boolean -): ScaleValue { - return { date: anchorOf(periodOf(value.date, to), trailing), scale: to }; + value: CalendarPreviewScaleValue, + to: CalendarPreviewScale, + trailing: boolean, + timeZone?: string +): CalendarPreviewScaleValue { + return { + date: anchorOf(periodOf(value.date, to, timeZone), trailing), + scale: to + }; } /** @@ -125,24 +131,25 @@ export function convertScale( * (emits 30 Sep) are available while H1 2026 (emits 30 Jun) is not. The two * rules coincide whenever `trailing` is false. * - * `min` and `max` are inclusive; either may be omitted for an open bound. They - * limit selection only — navigation is never clamped. + * `min` and `max` are inclusive and limit selection only — navigation is + * never clamped. */ export function isAvailable( value: Date | DayKey, - scale: Scale, + scale: CalendarPreviewScale, trailing: boolean, min?: Date | DayKey, - max?: Date | DayKey + max?: Date | DayKey, + timeZone?: string ): boolean { - const produced = anchorOf(periodOf(value, scale), trailing); - if (min !== undefined && produced < toKey(min)) return false; - if (max !== undefined && produced > toKey(max)) return false; + const produced = anchorOf(periodOf(value, scale, timeZone), trailing); + if (min !== undefined && produced < toKey(min, timeZone)) return false; + if (max !== undefined && produced > toKey(max, timeZone)) return false; return true; } -function toKey(date: Date | DayKey): DayKey { - if (typeof date !== 'string') return dayKey(date); +function toKey(date: Date | DayKey, timeZone?: string): DayKey { + if (typeof date !== 'string') return dayKey(date, timeZone); if (!isDayKey(date)) { throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(date)}`); } diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx new file mode 100644 index 000000000..d45b24943 --- /dev/null +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useCalendarPreviewContext } from './calendar-preview-context'; +import type { CalendarPreviewScale } from './lib/scale'; + +export interface UseCalendarReturn { + value: Date | null; + /** Commit a day, or clear with `null`. Emits `onValueChange`. */ + setValue: (value: Date | null) => void; + /* Read-only until the scale switcher lands in phase 5. Exposing a setter + now would be a public API we cannot take back if the switcher reshapes + it; adding one later is additive. */ + scale: CalendarPreviewScale; + month: Date; + /** Bounds never clamp the view. */ + setMonth: (month: Date) => void; + isDateUnavailable: (date: Date) => boolean; +} + +/** + * The enclosing `CalendarPreview`'s state, for building parts the library does + * not ship. Deliberately narrow — everything returned here is semver-covered. + */ +export function useCalendar(): UseCalendarReturn { + const { value, setValue, scale, month, setMonth, isDateUnavailable } = + useCalendarPreviewContext('useCalendar'); + + return { + value, + /* A null commit is a clear, and the day acted on is the day being + cleared. Reporting `'select'` with `new Date()` broke the context's + documented promise that `toDate()` is the day acted on — it handed back + today, which is a day nobody touched. */ + setValue: next => + next === null + ? setValue(null, 'clear', value ?? new Date()) + : setValue(next, 'select', next), + scale, + month, + setMonth, + isDateUnavailable + }; +} diff --git a/packages/raystack/icons/__tests__/bundle.test.ts b/packages/raystack/icons/__tests__/bundle.test.ts index b94cb5d5b..317c37247 100644 --- a/packages/raystack/icons/__tests__/bundle.test.ts +++ b/packages/raystack/icons/__tests__/bundle.test.ts @@ -4,14 +4,14 @@ import { join, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; /** - * `icons/icons.tsx` holds all 31 keys in one module, and a consumer must still + * `icons/icons.tsx` holds all 32 keys in one module, and a consumer must still * pay only for the keys it imports. This test is what keeps that true. * * Per-key removal from a single module depends on the `/*#__PURE__*\/` * annotation on every `createIcon(…)` call, and on nothing in the module having * a side effect. It also fails on any aggregate icon map, whether a merged * `{ ...defaultIcons, ...overrides }` in `IconProvider`, or a runtime - * `ICON_NAMES` array, because either puts all 31 icons in every bundle. + * `ICON_NAMES` array, because either puts all 32 icons in every bundle. */ /** vitest runs with the package root as the cwd. */ diff --git a/packages/raystack/icons/icons.tsx b/packages/raystack/icons/icons.tsx index 4f655e019..ca4ff3c05 100644 --- a/packages/raystack/icons/icons.tsx +++ b/packages/raystack/icons/icons.tsx @@ -1,6 +1,6 @@ 'use client'; -// The 31 icons Apsara's own components draw: the one place that pairs a key +// The 32 icons Apsara's own components draw: the one place that pairs a key // with a drawing. A key names the job or the glyph, never the library, so // changing icon library is an edit to this file and nothing else. // @@ -38,6 +38,7 @@ import { Sun, Table, TriangleAlert, + Undo2, X } from 'lucide-react'; import { createIcon } from './create-icon'; @@ -102,6 +103,8 @@ export const StopIcon = /*#__PURE__*/ createIcon('StopIcon', Square); export const SuccessIcon = /*#__PURE__*/ createIcon('SuccessIcon', CircleCheck); export const SunIcon = /*#__PURE__*/ createIcon('SunIcon', Sun); export const TableIcon = /*#__PURE__*/ createIcon('TableIcon', Table); +/** Restores a value to its default — the calendar's reset. */ +export const UndoIcon = /*#__PURE__*/ createIcon('UndoIcon', Undo2); export const WarningIcon = /*#__PURE__*/ createIcon( 'WarningIcon', TriangleAlert diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 83539e135..0dc65b745 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -20,6 +20,25 @@ export { type DateRange, RangePicker } from './components/calendar'; +export { + CalendarPreview, + type CalendarPreviewCaptionProps, + type CalendarPreviewChangeDetails, + type CalendarPreviewChangeReason, + type CalendarPreviewDayProps, + type CalendarPreviewDaysProps, + type CalendarPreviewFooterProps, + type CalendarPreviewGridProps, + type CalendarPreviewHeaderProps, + type CalendarPreviewNavProps, + type CalendarPreviewProps, + type CalendarPreviewResetProps, + type CalendarPreviewScale, + type CalendarPreviewScaleValue, + type CalendarPreviewWeekdayProps, + type UseCalendarReturn, + useCalendar +} from './components/calendar-preview'; export { Callout } from './components/callout'; export { Chat, diff --git a/packages/raystack/vitest.config.mjs b/packages/raystack/vitest.config.mjs index 440627a88..6579d1bfa 100644 --- a/packages/raystack/vitest.config.mjs +++ b/packages/raystack/vitest.config.mjs @@ -6,6 +6,10 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], globals: true, + /* Pinned so date tests do not depend on the machine's zone. Without it the + suite fails east of UTC+9, where a local-midnight year 10000 is still + year 9999 in UTC. CI passes only because its runners are UTC. */ + env: { TZ: 'UTC' }, css: { modules: { classNameStrategy: 'stable'