From 70384e6dd009363fb2aa28a570d53184230d80d5 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 4 Sep 2026 08:20:55 +0530 Subject: [PATCH 1/5] feat: CalendarPreview root and inline calendar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of RFC 005. Delivers the inline day view — a root that owns the state and eight parts that render it. No popover, no input: `.Trigger`, `.Content` and `.Input` land in PR 3. The root renders no DOM of its own. It holds value, view month and scale through `useControlled`, and hands them to the parts through a part-aware context hook whose generic value is stored as `unknown` and cast once, at the hook boundary. A part used outside the root throws a message naming the part the author actually wrote, not this file. Two things the RFC singles out: `.Reset` is a **value** reset, keyed off `defaultDate`. It renders only when there is something to restore — `defaultDate` is set and the value differs — and it leaves the visible month alone. `defaultDate` is a separate prop from `defaultValue` precisely because `useControlled` ignores `defaultValue` once `value` is passed, so a controlled consumer would otherwise never see the part at all. `.Caption`'s dropdown is **our own scroller**: two columns of plain buttons in a popup we own. No `Select` is mounted anywhere in this component — asserted, not asserted-to — which is what keeps the popover-dismissal loop `use-picker-popover.ts` spends 185 lines suppressing from coming back. Picking from it moves the view; it never selects a value. Everything else worth naming: - Bounds limit **selection**, never navigation. `minDate`/`maxDate` and `isDateUnavailable` disable cells; the nav buttons and the caption scroller still move the view wherever the user wants. Bounds compare as day-keys, so a `minDate` carrying a time of day still makes its own day selectable — the current family compares instants and silently disables it - `dateInfo` and `tooltipMessages` are now functions. The record form keyed cells by a formatted string and silently missed every day once a `timeZone` shifted the key. Info still renders above the date number - `.Grid` is the only file importing react-day-picker. It runs with `hideNavigation` and `captionLayout='label'`, and `mode`, `selected`, `onSelect`, `required`, `month`, `onMonthChange` and `timeZone` come from context rather than props — none is in `CalendarPreviewGridProps`, so nothing is force-overridden after the consumer's spread and spread-last holds for the first time in this family - Cells carry `data-selected`, `data-draft`, `data-unavailable`, `data-today`, `data-outside` and `data-scale` beside their slot. At day scale the draft is the roving-focus cell — arrowed to, not yet entered - `useCalendar()` ships from the barrel beside `useTour` and the other six, returning value, scale, view month, their setters and the availability predicate. Nothing more: what it returns is semver-covered Zero `slotProps`, zero `biome-ignore`, no `forwardRef`, `` throughout, every part spreads `...props` last, and the CSS carries no `Todo: var does not exist`. `components/calendar/` is untouched. 100% statement, function and line coverage on the new directory, 99.6% of branches — the one uncovered branch is a defensive guard in the root's `reset`, unreachable while `.Reset` is the only caller. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/calendar-preview.test.tsx | 956 ++++++++++++++++++ .../__tests__/data-slots.test.tsx | 274 +++++ .../__tests__/date-adapter.test.ts | 72 ++ .../calendar-preview-caption.tsx | 213 ++++ .../calendar-preview-context.tsx | 141 +++ .../calendar-preview-days.tsx | 80 ++ .../calendar-preview-footer.tsx | 47 + .../calendar-preview-grid.tsx | 391 +++++++ .../calendar-preview-header.tsx | 134 +++ .../calendar-preview-reset.tsx | 56 + .../calendar-preview-root.tsx | 281 +++++ .../calendar-preview.module.css | 372 +++++++ .../calendar-preview/calendar-preview.tsx | 30 + .../calendar-preview/date-adapter.ts | 55 +- .../components/calendar-preview/index.tsx | 21 + .../calendar-preview/use-calendar.tsx | 50 + packages/raystack/index.tsx | 19 + 17 files changed, 3191 insertions(+), 1 deletion(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx create mode 100644 packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-caption.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-context.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-days.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-footer.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-grid.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-header.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-reset.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-root.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview.module.css create mode 100644 packages/raystack/components/calendar-preview/calendar-preview.tsx create mode 100644 packages/raystack/components/calendar-preview/index.tsx create mode 100644 packages/raystack/components/calendar-preview/use-calendar.tsx diff --git a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx new file mode 100644 index 000000000..b17b1620f --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -0,0 +1,956 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import { defaultFormatValue } from '../calendar-preview-root'; +import { CalendarPreview as CalendarPreviewFromBarrel } from '../index'; +import { useCalendar } from '../use-calendar'; + +const TODAY = new Date(2026, 7, 15); +const AUGUST = new Date(2026, 7, 1); + +function renderCalendar(ui?: React.ReactNode, props = {}) { + return render( + + {ui ?? } + + ); +} + +/** + * The day button for a day of the displayed month, ignoring the outside days. + * + * Matched on the day-number slot rather than the cell's text, so a cell + * carrying `dateInfo` above the number still resolves. + */ +function dayCell(container: HTMLElement, day: string): HTMLElement { + const match = getAllSlots(container, 'calendar-preview-day').find( + cell => + getSlot(cell, 'calendar-preview-day-number')?.textContent === day && + !cell.hasAttribute('data-outside') + ); + if (!match) throw new Error(`No cell for day ${day}`); + return match; +} + +function openCaption(container: HTMLElement): void { + const caption = getSlot(container, 'calendar-preview-caption') as HTMLElement; + fireEvent.pointerDown(caption); + fireEvent.click(caption); +} + +describe('CalendarPreview root', () => { + it('renders an inline calendar from the root and the day view alone', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-days')).toBeInTheDocument(); + expect(getSlot(container, 'calendar-preview-grid')).toBeInTheDocument(); + }); + + it('throws a message naming the part when used outside a root', () => { + /* React logs the thrown error before it propagates; silence it so the + expected throw does not look like a failure in the run output. */ + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + expect(() => render()).toThrow( + 'CalendarPreview.Days must be used within ' + ); + error.mockRestore(); + }); + + it('commits a clicked day and reports the period and the day acted on', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { onValueChange }); + + fireEvent.click(dayCell(container, '20')); + + expect(onValueChange).toHaveBeenCalledTimes(1); + const [value, details] = onValueChange.mock.calls[0]; + expect(value).toEqual(new Date(2026, 7, 20)); + expect(details.reason).toBe('select'); + expect(details.period).toEqual({ start: '2026-08-20', end: '2026-08-20' }); + expect(details.toDate()).toEqual(new Date(2026, 7, 20)); + }); + + it('clears on a second click when clearable, and reports the day acted on', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { + defaultValue: new Date(2026, 7, 20), + onValueChange + }); + + fireEvent.click(dayCell(container, '20')); + + const [value, details] = onValueChange.mock.calls[0]; + expect(value).toBeNull(); + expect(details.reason).toBe('clear'); + expect(details.toDate()).toEqual(new Date(2026, 7, 20)); + }); + + it('keeps the selection when clearable is false', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { + defaultValue: new Date(2026, 7, 20), + clearable: false, + onValueChange + }); + + fireEvent.click(dayCell(container, '20')); + + expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2026, 7, 20)); + expect(dayCell(container, '20')).toHaveAttribute('data-selected'); + }); + + it('renders with no value when clearing is switched off', () => { + const { container } = renderCalendar(undefined, { clearable: false }); + expect( + getAllSlots(container, 'calendar-preview-day').filter(cell => + cell.hasAttribute('data-selected') + ) + ).toHaveLength(0); + }); + + it('commits nothing while readOnly', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { + readOnly: true, + onValueChange + }); + fireEvent.click(dayCell(container, '20')); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('disables every day when the root is disabled', () => { + const { container } = renderCalendar(undefined, { disabled: true }); + for (const cell of getAllSlots(container, 'calendar-preview-day')) { + expect(cell).toHaveAttribute('data-unavailable'); + } + }); +}); + +describe('CalendarPreview selection bounds', () => { + it('disables days outside minDate and maxDate but leaves the rest alone', () => { + const { container } = renderCalendar(undefined, { + minDate: new Date(2026, 7, 10), + maxDate: new Date(2026, 7, 20) + }); + expect(dayCell(container, '9')).toHaveAttribute('data-unavailable'); + expect(dayCell(container, '10')).not.toHaveAttribute('data-unavailable'); + expect(dayCell(container, '20')).not.toHaveAttribute('data-unavailable'); + expect(dayCell(container, '21')).toHaveAttribute('data-unavailable'); + }); + + it('treats a bound carrying a time of day as covering that whole day', () => { + const { container } = renderCalendar(undefined, { + minDate: new Date(2026, 7, 10, 23, 59) + }); + expect(dayCell(container, '10')).not.toHaveAttribute('data-unavailable'); + }); + + it('rejects individual days through isDateUnavailable', () => { + const { container } = renderCalendar(undefined, { + isDateUnavailable: (date: Date) => date.getDate() === 12 + }); + expect(dayCell(container, '12')).toHaveAttribute('data-unavailable'); + expect(dayCell(container, '13')).not.toHaveAttribute('data-unavailable'); + }); + + it('does not commit a day that is out of bounds', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { + minDate: new Date(2026, 7, 10), + onValueChange + }); + fireEvent.click(dayCell(container, '9')); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + /* The behavioural change the RFC calls out: `startMonth`/`endMonth` used to + clamp how far the user could navigate. Bounds now limit selection only. */ + it('never stops navigation at minDate', () => { + const { container } = renderCalendar(undefined, { + minDate: new Date(2026, 7, 1), + maxDate: new Date(2026, 7, 31) + }); + const prev = getSlot(container, 'calendar-preview-prev-month'); + expect(prev).not.toBeDisabled(); + + fireEvent.click(prev as HTMLElement); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'July 2026' + ); + fireEvent.click(prev as HTMLElement); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'June 2026' + ); + }); + + it('never stops navigation at maxDate', () => { + const { container } = renderCalendar(undefined, { + minDate: new Date(2026, 7, 1), + maxDate: new Date(2026, 7, 31) + }); + const next = getSlot(container, 'calendar-preview-next-month'); + expect(next).not.toBeDisabled(); + + fireEvent.click(next as HTMLElement); + fireEvent.click(next as HTMLElement); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'October 2026' + ); + }); + + it('still disables the days it navigated to when they are out of bounds', () => { + const { container } = renderCalendar(undefined, { + minDate: new Date(2026, 7, 1) + }); + fireEvent.click( + getSlot(container, 'calendar-preview-prev-month') as HTMLElement + ); + for (const cell of getAllSlots(container, 'calendar-preview-day')) { + if (!cell.hasAttribute('data-outside')) { + expect(cell).toHaveAttribute('data-unavailable'); + } + } + }); +}); + +describe('CalendarPreview month navigation', () => { + it('steps the view a month at a time', () => { + const { container } = renderCalendar(); + fireEvent.click( + getSlot(container, 'calendar-preview-next-month') as HTMLElement + ); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'September 2026' + ); + }); + + it('reports every move through onMonthChange', () => { + const onMonthChange = vi.fn(); + const { container } = renderCalendar(undefined, { onMonthChange }); + fireEvent.click( + getSlot(container, 'calendar-preview-prev-month') as HTMLElement + ); + expect(onMonthChange).toHaveBeenCalledWith(new Date(2026, 6, 1)); + }); + + it('does not move a controlled month on its own', () => { + const onMonthChange = vi.fn(); + const { container } = renderCalendar(undefined, { + month: AUGUST, + onMonthChange + }); + fireEvent.click( + getSlot(container, 'calendar-preview-next-month') as HTMLElement + ); + expect(onMonthChange).toHaveBeenCalledWith(new Date(2026, 8, 1)); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'August 2026' + ); + }); + + it('steps from the first month back and forth without drifting', () => { + const { container } = renderCalendar(undefined, { + defaultMonth: new Date(2026, 0, 31) + }); + const next = getSlot( + container, + 'calendar-preview-next-month' + ) as HTMLElement; + fireEvent.click(next); + fireEvent.click(next); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'March 2026' + ); + }); + + it('shows several months side by side and captions the span', () => { + const { container } = renderCalendar( + + ); + expect(getAllSlots(container, 'calendar-preview-table')).toHaveLength(2); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'August 2026 – September 2026' + ); + }); +}); + +describe('CalendarPreview.Reset', () => { + it('does not render without a defaultDate', () => { + const { container } = renderCalendar(undefined, { + defaultValue: new Date(2026, 7, 20) + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeNull(); + }); + + it('does not render when the value already equals the defaultDate', () => { + const { container } = renderCalendar(undefined, { + defaultDate: new Date(2026, 7, 20), + defaultValue: new Date(2026, 7, 20) + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeNull(); + }); + + it('renders once the value differs from the defaultDate', () => { + const { container } = renderCalendar(undefined, { + defaultDate: new Date(2026, 7, 20), + defaultValue: new Date(2026, 7, 10) + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeInTheDocument(); + }); + + it('renders when there is a defaultDate and no value at all', () => { + const { container } = renderCalendar(undefined, { + defaultDate: new Date(2026, 7, 20) + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeInTheDocument(); + }); + + it('restores the defaultDate on click', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { + 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][0]).toEqual(new Date(2026, 7, 20)); + expect(dayCell(container, '20')).toHaveAttribute('data-selected'); + /* Gone again, because there is no longer anything to restore. */ + expect(getSlot(container, 'calendar-preview-reset')).toBeNull(); + }); + + /* `defaultValue` is ignored by `useControlled` once `value` is passed, which + is exactly why the reset target is its own prop. */ + it('renders and resets under a controlled value', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar(undefined, { + defaultDate: new Date(2026, 7, 20), + value: new Date(2026, 7, 10), + onValueChange + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeInTheDocument(); + + fireEvent.click( + getSlot(container, 'calendar-preview-reset') as HTMLElement + ); + expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2026, 7, 20)); + }); + + it('is a value reset, not a view reset', () => { + const onMonthChange = vi.fn(); + const { container } = renderCalendar(undefined, { + defaultDate: new Date(2026, 7, 20), + defaultValue: new Date(2026, 7, 10), + onMonthChange + }); + fireEvent.click( + getSlot(container, 'calendar-preview-next-month') as HTMLElement + ); + onMonthChange.mockClear(); + + fireEvent.click( + getSlot(container, 'calendar-preview-reset') as HTMLElement + ); + + expect(onMonthChange).not.toHaveBeenCalled(); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'September 2026' + ); + }); + + it('is inert while readOnly', () => { + const { container } = renderCalendar(undefined, { + defaultDate: new Date(2026, 7, 20), + readOnly: true + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeDisabled(); + }); +}); + +describe('CalendarPreview.Caption', () => { + it('labels the displayed month', () => { + const { container } = renderCalendar(); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'August 2026' + ); + }); + + it('lets children replace the computed label, as Tour.Title does', () => { + const { container } = renderCalendar( + + + Q3 2026 + + + ); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'Q3 2026' + ); + }); + + it('is not a button unless it opens the scroller', () => { + const { container } = renderCalendar(); + expect(getSlot(container, 'calendar-preview-caption')?.tagName).toBe( + 'SPAN' + ); + }); + + /* The reason the scroller is ours: a `Select` portal is what the deleted + `use-picker-popover.ts` spent 185 lines teaching a popover to recognise. */ + it('mounts no Select anywhere when the scroller is open', () => { + const { container } = renderCalendar( + + + + + + ); + openCaption(container); + + expect( + getSlot(document.body, 'calendar-preview-caption-popup') + ).toBeInTheDocument(); + expect(document.body.querySelectorAll('select')).toHaveLength(0); + expect(screen.queryAllByRole('combobox')).toHaveLength(0); + expect(screen.queryAllByRole('listbox')).toHaveLength(0); + expect( + document.body.querySelectorAll('[data-slot^="select"]') + ).toHaveLength(0); + }); + + it('moves the view when a month is picked, without selecting anything', () => { + const onValueChange = vi.fn(); + const { container } = renderCalendar( + + + + + + , + { onValueChange } + ); + openCaption(container); + + const march = getAllSlots( + document.body, + 'calendar-preview-caption-month' + ).find(option => option.textContent === 'March'); + fireEvent.click(march as HTMLElement); + + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'March 2026' + ); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('moves the view when a year is picked', () => { + const { container } = renderCalendar( + + + + + + + ); + openCaption(container); + + const year = getAllSlots( + document.body, + 'calendar-preview-caption-year' + ).find(option => option.textContent === '2030'); + fireEvent.click(year as HTMLElement); + + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'August 2030' + ); + }); + + it('offers ten years either side of today by default', () => { + const { container } = renderCalendar( + + + + + + ); + openCaption(container); + const years = getAllSlots( + document.body, + 'calendar-preview-caption-year' + ).map(option => option.textContent); + expect(years[0]).toBe('2016'); + expect(years[years.length - 1]).toBe('2036'); + }); + + /* A bound the year column cannot reach would be a trap, so the default span + stretches to cover it — without limiting navigation either way. */ + it('stretches the default year span to cover a distant bound', () => { + const { container } = renderCalendar( + + + + + , + { maxDate: new Date(2050, 0, 1) } + ); + openCaption(container); + const years = getAllSlots( + document.body, + 'calendar-preview-caption-year' + ).map(option => option.textContent); + expect(years[years.length - 1]).toBe('2050'); + }); + + it('honours an explicit yearRange', () => { + const { container } = renderCalendar( + + + + + , + { yearRange: { from: 2025, to: 2027 } } + ); + openCaption(container); + expect( + getAllSlots(document.body, 'calendar-preview-caption-year') + ).toHaveLength(3); + }); +}); + +describe('CalendarPreview.Grid', () => { + it('mounts no Select and no navigation of its own', () => { + const { container } = renderCalendar(); + expect(container.querySelectorAll('select')).toHaveLength(0); + expect(container.querySelectorAll('.rdp-nav')).toHaveLength(0); + expect(screen.queryByLabelText('Choose the Month')).toBeNull(); + }); + + it('keeps the month accessible to a screen reader without a second caption', () => { + const { container } = renderCalendar(); + const grid = container.querySelector('[role="grid"]'); + expect(grid).toHaveAttribute( + 'aria-label', + expect.stringContaining('August') + ); + expect(getSlot(container, 'calendar-preview-caption')).toBeInTheDocument(); + }); + + it('renders dateInfo above the date number', () => { + const { container } = renderCalendar( + + (date.getDate() === 15 ? 'INFO' : null)} + /> + + ); + const cell = dayCell(container, '15'); + const parts = Array.from(cell.children).map(child => + child.getAttribute('data-slot') + ); + expect(parts).toEqual([ + 'calendar-preview-day-info', + 'calendar-preview-day-number' + ]); + }); + + it('shows no tooltip when tooltips are off', async () => { + renderCalendar( + + 'Never shown'} /> + + ); + expect(screen.queryByText('Never shown')).toBeNull(); + }); + + it('disables navigation while the grid is loading', () => { + const { container } = renderCalendar( + + + + + ); + expect(getSlot(container, 'calendar-preview-prev-month')).toBeDisabled(); + expect(getSlot(container, 'calendar-preview-next-month')).toBeDisabled(); + }); + + it('leaves navigation alone once loading finishes', () => { + const { container, rerender } = render( + + + + + + + ); + rerender( + + + + + + + ); + expect( + getSlot(container, 'calendar-preview-prev-month') + ).not.toBeDisabled(); + }); + + it('forwards weekStartsOn to the grid', () => { + const { container } = renderCalendar( + + + + ); + const first = getAllSlots(container, 'calendar-preview-weekday')[0]; + expect(first).toHaveTextContent('Mo'); + }); + + it('lets a consumer wrap the day slot through components', () => { + const { container } = renderCalendar( + + ( + + ) + }} + /> + + ); + expect(dayCell(container, '15')).toHaveAttribute('data-custom', 'true'); + }); + + it('spreads consumer props onto the grid root, last', () => { + const { container } = renderCalendar( + + + + ); + const grid = getSlot(container, 'calendar-preview-grid'); + expect(grid).toHaveAttribute('id', 'my-grid'); + expect(grid).toHaveClass('mine'); + }); +}); + +describe('CalendarPreview.Footer', () => { + it('renders a string', () => { + const { container } = renderCalendar( + Dates are inclusive + ); + const footer = getSlot(container, 'calendar-preview-footer'); + expect(footer).toHaveTextContent('Dates are inclusive'); + expect( + getSlot(container, 'calendar-preview-footer-text') + ).toBeInTheDocument(); + }); + + it('renders any node as given', () => { + const { container } = renderCalendar( + + + + ); + 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('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('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( + 'October 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..44c858907 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx @@ -0,0 +1,274 @@ +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-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', + '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-day', + 'calendar-preview-day-number', + '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() + ); + }); + + 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('marks the days that fall outside the displayed month', () => { + const { container } = renderCalendar(); + const outside = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-outside') + ); + /* August 2026 starts on a Saturday, so the grid opens with outside days. */ + 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('August'); + + 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 c16d607b9..374b5ccd7 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, + monthNames, monthOf, + monthStart, parseKey, + shiftMonths, startOfMonthKey, startOfQuarterKey, startOfYearKey, @@ -164,3 +170,69 @@ 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 spelled out', () => { + expect(formatCaptionLabel(new Date(2027, 8, 1))).toBe('September 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('August 2026'); + }); +}); + +describe('monthNames', () => { + it('lists twelve names, January first', () => { + const names = monthNames(); + expect(names).toHaveLength(12); + expect(names[0]).toBe('January'); + expect(names[11]).toBe('December'); + }); + + /* The caption column and the input parser must never disagree about a name. */ + it('round-trips through monthFromName', () => { + monthNames().forEach((name, index) => { + expect(monthFromName(name)).toBe(index + 1); + }); + }); +}); 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..333a63273 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx @@ -0,0 +1,213 @@ +'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, + monthNames, + monthStart, + shiftMonths +} from './date-adapter'; + +/* + * The two forms take different props because they are different elements: a + * plain caption is a `span`, and 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 — the displayed month, or the span of months when + * more than one is shown. Children replace the computed label entirely, so + * `Q3 2026` works. + * + * With `dropdown`, it becomes a button that opens a two-column month and year + * scroller. The scroller is ours: no `Select` is mounted anywhere in this + * component, which is what keeps the popover-dismissal loop the current family + * fights from coming back. Picking from it moves the view; it never selects a + * value. + */ +export function CalendarPreviewCaption(props: CalendarPreviewCaptionProps) { + return props.dropdown ? ( + + ) : ( + + ); +} + +CalendarPreviewCaption.displayName = 'CalendarPreview.Caption'; + +/** The caption text: one month, or the first and last of several. */ +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); + + /* Bring the current row into view when the scroller opens — a twenty-year + * column otherwise arrives scrolled to the wrong end. Optional-called + * because 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..2fb95e43f --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -0,0 +1,141 @@ +'use client'; + +import { createContext, type ReactNode, useContext } from 'react'; +import type { DayKey } from './date-adapter'; +import type { Scale, ScaleValue } from './lib/scale'; + +/** What caused a value to change. */ +export type CalendarPreviewChangeReason = + | 'select' + | 'input' + | 'clear' + | 'scale'; + +export interface CalendarPreviewChangeDetails { + /** What caused the change. */ + reason: CalendarPreviewChangeReason; + /** + * Both edges of the period the change occasioned, as `'YYYY-MM-DD'`. + * + * Month-end correct — February 2028 ends `2028-02-29`. At day scale the two + * edges are the same day. + */ + period: { start: DayKey; end: DayKey }; + /** + * The day this change was made from, as a `Date`. + * + * On a select it is the new value. On a clear it is the day that was clicked + * to deselect, so a consumer can always tell which cell the user acted on — + * it is never null, even when `value` is. + */ + toDate: () => Date; +} + +/** + * Everything the parts read. One object, owned by the root. + * + * `Value` is generic so the scale-aware arms in a later phase can carry a + * `ScaleValue` without a second context; it is stored as `unknown` on the + * context and cast once, at the hook boundary — the shape `Combobox` uses. + */ +export interface CalendarPreviewContextValue { + /** The committed value. */ + value: Value; + /** Commit a value and emit `onValueChange`. `occasion` is the day acted on. */ + setValue: ( + value: Value, + reason: CalendarPreviewChangeReason, + occasion: Date + ) => void; + /** The reset target. Read even when `value` is controlled. */ + defaultDate: Date | undefined; + /** Restore `defaultDate`. A value reset — it never moves the view. */ + reset: () => void; + /** The first month the grid displays. */ + month: Date; + /** Move the view. Never clamped by `minDate` / `maxDate`. */ + setMonth: (month: Date) => void; + /** The years the caption's year column offers. */ + yearRange: { from: number; to: number }; + /** The granularity the value is committed at. */ + scale: Scale; + setScale: (scale: Scale) => void; + /** `true` when the date is out of bounds or the consumer rejected it. */ + isDateUnavailable: (date: Date) => boolean; + /** Today, injectable so tests are not clock-dependent. */ + today: Date; + /** Forwarded to the grid. No conversion is done here. */ + timeZone: string | undefined; + /** Whether a committed value can be deselected back to `null`. */ + clearable: boolean; + disabled: boolean; + readOnly: boolean; + /** Renders a value for display. Used by the trigger and input parts. */ + formatValue: (value: Date | ScaleValue, scale: Scale) => string; +} + +const CalendarPreviewContext = + createContext | null>(null); + +export function CalendarPreviewProvider({ + value, + children +}: { + value: CalendarPreviewContextValue; + children: ReactNode; +}) { + return ( + {children} + ); +} + +/** + * The root's state, or a throw naming the part that asked for it. + * + * `part` is the display name of the caller, so the error points at the element + * the author actually 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; +} + +/** + * State shared between `.Header` and `.Grid`, provided by their common parent. + * + * `.Days` owns it rather than the root so two day views in one tree cannot + * disable each other's navigation. Absent when a part is used outside `.Days`. + */ +export interface CalendarPreviewDaysContextValue { + numberOfMonths: number; + /** Whether the grid inside this `.Days` is loading. */ + busy: boolean; + setBusy: (busy: boolean) => void; +} + +const CalendarPreviewDaysContext = + createContext(null); + +export function CalendarPreviewDaysProvider({ + value, + children +}: { + value: CalendarPreviewDaysContextValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** The enclosing `.Days` state, or `null` when there is no `.Days` above. */ +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..ca8fb6ff5 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -0,0 +1,80 @@ +'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; +} + +/** + * The day view: a header and a grid. Hugs its content, so the surface around + * it is never padded out to a fixed height the way the current calendar is. + * + * Owns the state the header and the grid share — how many months are shown, + * and whether the grid is loading — 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, + children: children ?? ( + <> + + + + ) + } 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..6967bd823 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx @@ -0,0 +1,47 @@ +'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; + +/** + * The row below the calendar. + * + * A bare string is wrapped in `Text` so the common case reads as + * `Dates are inclusive` + * without the caller having to know 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..261b32324 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -0,0 +1,391 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { + createContext, + type ReactNode, + useContext, + useEffect, + useMemo +} from 'react'; +import { + type CustomComponents, + type DayButtonProps, + DayPicker, + type DayPickerProps, + type MonthGridProps, + type RootProps, + type WeekdayProps +} 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'; + +/* + * The only file in `calendar-preview/` that imports react-day-picker. + * + * RDP earns its place for the day grid alone — roving tabindex, week + * construction, outside days, locale weekday order. What changes is the + * boundary: it runs with `hideNavigation` and `captionLayout='label'`, so it + * never mounts a `Select`, and `mode`, `selected`, `onSelect`, `required`, + * `month`, `onMonthChange` and `timeZone` come from root context rather than + * from props. None of them is in `CalendarPreviewGridProps`, so nothing has to + * be force-overridden after the consumer's spread — `...props` really is last. + */ + +/** Props the grid shares with the cells it renders. */ +interface GridContextValue { + dateInfo?: (date: Date) => ReactNode; + tooltipMessages?: (date: Date) => ReactNode; + showTooltip: boolean; + loading: boolean; + rootRender: useRender.ComponentProps<'div'>['render']; + rootRef: useRender.ComponentProps<'div'>['ref']; + rootProps: useRender.ComponentProps<'div'>; +} + +const GridContext = createContext(null); + +function useGridContext(part: string): GridContextValue { + const context = useContext(GridContext); + 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. */ + fixedWeeks?: boolean; + /** + * Render the days either side of the month. + * @defaultValue true + */ + 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, which 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, + showOutsideDays = true, + showWeekNumber, + weekStartsOn, + modifiers, + components, + dateInfo, + showTooltip = false, + tooltipMessages, + loading = false, + className, + render, + ref, + ...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 sits beside the grid, not inside it, so the loading state has + * to travel up to their common parent for navigation to go inert with it. */ + useEffect(() => { + if (!setBusy) return; + setBusy(loading); + return () => setBusy(false); + }, [loading, setBusy]); + + const gridContext: GridContextValue = { + dateInfo, + tooltipMessages, + showTooltip, + loading, + rootRender: render, + rootRef: ref, + rootProps: props + }; + + const slots = useMemo( + () => ({ + Root: CalendarPreviewGridRoot, + MonthGrid: CalendarPreviewWeeks, + DayButton: CalendarPreviewDay, + Weekday: CalendarPreviewWeekday, + ...components + }), + [components] + ); + + const handleSelect = (selected: Date | undefined, triggerDate: Date) => { + if (readOnly || disabled) return; + setValue(selected ?? null, selected ? 'select' : 'clear', triggerDate); + }; + + /* + * Everything outside the selection arm. `mode`, `required`, `selected` and + * `onSelect` stay on the elements below: react-day-picker discriminates its + * props union on the literal `required`, which a `boolean` cannot narrow, so + * the two arms are written out rather than cast away. The union is contained + * here and reaches no consumer. + */ + const base = { + month, + onMonthChange: setMonth, + timeZone, + today, + hideNavigation: true, + captionLayout: 'label', + numberOfMonths: days?.numberOfMonths ?? 1, + disabled: disabled ? true : isDateUnavailable, + fixedWeeks, + showOutsideDays, + showWeekNumber, + weekStartsOn, + modifiers, + components: slots, + className: cx(styles.grid, className), + 'data-slot': 'calendar-preview-grid', + classNames: GRID_CLASS_NAMES + } satisfies Omit & { + 'data-slot': string; + }; + + return ( + + {clearable ? ( + + ) : ( + + )} + + ); +} + +CalendarPreviewGrid.displayName = 'CalendarPreview.Grid'; + +/* + * `render`, `ref` and the consumer's remaining props reach the root here + * rather than on ``, which forwards only `className`, `style` and + * `data-*` to its root element. + */ +function CalendarPreviewGridRoot({ rootRef, ...rootProps }: RootProps) { + const { + rootRender, + rootRef: ref, + rootProps: extra + } = useGridContext('CalendarPreview.Grid'); + return useRender({ + defaultTagName: 'div', + ref, + render: rootRender, + props: mergeProps<'div'>(rootProps, extra) + }); +} + +/** The weeks table, plus the skeleton that covers it while loading. */ +function CalendarPreviewWeeks(props: MonthGridProps) { + const { loading } = useGridContext('CalendarPreview.Grid'); + return ( +
+ + + + ); +} + +export interface CalendarPreviewDayProps + extends DayButtonProps, + Pick, 'render' | 'ref'> {} + +/** + * One day cell, bound to react-day-picker's `DayButton` slot. + * + * Carries the cell state alongside its slot: `data-selected`, `data-draft`, + * `data-unavailable`, `data-today`, `data-outside` and `data-scale`. At day + * scale the draft is the roving-focus cell — arrowed to but not yet entered. + */ +export function CalendarPreviewDay({ + day, + modifiers, + className, + children, + render, + ref, + ...props +}: CalendarPreviewDayProps) { + const { scale } = useCalendarPreviewContext('CalendarPreview.Day'); + const { dateInfo, tooltipMessages, showTooltip } = useGridContext( + 'CalendarPreview.Day' + ); + + const info = dateInfo?.(day.date); + const message = showTooltip ? tooltipMessages?.(day.date) : null; + + const button = useRender({ + defaultTagName: 'button', + ref, + 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 + ) + }); + + if (message == null) return button; + + return ( + + + + {message} + + + ); +} + +CalendarPreviewDay.displayName = 'CalendarPreview.Day'; + +export interface CalendarPreviewWeekdayProps + extends WeekdayProps, + Pick, 'render' | 'ref'> {} + +/** One weekday heading, bound to react-day-picker's `Weekday` slot. */ +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'; + +/* + * The caption is rendered but visually hidden: `.Header` owns the visible one, + * while react-day-picker keeps labelling each month grid through `aria-label` + * on the table, so nothing is lost to a screen reader. + */ +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, + 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..d7a9611d4 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx @@ -0,0 +1,134 @@ +'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'>; + +/** + * The row above the grid. Composes the two nav buttons, the caption and the + * reset when given no children. + */ +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'>; + +/** + * Steps the view back one month. + * + * Never disabled by `minDate` — bounds limit selection, not navigation. It + * goes inert only while the calendar is disabled or its grid is loading. + */ +export function CalendarPreviewPrevMonth(props: CalendarPreviewNavProps) { + return ( + } + /> + ); +} + +CalendarPreviewPrevMonth.displayName = 'CalendarPreview.PrevMonth'; + +/** Steps the view forward one month. Navigation is never clamped by `maxDate`. */ +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..dfcb8c42e --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { Button } from '../button'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey } from './date-adapter'; + +export type CalendarPreviewResetProps = ComponentProps; + +/** + * Restores `defaultDate`. + * + * A **value** reset, not a view reset: it commits the default day and leaves + * the visible month where the user left it. + * + * Renders only when there is something to restore — `defaultDate` is set and + * the current value differs from it. `defaultDate` is a separate prop from + * `defaultValue` precisely so this works under a controlled `value`, which + * `useControlled` ignores `defaultValue` for. + */ +export function CalendarPreviewReset({ + className, + children, + onClick, + ...props +}: CalendarPreviewResetProps) { + const { value, defaultDate, reset, disabled, readOnly, timeZone } = + useCalendarPreviewContext('CalendarPreview.Reset'); + + if (!defaultDate) return null; + if (value && dayKey(value, timeZone) === dayKey(defaultDate, timeZone)) { + return null; + } + + return ( + + ); +} + +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..357629b3b --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -0,0 +1,281 @@ +'use client'; + +import { useControlled } from '@base-ui/utils/useControlled'; +import { type ReactNode, useCallback, useMemo } from 'react'; +import { + type CalendarPreviewChangeDetails, + type CalendarPreviewChangeReason, + type CalendarPreviewContextValue, + CalendarPreviewProvider +} from './calendar-preview-context'; +import { + dayKey, + formatDayLabel, + formatMonthLabel, + monthOf, + parseKey, + yearOf +} from './date-adapter'; +import { periodOf, type Scale, type ScaleValue } from './lib/scale'; + +/** How many years either side of today the caption offers by default. */ +const DEFAULT_YEAR_SPAN = 10; + +export interface CalendarPreviewProps { + /** The selected day (controlled). */ + value?: Date | null; + /** The initially selected day (uncontrolled). */ + defaultValue?: Date | null; + /** + * Called when a day is committed or cleared. + * + * `details.reason` says what caused it, `details.period` carries both edges + * of the period, and `details.toDate()` returns the day acted on even when + * `value` is `null`. + */ + 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 — `defaultValue` is ignored once + * `value` is passed, so a controlled consumer would otherwise never see + * `.Reset` at all. + */ + defaultDate?: Date; + + /** + * Renders a value for display. + * @defaultValue `DD/MM/YYYY` at day scale + */ + formatValue?: (value: Date | ScaleValue, scale: Scale) => string; + /** Forwarded to the grid. No conversion is done here. */ + 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; + + children?: ReactNode; +} + +/** + * The default value label: `DD/MM/YYYY` at day scale, and the period's own + * shorthand above it — `MMM YYYY`, `Q# YYYY`, `H# YYYY`, `YYYY`. + * + * Exported for its tests; `formatValue` replaces it wholesale. + */ +export function defaultFormatValue( + value: Date | ScaleValue, + scale: Scale +): 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, + formatValue = defaultFormatValue, + timeZone, + today: todayProp, + clearable = true, + disabled = false, + readOnly = false, + children +}: 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, + default: defaultMonth ?? defaultValue ?? today, + name: 'CalendarPreview', + state: 'month' + }); + + /* + * Uncontrolled for now: `scale`, `defaultScale` and `onScaleChange` arrive + * with the scale switcher. The state lives here from the start 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] + ); + + const setValue = useCallback( + ( + next: Date | null, + reason: CalendarPreviewChangeReason, + occasion: Date + ) => { + setValueUnwrapped(next); + onValueChange?.(next, { + reason, + period: periodOf(occasion, scale), + toDate: () => occasion + }); + }, + [setValueUnwrapped, onValueChange, scale] + ); + + const setScale = useCallback( + (next: Scale) => setScaleUnwrapped(next), + [setScaleUnwrapped] + ); + + const reset = useCallback(() => { + if (!defaultDate) return; + setValue(defaultDate, 'select', defaultDate); + }, [defaultDate, setValue]); + + /* + * Bounds compare as day-keys, so a `minDate` carrying a time-of-day still + * makes its own day selectable — the current family compares instants and + * silently disables it. + */ + 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] + ); + + /* Bounds limit selection, not navigation — but a year the user can never + * scroll to is a trap, so the default span stretches to cover them. */ + 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, + formatValue + }), + [ + value, + setValue, + defaultDate, + reset, + month, + setMonth, + yearRange, + scale, + setScale, + isDateUnavailable, + today, + timeZone, + clearable, + disabled, + readOnly, + formatValue + ] + ); + + return ( + } + > + {children} + + ); +} + +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..5757a58d1 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -0,0 +1,372 @@ +/* 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; +} + +.header { + display: flex; + align-items: center; + gap: var(--rs-space-2); + min-height: var(--rs-space-9); + margin-bottom: 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 between the two nav buttons so the label reads as centred + whether or not the reset is showing. */ +.caption { + flex: 1; + text-align: center; + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); + user-select: none; + -webkit-user-select: none; +} + +.caption-trigger { + display: inline-flex; + 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: transparent; + color: inherit; + font: inherit; + cursor: pointer; +} + +.caption-trigger:hover:not(:disabled) { + background: var(--rs-color-background-base-primary-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); +} + +.caption-option[data-active] { + background: var(--rs-color-background-accent-emphasis); + color: var(--rs-color-foreground-base-emphasis); +} + +.reset { + flex: none; +} + +.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; +} + +.week, +.weekdays { + display: flex; +} + +.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; +} + +.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; +} + +/* Inset ring: day cells pack edge-to-edge in the week row, so a flush or + outward ring would collide with the neighbouring day. */ +.day-button:focus-visible { + outline: var(--rs-focus-ring); + outline-offset: var(--rs-focus-ring-offset-inset); +} + +/* 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 32a1803ad..3dd575f22 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -21,6 +21,7 @@ */ import { TZDate } from '@date-fns/tz'; import { + addMonths, endOfMonth, endOfQuarter, endOfYear, @@ -57,7 +58,7 @@ const PARSE_REFERENCE = new Date(2000, 0, 1); * tooltip/`dateInfo` bug. */ export function dayKey(date: Date, timeZone?: string): DayKey { - return format(timeZone ? new TZDate(date, timeZone) : date, DAY_KEY_FORMAT); + return format(zoned(date, timeZone), DAY_KEY_FORMAT); } /** @@ -174,6 +175,58 @@ export function monthFromName(name: string): number | null { return null; } +/** + * `date` moved `delta` whole months, landing on the first of the month. + * + * Normalising to the first keeps repeated navigation from drifting: stepping + * forward from 31 January would otherwise clamp to 28 February and stay on the + * 28th for every month after it. + */ +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); +} + +/** + * `'20/05/2027'` — the default label for a value at day scale. + * + * Day-first, matching the input format `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'); +} + +/** `'May 2027'` — the default label for a value at month scale. */ +export function formatMonthLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'MMM yyyy'); +} + +/** `'May 2027'`, month spelled in full — the grid header's caption. */ +export function formatCaptionLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'MMMM yyyy'); +} + +/** + * The twelve month names in full, January first. + * + * Built from the same locale as {@link monthFromName} reads, so the caption's + * month column and the input parser can never disagree about a name. + */ +export function monthNames(): string[] { + return MONTH_INDEXES.map(index => format(new Date(2001, index, 1), 'MMMM')); +} + +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..3dc499780 --- /dev/null +++ b/packages/raystack/components/calendar-preview/index.tsx @@ -0,0 +1,21 @@ +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 { Scale, ScaleValue } from './lib/scale'; +export { type UseCalendarReturn, useCalendar } from './use-calendar'; 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..ad5d99bbd --- /dev/null +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { useCalendarPreviewContext } from './calendar-preview-context'; +import type { Scale } from './lib/scale'; + +export interface UseCalendarReturn { + /** The committed day, or `null`. */ + value: Date | null; + /** Commit a day, or clear with `null`. Emits `onValueChange`. */ + setValue: (value: Date | null) => void; + /** The granularity the value is committed at. */ + scale: Scale; + setScale: (scale: Scale) => void; + /** The first month the grid displays. */ + month: Date; + /** Move the view. Bounds never clamp it. */ + setMonth: (month: Date) => void; + /** Whether a day is out of bounds or rejected by `isDateUnavailable`. */ + isDateUnavailable: (date: Date) => boolean; +} + +/** + * The enclosing `CalendarPreview`'s state, for building parts the library does + * not ship. + * + * Deliberately narrow: everything returned here is public API covered by + * semver, so it carries the value, the scale, the view month, their setters + * and the availability predicate — and nothing else. + */ +export function useCalendar(): UseCalendarReturn { + const { + value, + setValue, + scale, + setScale, + month, + setMonth, + isDateUnavailable + } = useCalendarPreviewContext('useCalendar'); + + return { + value, + setValue: next => setValue(next, 'select', next ?? new Date()), + scale, + setScale, + month, + setMonth, + isDateUnavailable + }; +} diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 83539e135..41e68661a 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 CalendarPreviewWeekdayProps, + type Scale, + type ScaleValue, + type UseCalendarReturn, + useCalendar +} from './components/calendar-preview'; export { Callout } from './components/callout'; export { Chat, From d14913d9224251b6af8154dbdc925b1b627b9294 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 4 Sep 2026 10:56:02 +0530 Subject: [PATCH 2/5] feat: dress the CalendarPreview day view to the calendar frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The parts landed in the previous commit with placeholder chrome. This matches them to reference A, and finishes the UndoIcon that commit left imported but unmapped. Single month: the caption moves to the left and the reset joins the two nav buttons on the right, drawn as the undo glyph. Source order is the visual order, so nothing reorders in CSS and tab order follows the row. Several months: there is no single header to hold one caption, so each month captions itself. `.Days` drops the header above the grid and binds react-day-picker's MonthCaption slot instead — previous on the first month, next on the last, a spacer holding the absent button's place so every caption centres on its own grid. No reset in this layout; the Calendar Header component in the file has three variants and none of the two-month ones carries it. Also from the frames: weekday headings go to three letters, the caption abbreviates to `Apr 2024`, the scroller lists `Jan`/`Feb`/`Mar`, its chip and selected row take neutral grey rather than accent, and the popover anchors to the caption's start edge over the grid. `showOutsideDays` now defaults to false. Every new frame ends its grid on the last day of the month with the leading cells blank, and no frame shows an outside day. This diverges from today's DatePicker, which is priced in — the rewrite ships no shim. One frame detail is deliberately not encoded: April 1-16 render muted with today on the 17th, which a `minDate` demonstration and a built-in past bound draw identically. Read as a demonstration, so no default bound is applied — a past bound is not expressible as an opt-out and would break date-of-birth and "filter since" fields. Flagged for design. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/calendar-preview.test.tsx | 80 +++++++++++++---- .../__tests__/data-slots.test.tsx | 39 ++++++++- .../__tests__/date-adapter.test.ts | 24 +++++- .../calendar-preview-caption.tsx | 8 +- .../calendar-preview-days.tsx | 5 +- .../calendar-preview-grid.tsx | 86 ++++++++++++++++++- .../calendar-preview-header.tsx | 13 ++- .../calendar-preview-reset.tsx | 22 +++-- .../calendar-preview.module.css | 46 ++++++++-- .../calendar-preview/date-adapter.ts | 32 ++++++- .../raystack/icons/__tests__/bundle.test.ts | 4 +- packages/raystack/icons/icons.tsx | 5 +- 12 files changed, 310 insertions(+), 54 deletions(-) diff --git a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx index b17b1620f..545ca1f33 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -182,11 +182,11 @@ describe('CalendarPreview selection bounds', () => { fireEvent.click(prev as HTMLElement); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'July 2026' + 'Jul 2026' ); fireEvent.click(prev as HTMLElement); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'June 2026' + 'Jun 2026' ); }); @@ -201,7 +201,7 @@ describe('CalendarPreview selection bounds', () => { fireEvent.click(next as HTMLElement); fireEvent.click(next as HTMLElement); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'October 2026' + 'Oct 2026' ); }); @@ -227,7 +227,7 @@ describe('CalendarPreview month navigation', () => { getSlot(container, 'calendar-preview-next-month') as HTMLElement ); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'September 2026' + 'Sep 2026' ); }); @@ -251,7 +251,7 @@ describe('CalendarPreview month navigation', () => { ); expect(onMonthChange).toHaveBeenCalledWith(new Date(2026, 8, 1)); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'August 2026' + 'Aug 2026' ); }); @@ -266,18 +266,57 @@ describe('CalendarPreview month navigation', () => { fireEvent.click(next); fireEvent.click(next); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'March 2026' + 'Mar 2026' ); }); - it('shows several months side by side and captions the span', () => { + it('shows several months side by side, each captioned over its own grid', () => { const { container } = renderCalendar( ); expect(getAllSlots(container, 'calendar-preview-table')).toHaveLength(2); - expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'August 2026 – September 2026' + + /* Reference A captions each grid rather than the span, so there is one + caption per month and no single header row above them. */ + const captions = getAllSlots(container, 'calendar-preview-caption'); + expect(captions.map(node => node.textContent)).toEqual([ + 'Aug 2026', + 'Sep 2026' + ]); + expect(getSlot(container, 'calendar-preview-header')).toBeNull(); + }); + + it('splits the nav to the outer edges when showing several months', () => { + const { container } = renderCalendar( + ); + const [first, second] = getAllSlots( + container, + 'calendar-preview-month-header' + ); + + /* Previous belongs to the first month, next to the last, and neither + month carries a reset — the two-month header in the frames has none. */ + expect(getSlot(first, 'calendar-preview-prev-month')).not.toBeNull(); + expect(getSlot(first, 'calendar-preview-next-month')).toBeNull(); + expect(getSlot(second, 'calendar-preview-prev-month')).toBeNull(); + expect(getSlot(second, 'calendar-preview-next-month')).not.toBeNull(); + expect(getSlot(container, 'calendar-preview-reset')).toBeNull(); + }); + + it('navigates both months together from the split nav', () => { + const { container } = renderCalendar( + , + { defaultDate: new Date(2026, 7, 10) } + ); + fireEvent.click( + getSlot(container, 'calendar-preview-next-month') as HTMLElement + ); + expect( + getAllSlots(container, 'calendar-preview-caption').map( + node => node.textContent + ) + ).toEqual(['Sep 2026', 'Oct 2026']); }); }); @@ -365,7 +404,7 @@ describe('CalendarPreview.Reset', () => { expect(onMonthChange).not.toHaveBeenCalled(); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'September 2026' + 'Sep 2026' ); }); @@ -382,7 +421,7 @@ describe('CalendarPreview.Caption', () => { it('labels the displayed month', () => { const { container } = renderCalendar(); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'August 2026' + 'Aug 2026' ); }); @@ -445,11 +484,11 @@ describe('CalendarPreview.Caption', () => { const march = getAllSlots( document.body, 'calendar-preview-caption-month' - ).find(option => option.textContent === 'March'); + ).find(option => option.textContent === 'Mar'); fireEvent.click(march as HTMLElement); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'March 2026' + 'Mar 2026' ); expect(onValueChange).not.toHaveBeenCalled(); }); @@ -472,7 +511,7 @@ describe('CalendarPreview.Caption', () => { fireEvent.click(year as HTMLElement); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'August 2030' + 'Aug 2030' ); }); @@ -606,6 +645,15 @@ describe('CalendarPreview.Grid', () => { ).not.toBeDisabled(); }); + it('starts the week on Sunday and spells the weekdays in three letters', () => { + const { container } = renderCalendar(); + expect( + getAllSlots(container, 'calendar-preview-weekday').map( + node => node.textContent + ) + ).toEqual(['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']); + }); + it('forwards weekStartsOn to the grid', () => { const { container } = renderCalendar( @@ -613,7 +661,7 @@ describe('CalendarPreview.Grid', () => { ); const first = getAllSlots(container, 'calendar-preview-weekday')[0]; - expect(first).toHaveTextContent('Mo'); + expect(first).toHaveTextContent('Mon'); }); it('lets a consumer wrap the day slot through components', () => { @@ -947,7 +995,7 @@ describe('useCalendar', () => { fireEvent.click(screen.getByText('move')); expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( - 'October 2026' + 'Oct 2026' ); fireEvent.click(screen.getByText('clear')); diff --git a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx index 44c858907..9d9001d44 100644 --- a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx @@ -61,6 +61,26 @@ describe('CalendarPreview data-slot contract', () => { ); }); + it('exposes a slot for every element the two-month day view renders', () => { + const { container } = renderCalendar( + + ); + expectSlots(container, [ + 'calendar-preview-days', + 'calendar-preview-month-header', + 'calendar-preview-prev-month', + 'calendar-preview-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), @@ -215,12 +235,25 @@ describe('CalendarPreview state attributes', () => { expect(unavailable[unavailable.length - 1]).toHaveTextContent('9'); }); - it('marks the days that fall outside the displayed month', () => { + 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') ); - /* August 2026 starts on a Saturday, so the grid opens with outside days. */ expect(outside.length).toBeGreaterThan(0); expect(outside[0]).not.toHaveAttribute('data-today'); }); @@ -262,7 +295,7 @@ describe('CalendarPreview state attributes', () => { 'calendar-preview-caption-month' ).filter(option => option.hasAttribute('data-active')); expect(activeMonth).toHaveLength(1); - expect(activeMonth[0]).toHaveTextContent('August'); + expect(activeMonth[0]).toHaveTextContent('Aug'); const activeYear = getAllSlots( document.body, 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 374b5ccd7..3f4bbbd23 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -14,6 +14,7 @@ import { monthFromName, monthNames, monthOf, + monthShortNames, monthStart, parseKey, shiftMonths, @@ -209,15 +210,15 @@ describe('label formatters', () => { expect(formatMonthLabel(new Date(2027, 8, 1))).toBe('Sep 2027'); }); - it('formats a caption with the month spelled out', () => { - expect(formatCaptionLabel(new Date(2027, 8, 1))).toBe('September 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('August 2026'); + expect(formatCaptionLabel(instant, 'UTC')).toBe('Aug 2026'); }); }); @@ -236,3 +237,20 @@ describe('monthNames', () => { }); }); }); + +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/calendar-preview-caption.tsx b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx index 333a63273..80edd200f 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx @@ -14,7 +14,7 @@ import { } from './calendar-preview-context'; import { formatCaptionLabel, - monthNames, + monthShortNames, monthStart, shiftMonths } from './date-adapter'; @@ -121,9 +121,11 @@ function CaptionDropdown({ {children ?? label} + {/* Anchored to the caption's start edge and overlapping the grid, as + in reference A — the grid stays mounted behind it. */} @@ -135,7 +137,7 @@ function CaptionDropdown({ slot='calendar-preview-caption-months' optionSlot='calendar-preview-caption-month' label='Month' - options={monthNames().map((name, index) => ({ + options={monthShortNames().map((name, index) => ({ key: name, text: name, active: index === activeMonth, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx index ca8fb6ff5..2bd835b68 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -59,9 +59,12 @@ export function CalendarPreviewDays({ 'data-disabled': disabled || undefined, 'data-readonly': readOnly || undefined, 'data-busy': busy || undefined, + /* One month gets a header above the grid. Several months caption + themselves inside it, so a `.Header` here would be a second, + redundant row — see `.Grid`'s month caption. */ children: children ?? ( <> - + {numberOfMonths <= 1 && } ) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 261b32324..132515ccc 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -14,6 +14,7 @@ import { type DayButtonProps, DayPicker, type DayPickerProps, + type MonthCaptionProps, type MonthGridProps, type RootProps, type WeekdayProps @@ -25,6 +26,11 @@ import { useCalendarPreviewContext, useCalendarPreviewDaysContext } from './calendar-preview-context'; +import { + CalendarPreviewNextMonth, + CalendarPreviewPrevMonth +} from './calendar-preview-header'; +import { formatCaptionLabel, formatWeekdayLabel } from './date-adapter'; /* * The only file in `calendar-preview/` that imports react-day-picker. @@ -65,7 +71,12 @@ export interface CalendarPreviewGridProps fixedWeeks?: boolean; /** * Render the days either side of the month. - * @defaultValue true + * + * 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. */ @@ -93,7 +104,7 @@ export interface CalendarPreviewGridProps export function CalendarPreviewGrid({ fixedWeeks, - showOutsideDays = true, + showOutsideDays = false, showWeekNumber, weekStartsOn, modifiers, @@ -140,15 +151,25 @@ export function CalendarPreviewGrid({ rootProps: props }; + const months = days?.numberOfMonths ?? 1; + + /* + * One month keeps its caption in `.Header`, above the grid, and leaves the + * one react-day-picker renders visually hidden for the table's accessible + * name. Several months have no single header to put it in — the caption sits + * over its own grid, and the nav splits to the outer edges — so the month + * caption becomes the header, and `.Days` renders no `.Header` above. + */ const slots = useMemo( () => ({ Root: CalendarPreviewGridRoot, MonthGrid: CalendarPreviewWeeks, DayButton: CalendarPreviewDay, Weekday: CalendarPreviewWeekday, + ...(months > 1 ? { MonthCaption: CalendarPreviewMonthCaption } : {}), ...components }), - [components] + [components, months] ); const handleSelect = (selected: Date | undefined, triggerDate: Date) => { @@ -170,7 +191,8 @@ export function CalendarPreviewGrid({ today, hideNavigation: true, captionLayout: 'label', - numberOfMonths: days?.numberOfMonths ?? 1, + numberOfMonths: months, + formatters: GRID_FORMATTERS, disabled: disabled ? true : isDateUnavailable, fixedWeeks, showOutsideDays, @@ -256,6 +278,55 @@ function CalendarPreviewWeeks(props: MonthGridProps) { ); } +/** + * One month's own header, used only when several months are shown. + * + * Reference A splits the nav across the whole row: previous sits at the far + * left of the first month, next at the far right of the last, and each caption + * is centred over its own grid. A spacer holds the place of the button a month + * does not carry, so every caption centres on the same axis as its grid rather + * than drifting toward the side that has no button. There is no reset here — + * the two-month header in the frames does not have one. + */ +function CalendarPreviewMonthCaption({ + calendarMonth, + displayIndex, + /* Dropped, not merged: the class react-day-picker passes here is the one + that hides the caption for the single-month layout, which is exactly what + this header must not be. */ + className: _className, + ...props +}: MonthCaptionProps) { + const { timeZone } = useCalendarPreviewContext('CalendarPreview.Grid'); + const days = useCalendarPreviewDaysContext(); + const lastIndex = (days?.numberOfMonths ?? 1) - 1; + + return ( +
+ {displayIndex === 0 ? ( + + ) : ( +
+ ); +} + export interface CalendarPreviewDayProps extends DayButtonProps, Pick, 'render' | 'ref'> {} @@ -367,6 +438,13 @@ export function CalendarPreviewWeekday({ CalendarPreviewWeekday.displayName = 'CalendarPreview.Weekday'; +/* Three-letter weekday headings, against react-day-picker's two-letter + * default. Locale-derived, so a localized calendar gets its own abbreviation + * rather than a sliced English one. */ +const GRID_FORMATTERS: DayPickerProps['formatters'] = { + formatWeekdayName: date => formatWeekdayLabel(date) +}; + /* * The caption is rendered but visually hidden: `.Header` owns the visible one, * while react-day-picker keeps labelling each month grid through `aria-label` diff --git a/packages/raystack/components/calendar-preview/calendar-preview-header.tsx b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx index d7a9611d4..407b53b52 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-header.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx @@ -17,8 +17,15 @@ import { shiftMonths } from './date-adapter'; export type CalendarPreviewHeaderProps = useRender.ComponentProps<'div'>; /** - * The row above the grid. Composes the two nav buttons, the caption and the - * reset when given no children. + * The row above the grid: the caption on the left, and the reset and two nav + * buttons grouped on the right. + * + * Source order is the reading and tab order — caption, then undo, previous, + * next — so the row needs no CSS reordering to match reference A. + * + * This is the single-month header. Showing more than one month moves the + * caption and the nav into each month's own header, inside `.Grid`, which is + * the only place that can interleave them with react-day-picker's columns. */ export function CalendarPreviewHeader({ className, @@ -37,9 +44,9 @@ export function CalendarPreviewHeader({ 'data-slot': 'calendar-preview-header', children: children ?? ( <> - + ) diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx index dfcb8c42e..50b98d668 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -2,12 +2,13 @@ import { cx } from 'class-variance-authority'; import type { ComponentProps } from 'react'; -import { Button } from '../button'; +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; +export type CalendarPreviewResetProps = ComponentProps; /** * Restores `defaultDate`. @@ -19,6 +20,10 @@ export type CalendarPreviewResetProps = ComponentProps; * the current value differs from it. `defaultDate` is a separate prop from * `defaultValue` precisely so this works under a controlled `value`, which * `useControlled` ignores `defaultValue` for. + * + * Drawn as the undo glyph and grouped with the two nav buttons, which is where + * the single-month header in reference A puts it. The two-month header has no + * reset at all — see `.Header`. */ export function CalendarPreviewReset({ className, @@ -35,21 +40,20 @@ export function CalendarPreviewReset({ } return ( - + {children ?? } +
); } diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 5757a58d1..22ce95915 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -32,11 +32,12 @@ cursor: not-allowed; } -/* Takes the space between the two nav buttons so the label reads as centred - whether or not the reset is showing. */ +/* 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: center; + text-align: start; font-weight: var(--rs-font-weight-medium); font-size: var(--rs-font-size-mini); line-height: var(--rs-line-height-mini); @@ -46,22 +47,27 @@ -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: transparent; + background: var(--rs-color-background-neutral-secondary); color: inherit; font: inherit; cursor: pointer; } .caption-trigger:hover:not(:disabled) { - background: var(--rs-color-background-base-primary-hover); + background: var(--rs-color-background-neutral-secondary-hover); } .caption-trigger:focus-visible { @@ -124,15 +130,41 @@ 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-accent-emphasis); - color: var(--rs-color-foreground-base-emphasis); + background: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); } .reset { flex: none; } +/* The per-month header, used only when several months are shown: previous at + the far left of the first month, next at the far right of the last, and each + caption centred over its own grid. */ +.month-header { + display: flex; + align-items: center; + gap: var(--rs-space-2); + min-height: var(--rs-space-9); + margin-bottom: var(--rs-space-3); +} + +.month-header-caption { + flex: 1; + text-align: center; +} + +/* Holds the place of the nav button this month does not carry, so the caption + centres on its grid instead of drifting to the buttonless side. */ +.nav-spacer { + flex: none; + width: var(--rs-space-6); + height: var(--rs-space-6); +} + .grid { position: relative; } diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 3dd575f22..904b6d6ca 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -206,9 +206,26 @@ export function formatMonthLabel(date: Date, timeZone?: string): string { return format(zoned(date, timeZone), 'MMM yyyy'); } -/** `'May 2027'`, month spelled in full — the grid header's caption. */ +/** + * `'May 2027'` — the grid header's caption. + * + * Abbreviated, matching reference A, and so identical to + * {@link formatMonthLabel} today. They stay separate functions because they + * answer different questions — what the grid is showing, versus what a + * month-scale value means — and only one of them is the caption. + */ export function formatCaptionLabel(date: Date, timeZone?: string): string { - return format(zoned(date, timeZone), 'MMMM yyyy'); + return format(zoned(date, timeZone), 'MMM yyyy'); +} + +/** + * `'Sun'` — one weekday heading. + * + * Three letters, not react-day-picker's two-letter default: reference A's + * frames spell them `Sun Mon Tue`, and the day cell is wide enough for it. + */ +export function formatWeekdayLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'EEE'); } /** @@ -221,6 +238,17 @@ export function monthNames(): string[] { return MONTH_INDEXES.map(index => format(new Date(2001, index, 1), 'MMMM')); } +/** + * The twelve month names abbreviated, January first — `'Jan'`, `'Feb'`. + * + * What the caption's month column shows: the scroller is a narrow column + * beside the years, and reference A abbreviates it. {@link monthFromName} + * accepts this form too, so the parser still agrees with it. + */ +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 { diff --git a/packages/raystack/icons/__tests__/bundle.test.ts b/packages/raystack/icons/__tests__/bundle.test.ts index 0b749f670..2f049a60d 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 — 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 bba8d97a3..d6357d6b1 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 From 3660a8e80c4d1f70f04eb9e48ed8acdf53c424eb Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 4 Sep 2026 15:04:23 +0530 Subject: [PATCH 3/5] fix: align the calendar header to its date columns, and trim the module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header sat on a different grid from the days beneath it, in three compounding ways. There is no global `box-sizing: border-box` here, so the cells were sizing content-box: a day cell carried its 1px border and the user agent's 1px cell padding on top of the 40px column and came out 44px, while a weekday heading came out 42px. Two pixels per column, and by Saturday the heading stood 13px clear of the days under it. Cells now size to the border box with the agent's padding dropped, so both are the same 40px column and every column measures zero drift. The table's default 2px border-spacing ringed the grid, leaving the header wider than the columns it captions. It is now zero. Aligning the header to the column box was still optically wrong, because a weekday label is centred inside its cell rather than flush to it: the caption read as sitting left of "Sun". Both headers take 8px of inline padding, which is the bearing that label leaves. A week-number column is a gutter rather than a date column, so the single-month header adds its width on top — asked of the rendered grid through `:has()`, since `showWeekNumber` is a `.Grid` prop and the header is its sibling. Also, against the standing rules: the two spacer spans in the two-month header existed only to carry a class, so the header is three fixed grid tracks and the empty one reserves itself. `monthNames()` went dead when the caption scroller moved to abbreviations, and is deleted with its test. Comments across the module are cut back to the constraints that are not obvious from the code — file-header essays and JSDoc restating a signature are gone, and `date-adapter.ts` loses 89 lines without losing a fact. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/date-adapter.test.ts | 17 -- .../calendar-preview-caption.tsx | 25 +-- .../calendar-preview-context.tsx | 58 ++----- .../calendar-preview-days.tsx | 15 +- .../calendar-preview-footer.tsx | 10 +- .../calendar-preview-grid.tsx | 103 ++++-------- .../calendar-preview-header.tsx | 21 +-- .../calendar-preview-reset.tsx | 17 +- .../calendar-preview-root.tsx | 42 ++--- .../calendar-preview.module.css | 54 +++++-- .../calendar-preview/date-adapter.ts | 149 ++++-------------- .../calendar-preview/use-calendar.tsx | 12 +- 12 files changed, 149 insertions(+), 374 deletions(-) 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 3f4bbbd23..f1b0d7483 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -12,7 +12,6 @@ import { formatMonthLabel, isDayKey, monthFromName, - monthNames, monthOf, monthShortNames, monthStart, @@ -222,22 +221,6 @@ describe('label formatters', () => { }); }); -describe('monthNames', () => { - it('lists twelve names, January first', () => { - const names = monthNames(); - expect(names).toHaveLength(12); - expect(names[0]).toBe('January'); - expect(names[11]).toBe('December'); - }); - - /* The caption column and the input parser must never disagree about a name. */ - it('round-trips through monthFromName', () => { - monthNames().forEach((name, index) => { - expect(monthFromName(name)).toBe(index + 1); - }); - }); -}); - describe('monthShortNames', () => { it('lists twelve abbreviations, January first', () => { const names = monthShortNames(); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx index 80edd200f..7d841e979 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx @@ -19,24 +19,19 @@ import { shiftMonths } from './date-adapter'; -/* - * The two forms take different props because they are different elements: a - * plain caption is a `span`, and one that opens the scroller is a `button`. - */ +/* 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 — the displayed month, or the span of months when - * more than one is shown. Children replace the computed label entirely, so + * The label above the grid. Children replace it entirely, so * `Q3 2026` works. * - * With `dropdown`, it becomes a button that opens a two-column month and year - * scroller. The scroller is ours: no `Select` is mounted anywhere in this - * component, which is what keeps the popover-dismissal loop the current family - * fights from coming back. Picking from it moves the view; it never selects a - * value. + * 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 ? ( @@ -48,7 +43,6 @@ export function CalendarPreviewCaption(props: CalendarPreviewCaptionProps) { CalendarPreviewCaption.displayName = 'CalendarPreview.Caption'; -/** The caption text: one month, or the first and last of several. */ function useCaptionLabel(): ReactNode { const { month, timeZone } = useCalendarPreviewContext( 'CalendarPreview.Caption' @@ -121,8 +115,6 @@ function CaptionDropdown({ {children ?? label} - {/* Anchored to the caption's start edge and overlapping the grid, as - in reference A — the grid stays mounted behind it. */} (null); - /* Bring the current row into view when the scroller opens — a twenty-year - * column otherwise arrives scrolled to the wrong end. Optional-called - * because jsdom does not implement scrollIntoView. */ + /* A twenty-year column otherwise opens scrolled to the wrong end. Optional + call: jsdom does not implement scrollIntoView. */ useEffect(() => { activeRef.current?.scrollIntoView?.({ block: 'center' }); }, []); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index 2fb95e43f..73d73e484 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -14,63 +14,41 @@ export type CalendarPreviewChangeReason = export interface CalendarPreviewChangeDetails { /** What caused the change. */ reason: CalendarPreviewChangeReason; - /** - * Both edges of the period the change occasioned, as `'YYYY-MM-DD'`. - * - * Month-end correct — February 2028 ends `2028-02-29`. At day scale the two - * edges are the same day. - */ + /** Both edges, month-end correct. At day scale they are the same day. */ period: { start: DayKey; end: DayKey }; /** - * The day this change was made from, as a `Date`. - * - * On a select it is the new value. On a clear it is the day that was clicked - * to deselect, so a consumer can always tell which cell the user acted on — - * it is never null, even when `value` is. + * The day acted on — never null, even when `value` is, so a clear still says + * which cell the user clicked. */ toDate: () => Date; } -/** - * Everything the parts read. One object, owned by the root. - * - * `Value` is generic so the scale-aware arms in a later phase can carry a - * `ScaleValue` without a second context; it is stored as `unknown` on the - * context and cast once, at the hook boundary — the shape `Combobox` uses. - */ +/* Generic so a later phase's scale-aware arms carry a `ScaleValue` without a + second context: stored as `unknown`, cast once at the hook boundary. */ export interface CalendarPreviewContextValue { - /** The committed value. */ value: Value; - /** Commit a value and emit `onValueChange`. `occasion` is the day acted on. */ + /** `occasion` is the day acted on, which a cleared `value` cannot carry. */ setValue: ( value: Value, reason: CalendarPreviewChangeReason, occasion: Date ) => void; - /** The reset target. Read even when `value` is controlled. */ + /** Read even when `value` is controlled. */ defaultDate: Date | undefined; - /** Restore `defaultDate`. A value reset — it never moves the view. */ + /** A value reset — it never moves the view. */ reset: () => void; - /** The first month the grid displays. */ month: Date; - /** Move the view. Never clamped by `minDate` / `maxDate`. */ + /** Never clamped by `minDate` / `maxDate`. */ setMonth: (month: Date) => void; - /** The years the caption's year column offers. */ yearRange: { from: number; to: number }; - /** The granularity the value is committed at. */ scale: Scale; setScale: (scale: Scale) => void; - /** `true` when the date is out of bounds or the consumer rejected it. */ isDateUnavailable: (date: Date) => boolean; - /** Today, injectable so tests are not clock-dependent. */ today: Date; - /** Forwarded to the grid. No conversion is done here. */ timeZone: string | undefined; - /** Whether a committed value can be deselected back to `null`. */ clearable: boolean; disabled: boolean; readOnly: boolean; - /** Renders a value for display. Used by the trigger and input parts. */ formatValue: (value: Date | ScaleValue, scale: Scale) => string; } @@ -89,12 +67,8 @@ export function CalendarPreviewProvider({ ); } -/** - * The root's state, or a throw naming the part that asked for it. - * - * `part` is the display name of the caller, so the error points at the element - * the author actually wrote rather than at this file. - */ +/* `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 { @@ -105,15 +79,10 @@ export function useCalendarPreviewContext( return context as CalendarPreviewContextValue; } -/** - * State shared between `.Header` and `.Grid`, provided by their common parent. - * - * `.Days` owns it rather than the root so two day views in one tree cannot - * disable each other's navigation. Absent when a part is used outside `.Days`. - */ +/* `.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; - /** Whether the grid inside this `.Days` is loading. */ busy: boolean; setBusy: (busy: boolean) => void; } @@ -135,7 +104,6 @@ export function CalendarPreviewDaysProvider({ ); } -/** The enclosing `.Days` state, or `null` when there is no `.Days` above. */ 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 index 2bd835b68..8d8664ccf 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -21,14 +21,8 @@ export interface CalendarPreviewDaysProps numberOfMonths?: number; } -/** - * The day view: a header and a grid. Hugs its content, so the surface around - * it is never padded out to a fixed height the way the current calendar is. - * - * Owns the state the header and the grid share — how many months are shown, - * and whether the grid is loading — so two day views in one tree cannot - * disable each other's navigation. - */ +/* 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, @@ -59,9 +53,8 @@ export function CalendarPreviewDays({ 'data-disabled': disabled || undefined, 'data-readonly': readOnly || undefined, 'data-busy': busy || undefined, - /* One month gets a header above the grid. Several months caption - themselves inside it, so a `.Header` here would be a second, - redundant row — see `.Grid`'s month caption. */ + /* Several months caption themselves inside the grid, so a `.Header` + here would be a second, redundant row. */ children: children ?? ( <> {numberOfMonths <= 1 && } diff --git a/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx index 6967bd823..da6af402b 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx @@ -8,14 +8,8 @@ import styles from './calendar-preview.module.css'; export type CalendarPreviewFooterProps = ComponentProps; -/** - * The row below the calendar. - * - * A bare string is wrapped in `Text` so the common case reads as - * `Dates are inclusive` - * without the caller having to know the type scale; anything else renders as - * given. - */ +/* 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, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx index 132515ccc..47d35c777 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -32,19 +32,10 @@ import { } from './calendar-preview-header'; import { formatCaptionLabel, formatWeekdayLabel } from './date-adapter'; -/* - * The only file in `calendar-preview/` that imports react-day-picker. - * - * RDP earns its place for the day grid alone — roving tabindex, week - * construction, outside days, locale weekday order. What changes is the - * boundary: it runs with `hideNavigation` and `captionLayout='label'`, so it - * never mounts a `Select`, and `mode`, `selected`, `onSelect`, `required`, - * `month`, `onMonthChange` and `timeZone` come from root context rather than - * from props. None of them is in `CalendarPreviewGridProps`, so nothing has to - * be force-overridden after the consumer's spread — `...props` really is last. - */ - -/** Props the grid shares with the cells it renders. */ +/* 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. */ interface GridContextValue { dateInfo?: (date: Date) => ReactNode; tooltipMessages?: (date: Date) => ReactNode; @@ -91,7 +82,7 @@ export interface CalendarPreviewGridProps * Extra content for a day, rendered above the date number. * * A function, not a record: the record form keyed cells by a formatted - * string, which silently missed every day once a `timeZone` shifted the key. + * 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 */ @@ -133,8 +124,8 @@ export function CalendarPreviewGrid({ const days = useCalendarPreviewDaysContext(); const setBusy = days?.setBusy; - /* The header sits beside the grid, not inside it, so the loading state has - * to travel up to their common parent for navigation to go inert with it. */ + /* 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); @@ -153,13 +144,8 @@ export function CalendarPreviewGrid({ const months = days?.numberOfMonths ?? 1; - /* - * One month keeps its caption in `.Header`, above the grid, and leaves the - * one react-day-picker renders visually hidden for the table's accessible - * name. Several months have no single header to put it in — the caption sits - * over its own grid, and the nav splits to the outer edges — so the month - * caption becomes the header, and `.Days` renders no `.Header` above. - */ + /* 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, @@ -177,13 +163,9 @@ export function CalendarPreviewGrid({ setValue(selected ?? null, selected ? 'select' : 'clear', triggerDate); }; - /* - * Everything outside the selection arm. `mode`, `required`, `selected` and - * `onSelect` stay on the elements below: react-day-picker discriminates its - * props union on the literal `required`, which a `boolean` cannot narrow, so - * the two arms are written out rather than cast away. The union is contained - * here and reaches no consumer. - */ + /* `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, @@ -232,11 +214,8 @@ export function CalendarPreviewGrid({ CalendarPreviewGrid.displayName = 'CalendarPreview.Grid'; -/* - * `render`, `ref` and the consumer's remaining props reach the root here - * rather than on ``, which forwards only `className`, `style` and - * `data-*` to its root element. - */ +/* `` 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, @@ -251,7 +230,6 @@ function CalendarPreviewGridRoot({ rootRef, ...rootProps }: RootProps) { }); } -/** The weeks table, plus the skeleton that covers it while loading. */ function CalendarPreviewWeeks(props: MonthGridProps) { const { loading } = useGridContext('CalendarPreview.Grid'); return ( @@ -278,28 +256,19 @@ function CalendarPreviewWeeks(props: MonthGridProps) { ); } -/** - * One month's own header, used only when several months are shown. - * - * Reference A splits the nav across the whole row: previous sits at the far - * left of the first month, next at the far right of the last, and each caption - * is centred over its own grid. A spacer holds the place of the button a month - * does not carry, so every caption centres on the same axis as its grid rather - * than drifting toward the side that has no button. There is no reset here — - * the two-month header in the frames does not have one. - */ +/* 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, - /* Dropped, not merged: the class react-day-picker passes here is the one - that hides the caption for the single-month layout, which is exactly what - this header must not be. */ + /* 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(); - const lastIndex = (days?.numberOfMonths ?? 1) - 1; return (
- {displayIndex === 0 ? ( - - ) : ( -
); @@ -331,13 +296,8 @@ export interface CalendarPreviewDayProps extends DayButtonProps, Pick, 'render' | 'ref'> {} -/** - * One day cell, bound to react-day-picker's `DayButton` slot. - * - * Carries the cell state alongside its slot: `data-selected`, `data-draft`, - * `data-unavailable`, `data-today`, `data-outside` and `data-scale`. At day - * scale the draft is the roving-focus cell — arrowed to but not yet entered. - */ +/* 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, @@ -415,7 +375,6 @@ export interface CalendarPreviewWeekdayProps extends WeekdayProps, Pick, 'render' | 'ref'> {} -/** One weekday heading, bound to react-day-picker's `Weekday` slot. */ export function CalendarPreviewWeekday({ className, render, @@ -438,18 +397,14 @@ export function CalendarPreviewWeekday({ CalendarPreviewWeekday.displayName = 'CalendarPreview.Weekday'; -/* Three-letter weekday headings, against react-day-picker's two-letter - * default. Locale-derived, so a localized calendar gets its own abbreviation - * rather than a sliced English one. */ +/* Locale-derived in the adapter, so a localized calendar gets its own + abbreviation rather than a sliced English one. */ const GRID_FORMATTERS: DayPickerProps['formatters'] = { formatWeekdayName: date => formatWeekdayLabel(date) }; -/* - * The caption is rendered but visually hidden: `.Header` owns the visible one, - * while react-day-picker keeps labelling each month grid through `aria-label` - * on the table, so nothing is lost to a screen reader. - */ +/* 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, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-header.tsx b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx index 407b53b52..15f00da58 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-header.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx @@ -16,17 +16,8 @@ import { shiftMonths } from './date-adapter'; export type CalendarPreviewHeaderProps = useRender.ComponentProps<'div'>; -/** - * The row above the grid: the caption on the left, and the reset and two nav - * buttons grouped on the right. - * - * Source order is the reading and tab order — caption, then undo, previous, - * next — so the row needs no CSS reordering to match reference A. - * - * This is the single-month header. Showing more than one month moves the - * caption and the nav into each month's own header, inside `.Grid`, which is - * the only place that can interleave them with react-day-picker's columns. - */ +/* 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, @@ -62,12 +53,7 @@ CalendarPreviewHeader.displayName = 'CalendarPreview.Header'; export type CalendarPreviewNavProps = useRender.ComponentProps<'button'>; -/** - * Steps the view back one month. - * - * Never disabled by `minDate` — bounds limit selection, not navigation. It - * goes inert only while the calendar is disabled or its grid is loading. - */ +/* Never disabled by `minDate`: bounds limit selection, not navigation. */ export function CalendarPreviewPrevMonth(props: CalendarPreviewNavProps) { return ( ; /** - * Restores `defaultDate`. - * - * A **value** reset, not a view reset: it commits the default day and leaves - * the visible month where the user left it. - * - * Renders only when there is something to restore — `defaultDate` is set and - * the current value differs from it. `defaultDate` is a separate prop from - * `defaultValue` precisely so this works under a controlled `value`, which - * `useControlled` ignores `defaultValue` for. - * - * Drawn as the undo glyph and grouped with the two nav buttons, which is where - * the single-month header in reference A puts it. The two-month header has no - * reset at all — see `.Header`. + * Restores `defaultDate`. A value reset, not a view reset — it leaves the + * visible month alone, and renders only when the value differs from the + * default. Keyed off `defaultDate` rather than `defaultValue` so it still + * shows under a controlled `value`. */ export function CalendarPreviewReset({ className, diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index 357629b3b..0f35aa082 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -18,7 +18,6 @@ import { } from './date-adapter'; import { periodOf, type Scale, type ScaleValue } from './lib/scale'; -/** How many years either side of today the caption offers by default. */ const DEFAULT_YEAR_SPAN = 10; export interface CalendarPreviewProps { @@ -26,13 +25,7 @@ export interface CalendarPreviewProps { value?: Date | null; /** The initially selected day (uncontrolled). */ defaultValue?: Date | null; - /** - * Called when a day is committed or cleared. - * - * `details.reason` says what caused it, `details.period` carries both edges - * of the period, and `details.toDate()` returns the day acted on even when - * `value` is `null`. - */ + /** Called when a day is committed or cleared. */ onValueChange?: ( value: Date | null, details: CalendarPreviewChangeDetails @@ -61,11 +54,9 @@ export interface CalendarPreviewProps { isDateUnavailable?: (date: Date) => boolean; /** - * The day `.Reset` restores. - * - * Read even when `value` is controlled — `defaultValue` is ignored once - * `value` is passed, so a controlled consumer would otherwise never see - * `.Reset` at all. + * The day `.Reset` restores. Read even when `value` is controlled, which + * `defaultValue` is not — otherwise a controlled consumer never sees + * `.Reset`. */ defaultDate?: Date; @@ -100,12 +91,7 @@ export interface CalendarPreviewProps { children?: ReactNode; } -/** - * The default value label: `DD/MM/YYYY` at day scale, and the period's own - * shorthand above it — `MMM YYYY`, `Q# YYYY`, `H# YYYY`, `YYYY`. - * - * Exported for its tests; `formatValue` replaces it wholesale. - */ +/* Exported for its tests; `formatValue` replaces it wholesale. */ export function defaultFormatValue( value: Date | ScaleValue, scale: Scale @@ -158,11 +144,8 @@ export function CalendarPreviewRoot({ state: 'month' }); - /* - * Uncontrolled for now: `scale`, `defaultScale` and `onScaleChange` arrive - * with the scale switcher. The state lives here from the start so the parts - * and `useCalendar()` read it from one place either way. - */ + /* 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', @@ -204,11 +187,8 @@ export function CalendarPreviewRoot({ setValue(defaultDate, 'select', defaultDate); }, [defaultDate, setValue]); - /* - * Bounds compare as day-keys, so a `minDate` carrying a time-of-day still - * makes its own day selectable — the current family compares instants and - * silently disables it. - */ + /* 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); @@ -219,8 +199,8 @@ export function CalendarPreviewRoot({ [minDate, maxDate, isDateUnavailableProp, timeZone] ); - /* Bounds limit selection, not navigation — but a year the user can never - * scroll to is a trap, so the default span stretches to cover them. */ + /* 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(); diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 22ce95915..3451ecb92 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -14,12 +14,24 @@ 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 { @@ -141,28 +153,30 @@ flex: none; } -/* The per-month header, used only when several months are shown: previous at - the far left of the first month, next at the far right of the last, and each - caption centred over its own grid. */ +/* 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: flex; + 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 { - flex: 1; + grid-column: 2; text-align: center; } -/* Holds the place of the nav button this month does not carry, so the caption - centres on its grid instead of drifting to the buttonless side. */ -.nav-spacer { - flex: none; - width: var(--rs-space-6); - height: var(--rs-space-6); +.month-header-next { + grid-column: 3; } .grid { @@ -203,11 +217,29 @@ 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; diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts index 904b6d6ca..e7970bec2 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -1,24 +1,6 @@ -/* - * 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, @@ -33,58 +15,36 @@ 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; const DAY_KEY_FORMAT = 'yyyy-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 { return format(zoned(date, timeZone), DAY_KEY_FORMAT); } -/** - * 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)}`); @@ -96,18 +56,9 @@ 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. The year + bound is what a four-digit key holds, so this and isDayKey always agree. */ export function dayKeyFromParts( year: number, month: number, @@ -124,7 +75,7 @@ export function startOfMonthKey(key: DayKey): DayKey { return dayKey(startOfMonth(parseKey(key))); } -/** The last day of the month containing `key` — leap-correct by construction. */ +/** The last day of the month containing `key`. */ export function endOfMonthKey(key: DayKey): DayKey { return dayKey(endOfMonth(parseKey(key))); } @@ -159,14 +110,8 @@ 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. A localized picker passes a + locale through here rather than growing a second lookup elsewhere. */ export function monthFromName(name: string): number | null { for (const pattern of ['MMMM', 'MMM']) { const date = parse(name, pattern, PARSE_REFERENCE); @@ -175,13 +120,8 @@ export function monthFromName(name: string): number | null { return null; } -/** - * `date` moved `delta` whole months, landing on the first of the month. - * - * Normalising to the first keeps repeated navigation from drifting: stepping - * forward from 31 January would otherwise clamp to 28 February and stay on the - * 28th for every month after it. - */ +/* 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); } @@ -191,12 +131,8 @@ export function monthStart(year: number, monthIndex: number): Date { return new Date(year, monthIndex, 1); } -/** - * `'20/05/2027'` — the default label for a value at day scale. - * - * Day-first, matching the input format `lib/parse.ts` accepts, so a rendered - * value can be typed straight back in. - */ +/* 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'); } @@ -206,45 +142,20 @@ export function formatMonthLabel(date: Date, timeZone?: string): string { return format(zoned(date, timeZone), 'MMM yyyy'); } -/** - * `'May 2027'` — the grid header's caption. - * - * Abbreviated, matching reference A, and so identical to - * {@link formatMonthLabel} today. They stay separate functions because they - * answer different questions — what the grid is showing, versus what a - * month-scale value means — and only one of them is the caption. - */ +/* 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'); } -/** - * `'Sun'` — one weekday heading. - * - * Three letters, not react-day-picker's two-letter default: reference A's - * frames spell them `Sun Mon Tue`, and the day cell is wide enough for it. - */ +/* 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'); } -/** - * The twelve month names in full, January first. - * - * Built from the same locale as {@link monthFromName} reads, so the caption's - * month column and the input parser can never disagree about a name. - */ -export function monthNames(): string[] { - return MONTH_INDEXES.map(index => format(new Date(2001, index, 1), 'MMMM')); -} - -/** - * The twelve month names abbreviated, January first — `'Jan'`, `'Feb'`. - * - * What the caption's month column shows: the scroller is a narrow column - * beside the years, and reference A abbreviates it. {@link monthFromName} - * accepts this form too, so the parser still agrees with it. - */ +/* 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')); } diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx index ad5d99bbd..df405e35a 100644 --- a/packages/raystack/components/calendar-preview/use-calendar.tsx +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -4,28 +4,20 @@ import { useCalendarPreviewContext } from './calendar-preview-context'; import type { Scale } from './lib/scale'; export interface UseCalendarReturn { - /** The committed day, or `null`. */ value: Date | null; /** Commit a day, or clear with `null`. Emits `onValueChange`. */ setValue: (value: Date | null) => void; - /** The granularity the value is committed at. */ scale: Scale; setScale: (scale: Scale) => void; - /** The first month the grid displays. */ month: Date; - /** Move the view. Bounds never clamp it. */ + /** Bounds never clamp the view. */ setMonth: (month: Date) => void; - /** Whether a day is out of bounds or rejected by `isDateUnavailable`. */ isDateUnavailable: (date: Date) => boolean; } /** * The enclosing `CalendarPreview`'s state, for building parts the library does - * not ship. - * - * Deliberately narrow: everything returned here is public API covered by - * semver, so it carries the value, the scale, the view month, their setters - * and the availability predicate — and nothing else. + * not ship. Deliberately narrow — everything returned here is semver-covered. */ export function useCalendar(): UseCalendarReturn { const { From 26b03c26aab6150aae1a6c84bf79d201d851615c Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 4 Sep 2026 15:04:35 +0530 Subject: [PATCH 4/5] docs: add the CalendarPreview page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers what this PR ships: composition and children overriding a part's computed content, `.Reset` keyed off `defaultDate`, selection bounds that never clamp navigation, grid layout with outside days off by default, `dateInfo` and `tooltipMessages` as functions, and the caption's own month and year scroller. Slot and cell-state tables are included, since both are semver-covered. `useCalendar()` is documented as a fenced example rather than a live one: `noInline` is not set on the docs' react-live provider, so a demo that declares a component would render a runtime error on the page. The footer demos wrap their two parts in a column. The root renders no element of its own, so `.Days` and `.Footer` stack on normal block flow but sit side by side inside the flex row the preview centres with — the `.Footer` section says so. Also corrects the icon count in the demo scope comment, which the undo glyph made stale. Co-Authored-By: Claude Opus 5 (1M context) --- apps/www/src/components/demo/demo.tsx | 2 +- .../docs/components/calendar-preview/demo.ts | 236 ++++++++++++++++++ .../components/calendar-preview/index.mdx | 193 ++++++++++++++ .../docs/components/calendar-preview/props.ts | 144 +++++++++++ 4 files changed, 574 insertions(+), 1 deletion(-) create mode 100644 apps/www/src/content/docs/components/calendar-preview/demo.ts create mode 100644 apps/www/src/content/docs/components/calendar-preview/index.mdx create mode 100644 apps/www/src/content/docs/components/calendar-preview/props.ts diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx index ba98b172d..a6bca6c79 100644 --- a/apps/www/src/components/demo/demo.tsx +++ b/apps/www/src/components/demo/demo.tsx @@ -62,7 +62,7 @@ import { DemoProps } from './types'; export default function Demo(props: DemoProps) { const { data, - // `...Apsara` carries the 31 icons Apsara publishes, so none of those needs + // `...Apsara` carries the 32 icons Apsara publishes, so none of those needs // its own entry — and nothing below may repeat one of their keys, because a // later key shadows the spread. A demo that needs any other glyph names a // lucide component from the block above and sizes it at the call site, diff --git a/apps/www/src/content/docs/components/calendar-preview/demo.ts b/apps/www/src/content/docs/components/calendar-preview/demo.ts new file mode 100644 index 000000000..47a173026 --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -0,0 +1,236 @@ +'use client'; + +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: ` + + + Q3 2024 + + + + + + ` + }, + { + 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: '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..06e2c9529 --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -0,0 +1,193 @@ +--- +title: Calendar Preview +description: A subcomposed calendar that owns its selection and view state. +source: packages/raystack/components/calendar-preview +--- + +import { + preview, + compositionDemo, + resetDemo, + boundsDemo, + gridDemo, + dateInfoDemo, +} from "./demo.ts"; + + + +## 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 + + + + + + + + + + + + + + + +``` + +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, and provides both to every part. + + + +### 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`. Renders only when there is something to restore. + +### CalendarPreview.Footer + +The row below the calendar. A bare string is wrapped in `Text`; anything else renders as given. + +The root renders no element of its own — it is a state owner, not a box — so `.Days` and `.Footer` stack on normal block flow but sit side by side inside a flex row. Give them a container when the surrounding layout is one. + +### 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, setScale, month, setMonth, isDateUnavailable } = useCalendar() +``` + +Calling it outside a `CalendarPreview` throws, naming the part that asked. + +### Slots + +Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): + +| Slot | Element | +|------|---------| +| `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 | +| `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-day` | The `