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 `` for a single day |
+| `calendar-preview-day-number` | The day number inside a day button |
+| `calendar-preview-day-info` | Content above the number (when `dateInfo` resolves) |
+| `calendar-preview-day-tooltip` | The tooltip shown on hover |
+| `calendar-preview-footer` | The footer row |
+| `calendar-preview-footer-text` | The `Text` wrapping a string footer |
+
+Day cells also carry their state, so a stylesheet can target it without a class:
+
+| Attribute | Set when |
+|------|------|
+| `data-selected` | The day is the committed value |
+| `data-draft` | The day has roving focus but is not committed |
+| `data-unavailable` | The day is out of bounds or rejected by `isDateUnavailable` |
+| `data-today` | The day is `today` |
+| `data-outside` | The day belongs to an adjacent month |
+| `data-scale` | The granularity the value is committed at |
+
+## Examples
+
+### Composition
+
+Each part renders a default; children replace it.
+
+
+
+### Reset
+
+`.Reset` restores `defaultDate` and **leaves the visible month alone** — it is a value reset, not a view reset. It renders only when `defaultDate` is set *and* the current value differs from it, so the button disappears once there is nothing to restore.
+
+`defaultDate` is a separate prop from `defaultValue` because `defaultValue` is ignored once `value` is passed. Keying the reset off its own prop is what makes it work for a controlled calendar.
+
+
+
+### Selection bounds
+
+`minDate`, `maxDate` and `isDateUnavailable` disable cells. **None of them clamps navigation** — the chevrons and the scroller still reach any month. Bounds compare whole calendar days, so a `minDate` carrying a time of day still leaves its own day selectable.
+
+
+
+### Grid layout
+
+Outside days are **off by default**, so a grid ends on the last day of its month with the leading cells blank.
+
+
+
+### Date information and tooltips
+
+`dateInfo` and `tooltipMessages` are functions of the date, not records keyed by a formatted string. `dateInfo` content renders above the day number; today's dot sits below it, so the two never collide.
+
+
+
+### Month and year scroller
+
+` ` turns the caption into a filled chip that opens two adjacent scrolling columns. It is a plain popover of buttons, not a `Select` — picking from either column moves the view and never selects a value.
+
+## Accessibility
+
+- Arrow keys move between days; the focused cell carries `data-draft` until it is committed
+- Each grid is labelled with its month, so the caption is not the only announcement
+- Nav buttons carry `aria-label`, and the scroller's columns are labelled groups
+- Selected and unavailable days are announced through their native button state
diff --git a/apps/www/src/content/docs/components/calendar-preview/props.ts b/apps/www/src/content/docs/components/calendar-preview/props.ts
new file mode 100644
index 000000000..298693923
--- /dev/null
+++ b/apps/www/src/content/docs/components/calendar-preview/props.ts
@@ -0,0 +1,144 @@
+import { ReactNode } from 'react';
+
+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.toDate()` returns the
+ * day acted on even when `value` is `null`.
+ * @example onValueChange={(value, details) => console.log(details.reason)}
+ */
+ onValueChange?: (
+ value: Date | null,
+ details: {
+ reason: 'select' | 'input' | 'clear' | 'scale';
+ period: { start: string; end: string };
+ toDate: () => Date;
+ }
+ ) => void;
+
+ /** The first month the grid displays (controlled). */
+ month?: Date;
+
+ /**
+ * The month the grid opens on.
+ * @example defaultMonth={new Date(2024, 3, 1)}
+ */
+ defaultMonth?: Date;
+
+ /** Called when the view moves. */
+ onMonthChange?: (month: Date) => void;
+
+ /**
+ * The years the caption's year column offers.
+ * Defaults to ten years either side of `today`, widened to cover any bound.
+ */
+ yearRange?: { from: number; to: number };
+
+ /**
+ * Earliest selectable day, inclusive. Never clamps navigation.
+ * @example minDate={new Date(2024, 3, 17)}
+ */
+ minDate?: Date;
+
+ /** Latest selectable day, inclusive. Never clamps navigation. */
+ maxDate?: Date;
+
+ /**
+ * Reject individual days, on top of `minDate` / `maxDate`.
+ * @example isDateUnavailable={date => date.getDay() === 0}
+ */
+ isDateUnavailable?: (date: Date) => boolean;
+
+ /**
+ * The day `.Reset` restores. Read even when `value` is controlled, which
+ * `defaultValue` is not.
+ */
+ defaultDate?: Date;
+
+ /** Renders a value for display. Defaults to `DD/MM/YYYY` at day scale. */
+ formatValue?: (value: Date, scale: string) => string;
+
+ /** Forwarded to the grid. No conversion is done here. */
+ timeZone?: string;
+
+ /** Today, injectable so a calendar renders deterministically in tests. */
+ today?: Date;
+
+ /**
+ * Whether clicking the selected day deselects it.
+ * @default true
+ */
+ clearable?: boolean;
+
+ /** @default false */
+ disabled?: boolean;
+
+ /**
+ * Whether the value can be read and navigated but not changed.
+ * @default false
+ */
+ readOnly?: boolean;
+}
+
+export interface CalendarPreviewDaysProps {
+ /**
+ * How many months the grid shows side by side. More than one moves the
+ * caption and nav into each month's own header.
+ * @default 1
+ */
+ numberOfMonths?: number;
+}
+
+export interface CalendarPreviewCaptionProps {
+ /**
+ * Turn the caption into a trigger for the month and year scroller.
+ * @default false
+ */
+ dropdown?: boolean;
+}
+
+export interface CalendarPreviewGridProps {
+ /** Always render six week rows, so the grid height never jumps. */
+ fixedWeeks?: boolean;
+
+ /**
+ * Render the days either side of the month.
+ * @default false
+ */
+ showOutsideDays?: boolean;
+
+ /** Render a week-number column. */
+ showWeekNumber?: boolean;
+
+ /**
+ * First day of the week, 0 (Sunday) to 6.
+ * @default 0
+ */
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+
+ /** Extra day modifiers, passed through to react-day-picker. */
+ modifiers?: Record;
+
+ /** Override react-day-picker's component slots. */
+ components?: Record;
+
+ /**
+ * Extra content for a day, rendered above the date number.
+ * @example dateInfo={date => (date.getDate() === 1 ? : null)}
+ */
+ dateInfo?: (date: Date) => ReactNode;
+
+ /** @default false */
+ showTooltip?: boolean;
+
+ /** The tooltip for a day, or nothing. */
+ tooltipMessages?: (date: Date) => ReactNode;
+
+ /** Cover the grid with a skeleton and stop navigation. */
+ loading?: boolean;
+}
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..70c45b231
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
@@ -0,0 +1,1031 @@
+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(
+ 'Jul 2026'
+ );
+ fireEvent.click(prev as HTMLElement);
+ expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent(
+ 'Jun 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(
+ 'Oct 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(
+ 'Sep 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(
+ 'Aug 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(
+ 'Mar 2026'
+ );
+ });
+
+ it('shows several months side by side, each captioned over its own grid', () => {
+ const { container } = renderCalendar(
+
+ );
+ expect(getAllSlots(container, 'calendar-preview-table')).toHaveLength(2);
+
+ /* 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']);
+ });
+});
+
+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(
+ 'Sep 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(
+ 'Aug 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 === 'Mar');
+ fireEvent.click(march as HTMLElement);
+
+ expect(getSlot(container, 'calendar-preview-caption')).toHaveTextContent(
+ 'Mar 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(
+ 'Aug 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('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(
+
+
+
+ );
+ const first = getAllSlots(container, 'calendar-preview-weekday')[0];
+ expect(first).toHaveTextContent('Mon');
+ });
+
+ 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(
+
+ Pick a preset
+
+ );
+ 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)))}
+
+ setValue(new Date(2026, 7, 20))}>
+ set
+
+ setMonth(new Date(2026, 9, 1))}>
+ move
+
+ setValue(null)}>
+ clear
+
+
+ );
+ }
+
+ 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 => (
+
+ {option.text}
+
+ ))}
+
+ );
+}
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,