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..f35dfb867 --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -0,0 +1,232 @@ +'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..e0bf02336 --- /dev/null +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -0,0 +1,194 @@ +--- +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, provides both to every part, and renders a column that hugs its content. Also takes `render`, `className` and `ref`. + + + +### CalendarPreview.Days + +The day view — a header and a grid. Hugs its content rather than reserving a fixed height. + + + +### CalendarPreview.Caption + +The month label above the grid, and optionally the trigger for the month and year scroller. + + + +### CalendarPreview.Grid + +The day grid. Layout and per-day data live here rather than on the root, so a calendar with two grids can configure them independently. + + + +### CalendarPreview.Header + +The row above the grid. Composes `.Caption`, `.Reset`, `.PrevMonth` and `.NextMonth` when given no children. Takes `render`, `className` and `ref`. + +### CalendarPreview.PrevMonth / CalendarPreview.NextMonth + +Step the view one month. Never disabled by `minDate` or `maxDate` — bounds limit selection, not navigation. + +### CalendarPreview.Reset + +Restores `defaultDate`. 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. + +It needs no container of its own: the root renders a column that hugs its content, so `.Days` and `.Footer` stack whatever the surrounding layout does. + +### useCalendar + +Reads the enclosing root's state, for building parts the library does not ship. Deliberately narrow: + +```tsx +import { useCalendar } from '@raystack/apsara' + +const { value, setValue, scale, 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` | The root, a column wrapping the parts | +| `calendar-preview-days` | The day view surface | +| `calendar-preview-header` | The header row, single-month layout | +| `calendar-preview-month-header` | One month's header, when several months are shown | +| `calendar-preview-caption` | The month label | +| `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 ` + + ); + const footer = getSlot(container, 'calendar-preview-footer'); + expect(within(footer as HTMLElement).getByRole('button')).toHaveTextContent( + 'Pick a preset' + ); + expect(getSlot(container, 'calendar-preview-footer-text')).toBeNull(); + }); +}); + +describe('CalendarPreview part contract', () => { + /* Every part takes `render`, `className`, `ref` and carries a `data-slot`, + with the consumer's props spread last. */ + const parts: Array<[string, string, React.ReactNode]> = [ + [ + 'Days', + 'calendar-preview-days', + + ], + [ + 'Header', + 'calendar-preview-header', + + ], + [ + 'PrevMonth', + 'calendar-preview-prev-month', + + ], + [ + 'NextMonth', + 'calendar-preview-next-month', + + ], + [ + 'Caption', + 'calendar-preview-caption', + + ], + [ + 'Grid', + 'calendar-preview-grid', + + ], + [ + 'Footer', + 'calendar-preview-footer', + + ] + ]; + + it.each( + parts + )('%s carries its slot and spreads props last', (_name, slot, element) => { + const { container } = renderCalendar(element); + const node = getSlot(container, slot); + expect(node).toBeInTheDocument(); + expect(node).toHaveClass('mine'); + expect(node).toHaveAttribute('data-mine', 'true'); + }); + + it('carries its own slot on the root, and spreads props last', () => { + const { container } = render( + + + + ); + const root = getSlot(container, 'calendar-preview'); + expect(root).toBeInTheDocument(); + expect(root).toHaveClass('mine'); + expect(root).toHaveAttribute('data-mine', 'true'); + expect(root).toHaveAttribute('data-scale', 'day'); + }); + + /* The root is a box, not a bare provider: without one, `.Days` and `.Footer` + inherit the surrounding layout and sit side by side in a flex row. */ + it('contains the day view and the footer rather than emitting them loose', () => { + const { container } = render( + + + Dates are inclusive + + ); + const root = getSlot(container, 'calendar-preview') as HTMLElement; + expect(getSlot(root, 'calendar-preview-days')?.parentElement).toBe(root); + expect(getSlot(root, 'calendar-preview-footer')?.parentElement).toBe(root); + }); + + it('renders .Reset with its slot and the consumer props', () => { + const { container } = renderCalendar( + , + { defaultDate: new Date(2026, 7, 20) } + ); + const node = getSlot(container, 'calendar-preview-reset'); + expect(node).toHaveClass('mine'); + expect(node).toHaveAttribute('data-mine', 'true'); + }); + + it('lets render replace the element each part produces', () => { + const { container } = renderCalendar( + }> + }> + } /> + + + ); + expect(getSlot(container, 'calendar-preview-days')?.tagName).toBe( + 'SECTION' + ); + expect(getSlot(container, 'calendar-preview-header')?.tagName).toBe('NAV'); + expect(getSlot(container, 'calendar-preview-caption')?.tagName).toBe('H2'); + }); + + it('forwards ref to the element each part produces', () => { + const days = { current: null as HTMLDivElement | null }; + const header = { current: null as HTMLDivElement | null }; + const grid = { current: null as HTMLDivElement | null }; + renderCalendar( + + + + + ); + expect(days.current).toHaveAttribute('data-slot', 'calendar-preview-days'); + expect(header.current).toHaveAttribute( + 'data-slot', + 'calendar-preview-header' + ); + expect(grid.current).toHaveAttribute('data-slot', 'calendar-preview-grid'); + }); +}); + +describe('CalendarPreview part boundaries', () => { + it('names the part when a cell is used outside a grid', () => { + const error = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + expect(() => + render( + + + + ) + ).toThrow('CalendarPreview.Day must be used within '); + error.mockRestore(); + }); + + it('runs a consumer onClick alongside the reset', () => { + const onClick = vi.fn(); + const onValueChange = vi.fn(); + const { container } = renderCalendar( + , + { defaultDate: new Date(2026, 7, 20), onValueChange } + ); + fireEvent.click( + getSlot(container, 'calendar-preview-reset') as HTMLElement + ); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledTimes(1); + }); + + it('leaves the reset inert while the calendar is disabled', () => { + const { container } = renderCalendar(, { + defaultDate: new Date(2026, 7, 20), + disabled: true + }); + expect(getSlot(container, 'calendar-preview-reset')).toBeDisabled(); + }); + + it('scrolls the active row of the caption scroller into view', () => { + const scrollIntoView = vi.fn(); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + value: scrollIntoView, + writable: true, + configurable: true + }); + const { container } = renderCalendar( + + + + + + ); + openCaption(container); + /* One per column — the active month and the active year. */ + expect(scrollIntoView).toHaveBeenCalledTimes(2); + expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center' }); + Reflect.deleteProperty(Element.prototype, 'scrollIntoView'); + }); +}); + +describe('CalendarPreview public surface', () => { + /* The scope boundary for this phase, asserted rather than described: the + popover, the input and the period views land in later PRs, and a part + appearing here early would be public API shipped by accident. */ + /* `displayName` is an own property of the root function `Object.assign` + writes the parts onto, so it is not one of them. */ + const partNames = Object.keys(CalendarPreviewFromBarrel).filter( + key => key !== 'displayName' + ); + + it('exports exactly the parts this phase builds', () => { + expect(partNames.sort()).toEqual( + [ + 'Caption', + 'Day', + 'Days', + 'Footer', + 'Grid', + 'Header', + 'NextMonth', + 'PrevMonth', + 'Reset', + 'Weekday' + ].sort() + ); + }); + + it('gives every part a displayName', () => { + expect(CalendarPreviewFromBarrel.displayName).toBe('CalendarPreview'); + for (const name of partNames) { + const part = CalendarPreviewFromBarrel[ + name as keyof typeof CalendarPreviewFromBarrel + ] as { displayName?: string }; + expect(part.displayName, `${name} has no displayName`).toBe( + `CalendarPreview.${name}` + ); + } + }); +}); + +describe('defaultFormatValue', () => { + it('formats a day as DD/MM/YYYY', () => { + expect(defaultFormatValue(new Date(2027, 4, 20), 'day')).toBe('20/05/2027'); + }); + + it('formats the coarser scales by their own shorthand', () => { + const value = { date: '2026-08-31', scale: 'month' as const }; + expect(defaultFormatValue(value, 'month')).toBe('Aug 2026'); + expect(defaultFormatValue(value, 'quarter')).toBe('Q3 2026'); + expect(defaultFormatValue(value, 'halfYear')).toBe('H2 2026'); + expect(defaultFormatValue(value, 'year')).toBe('2026'); + expect( + defaultFormatValue({ date: '2026-02-01', scale: 'day' }, 'halfYear') + ).toBe('H1 2026'); + }); +}); + +describe('useCalendar', () => { + function Probe() { + const { value, setValue, month, setMonth, scale, isDateUnavailable } = + useCalendar(); + return ( +
+ {value ? value.getDate() : 'none'} + {month.getMonth()} + {scale} + + {String(isDateUnavailable(new Date(2026, 7, 1)))} + + + + +
+ ); + } + + it('reads the value, the view month, the scale and the predicate', () => { + render( + + + + ); + expect(screen.getByTestId('value')).toHaveTextContent('none'); + expect(screen.getByTestId('month')).toHaveTextContent('7'); + expect(screen.getByTestId('scale')).toHaveTextContent('day'); + expect(screen.getByTestId('blocked')).toHaveTextContent('true'); + }); + + it('commits through the same state the parts use', () => { + function Harness() { + const [value, setValue] = useState(null); + return ( + + + + + ); + } + const { container } = render(); + + fireEvent.click(screen.getByText('set')); + expect(screen.getByTestId('value')).toHaveTextContent('20'); + expect(dayCell(container, '20')).toHaveAttribute('data-selected'); + + fireEvent.click(screen.getByText('move')); + expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent( + 'Oct 2026' + ); + + fireEvent.click(screen.getByText('clear')); + expect(screen.getByTestId('value')).toHaveTextContent('none'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx new file mode 100644 index 000000000..cdb972366 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/data-slots.test.tsx @@ -0,0 +1,310 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; + +const TODAY = new Date(2026, 7, 15); +const AUGUST = new Date(2026, 7, 1); + +function renderCalendar(ui?: React.ReactNode, props = {}) { + return render( + + {ui ?? } + + ); +} + +describe('CalendarPreview data-slot contract', () => { + it('exposes a slot for every element the default day view renders', () => { + const { container } = renderCalendar(); + expectSlots(container, [ + 'calendar-preview', + 'calendar-preview-days', + 'calendar-preview-header', + 'calendar-preview-prev-month', + 'calendar-preview-caption', + 'calendar-preview-next-month', + 'calendar-preview-grid', + 'calendar-preview-weeks', + 'calendar-preview-table', + 'calendar-preview-skeleton', + 'calendar-preview-weekday', + 'calendar-preview-day', + 'calendar-preview-day-number' + ]); + }); + + it('renders exactly the documented slots, and no others', () => { + const { container } = renderCalendar(); + const rendered = new Set( + Array.from(container.querySelectorAll('[data-slot]')) + .map(element => element.getAttribute('data-slot') ?? '') + .filter(name => name.startsWith('calendar-preview')) + ); + /* Fails on a typo or an undocumented addition as loudly as on a rename, + which is the point: slot names are semver-covered public API. */ + expect([...rendered].sort()).toEqual( + [ + 'calendar-preview', + 'calendar-preview-day', + 'calendar-preview-day-number', + 'calendar-preview-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 a slot for every element the two-month day view renders', () => { + const { container } = renderCalendar( + + ); + expectSlots(container, [ + 'calendar-preview', + 'calendar-preview-days', + 'calendar-preview-month-header', + 'calendar-preview-prev-month', + 'calendar-preview-caption', + 'calendar-preview-next-month', + 'calendar-preview-grid', + 'calendar-preview-table', + 'calendar-preview-weekday', + 'calendar-preview-day' + ]); + /* The single-month header is the one slot this layout must not render — + each month captions itself instead. */ + expect(getSlot(container, 'calendar-preview-header')).toBeNull(); + }); + + it('exposes the reset slot only when there is something to restore', () => { + const { container } = renderCalendar(undefined, { + defaultDate: new Date(2026, 7, 10), + defaultValue: new Date(2026, 7, 20) + }); + expect(getSlot(container, 'calendar-preview-reset')).not.toBeNull(); + }); + + it('exposes the footer slots when a footer is mounted', () => { + const { container } = renderCalendar( + <> + + Dates are inclusive + + ); + expectSlots(container, [ + 'calendar-preview-footer', + 'calendar-preview-footer-text' + ]); + }); + + it('exposes the day-info slot only where dateInfo returns something', () => { + const { container } = renderCalendar( + + (date.getDate() === 15 ? 'INFO' : null)} + /> + + ); + expect(getAllSlots(container, 'calendar-preview-day-info')).toHaveLength(1); + }); + + it('omits the day-info slot when no dateInfo is given', () => { + const { container } = renderCalendar(); + expect(getSlot(container, 'calendar-preview-day-info')).toBeNull(); + }); + + it('exposes the tooltip slot on hover when tooltips are enabled', async () => { + renderCalendar( + + (date.getDate() === 15 ? 'Fifteenth' : null)} + /> + + ); + const user = userEvent.setup(); + const day = screen.getByText('15').closest('button'); + await user.hover(day as HTMLButtonElement); + expect(await screen.findByText('Fifteenth')).toBeInTheDocument(); + expect( + getSlot(document.body, 'calendar-preview-day-tooltip') + ).not.toBeNull(); + }); + + it('exposes the caption scroller slots once it is opened', () => { + const { container } = renderCalendar( + + + + + + ); + const caption = getSlot(container, 'calendar-preview-caption'); + fireEvent.pointerDown(caption as HTMLElement); + fireEvent.click(caption as HTMLElement); + expectSlots(document.body, [ + 'calendar-preview-caption-positioner', + 'calendar-preview-caption-popup', + 'calendar-preview-caption-months', + 'calendar-preview-caption-month', + 'calendar-preview-caption-years', + 'calendar-preview-caption-year' + ]); + }); +}); + +describe('CalendarPreview state attributes', () => { + it('marks the day view with its scale and its inert states', () => { + const { container } = renderCalendar(undefined, { + disabled: true, + readOnly: true + }); + const days = getSlot(container, 'calendar-preview-days'); + expect(days).toHaveAttribute('data-scale', 'day'); + expect(days).toHaveAttribute('data-disabled', 'true'); + expect(days).toHaveAttribute('data-readonly', 'true'); + }); + + it('marks the day view busy while its grid is loading', () => { + const { container } = renderCalendar( + + + + + ); + expect(getSlot(container, 'calendar-preview-days')).toHaveAttribute( + 'data-busy', + 'true' + ); + expect(getSlot(container, 'calendar-preview-skeleton')).toHaveAttribute( + 'data-visible', + 'true' + ); + expect(getSlot(container, 'calendar-preview-table')).toHaveAttribute( + 'aria-busy', + 'true' + ); + }); + + it('carries the scale on the caption and on every cell', () => { + const { container } = renderCalendar(); + expect(getSlot(container, 'calendar-preview-caption')).toHaveAttribute( + 'data-scale', + 'day' + ); + for (const cell of getAllSlots(container, 'calendar-preview-day')) { + expect(cell).toHaveAttribute('data-scale', 'day'); + } + }); + + it('marks the selected cell, and only that one', () => { + const { container } = renderCalendar(undefined, { + defaultValue: new Date(2026, 7, 20) + }); + const selected = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-selected') + ); + expect(selected).toHaveLength(1); + expect(selected[0]).toHaveTextContent('20'); + }); + + it("marks today's cell", () => { + const { container } = renderCalendar(); + const today = getAllSlots(container, 'calendar-preview-day').filter(cell => + cell.hasAttribute('data-today') + ); + expect(today).toHaveLength(1); + expect(today[0]).toHaveTextContent('15'); + }); + + it('marks unavailable cells and leaves the rest unmarked', () => { + const { container } = renderCalendar(undefined, { + minDate: new Date(2026, 7, 10) + }); + const cells = getAllSlots(container, 'calendar-preview-day'); + const unavailable = cells.filter(cell => + cell.hasAttribute('data-unavailable') + ); + expect(unavailable.length).toBeGreaterThan(0); + expect(unavailable.length).toBeLessThan(cells.length); + expect(unavailable[unavailable.length - 1]).toHaveTextContent('9'); + }); + + it('renders no outside days by default', () => { + const { container } = renderCalendar(); + /* August 2026 starts on a Saturday, so a grid that showed outside days + would open with five of them. Reference A leaves those cells blank. */ + const outside = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-outside') + ); + expect(outside).toHaveLength(0); + }); + + it('marks the days that fall outside the displayed month when asked', () => { + const { container } = renderCalendar( + + + + ); + const outside = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-outside') + ); + expect(outside.length).toBeGreaterThan(0); + expect(outside[0]).not.toHaveAttribute('data-today'); + }); + + it('marks the focused cell as the draft until it is committed', () => { + const { container } = renderCalendar(); + expect( + getAllSlots(container, 'calendar-preview-day').filter(cell => + cell.hasAttribute('data-draft') + ) + ).toHaveLength(0); + + const day = screen.getByText('20').closest('button'); + fireEvent.focus(day as HTMLButtonElement); + + const drafted = getAllSlots(container, 'calendar-preview-day').filter( + cell => cell.hasAttribute('data-draft') + ); + expect(drafted).toHaveLength(1); + expect(drafted[0]).toHaveTextContent('20'); + expect(drafted[0]).not.toHaveAttribute('data-selected'); + }); + + it('marks the active row in each caption column', () => { + const { container } = renderCalendar( + + + + + + ); + const caption = getSlot(container, 'calendar-preview-caption'); + expect(caption).toHaveAttribute('data-dropdown', 'true'); + fireEvent.pointerDown(caption as HTMLElement); + fireEvent.click(caption as HTMLElement); + + const activeMonth = getAllSlots( + document.body, + 'calendar-preview-caption-month' + ).filter(option => option.hasAttribute('data-active')); + expect(activeMonth).toHaveLength(1); + expect(activeMonth[0]).toHaveTextContent('Aug'); + + const activeYear = getAllSlots( + document.body, + 'calendar-preview-caption-year' + ).filter(option => option.hasAttribute('data-active')); + expect(activeYear).toHaveLength(1); + expect(activeYear[0]).toHaveTextContent('2026'); + }); +}); diff --git a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts index c16d607b9..f1b0d7483 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -7,10 +7,16 @@ import { endOfQuarterKey, endOfYearKey, epoch, + formatCaptionLabel, + formatDayLabel, + formatMonthLabel, isDayKey, monthFromName, monthOf, + monthShortNames, + monthStart, parseKey, + shiftMonths, startOfMonthKey, startOfQuarterKey, startOfYearKey, @@ -164,3 +170,70 @@ describe('monthFromName', () => { expect(monthFromName(name)).toBeNull(); }); }); + +describe('shiftMonths', () => { + it('moves whole months and lands on the first', () => { + expect(dayKey(shiftMonths(new Date(2026, 7, 15), 1))).toBe('2026-09-01'); + expect(dayKey(shiftMonths(new Date(2026, 7, 15), -1))).toBe('2026-07-01'); + expect(dayKey(shiftMonths(new Date(2026, 7, 15), 0))).toBe('2026-08-01'); + }); + + it('crosses a year boundary in both directions', () => { + expect(dayKey(shiftMonths(new Date(2026, 11, 10), 1))).toBe('2027-01-01'); + expect(dayKey(shiftMonths(new Date(2026, 0, 10), -1))).toBe('2025-12-01'); + }); + + /* Stepping from the 31st would otherwise clamp to the 28th and stay there. */ + it('does not drift when stepping repeatedly from a long month', () => { + let month = new Date(2026, 0, 31); + for (let step = 0; step < 3; step += 1) month = shiftMonths(month, 1); + expect(dayKey(month)).toBe('2026-04-01'); + }); +}); + +describe('monthStart', () => { + it('builds the first of a month from a 0-indexed month', () => { + expect(dayKey(monthStart(2026, 0))).toBe('2026-01-01'); + expect(dayKey(monthStart(2026, 11))).toBe('2026-12-01'); + }); +}); + +describe('label formatters', () => { + it('formats a day as DD/MM/YYYY', () => { + expect(formatDayLabel(new Date(2027, 4, 20))).toBe('20/05/2027'); + expect(formatDayLabel(new Date(2027, 0, 5))).toBe('05/01/2027'); + }); + + it('formats a month in short form', () => { + expect(formatMonthLabel(new Date(2027, 4, 20))).toBe('May 2027'); + expect(formatMonthLabel(new Date(2027, 8, 1))).toBe('Sep 2027'); + }); + + it('formats a caption with the month abbreviated', () => { + expect(formatCaptionLabel(new Date(2027, 8, 1))).toBe('Sep 2027'); + }); + + it('reads the labels in an explicit zone', () => { + const instant = new Date(Date.UTC(2026, 7, 31, 20, 0)); + expect(formatDayLabel(instant, 'Asia/Tokyo')).toBe('01/09/2026'); + expect(formatMonthLabel(instant, 'Asia/Tokyo')).toBe('Sep 2026'); + expect(formatCaptionLabel(instant, 'UTC')).toBe('Aug 2026'); + }); +}); + +describe('monthShortNames', () => { + it('lists twelve abbreviations, January first', () => { + const names = monthShortNames(); + expect(names).toHaveLength(12); + expect(names[0]).toBe('Jan'); + expect(names[11]).toBe('Dec'); + }); + + /* The caption's month column shows these, so the parser has to take them + back — the same contract the full names carry. */ + it('round-trips through monthFromName', () => { + monthShortNames().forEach((name, index) => { + expect(monthFromName(name)).toBe(index + 1); + }); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx new file mode 100644 index 000000000..7d841e979 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { + mergeProps, + Popover as PopoverPrimitive, + useRender +} from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { type ReactNode, useEffect, useRef } from 'react'; +import styles from './calendar-preview.module.css'; +import { + useCalendarPreviewContext, + useCalendarPreviewDaysContext +} from './calendar-preview-context'; +import { + formatCaptionLabel, + monthShortNames, + monthStart, + shiftMonths +} from './date-adapter'; + +/* Two elements, so two prop shapes: a plain caption is a `span`, one that + opens the scroller is a `button`. */ +export type CalendarPreviewCaptionProps = + | ({ dropdown?: false } & useRender.ComponentProps<'span'>) + | ({ dropdown: true } & useRender.ComponentProps<'button'>); + +/** + * The label above the grid. Children replace it entirely, so + * `Q3 2026` works. + * + * With `dropdown` it opens our own month and year scroller. No `Select` may be + * mounted here — one is what makes the popover dismissal loop return. Picking + * moves the view; it never selects a value. + */ +export function CalendarPreviewCaption(props: CalendarPreviewCaptionProps) { + return props.dropdown ? ( + + ) : ( + + ); +} + +CalendarPreviewCaption.displayName = 'CalendarPreview.Caption'; + +function useCaptionLabel(): ReactNode { + const { month, timeZone } = useCalendarPreviewContext( + 'CalendarPreview.Caption' + ); + const days = useCalendarPreviewDaysContext(); + const count = days?.numberOfMonths ?? 1; + if (count <= 1) return formatCaptionLabel(month, timeZone); + const last = shiftMonths(month, count - 1); + return `${formatCaptionLabel(month, timeZone)} – ${formatCaptionLabel(last, timeZone)}`; +} + +function CaptionLabel({ + dropdown: _dropdown, + className, + children, + render, + ref, + ...props +}: { dropdown?: false } & useRender.ComponentProps<'span'>) { + const { scale } = useCalendarPreviewContext('CalendarPreview.Caption'); + const label = useCaptionLabel(); + + return useRender({ + defaultTagName: 'span', + ref, + render, + props: mergeProps<'span'>( + { + className: cx(styles.caption, className), + 'data-slot': 'calendar-preview-caption', + 'data-scale': scale, + children: children ?? label + } as useRender.ComponentProps<'span'>, + props + ) + }); +} + +function CaptionDropdown({ + dropdown: _dropdown, + className, + children, + render, + ref, + ...props +}: { dropdown: true } & useRender.ComponentProps<'button'>) { + const { month, setMonth, yearRange, scale, disabled } = + useCalendarPreviewContext('CalendarPreview.Caption'); + const label = useCaptionLabel(); + + const activeMonth = month.getMonth(); + const activeYear = month.getFullYear(); + const years: number[] = []; + for (let year = yearRange.from; year <= yearRange.to; year += 1) { + years.push(year); + } + + return ( + + + {children ?? label} + + + + + ({ + key: name, + text: name, + active: index === activeMonth, + onSelect: () => setMonth(monthStart(activeYear, index)) + }))} + /> + ({ + key: String(year), + text: String(year), + active: year === activeYear, + onSelect: () => setMonth(monthStart(year, activeMonth)) + }))} + /> + + + + + ); +} + +interface CaptionOption { + key: string; + text: string; + active: boolean; + onSelect: () => void; +} + +function CaptionColumn({ + slot, + optionSlot, + label, + options +}: { + slot: string; + optionSlot: string; + label: string; + options: CaptionOption[]; +}) { + const activeRef = useRef(null); + + /* A twenty-year column otherwise opens scrolled to the wrong end. Optional + call: jsdom does not implement scrollIntoView. */ + useEffect(() => { + activeRef.current?.scrollIntoView?.({ block: 'center' }); + }, []); + + return ( +
+ {options.map(option => ( + + ))} +
+ ); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx new file mode 100644 index 000000000..73d73e484 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -0,0 +1,109 @@ +'use client'; + +import { createContext, type ReactNode, useContext } from 'react'; +import type { DayKey } from './date-adapter'; +import type { 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, month-end correct. At day scale they are the same day. */ + period: { start: DayKey; end: DayKey }; + /** + * The day acted on — never null, even when `value` is, so a clear still says + * which cell the user clicked. + */ + toDate: () => Date; +} + +/* Generic so a later phase's scale-aware arms carry a `ScaleValue` without a + second context: stored as `unknown`, cast once at the hook boundary. */ +export interface CalendarPreviewContextValue { + value: Value; + /** `occasion` is the day acted on, which a cleared `value` cannot carry. */ + setValue: ( + value: Value, + reason: CalendarPreviewChangeReason, + occasion: Date + ) => void; + /** Read even when `value` is controlled. */ + defaultDate: Date | undefined; + /** A value reset — it never moves the view. */ + reset: () => void; + month: Date; + /** Never clamped by `minDate` / `maxDate`. */ + setMonth: (month: Date) => void; + yearRange: { from: number; to: number }; + scale: Scale; + setScale: (scale: Scale) => void; + isDateUnavailable: (date: Date) => boolean; + today: Date; + timeZone: string | undefined; + clearable: boolean; + disabled: boolean; + readOnly: boolean; + formatValue: (value: Date | ScaleValue, scale: Scale) => string; +} + +const CalendarPreviewContext = + createContext | null>(null); + +export function CalendarPreviewProvider({ + value, + children +}: { + value: CalendarPreviewContextValue; + children: ReactNode; +}) { + return ( + {children} + ); +} + +/* `part` is the caller's display name, so the throw points at the element the + author wrote rather than at this file. */ +export function useCalendarPreviewContext( + part: string +): CalendarPreviewContextValue { + const context = useContext(CalendarPreviewContext); + if (!context) { + throw new Error(`${part} must be used within `); + } + return context as CalendarPreviewContextValue; +} + +/* `.Days` owns this rather than the root, so two day views in one tree cannot + disable each other's navigation. */ +export interface CalendarPreviewDaysContextValue { + numberOfMonths: number; + busy: boolean; + setBusy: (busy: boolean) => void; +} + +const CalendarPreviewDaysContext = + createContext(null); + +export function CalendarPreviewDaysProvider({ + value, + children +}: { + value: CalendarPreviewDaysContextValue; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useCalendarPreviewDaysContext(): CalendarPreviewDaysContextValue | null { + return useContext(CalendarPreviewDaysContext); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx new file mode 100644 index 000000000..8d8664ccf --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import { useMemo, useState } from 'react'; +import styles from './calendar-preview.module.css'; +import { + type CalendarPreviewDaysContextValue, + CalendarPreviewDaysProvider, + useCalendarPreviewContext +} from './calendar-preview-context'; +import { CalendarPreviewGrid } from './calendar-preview-grid'; +import { CalendarPreviewHeader } from './calendar-preview-header'; + +export interface CalendarPreviewDaysProps + extends useRender.ComponentProps<'div'> { + /** + * How many months the grid shows side by side. + * @defaultValue 1 + */ + numberOfMonths?: number; +} + +/* Owns what the header and grid share, so two day views in one tree cannot + disable each other's navigation. */ +export function CalendarPreviewDays({ + numberOfMonths = 1, + className, + children, + render, + ref, + ...props +}: CalendarPreviewDaysProps) { + const { disabled, readOnly, scale } = useCalendarPreviewContext( + 'CalendarPreview.Days' + ); + const [busy, setBusy] = useState(false); + + const context = useMemo( + () => ({ numberOfMonths, busy, setBusy }), + [numberOfMonths, busy] + ); + + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.days, className), + 'data-slot': 'calendar-preview-days', + 'data-scale': scale, + 'data-disabled': disabled || undefined, + 'data-readonly': readOnly || undefined, + 'data-busy': busy || undefined, + /* Several months caption themselves inside the grid, so a `.Header` + here would be a second, redundant row. */ + children: children ?? ( + <> + {numberOfMonths <= 1 && } + + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); + + return ( + + {element} + + ); +} + +CalendarPreviewDays.displayName = 'CalendarPreview.Days'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx new file mode 100644 index 000000000..da6af402b --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { Flex } from '../flex'; +import { Text } from '../text'; +import styles from './calendar-preview.module.css'; + +export type CalendarPreviewFooterProps = ComponentProps; + +/* A bare string is wrapped in `Text` so the common case needs no knowledge of + the type scale; anything else renders as given. */ +export function CalendarPreviewFooter({ + className, + children, + ...props +}: CalendarPreviewFooterProps) { + return ( + + {typeof children === 'string' ? ( + + {children} + + ) : ( + children + )} + + ); +} + +CalendarPreviewFooter.displayName = 'CalendarPreview.Footer'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx new file mode 100644 index 000000000..47d35c777 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx @@ -0,0 +1,424 @@ +'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 MonthCaptionProps, + 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'; +import { + CalendarPreviewNextMonth, + CalendarPreviewPrevMonth +} from './calendar-preview-header'; +import { formatCaptionLabel, formatWeekdayLabel } from './date-adapter'; + +/* The only file that may import react-day-picker. It runs with + `hideNavigation` and `captionLayout='label'` so it never mounts a `Select`, + and the selection props come from root context rather than from + `CalendarPreviewGridProps` — which is what lets `...props` stay last. */ +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. + * + * Off, unlike the current `DatePicker`: reference A ends every grid on the + * last day of its month and leaves the leading cells blank. The cells are + * still rendered, so the week rows keep their shape — they are just empty. + * + * @defaultValue false + */ + showOutsideDays?: boolean; + /** Render a week-number column. */ + showWeekNumber?: boolean; + /** First day of the week, 0 (Sunday) to 6. */ + weekStartsOn?: DayPickerProps['weekStartsOn']; + /** Extra day modifiers, passed through to react-day-picker. */ + modifiers?: DayPickerProps['modifiers']; + /** Override react-day-picker's component slots. */ + components?: Partial; + /** + * Extra content for a day, rendered above the date number. + * + * A function, not a record: the record form keyed cells by a formatted + * string and silently missed every day once a `timeZone` shifted the key. + */ + dateInfo?: (date: Date) => ReactNode; + /** Whether day tooltips are shown at all. @defaultValue false */ + showTooltip?: boolean; + /** The tooltip for a day, or nothing. A function, for the same reason. */ + tooltipMessages?: (date: Date) => ReactNode; + /** Cover the grid with a skeleton and stop navigation. */ + loading?: boolean; +} + +export function CalendarPreviewGrid({ + fixedWeeks, + showOutsideDays = false, + 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 is a sibling, so loading has to reach their common parent for + navigation to go inert with it. */ + useEffect(() => { + if (!setBusy) return; + setBusy(loading); + return () => setBusy(false); + }, [loading, setBusy]); + + const gridContext: GridContextValue = { + dateInfo, + tooltipMessages, + showTooltip, + loading, + rootRender: render, + rootRef: ref, + rootProps: props + }; + + const months = days?.numberOfMonths ?? 1; + + /* Several months have no single header to caption them, so each month + captions itself and `.Days` renders no `.Header` above. */ + const slots = useMemo( + () => ({ + Root: CalendarPreviewGridRoot, + MonthGrid: CalendarPreviewWeeks, + DayButton: CalendarPreviewDay, + Weekday: CalendarPreviewWeekday, + ...(months > 1 ? { MonthCaption: CalendarPreviewMonthCaption } : {}), + ...components + }), + [components, months] + ); + + const handleSelect = (selected: Date | undefined, triggerDate: Date) => { + if (readOnly || disabled) return; + setValue(selected ?? null, selected ? 'select' : 'clear', triggerDate); + }; + + /* `mode`, `required`, `selected` and `onSelect` stay on the elements below: + RDP discriminates its union on the literal `required`, which a `boolean` + cannot narrow, so both arms are written out rather than cast away. */ + const base = { + month, + onMonthChange: setMonth, + timeZone, + today, + hideNavigation: true, + captionLayout: 'label', + numberOfMonths: months, + formatters: GRID_FORMATTERS, + disabled: disabled ? true : isDateUnavailable, + fixedWeeks, + showOutsideDays, + showWeekNumber, + weekStartsOn, + modifiers, + components: slots, + className: cx(styles.grid, className), + 'data-slot': 'calendar-preview-grid', + classNames: GRID_CLASS_NAMES + } satisfies Omit & { + 'data-slot': string; + }; + + return ( + + {clearable ? ( + + ) : ( + + )} + + ); +} + +CalendarPreviewGrid.displayName = 'CalendarPreview.Grid'; + +/* `` forwards only `className`, `style` and `data-*` to its root, + so `render`, `ref` and the consumer's props have to land here instead. */ +function CalendarPreviewGridRoot({ rootRef, ...rootProps }: RootProps) { + const { + rootRender, + rootRef: ref, + rootProps: extra + } = useGridContext('CalendarPreview.Grid'); + return useRender({ + defaultTagName: 'div', + ref, + render: rootRender, + props: mergeProps<'div'>(rootProps, extra) + }); +} + +function CalendarPreviewWeeks(props: MonthGridProps) { + const { loading } = useGridContext('CalendarPreview.Grid'); + return ( +
+
+ + + ); +} + +/* Three fixed grid columns rather than spacer elements: the empty nav track is + still reserved when a month carries no button, so every caption centres on + its own grid instead of drifting toward the buttonless side. */ +function CalendarPreviewMonthCaption({ + calendarMonth, + displayIndex, + /* The class react-day-picker passes here hides the caption, which is what + the single-month layout wants and this header must not be. */ + className: _className, + ...props +}: MonthCaptionProps) { + const { timeZone } = useCalendarPreviewContext('CalendarPreview.Grid'); + const days = useCalendarPreviewDaysContext(); + + return ( +
+ {displayIndex === 0 && ( + + )} + + {formatCaptionLabel(calendarMonth.date, timeZone)} + + {displayIndex === (days?.numberOfMonths ?? 1) - 1 && ( + + )} +
+ ); +} + +export interface CalendarPreviewDayProps + extends DayButtonProps, + Pick, 'render' | 'ref'> {} + +/* At day scale the draft is the roving-focus cell — arrowed to, not entered. + PR 5's scale-switch draft writes the same attribute. */ +export function CalendarPreviewDay({ + day, + modifiers, + className, + children, + render, + ref, + ...props +}: CalendarPreviewDayProps) { + const { scale } = 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'> {} + +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'; + +/* 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) +}; + +/* month_caption is hidden, not removed: `.Header` owns the visible caption, + and RDP still labels each table through it. */ +const GRID_CLASS_NAMES: DayPickerProps['classNames'] = { + months: styles.months, + month: styles.month, + month_caption: styles['month-caption'], + caption_label: styles['caption-label'], + weeks: styles.weeks, + week: styles.week, + weekdays: styles.weekdays, + day: styles.day, + today: styles.today, + outside: styles.outside, + disabled: styles.disabled, + selected: styles.selected, + hidden: styles.hidden, + week_number: styles['week-number'], + week_number_header: styles['week-number-header'] +}; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-header.tsx b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx new file mode 100644 index 000000000..15f00da58 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { cx } from 'class-variance-authority'; +import type { ReactNode } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '~/icons'; +import { IconButton } from '../icon-button'; +import styles from './calendar-preview.module.css'; +import { CalendarPreviewCaption } from './calendar-preview-caption'; +import { + useCalendarPreviewContext, + useCalendarPreviewDaysContext +} from './calendar-preview-context'; +import { CalendarPreviewReset } from './calendar-preview-reset'; +import { shiftMonths } from './date-adapter'; + +export type CalendarPreviewHeaderProps = useRender.ComponentProps<'div'>; + +/* Single-month only, and source order is tab order, so the row needs no CSS + reordering. Several months caption themselves inside `.Grid`. */ +export function CalendarPreviewHeader({ + className, + children, + render, + ref, + ...props +}: CalendarPreviewHeaderProps) { + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.header, className), + 'data-slot': 'calendar-preview-header', + children: children ?? ( + <> + + + + + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); + + return element; +} + +CalendarPreviewHeader.displayName = 'CalendarPreview.Header'; + +export type CalendarPreviewNavProps = useRender.ComponentProps<'button'>; + +/* Never disabled by `minDate`: bounds limit selection, not navigation. */ +export function CalendarPreviewPrevMonth(props: CalendarPreviewNavProps) { + return ( + } + /> + ); +} + +CalendarPreviewPrevMonth.displayName = 'CalendarPreview.PrevMonth'; + +export function CalendarPreviewNextMonth(props: CalendarPreviewNavProps) { + return ( + } + /> + ); +} + +CalendarPreviewNextMonth.displayName = 'CalendarPreview.NextMonth'; + +interface NavButtonProps extends CalendarPreviewNavProps { + delta: number; + slot: string; + label: string; + icon: ReactNode; +} + +function CalendarPreviewNavButton({ + delta, + slot, + label, + icon, + className, + children, + render, + ref, + ...props +}: NavButtonProps) { + const { month, setMonth, disabled } = useCalendarPreviewContext( + 'CalendarPreview.Header' + ); + const days = useCalendarPreviewDaysContext(); + const inert = disabled || (days?.busy ?? false); + + return useRender({ + defaultTagName: 'button', + ref, + render: render ?? , + props: mergeProps<'button'>( + { + type: 'button', + className: cx(styles['nav-button'], className), + 'data-slot': slot, + 'aria-label': label, + disabled: inert, + onClick: () => setMonth(shiftMonths(month, delta)), + children: children ?? icon + } as useRender.ComponentProps<'button'>, + props + ) + }); +} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx new file mode 100644 index 000000000..b736e3447 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { cx } from 'class-variance-authority'; +import type { ComponentProps } from 'react'; +import { UndoIcon } from '~/icons'; +import { IconButton } from '../icon-button'; +import styles from './calendar-preview.module.css'; +import { useCalendarPreviewContext } from './calendar-preview-context'; +import { dayKey } from './date-adapter'; + +export type CalendarPreviewResetProps = ComponentProps; + +/** + * Restores `defaultDate`. 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, + 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 ( + { + onClick?.(event); + reset(); + }} + {...props} + > + {children ?? } + + ); +} + +CalendarPreviewReset.displayName = 'CalendarPreview.Reset'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx new file mode 100644 index 000000000..c4fb9dcd2 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -0,0 +1,289 @@ +'use client'; + +import { mergeProps, useRender } from '@base-ui/react'; +import { useControlled } from '@base-ui/utils/useControlled'; +import { cx } from 'class-variance-authority'; +import { useCallback, useMemo } from 'react'; +import styles from './calendar-preview.module.css'; +import { + type CalendarPreviewChangeDetails, + type CalendarPreviewChangeReason, + type CalendarPreviewContextValue, + CalendarPreviewProvider +} from './calendar-preview-context'; +import { + dayKey, + formatDayLabel, + formatMonthLabel, + monthOf, + parseKey, + yearOf +} from './date-adapter'; +import { periodOf, type Scale, type ScaleValue } from './lib/scale'; + +const DEFAULT_YEAR_SPAN = 10; + +/* `defaultValue` is omitted because `HTMLAttributes` already declares it as a + form value, which is not what it means here. */ +export interface CalendarPreviewProps + extends Omit, 'defaultValue'> { + /** The selected day (controlled). */ + value?: Date | null; + /** The initially selected day (uncontrolled). */ + defaultValue?: Date | null; + /** Called when a day is committed or cleared. */ + onValueChange?: ( + value: Date | null, + details: CalendarPreviewChangeDetails + ) => void; + + /** The first month the grid displays (controlled). */ + month?: Date; + /** + * The month the grid opens on. + * @defaultValue the month of `value`, else `today` + */ + defaultMonth?: Date; + /** Called when the view moves. */ + onMonthChange?: (month: Date) => void; + /** + * The years the caption's year column offers. + * @defaultValue ten years either side of `today`, widened to cover any bound + */ + yearRange?: { from: number; to: number }; + + /** Earliest selectable day, inclusive. Never clamps navigation. */ + minDate?: Date; + /** Latest selectable day, inclusive. Never clamps navigation. */ + maxDate?: Date; + /** Reject individual days. Applied on top of `minDate` / `maxDate`. */ + isDateUnavailable?: (date: Date) => boolean; + + /** + * The day `.Reset` restores. Read even when `value` is controlled, which + * `defaultValue` is not — otherwise a controlled consumer never sees + * `.Reset`. + */ + 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; +} + +/* 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, + className, + children, + render, + ref, + ...props +}: CalendarPreviewProps) { + const today = useMemo(() => todayProp ?? new Date(), [todayProp]); + + const [value, setValueUnwrapped] = useControlled({ + controlled: valueProp, + default: defaultValue, + name: 'CalendarPreview', + state: 'value' + }); + + const [month, setMonthUnwrapped] = useControlled({ + controlled: monthProp, + default: defaultMonth ?? defaultValue ?? today, + name: 'CalendarPreview', + state: 'month' + }); + + /* Uncontrolled until the scale switcher lands in PR 5. The state lives here + now so the parts and `useCalendar()` read it from one place either way. */ + const [scale, setScaleUnwrapped] = useControlled({ + controlled: undefined, + default: 'day', + name: 'CalendarPreview', + state: 'scale' + }); + + const setMonth = useCallback( + (next: Date) => { + setMonthUnwrapped(next); + onMonthChange?.(next); + }, + [setMonthUnwrapped, onMonthChange] + ); + + 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]); + + /* Day-keys, not instants: a `minDate` carrying a time of day still leaves + its own day selectable, which the current family gets wrong. */ + const isDateUnavailable = useCallback( + (date: Date) => { + const key = dayKey(date, timeZone); + if (minDate && key < dayKey(minDate, timeZone)) return true; + if (maxDate && key > dayKey(maxDate, timeZone)) return true; + return isDateUnavailableProp?.(date) ?? false; + }, + [minDate, maxDate, isDateUnavailableProp, timeZone] + ); + + /* A year the user can never scroll to is a trap, so the span stretches to + cover the bounds even though bounds never clamp navigation. */ + const yearRange = useMemo(() => { + if (yearRangeProp) return yearRangeProp; + const base = today.getFullYear(); + const years = [base - DEFAULT_YEAR_SPAN, base + DEFAULT_YEAR_SPAN]; + if (minDate) years.push(minDate.getFullYear()); + if (maxDate) years.push(maxDate.getFullYear()); + return { from: Math.min(...years), to: Math.max(...years) }; + }, [yearRangeProp, today, minDate, maxDate]); + + const context = useMemo>( + () => ({ + value, + setValue, + defaultDate, + reset, + month, + setMonth, + yearRange, + scale, + setScale, + isDateUnavailable, + today, + timeZone, + clearable, + disabled, + readOnly, + formatValue + }), + [ + value, + setValue, + defaultDate, + reset, + month, + setMonth, + yearRange, + scale, + setScale, + isDateUnavailable, + today, + timeZone, + clearable, + disabled, + readOnly, + formatValue + ] + ); + + /* A real element, not a bare provider: `.Days` and `.Footer` are in-flow + siblings, and without a box of their own they inherit whatever the + surrounding layout does — sitting side by side inside a flex row. */ + const element = useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.root, className), + 'data-slot': 'calendar-preview', + 'data-scale': scale, + 'data-disabled': disabled || undefined, + 'data-readonly': readOnly || undefined, + children + } as useRender.ComponentProps<'div'>, + props + ) + }); + + return ( + } + > + {element} + + ); +} + +CalendarPreviewRoot.displayName = 'CalendarPreview'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css new file mode 100644 index 000000000..020732e38 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -0,0 +1,444 @@ +/* Hugs its content and stacks its parts, so `.Days` and `.Footer` sit one + above the other whatever the surrounding layout does. */ +.root { + display: flex; + flex-direction: column; + width: fit-content; +} + +/* The day view hugs its content — no reserved height, so the surface around it + can size itself instead of being padded out to a fixed number. */ +.days { + display: flex; + flex-direction: column; + width: fit-content; + padding: var(--rs-space-3); + border-radius: var(--rs-radius-4); + background: var(--rs-color-background-base-primary); + color: var(--rs-color-foreground-base-primary); +} + +.days[data-disabled] { + pointer-events: none; +} + +/* Inset by the gap a weekday label leaves inside its 40px cell. Aligning the + header to the column box instead would sit the caption visibly left of + "Sun", because the label is centred in the cell rather than flush to it. */ +.header { + display: flex; + align-items: center; + gap: var(--rs-space-2); + min-height: var(--rs-space-9); + margin-bottom: var(--rs-space-3); + padding-inline: var(--rs-space-3); +} + +/* The week-number column is a gutter, not a date column, so the caption starts + past it — aligned with Sunday rather than with the grid's outer edge. The + header cannot read `showWeekNumber`, which is a `.Grid` prop, so it asks the + rendered grid instead. */ +.days:has(.week-number-header) .header { + padding-inline-start: calc(var(--rs-space-10) + var(--rs-space-3)); +} + +.nav-button { + flex: none; + color: var(--rs-color-foreground-base-primary); +} + +.nav-button:disabled { + color: var(--rs-color-foreground-base-tertiary); + cursor: not-allowed; +} + +/* Takes the space left of the buttons, so the caption sits against the start + edge and the reset and two nav buttons group at the end — the single-month + header in reference A. Source order already matches, so nothing reorders. */ +.caption { + flex: 1; + text-align: start; + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); + user-select: none; + -webkit-user-select: none; +} + +/* The caption that opens the scroller is a filled chip, so the affordance + reads without an adjacent glyph. `flex: none` undoes `.caption`'s stretch — + the chip hugs its label rather than running to the nav buttons. */ +.caption-trigger { + display: inline-flex; + flex: none; + margin-inline-end: auto; + align-items: center; + justify-content: center; + gap: var(--rs-space-1); + padding: var(--rs-space-1) var(--rs-space-3); + border: none; + border-radius: var(--rs-radius-2); + background: var(--rs-color-background-neutral-secondary); + color: inherit; + font: inherit; + cursor: pointer; +} + +.caption-trigger:hover:not(:disabled) { + background: var(--rs-color-background-neutral-secondary-hover); +} + +.caption-trigger:focus-visible { + outline: var(--rs-focus-ring); + outline-offset: var(--rs-focus-ring-offset-inset); +} + +.caption-trigger:disabled { + color: var(--rs-color-foreground-base-tertiary); + cursor: not-allowed; +} + +.caption-positioner { + z-index: 1; +} + +/* Our own scroller, not a Select: two plain columns of buttons in a popup we + own, so nothing here portals a listbox the surrounding popover has to + recognise as inside itself. */ +.caption-popup { + display: flex; + gap: var(--rs-space-2); + padding: var(--rs-space-2); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-4); + background: var(--rs-color-background-base-primary); + box-shadow: var(--rs-shadow-lifted); +} + +.caption-column { + display: flex; + flex-direction: column; + gap: var(--rs-space-1); + overflow-y: auto; + /* Six rows of the day-cell height; taller lists scroll. */ + max-height: calc(var(--rs-space-10) * 6); +} + +.caption-option { + flex: none; + padding: var(--rs-space-2) var(--rs-space-3); + border: none; + border-radius: var(--rs-radius-2); + background: transparent; + color: var(--rs-color-foreground-base-primary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.caption-option:hover { + background: var(--rs-color-background-base-primary-hover); +} + +.caption-option:focus-visible { + outline: var(--rs-focus-ring); + outline-offset: var(--rs-focus-ring-offset-inset); +} + +/* Grey, not accent: the scroller marks which month and year are in view, which + is a different thing from the selected day the grid fills in accent. */ +.caption-option[data-active] { + background: var(--rs-color-background-neutral-secondary); + color: var(--rs-color-foreground-base-primary); +} + +.reset { + flex: none; +} + +/* Both nav tracks stay reserved whether or not this month draws a button, so + the caption centres on its grid rather than on the remaining space. The + track width is the size-3 IconButton the nav renders. */ +.month-header { + display: grid; + grid-template-columns: var(--rs-space-6) 1fr var(--rs-space-6); + align-items: center; + gap: var(--rs-space-2); + min-height: var(--rs-space-9); + margin-bottom: var(--rs-space-3); + padding-inline: var(--rs-space-3); +} + +.month-header-prev { + grid-column: 1; +} + +.month-header-caption { + grid-column: 2; + text-align: center; +} + +.month-header-next { + grid-column: 3; +} + +.grid { + position: relative; +} + +.months { + display: flex; + gap: var(--rs-space-4); +} + +.month { + display: flex; + flex-direction: column; +} + +/* `.Header` owns the visible caption. This one stays in the tree because + react-day-picker points each grid's accessible name at the month, and a + removed node would take that name with it. */ +.month-caption { + position: absolute; + /* A hairline box, not a spacing value — the space scale starts at 2px. */ + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + +.caption-label { + font: inherit; +} + +.weeks { + position: relative; +} + +/* The user-agent's 2px border-spacing would ring the grid, leaving the header + two pixels wider than the columns it sits above. */ +.weeks table { + border-spacing: 0; +} + +.week, +.weekdays { + display: flex; +} + +/* Cells size to the border box and drop the user-agent's table-cell padding, + so a heading and the days under it are the same 40px column. Content-box + would make the bordered day cell 4px wider than its heading, and the two + rows would drift a column apart by Saturday. */ +.weekday, +.day, +.week-number, +.week-number-header { + box-sizing: border-box; + padding: 0; +} + +.weekday { + display: flex; + align-items: center; + justify-content: center; + width: var(--rs-space-10); + height: var(--rs-space-10); + color: var(--rs-color-foreground-base-secondary); + text-align: center; + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.week-number, +.week-number-header { + display: flex; + align-items: center; + justify-content: center; + width: var(--rs-space-10); + height: var(--rs-space-10); + color: var(--rs-color-foreground-base-tertiary); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.day { + width: var(--rs-space-10); + height: var(--rs-space-10); + margin-bottom: var(--rs-space-1); + border: 1px solid transparent; + border-radius: var(--rs-radius-5); + background-color: var(--rs-color-background-base-primary); + color: var(--rs-color-foreground-base-primary); + text-align: center; + font-weight: var(--rs-font-weight-regular); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.day:hover:not(.disabled):not(.outside):not(.selected) { + border-color: var(--rs-color-border-accent-emphasis-hover); + background-color: transparent; +} + +.selected { + background: var(--rs-color-background-accent-emphasis); +} + +.selected .day-button { + color: var(--rs-color-foreground-base-emphasis); +} + +.selected .day-button:active { + background-color: var(--rs-color-background-accent-emphasis-hover); +} + +.outside { + color: var(--rs-color-foreground-base-tertiary); +} + +.disabled { + opacity: 0.5; +} + +.hidden { + visibility: hidden; +} + +.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..e7970bec2 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -1,26 +1,9 @@ -/* - * The one module in `calendar-preview/` allowed to call a date library. - * - * Everything else — `lib/scale.ts`, `lib/parse.ts` and, from phase 1, the parts - * — goes through this surface. Two reasons, both from RFC 005: - * - * 1. `dayjs.extend()` is import-order dependent. A module that formats a - * quarter works or throws depending on whether some *other* module has - * already run its `extend()`. date-fns has no plugin registry, so the - * failure class disappears — but only while a single file owns the - * imports. Adding a date-library import elsewhere in `calendar-preview/` - * re-opens it. - * 2. The library stays swappable. Base UI ships `./internals/temporal` with - * date-fns and Luxon adapters; adopting it later is an edit to this file - * and nothing else. - * - * Day values are timeless. The canonical form is a `DayKey` — `'YYYY-MM-DD'`, - * no time, no zone — and it is what crosses every boundary in `lib/`. Two - * day-keys compare correctly with `<`, `>` and `===`, so ordering a day - * against a bound needs no library call and cannot drift by a timezone. - */ +/* The only module in `calendar-preview/` that may import a date library. + Importing one elsewhere re-opens the import-order failure `dayjs.extend()` + caused, and costs the swappability the RFC keeps for Temporal. */ import { TZDate } from '@date-fns/tz'; import { + addMonths, endOfMonth, endOfQuarter, endOfYear, @@ -32,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(timeZone ? new TZDate(date, timeZone) : date, DAY_KEY_FORMAT); + 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)}`); @@ -95,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, @@ -123,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))); } @@ -158,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); @@ -174,6 +120,52 @@ export function monthFromName(name: string): number | null { return null; } +/* Normalising to the first stops repeated navigation drifting: stepping on + from 31 January would clamp to the 28th and stay there. */ +export function shiftMonths(date: Date, delta: number): Date { + return addMonths(startOfMonth(date), delta); +} + +/** The first day of a calendar month. `monthIndex` is 0-11, as on `Date`. */ +export function monthStart(year: number, monthIndex: number): Date { + return new Date(year, monthIndex, 1); +} + +/* Day-first, matching what `lib/parse.ts` accepts, so a rendered value can be + typed straight back in. */ +export function formatDayLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'dd/MM/yyyy'); +} + +/** `'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'); +} + +/* Identical to formatMonthLabel today, kept separate because they answer + different questions: what the grid shows, versus what a value means. */ +export function formatCaptionLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'MMM yyyy'); +} + +/* Three letters, against react-day-picker's two-letter default — the frames + spell them `Sun Mon Tue`. */ +export function formatWeekdayLabel(date: Date, timeZone?: string): string { + return format(zoned(date, timeZone), 'EEE'); +} + +/* Same locale as monthFromName parses, so the caption's month column and the + input parser cannot disagree about a name. */ +export function monthShortNames(): string[] { + return MONTH_INDEXES.map(index => format(new Date(2001, index, 1), 'MMM')); +} + +const MONTH_INDEXES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; + +function zoned(date: Date, timeZone?: string): Date { + return timeZone ? new TZDate(date, timeZone) : date; +} + function parseStrict(value: string): Date { return parse(value, DAY_KEY_FORMAT, PARSE_REFERENCE); } diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx new file mode 100644 index 000000000..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..df405e35a --- /dev/null +++ b/packages/raystack/components/calendar-preview/use-calendar.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useCalendarPreviewContext } from './calendar-preview-context'; +import type { Scale } from './lib/scale'; + +export interface UseCalendarReturn { + value: Date | null; + /** Commit a day, or clear with `null`. Emits `onValueChange`. */ + setValue: (value: Date | null) => void; + scale: Scale; + setScale: (scale: Scale) => void; + month: Date; + /** Bounds never clamp the view. */ + setMonth: (month: Date) => void; + isDateUnavailable: (date: Date) => boolean; +} + +/** + * The enclosing `CalendarPreview`'s state, for building parts the library does + * not ship. Deliberately narrow — everything returned here is semver-covered. + */ +export function useCalendar(): UseCalendarReturn { + const { + value, + setValue, + scale, + 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/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 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,