diff --git a/apps/www/src/content/docs/components/calendar-preview/demo.ts b/apps/www/src/content/docs/components/calendar-preview/demo.ts
index ddf311697..d7a2fe5ff 100644
--- a/apps/www/src/content/docs/components/calendar-preview/demo.ts
+++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts
@@ -145,6 +145,17 @@ export const resetDemo = {
`
},
+ {
+ name: 'Range',
+ code: `
+
+ `
+ },
{
name: 'Clear the selection',
code: `
`
},
+ {
+ name: 'Reset',
+ code: `
+
+
+
+
+
+
+ `
+ },
{
name: 'Invalid input',
code: `
@@ -422,3 +448,190 @@ function CalendarPreviewInvalidExample() {
}
]
};
+
+export const rangeDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Basic',
+ code: `
+
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Disabled',
+ code: `
+
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Disabled dates',
+ code: ` date.getDay() === 0 || date.getDay() === 6}
+ >
+
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Without calendar icon',
+ code: `
+
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Read-only start',
+ code: `
+
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Reset',
+ code: `
+
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Invalid input',
+ code: `
+function CalendarPreviewRangeInvalidExample() {
+ const [defaultError, setDefaultError] = React.useState();
+ const [customError, setCustomError] = React.useState();
+
+ const range = {
+ selection: 'range',
+ defaultMonth: new Date(2024, 3, 1),
+ defaultValue: { from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) }
+ };
+
+ return (
+
+
+
+
+
+ setDefaultError(message)}
+ />
+ setDefaultError(message)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ setCustomError(message)}
+ />
+ setCustomError(message)}
+ />
+
+
+
+
+
+
+
+
+ );
+}`
+ },
+ {
+ name: 'Custom trigger',
+ code: `
+ }>
+ 10 Apr – 20 Apr
+
+
+
+
+ `
+ }
+ ]
+};
diff --git a/apps/www/src/content/docs/components/calendar-preview/index.mdx b/apps/www/src/content/docs/components/calendar-preview/index.mdx
index 1858b3de3..2199cff04 100644
--- a/apps/www/src/content/docs/components/calendar-preview/index.mdx
+++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx
@@ -13,6 +13,7 @@ import {
gridDemo,
dateInfoDemo,
pickerDemo,
+ rangeDemo,
} from "./demo.ts";
@@ -212,7 +213,16 @@ 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.
+`.Reset` restores `defaultDate` and **leaves the visible month alone** — it is a value reset, not a view reset. It renders whenever `defaultDate` is set, and goes disabled once there is nothing left to restore rather than unmounting: removing the focused element would strand a keyboard user, and dropping a child from the header would shift both nav buttons sideways every time the value crossed the default.
+
+`defaultDate` follows the selection. At `selection="range"` it takes a range, and both edges have to match before the button counts as restored:
+
+```tsx
+
+```
`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.
@@ -263,6 +273,51 @@ The popover opens when the input takes focus. Enter, blur and an outside click a
+### Range selection
+
+`selection="range"` turns clicks into endpoints. Give each `.Input` a `field`:
+
+```tsx
+
+
+
+
+
+
+
+
+
+```
+
+**`onValueChange` fires on a complete range or not at all.** `to` is not nullable, so there is no partial `{ from?, to? }` to gate on. The half-built range stays internal — the grid styles the track from it, but nothing is emitted until the second endpoint lands.
+
+The click machine:
+
+| State | A click does |
+|---|---|
+| Nothing selected | sets `from`, moves focus to the end field |
+| `from` only, later day | completes the range, emits, closes the popover |
+| `from` only, earlier day | that day becomes the new `from` |
+| Complete range | restarts — the new day is `from`, and the value stays at the previous range until the new one completes |
+
+Completing asks the popover to close through `onOpenChange`, so a consumer holding `open` open is not fought.
+
+**Typing is stricter than clicking.** A click means "the next endpoint", so an earlier day restarts
+the range, as the table above says. Typing names the field it lands in, so an endpoint that crosses
+its partner is rejected instead: `onValidityChange` reports `out-of-order`, the field goes red, and
+nothing is emitted. Two endpoints on the same day are a valid range.
+
+```tsx
+ setError(message)}
+/>
+```
+
+Instead of a `lock` prop, mark one endpoint's `.Input` as `readOnly` — the grid will not rewrite it. **A read-only endpoint with no value makes the range unsatisfiable:** the free endpoint sets, the range never completes, and nothing emits. Give a read-only endpoint a value.
+
+
### Invalid typed dates
Typing is checked on every keystroke, and a date that fails is **never committed** — `onValueChange`
@@ -288,10 +343,13 @@ it is `undefined` while valid, which is exactly what [Field](/docs/components/fi
```
-The default is a flat **"Invalid input"** for every reason. It stays deliberately vague because only
+The default is a flat **"Invalid input"** for most reasons. It stays deliberately vague because only
you know the field's bounds — the component cannot say *which* dates would be accepted without
inventing wording it has no basis for.
+`out-of-order` is the exception, and gets a real default: it needs no knowledge of your bounds, only
+of which endpoint was typed.
+
Override it with `errorMessages`, per reason. Anything left out keeps the default, so wording one
reason does not mean restating the rest:
@@ -312,6 +370,7 @@ The reason is also on the payload if you would rather branch on it yourself:
| `unparseable` | The text is not a date the input could read at all |
| `out-of-bounds` | A real date, outside `minDate` / `maxDate` |
| `unavailable` | A real date in range that `isDateUnavailable` rejected |
+| `out-of-order` | Range only — the endpoint crossed its partner |
It fires only when validity *changes*, not on every keystroke, so it is safe to drive state with.
diff --git a/apps/www/src/content/docs/components/calendar-preview/props.ts b/apps/www/src/content/docs/components/calendar-preview/props.ts
index 2d6adcba0..57fdcdda5 100644
--- a/apps/www/src/content/docs/components/calendar-preview/props.ts
+++ b/apps/www/src/content/docs/components/calendar-preview/props.ts
@@ -57,9 +57,10 @@ export interface CalendarPreviewProps {
/**
* The day `.Reset` restores. Read even when `value` is controlled, which
* `defaultValue` is not. `null` is a default of nothing selected, so
- * `.Reset` clears; omitting the prop renders no button at all.
+ * `.Reset` clears; omitting the prop renders no button at all. Takes a
+ * range at `selection="range"`.
*/
- defaultDate?: Date | null;
+ defaultDate?: Date | { from: Date; to: Date } | null;
/**
* The zone the grid reads days in. Forwarded to the grid; the component does
@@ -242,17 +243,20 @@ export interface CalendarPreviewInputProps {
*/
onValidityChange?: (validity: {
valid: boolean;
- reason?: 'unparseable' | 'out-of-bounds' | 'unavailable';
+ reason?: 'unparseable' | 'out-of-bounds' | 'unavailable' | 'out-of-order';
message?: string;
}) => void;
/**
* Replaces the message for one or more reasons; anything left out keeps the
* default.
- * @default "Invalid input" for every reason
+ * @default "Invalid input", except out-of-order, which words itself
*/
errorMessages?: Partial<
- Record<'unparseable' | 'out-of-bounds' | 'unavailable', string>
+ Record<
+ 'unparseable' | 'out-of-bounds' | 'unavailable' | 'out-of-order',
+ string
+ >
>;
/** Read and navigable, but not typeable. */
diff --git a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
index 022e7f7b0..6be52116d 100644
--- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
+++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
@@ -1454,7 +1454,9 @@ describe('useCalendar', () => {
useCalendar();
return (
- {value ? value.getDate() : 'none'}
+
+ {value instanceof Date ? value.getDate() : 'none'}
+ {month.getMonth()}{scale}
diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx
new file mode 100644
index 000000000..7255d864f
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx
@@ -0,0 +1,508 @@
+import { fireEvent, render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { 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 renderRange(props = {}, children?: React.ReactNode) {
+ return render(
+
+ {children ?? }
+
+ );
+}
+
+function day(container: HTMLElement, text: string): HTMLElement {
+ const match = getAllSlots(container, 'calendar-preview-day').find(
+ cell =>
+ getSlot(cell, 'calendar-preview-day-number')?.textContent === text &&
+ !cell.hasAttribute('data-outside')
+ );
+ if (!match) throw new Error(`no cell for ${text}`);
+ return match;
+}
+
+describe('CalendarPreview range machine', () => {
+ it('does not emit on the first click', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({ onValueChange });
+ fireEvent.click(day(container, '10'));
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('emits once, with both edges, when the range completes', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({ onValueChange });
+ fireEvent.click(day(container, '10'));
+ fireEvent.click(day(container, '20'));
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ from: new Date(2026, 7, 10),
+ to: new Date(2026, 7, 20)
+ });
+ });
+
+ it('treats an earlier second click as a new start, still emitting nothing', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({ onValueChange });
+ fireEvent.click(day(container, '20'));
+ fireEvent.click(day(container, '10'));
+ expect(onValueChange).not.toHaveBeenCalled();
+ /* The earlier day became the new start, so a later click completes. */
+ fireEvent.click(day(container, '15'));
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ from: new Date(2026, 7, 10),
+ to: new Date(2026, 7, 15)
+ });
+ });
+
+ it('restarts from a click on a complete range, and emits nothing until it completes again', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({ onValueChange });
+ fireEvent.click(day(container, '10'));
+ fireEvent.click(day(container, '20'));
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(day(container, '5'));
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(day(container, '8'));
+ expect(onValueChange).toHaveBeenCalledTimes(2);
+ expect(onValueChange.mock.calls[1][0]).toEqual({
+ from: new Date(2026, 7, 5),
+ to: new Date(2026, 7, 8)
+ });
+ });
+
+ it('marks the endpoints and the days between them', () => {
+ const { container } = renderRange();
+ fireEvent.click(day(container, '10'));
+ fireEvent.click(day(container, '13'));
+
+ expect(day(container, '10')).toHaveAttribute('data-range-start');
+ expect(day(container, '13')).toHaveAttribute('data-range-end');
+ for (const between of ['11', '12']) {
+ expect(day(container, between)).toHaveAttribute('data-range-middle');
+ }
+ expect(day(container, '9')).not.toHaveAttribute('data-range-middle');
+ });
+
+ it('renders a controlled range without a click', () => {
+ const { container } = renderRange({
+ value: { from: new Date(2026, 7, 10), to: new Date(2026, 7, 12) }
+ });
+ expect(day(container, '10')).toHaveAttribute('data-range-start');
+ expect(day(container, '12')).toHaveAttribute('data-range-end');
+ });
+});
+
+describe('CalendarPreview range inputs', () => {
+ const picker = (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ const inputs = (container: HTMLElement) =>
+ getAllSlots(container, 'calendar-preview-input') as HTMLInputElement[];
+
+ it('gives each endpoint its own field and placeholder', () => {
+ const { container } = renderRange({}, picker);
+ const [start, end] = inputs(container);
+ expect(start).toHaveAttribute('data-field', 'start');
+ expect(end).toHaveAttribute('data-field', 'end');
+ expect(start).toHaveAttribute('placeholder', 'Select start date');
+ expect(end).toHaveAttribute('placeholder', 'Select end date');
+ });
+
+ it('advances the active endpoint to the end after the first click', () => {
+ const { container } = renderRange({}, picker);
+ const [start, end] = inputs(container);
+ expect(start).toHaveAttribute('data-active', 'true');
+ expect(end).not.toHaveAttribute('data-active');
+
+ fireEvent.focus(start);
+ fireEvent.click(day(document.body, '10'));
+
+ expect(end).toHaveAttribute('data-active', 'true');
+ expect(start).not.toHaveAttribute('data-active');
+ });
+
+ it('shows each endpoint in its own field', () => {
+ const { container } = renderRange({}, picker);
+ fireEvent.focus(inputs(container)[0]);
+ fireEvent.click(day(document.body, '10'));
+ fireEvent.click(day(document.body, '20'));
+ const [start, end] = inputs(container);
+ expect(start.value).toBe('10/08/2026');
+ expect(end.value).toBe('20/08/2026');
+ });
+
+ /* `lock` is gone: a read-only endpoint is one read-only `.Input`. */
+ it('never lets a grid click rewrite a read-only endpoint', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange(
+ {
+ onValueChange,
+ value: { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) }
+ },
+ <>
+
+
+
+
+
+
+
+ >
+ );
+ fireEvent.focus(inputs(container)[1]);
+ /* A click that would restart the range has to rewrite `from`, which is
+ read-only, so nothing moves. */
+ fireEvent.click(day(document.body, '5'));
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+});
+
+describe('CalendarPreview range auto-close', () => {
+ const picker = (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ const isOpen = () =>
+ getSlot(document.body, 'calendar-preview-content') !== null;
+
+ it('closes through onOpenChange when the range completes', () => {
+ const onOpenChange = vi.fn();
+ const { container } = renderRange({ onOpenChange }, picker);
+ fireEvent.focus(
+ getAllSlots(container, 'calendar-preview-input')[0] as HTMLElement
+ );
+ expect(isOpen()).toBe(true);
+
+ fireEvent.click(day(document.body, '10'));
+ expect(isOpen()).toBe(true);
+
+ fireEvent.click(day(document.body, '20'));
+ expect(isOpen()).toBe(false);
+ expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything());
+ });
+
+ /* Completing a range hands focus back to the trigger, and an unguarded
+ focus handler reopens the popover on the way out. jsdom does not restore
+ focus the way a browser does, so this asserts the guard rather than the
+ symptom: the close must be the last thing that happens. */
+ it('does not reopen on the focus that follows an auto-close', () => {
+ const onOpenChange = vi.fn();
+ const { container } = renderRange({ onOpenChange }, picker);
+ const [start] = getAllSlots(
+ container,
+ 'calendar-preview-input'
+ ) as HTMLElement[];
+ fireEvent.focus(start);
+
+ fireEvent.click(day(document.body, '10'));
+ fireEvent.click(day(document.body, '20'));
+ expect(isOpen()).toBe(false);
+
+ /* The browser returns focus to the trigger here. */
+ fireEvent.focus(start);
+ expect(isOpen()).toBe(false);
+ const calls = onOpenChange.mock.calls;
+ expect(calls[calls.length - 1][0]).toBe(false);
+ });
+
+ /* Completing a range asks to close; a consumer holding `open` open wins. */
+ it('does not fight a controlled open', () => {
+ const onOpenChange = vi.fn();
+ renderRange({ open: true, onOpenChange }, picker);
+ fireEvent.click(day(document.body, '10'));
+ fireEvent.click(day(document.body, '20'));
+ expect(isOpen()).toBe(true);
+ expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything());
+ });
+});
+
+describe('CalendarPreview range parts that read the value', () => {
+ const RANGE = { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) };
+
+ const typeAndCommit = (input: HTMLInputElement, text: string) => {
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: text } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ };
+
+ const inputs = (container: HTMLElement) =>
+ getAllSlots(container, 'calendar-preview-input') as HTMLInputElement[];
+
+ const picker = (
+ <>
+
+
+
+
+
+
+
+ >
+ );
+
+ /* `.Days` renders `.Header` renders `.Reset`, so this is the default
+ composition — it threw on `dayKey(range)` before the shape guard. */
+ it('renders the default composition with a range value and a defaultDate', () => {
+ expect(() =>
+ renderRange({ defaultValue: RANGE, defaultDate: RANGE.from })
+ ).not.toThrow();
+ });
+
+ it('restores a range defaultDate, and disables itself once restored', () => {
+ const onValueChange = vi.fn();
+ const RESTORED = { from: new Date(2026, 7, 3), to: new Date(2026, 7, 7) };
+ const { container } = renderRange({
+ defaultValue: RANGE,
+ defaultDate: RESTORED,
+ onValueChange
+ });
+ const reset = getSlot(container, 'calendar-preview-reset') as HTMLElement;
+ expect(reset).not.toBeNull();
+ expect(reset).not.toBeDisabled();
+ fireEvent.click(reset);
+ expect(onValueChange).toHaveBeenCalledWith(
+ RESTORED,
+ expect.objectContaining({ reason: 'reset' })
+ );
+ });
+
+ it('starts restored when the value already equals the range default', () => {
+ const { container } = renderRange({
+ defaultValue: RANGE,
+ defaultDate: RANGE
+ });
+ const reset = getSlot(container, 'calendar-preview-reset') as HTMLElement;
+ expect(reset).toBeDisabled();
+ expect(reset).toHaveAttribute('data-restored');
+ });
+
+ /* Both edges have to match — a shared start is not a restored range. */
+ it('is not restored when only one edge matches the default', () => {
+ const { container } = renderRange({
+ defaultValue: RANGE,
+ defaultDate: { from: RANGE.from, to: new Date(2026, 7, 25) }
+ });
+ expect(getSlot(container, 'calendar-preview-reset')).not.toBeDisabled();
+ });
+
+ /* Clearing is shape-agnostic, so a `null` default keeps working. */
+ it('keeps .Reset for a null defaultDate, and clears the range', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({
+ defaultValue: RANGE,
+ defaultDate: null,
+ onValueChange
+ });
+ const reset = getSlot(container, 'calendar-preview-reset');
+ expect(reset).not.toBeNull();
+ fireEvent.click(reset as HTMLElement);
+ expect(onValueChange).toHaveBeenCalledWith(null, expect.anything());
+ });
+
+ it('labels a childless .Trigger with both endpoints', () => {
+ const { container } = renderRange(
+ { defaultValue: RANGE },
+
+ );
+ const trigger = getSlot(container, 'calendar-preview-trigger');
+ expect(trigger?.textContent).toContain('10/08/2026');
+ expect(trigger?.textContent).toContain('20/08/2026');
+ });
+
+ it('edits the end without disturbing the start', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange(
+ { defaultValue: RANGE, onValueChange },
+ picker
+ );
+ const [start, end] = inputs(container);
+ typeAndCommit(end, '25/08/2026');
+ expect(start.value).toBe('10/08/2026');
+ expect(end.value).toBe('25/08/2026');
+ expect(onValueChange).toHaveBeenCalledWith(
+ { from: RANGE.from, to: new Date(2026, 7, 25) },
+ expect.objectContaining({ reason: 'input' })
+ );
+ });
+
+ it('edits the start without disturbing the end', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange(
+ { defaultValue: RANGE, onValueChange },
+ picker
+ );
+ const [start, end] = inputs(container);
+ typeAndCommit(start, '05/08/2026');
+ expect(start.value).toBe('05/08/2026');
+ expect(end.value).toBe('20/08/2026');
+ expect(onValueChange).toHaveBeenCalledWith(
+ { from: new Date(2026, 7, 5), to: RANGE.to },
+ expect.objectContaining({ reason: 'input' })
+ );
+ });
+
+ it('restarts, and emits nothing, when a typed end crosses the start', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange(
+ { defaultValue: RANGE, onValueChange },
+ picker
+ );
+ const [, end] = inputs(container);
+ typeAndCommit(end, '01/08/2026');
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('refuses a typed endpoint the consumer marked read-only', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange(
+ { defaultValue: RANGE, onValueChange },
+
+
+
+
+ );
+ const [start] = inputs(container);
+ typeAndCommit(start, '05/08/2026');
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+});
+
+describe('CalendarPreview range order validation', () => {
+ const RANGE = { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) };
+
+ const inputs = (container: HTMLElement) =>
+ getAllSlots(container, 'calendar-preview-input') as HTMLInputElement[];
+
+ const typeAndCommit = (input: HTMLInputElement, text: string) => {
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: text } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ };
+
+ const picker = (onValidityChange?: (v: unknown) => void) => (
+
+
+
+
+ );
+
+ it('rejects an end typed before the start, and emits nothing', () => {
+ const onValidityChange = vi.fn();
+ const onValueChange = vi.fn();
+ const { container } = renderRange(
+ { defaultValue: RANGE, onValueChange },
+ picker(onValidityChange)
+ );
+ const [start, end] = inputs(container);
+ typeAndCommit(end, '01/08/2026');
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'out-of-order',
+ message: 'End date cannot be before the start date'
+ });
+ expect(onValueChange).not.toHaveBeenCalled();
+ expect(start.value).toBe('10/08/2026');
+ expect(end).toHaveAttribute('data-invalid');
+ });
+
+ it('rejects a start typed after the end', () => {
+ const onValidityChange = vi.fn();
+ const { container } = renderRange(
+ { defaultValue: RANGE },
+ picker(onValidityChange)
+ );
+ const [start] = inputs(container);
+ typeAndCommit(start, '25/08/2026');
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'out-of-order',
+ message: 'Start date cannot be after the end date'
+ });
+ expect(start).toHaveAttribute('data-invalid');
+ });
+
+ it('allows the two endpoints to be the same day', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange(
+ { defaultValue: RANGE, onValueChange },
+ picker()
+ );
+ const [, end] = inputs(container);
+ typeAndCommit(end, '10/08/2026');
+ expect(onValueChange).toHaveBeenCalledWith(
+ { from: RANGE.from, to: RANGE.from },
+ expect.objectContaining({ reason: 'input' })
+ );
+ });
+
+ it('checks a half-built range against its own start', () => {
+ const onValidityChange = vi.fn();
+ const { container } = renderRange({}, picker(onValidityChange));
+ const [start, end] = inputs(container);
+ typeAndCommit(start, '10/08/2026');
+ typeAndCommit(end, '05/08/2026');
+ expect(onValidityChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ reason: 'out-of-order' })
+ );
+ });
+
+ it('takes an errorMessages override for the new reason', () => {
+ const onValidityChange = vi.fn();
+ const { container } = renderRange(
+ { defaultValue: RANGE },
+
+
+
+
+ );
+ const [, end] = inputs(container);
+ typeAndCommit(end, '01/08/2026');
+ expect(onValidityChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({ message: 'Pick a day after the start' })
+ );
+ });
+
+ /* The grid keeps its restart rule — only typing is strict. */
+ it('still lets a grid click restart the range from an earlier day', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({ defaultValue: RANGE, onValueChange });
+ fireEvent.click(day(container, '5'));
+ expect(onValueChange).not.toHaveBeenCalled();
+ expect(day(container, '5')).toHaveAttribute('data-selected');
+ });
+});
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
index e992def87..4e6684607 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
@@ -18,6 +18,24 @@ export type CalendarPreviewChangeReason =
export type CalendarPreviewOpenChangeDetails = Popover.Root.ChangeEventDetails;
+/** Which endpoint a range `.Input` addresses. */
+export type CalendarPreviewField = 'start' | 'end';
+
+/**
+ * A completed range. Neither edge is nullable: a range that is still being
+ * built is a draft, and drafts are never emitted.
+ */
+export interface CalendarPreviewDateRange {
+ from: Date;
+ to: Date;
+}
+
+/** A range mid-build. `to` is absent until the second click lands. */
+export interface CalendarPreviewDraftRange {
+ from: Date;
+ to?: Date;
+}
+
export interface CalendarPreviewChangeDetails {
/** What caused the change. */
reason: CalendarPreviewChangeReason;
@@ -55,7 +73,7 @@ export interface CalendarPreviewContextValue {
*/
shouldIgnoreFocusOpen: () => boolean;
/** Read even when `value` is controlled. */
- defaultDate: Date | null | undefined;
+ defaultDate: Date | CalendarPreviewDateRange | null | undefined;
/** A value reset — it never moves the view. */
reset: () => void;
month: Date;
@@ -78,6 +96,31 @@ export interface CalendarPreviewContextValue {
value: Date | CalendarPreviewScaleValue,
scale: CalendarPreviewScale
) => string;
+
+ selection: 'single' | 'range';
+ /**
+ * Commits a clicked day. Single scale commits it directly; range runs the
+ * from/to machine, which lives here because completing a range both writes
+ * the value and closes the popover.
+ */
+ selectDay: (date: Date) => void;
+ /** Writes one named endpoint, for a typed `.Input`. */
+ setEndpoint: (field: CalendarPreviewField, date: Date) => void;
+ /**
+ * The range as the grid should draw it — the draft while one is being built,
+ * the committed value otherwise. Never emitted; the track between endpoints
+ * is styled from it.
+ */
+ draft: CalendarPreviewDraftRange | null;
+ /** The endpoint the next click fills. `.Input` reads it to show focus. */
+ activeField: CalendarPreviewField;
+ setActiveField: (field: CalendarPreviewField) => void;
+ /**
+ * Which endpoints a `.Input` has declared read-only, so a grid click cannot
+ * rewrite one. Registered by the inputs, because `readOnly` is their prop.
+ */
+ fieldReadOnly: Record;
+ setFieldReadOnly: (field: CalendarPreviewField, readOnly: boolean) => void;
}
const CalendarPreviewContext =
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx
index c7233f799..78154e1e9 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx
@@ -149,7 +149,9 @@ export function CalendarPreviewGrid({
}: CalendarPreviewGridProps) {
const {
value,
- setValue,
+ selection,
+ selectDay,
+ draft,
month,
setMonth,
isDateUnavailable,
@@ -202,13 +204,16 @@ export function CalendarPreviewGrid({
[components, months]
);
- /* No `readOnly` / `disabled` check here — the root's `setValue` owns it, so
- every path in and out of the calendar inherits the same guard. */
+ /* Every click goes to the root, which owns both the single commit and the
+ from/to machine — completing a range has to close the popover, and that
+ must travel through the root's open state rather than from in here. It
+ also keeps the `readOnly` / `disabled` guard in one place, so every path
+ in and out of the calendar inherits the same one. */
const handleSelect = useCallback(
- (selected: Date | undefined, triggerDate: Date) => {
- setValue(selected ?? null, selected ? 'select' : 'clear', triggerDate);
+ (_selected: unknown, triggerDate: Date) => {
+ selectDay(triggerDate);
},
- [setValue]
+ [selectDay]
);
/* `mode`, `required`, `selected` and `onSelect` stay on the elements below:
@@ -242,12 +247,20 @@ export function CalendarPreviewGrid({
return (
- {clearable ? (
+ {selection === 'range' ? (
+
+ ) : clearable ? (
) : (
@@ -255,7 +268,7 @@ export function CalendarPreviewGrid({
{...base}
mode='single'
required
- selected={value ?? undefined}
+ selected={(value as Date | null) ?? undefined}
onSelect={handleSelect}
/>
)}
@@ -396,6 +409,9 @@ export function CalendarPreviewDay({
'data-slot': 'calendar-preview-day',
'data-scale': scale,
'data-selected': modifiers.selected || undefined,
+ 'data-range-start': modifiers.range_start || undefined,
+ 'data-range-middle': modifiers.range_middle || undefined,
+ 'data-range-end': modifiers.range_end || undefined,
'data-draft': (modifiers.focused && !modifiers.selected) || undefined,
'data-unavailable': modifiers.disabled || undefined,
'data-today': modifiers.today || undefined,
@@ -527,6 +543,9 @@ const GRID_CLASS_NAMES: DayPickerProps['classNames'] = {
disabled: styles.disabled,
selected: styles.selected,
hidden: styles.hidden,
+ range_start: styles['range-start'],
+ range_middle: styles['range-middle'],
+ range_end: styles['range-end'],
week_number: styles['week-number'],
week_number_header: styles['week-number-header']
};
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx
index bc1c3b464..ee4d338b5 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx
@@ -1,8 +1,9 @@
import { cx } from 'class-variance-authority';
-import { type ComponentProps, useRef, useState } from 'react';
+import { type ComponentProps, useEffect, useRef, useState } from 'react';
import { CalendarIcon } from '~/icons';
import { Input } from '../input';
import styles from './calendar-preview.module.css';
+import type { CalendarPreviewField } from './calendar-preview-context';
import { useCalendarPreviewContext } from './calendar-preview-context';
import { dayKey, parseKey } from './date-adapter';
import { parseScaleInput } from './lib/parse';
@@ -10,7 +11,8 @@ import { parseScaleInput } from './lib/parse';
export type CalendarPreviewInputInvalidReason =
| 'unparseable'
| 'out-of-bounds'
- | 'unavailable';
+ | 'unavailable'
+ | 'out-of-order';
export type CalendarPreviewInputValidity = {
valid: boolean;
@@ -26,6 +28,11 @@ export interface CalendarPreviewInputProps
extends Omit, 'value' | 'defaultValue'> {
/** Called when the typed text starts or stops being a usable date. */
onValidityChange?: (validity: CalendarPreviewInputValidity) => void;
+ /**
+ * Which endpoint this field addresses, at `selection='range'`. Two inputs,
+ * each addressable — rather than one bag of props per endpoint.
+ */
+ field?: CalendarPreviewField;
/**
* Replaces the message for one or more reasons; anything left out keeps the
* default. That default is one flat string because only the consumer knows
@@ -39,6 +46,13 @@ export interface CalendarPreviewInputProps
const DEFAULT_INVALID_MESSAGE = 'Invalid input';
+/* The one reason the component can word itself: it needs no knowledge of the
+ field's bounds. */
+const DEFAULT_OUT_OF_ORDER: Record = {
+ start: 'Start date cannot be after the end date',
+ end: 'End date cannot be before the start date'
+};
+
const VALID: CalendarPreviewInputValidity = { valid: true };
/**
@@ -49,12 +63,14 @@ const VALID: CalendarPreviewInputValidity = { valid: true };
* popover, which blurs and therefore commits too.
*/
export function CalendarPreviewInput({
- placeholder = 'Select date',
+ field = 'start',
+ placeholder,
trailingIcon = ,
onValidityChange,
errorMessages,
onKeyDown,
onBlur,
+ onFocus,
className,
readOnly: readOnlyProp,
...props
@@ -71,11 +87,27 @@ export function CalendarPreviewInput({
clearable,
today,
disabled,
- readOnly
+ readOnly,
+ selection,
+ setEndpoint,
+ draft,
+ activeField,
+ setActiveField,
+ setFieldReadOnly
} = useCalendarPreviewContext('CalendarPreview.Input');
+ const isRange = selection === 'range';
+
+ /* The grid has to know which endpoint refuses a write, and `readOnly` is
+ this input's prop, so it registers rather than the root guessing. */
+ useEffect(() => {
+ if (!isRange) return;
+ setFieldReadOnly(field, Boolean(readOnlyProp));
+ return () => setFieldReadOnly(field, false);
+ }, [isRange, field, readOnlyProp, setFieldReadOnly]);
+
/* Null means "show the committed value"; a string is the user's draft. */
- const [draft, setDraft] = useState(null);
+ const [text, setText] = useState(null);
const lastReported = useRef(VALID);
/* Derived from the reason rather than returned alongside it, so the reason
@@ -89,7 +121,9 @@ export function CalendarPreviewInput({
...validity,
message:
(validity.reason && errorMessages?.[validity.reason]) ??
- DEFAULT_INVALID_MESSAGE
+ (validity.reason === 'out-of-order'
+ ? DEFAULT_OUT_OF_ORDER[field]
+ : DEFAULT_INVALID_MESSAGE)
};
const report = (candidate: CalendarPreviewInputValidity) => {
@@ -122,34 +156,63 @@ export function CalendarPreviewInput({
return { valid: false, reason: 'out-of-bounds' };
}
if (isDateUnavailable(date)) return { valid: false, reason: 'unavailable' };
+ /* The checks above read one date on its own and cannot see the partner. A
+ grid click restarts instead of rejecting, on purpose. Equal days are a
+ valid range. */
+ const partner = field === 'start' ? draft?.to : draft?.from;
+ if (isRange && partner) {
+ const typed = dayKey(date, timeZone);
+ const against = dayKey(partner, timeZone);
+ if (field === 'start' ? typed > against : typed < against) {
+ return { valid: false, reason: 'out-of-order' };
+ }
+ }
return date;
};
const commit = () => {
- if (draft === null) return;
- const text = draft.trim();
- if (text === '') {
+ if (text === null) return;
+ const trimmed = text.trim();
+ if (trimmed === '') {
if (clearable && value) setValue(null, 'clear', today);
- setDraft(null);
+ setText(null);
report(VALID);
return;
}
- const resolved = resolve(text);
- if (resolved instanceof Date) {
- setValue(resolved, 'input', resolved);
- setDraft(null);
- report(VALID);
- }
+ const resolved = resolve(trimmed);
+ if (!(resolved instanceof Date)) return;
+ if (isRange) setEndpoint(field, resolved);
+ else setValue(resolved, 'input', resolved);
+ setText(null);
+ report(VALID);
};
const inert = disabled || readOnly || readOnlyProp;
+ const endpoint = isRange
+ ? ((field === 'start' ? draft?.from : draft?.to) ?? null)
+ : (value as Date | null);
+ const committedText = endpoint ? formatValue(endpoint, scale) : '';
+ const resolvedPlaceholder =
+ placeholder ??
+ (isRange
+ ? field === 'start'
+ ? 'Select start date'
+ : 'Select end date'
+ : 'Select date');
+
return (
{
+ onFocus?.(event);
+ if (isRange) setActiveField(field);
+ }}
trailingIcon={trailingIcon}
disabled={disabled}
readOnly={readOnly || readOnlyProp}
@@ -161,10 +224,10 @@ export function CalendarPreviewInput({
{...(lastReported.current.valid
? {}
: { 'aria-invalid': true, 'data-invalid': true })}
- value={draft ?? (value ? formatValue(value, scale) : '')}
+ value={text ?? committedText}
onValueChange={text => {
if (inert) return;
- setDraft(text);
+ setText(text);
if (text.trim() === '') {
report(VALID);
return;
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx
index a994fbb67..6a3e0d221 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx
@@ -6,12 +6,14 @@ import { UndoIcon } from '~/icons';
import { IconButton } from '../icon-button';
import styles from './calendar-preview.module.css';
import { useCalendarPreviewContext } from './calendar-preview-context';
+import type { CalendarPreviewValue } from './calendar-preview-root';
import { dayKey } from './date-adapter';
export type CalendarPreviewResetProps = ComponentProps;
/**
- * Restores `defaultDate`, or clears the selection when it is `null`. A value
+ * Restores `defaultDate` — a day, or a range at range selection — or clears
+ * the selection when it is `null`. A value
* reset, not a view reset — it leaves the
* visible month alone. Keyed off `defaultDate` rather than `defaultValue` so
* it still shows under a controlled `value`.
@@ -29,17 +31,25 @@ export function CalendarPreviewReset({
...props
}: CalendarPreviewResetProps) {
const { value, defaultDate, reset, disabled, readOnly, timeZone } =
- useCalendarPreviewContext('CalendarPreview.Reset');
+ useCalendarPreviewContext('CalendarPreview.Reset');
/* No `defaultDate` means the part has no job at all, which is a different
thing from having nothing to restore right now — `null` is a default. */
if (defaultDate === undefined) return null;
+ const sameDay = (a: Date, b: Date) =>
+ dayKey(a, timeZone) === dayKey(b, timeZone);
+
+ /* Both edges have to match: a shared start is not a restored range. */
const restored =
defaultDate === null
? value == null
: value != null &&
- dayKey(value, timeZone) === dayKey(defaultDate, timeZone);
+ (defaultDate instanceof Date
+ ? value instanceof Date && sameDay(value, defaultDate)
+ : !(value instanceof Date) &&
+ sameDay(value.from, defaultDate.from) &&
+ sameDay(value.to, defaultDate.to));
return (
, 'defaultValue'> {
+export type CalendarPreviewValue = Date | CalendarPreviewDateRange | null;
+
+/* Selection arms are discriminated on `selection`, so a single-day consumer
+ keeps a `Date | null` callback and a range consumer gets a range that has
+ both edges. One shared `value` type would widen both. */
+interface CalendarPreviewSingleProps {
+ selection?: 'single';
/** 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 day `.Reset` restores, read even when `value` is controlled — which
+ * `defaultValue` is not. `null` is a default of *nothing selected*, so
+ * `.Reset` clears; omitting it renders no button at all.
+ */
+ defaultDate?: Date | null;
+}
+interface CalendarPreviewRangeProps {
+ selection: 'range';
+ /** The selected range (controlled). Both edges, or nothing. */
+ value?: CalendarPreviewDateRange | null;
+ /** The initial range (uncontrolled). */
+ defaultValue?: CalendarPreviewDateRange | null;
+ /**
+ * Fires on a **complete** range or not at all. The half-built state stays
+ * internal, so there is no partial `{ from?, to? }` to gate on.
+ */
+ onValueChange?: (
+ value: CalendarPreviewDateRange | null,
+ details: CalendarPreviewChangeDetails
+ ) => void;
+ /**
+ * The range `.Reset` restores, read even when `value` is controlled — which
+ * `defaultValue` is not. `null` is a default of *nothing selected*, so
+ * `.Reset` clears; omitting it renders no button at all.
+ */
+ defaultDate?: CalendarPreviewDateRange | null;
+}
+
+export type CalendarPreviewProps = (
+ | CalendarPreviewSingleProps
+ | CalendarPreviewRangeProps
+) &
+ CalendarPreviewSharedProps;
+
+interface CalendarPreviewSharedProps
+ extends Omit, 'defaultValue' | 'onChange'> {
/** Whether the popover is open (controlled). Ignored by an inline calendar. */
open?: boolean;
/** @defaultValue false */
@@ -75,16 +132,6 @@ export interface CalendarPreviewProps
/** Reject individual days. Applied on top of `minDate` / `maxDate`. */
isDateUnavailable?: (date: Date) => boolean;
- /**
- * The day `.Reset` restores. Read even when `value` is controlled, which
- * `defaultValue` is not — otherwise a controlled consumer never sees
- * `.Reset`.
- *
- * `null` is a default of *nothing selected*, so `.Reset` clears. Omitting
- * the prop is different: the part then has no job and does not render.
- */
- defaultDate?: Date | null;
-
/**
* Renders a value for display.
* @defaultValue `DD/MM/YYYY` at day scale
@@ -152,6 +199,7 @@ export function defaultFormatValue(
}
export function CalendarPreviewRoot({
+ selection = 'single',
value: valueProp,
defaultValue = null,
onValueChange,
@@ -180,7 +228,16 @@ export function CalendarPreviewRoot({
}: CalendarPreviewProps) {
const today = useMemo(() => todayProp ?? new Date(), [todayProp]);
- const [value, setValueUnwrapped] = useControlled({
+ /* The public props are discriminated on `selection`; the implementation is
+ shared and works in the widened value. This is the one seam between them. */
+ const emit = onValueChange as
+ | ((
+ value: CalendarPreviewValue,
+ details: CalendarPreviewChangeDetails
+ ) => void)
+ | undefined;
+
+ const [value, setValueUnwrapped] = useControlled({
controlled: valueProp,
default: defaultValue,
name: 'CalendarPreview',
@@ -193,7 +250,11 @@ export function CalendarPreviewRoot({
moment `value` is controlled, so reading it alone opened a controlled
calendar on today's month with the selection off-screen — against this
prop's own documented default. */
- default: defaultMonth ?? valueProp ?? defaultValue ?? today,
+ default:
+ defaultMonth ??
+ monthAnchor(valueProp) ??
+ monthAnchor(defaultValue) ??
+ today,
name: 'CalendarPreview',
state: 'month'
});
@@ -221,19 +282,19 @@ export function CalendarPreviewRoot({
the consumer asked to be read-only. */
const setValue = useCallback(
(
- next: Date | null,
+ next: CalendarPreviewValue,
reason: CalendarPreviewChangeReason,
occasion: Date
) => {
if (readOnly || disabled) return;
setValueUnwrapped(next);
- onValueChange?.(next, {
+ emit?.(next, {
reason,
period: periodOf(occasion, scale, timeZone),
toDate: () => occasion
});
},
- [setValueUnwrapped, onValueChange, scale, timeZone, readOnly, disabled]
+ [setValueUnwrapped, emit, scale, timeZone, readOnly, disabled]
);
const [open, setOpenUnwrapped] = useControlled({
@@ -243,10 +304,11 @@ export function CalendarPreviewRoot({
state: 'open'
});
- /* Escape and a press on the trigger both leave focus on the trigger, so the
- focus event that follows would immediately undo the close. Recording the
- reason lets `.Trigger` swallow exactly that one focus — the same rule
- floating-ui's own `useFocus` applies. */
+ /* Escape, a press on the trigger, and completing a range all leave focus on
+ the trigger, so the focus event that follows would immediately undo the
+ close. Recording the reason lets `.Trigger` swallow exactly that one focus
+ — the rule floating-ui's own `useFocus` applies, plus `closePress`, which
+ is ours because auto-closing on completion is. */
const focusOpenBlocked = useRef(false);
const setOpen = useCallback(
@@ -254,7 +316,8 @@ export function CalendarPreviewRoot({
if (
!next &&
(details.reason === REASONS.escapeKey ||
- details.reason === REASONS.triggerPress)
+ details.reason === REASONS.triggerPress ||
+ details.reason === REASONS.closePress)
) {
focusOpenBlocked.current = true;
}
@@ -275,6 +338,106 @@ export function CalendarPreviewRoot({
[setScaleUnwrapped]
);
+ const [draft, setDraft] = useState(null);
+ const [activeField, setActiveField] = useState('start');
+ const [fieldReadOnly, setFieldReadOnlyState] = useState<
+ Record
+ >({ start: false, end: false });
+
+ const setFieldReadOnly = useCallback(
+ (field: CalendarPreviewField, next: boolean) => {
+ setFieldReadOnlyState(current =>
+ current[field] === next ? current : { ...current, [field]: next }
+ );
+ },
+ []
+ );
+
+ /*
+ * The from/to machine, unchanged from the shipped picker:
+ * no from -> set from, advance to the end input
+ * from, day earlier -> that day becomes the new from
+ * from, day later -> completes, emits, closes
+ * from and to -> restart from the new day
+ *
+ * It lives on the root because completing a range both writes the value and
+ * closes the popover, and closing has to go through `setOpen` so a consumer
+ * controlling `open` is not fought.
+ */
+ const selectDay = useCallback(
+ (date: Date) => {
+ if (readOnly || disabled) return;
+
+ if (selection === 'single') {
+ const isSame =
+ value instanceof Date &&
+ dayKey(value, timeZone) === dayKey(date, timeZone);
+ if (isSame && clearable) setValue(null, 'clear', date);
+ else setValue(date, 'select', date);
+ return;
+ }
+
+ const from = draft?.from;
+ if (!from || draft?.to) {
+ if (fieldReadOnly.start) return;
+ setDraft({ from: date });
+ setActiveField('end');
+ return;
+ }
+
+ if (dayKey(date, timeZone) < dayKey(from, timeZone)) {
+ if (fieldReadOnly.start) return;
+ setDraft({ from: date });
+ return;
+ }
+
+ if (fieldReadOnly.end) return;
+ setDraft(null);
+ setActiveField('start');
+ setValue({ from, to: date }, 'select', date);
+ setOpen(
+ false,
+ createChangeEventDetails(REASONS.closePress, undefined, undefined)
+ );
+ },
+ [
+ selection,
+ value,
+ draft,
+ fieldReadOnly,
+ clearable,
+ timeZone,
+ readOnly,
+ disabled,
+ setValue,
+ setOpen
+ ]
+ );
+
+ /* A click means "the next endpoint"; typing into a field means that field,
+ so a typed date cannot go through `selectDay`. */
+ const setEndpoint = useCallback(
+ (field: CalendarPreviewField, date: Date) => {
+ if (readOnly || disabled || fieldReadOnly[field]) return;
+
+ const base = draft ?? (isRange(value) ? value : null);
+ const from = field === 'start' ? date : base?.from;
+ const to = field === 'end' ? date : base?.to;
+
+ /* An ordered pair completes. Anything else — one edge still missing, or
+ a typed day that crossed its partner — restarts from that day. */
+ if (from && to && dayKey(from, timeZone) <= dayKey(to, timeZone)) {
+ setDraft(null);
+ setActiveField('start');
+ setValue({ from, to }, 'input', date);
+ return;
+ }
+ setDraft({ from: date });
+ setActiveField('end');
+ },
+ [value, draft, fieldReadOnly, timeZone, readOnly, disabled, setValue]
+ );
+
/* `'reset'`, not `'select'`: restoring the default is not a pick, and a
consumer that logs or validates on selection needs to tell them apart. */
const reset = useCallback(() => {
@@ -283,11 +446,12 @@ export function CalendarPreviewRoot({
claim a day was restored when none was. */
if (defaultDate === null) {
if (value == null) return;
- setValue(null, 'clear', value);
+ setValue(null, 'clear', monthAnchor(value) ?? today);
return;
}
- setValue(defaultDate, 'reset', defaultDate);
- }, [defaultDate, value, setValue]);
+ /* `occasion` is one day, so a range reports the day it starts on. */
+ setValue(defaultDate, 'reset', monthAnchor(defaultDate) ?? today);
+ }, [defaultDate, value, setValue, today]);
/* Day-keys, not instants: a `minDate` carrying a time of day still leaves
its own day selectable, which the current family gets wrong. */
@@ -312,10 +476,18 @@ export function CalendarPreviewRoot({
return { from: Math.min(...years), to: Math.max(...years) };
}, [yearRangeProp, today, minDate, maxDate]);
- const context = useMemo>(
+ const context = useMemo>(
() => ({
value,
setValue,
+ selection,
+ selectDay,
+ setEndpoint,
+ draft: draft ?? (isRange(value) ? value : null),
+ activeField,
+ setActiveField,
+ fieldReadOnly,
+ setFieldReadOnly,
open,
setOpen,
shouldIgnoreFocusOpen,
@@ -339,6 +511,13 @@ export function CalendarPreviewRoot({
[
value,
setValue,
+ selection,
+ selectDay,
+ setEndpoint,
+ draft,
+ activeField,
+ fieldReadOnly,
+ setFieldReadOnly,
open,
setOpen,
shouldIgnoreFocusOpen,
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
index d1ea944e6..3ffad3661 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
@@ -5,6 +5,7 @@ import { cx } from 'class-variance-authority';
import { type ComponentProps, type FocusEvent, useRef } from 'react';
import styles from './calendar-preview.module.css';
import { useCalendarPreviewContext } from './calendar-preview-context';
+import type { CalendarPreviewValue } from './calendar-preview-root';
export interface CalendarPreviewTriggerProps
extends useRender.ComponentProps<'div'> {
@@ -45,7 +46,9 @@ export function CalendarPreviewTrigger({
shouldIgnoreFocusOpen,
disabled,
readOnly
- } = useCalendarPreviewContext('CalendarPreview.Trigger');
+ } = useCalendarPreviewContext(
+ 'CalendarPreview.Trigger'
+ );
/* Tracks the pointer, not the open state: Base UI owns whether the popover
is open, and this only says whether a press is mid-flight. */
@@ -87,10 +90,16 @@ export function CalendarPreviewTrigger({
)
} as ComponentProps;
+ /* `formatValue` takes a single day, so a range formats as its two ends. */
+ const label =
+ value instanceof Date
+ ? formatValue(value, scale)
+ : value
+ ? `${formatValue(value.from, scale)} – ${formatValue(value.to, scale)}`
+ : placeholder;
+
return (
-
- {children ?? (value ? formatValue(value, scale) : placeholder)}
-
+ {children ?? label}
);
}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css
index 5787f7091..266fc55ca 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview.module.css
+++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css
@@ -478,3 +478,59 @@
.input {
width: 100%;
}
+
+/* The endpoints are pill-rounded on their outer edges and the days between sit
+ on one continuous band. The track is drawn on the cell rather than the day
+ button so neighbouring cells meet with no seam. */
+.range-middle {
+ background: var(--rs-color-background-neutral-secondary);
+ border-radius: 0;
+}
+
+/* react-day-picker marks every day of the range `selected`, and the single-day
+ rule paints that white for the accent pill. The days on the track are on
+ grey, so they keep the ordinary text colour. */
+.range-middle .day-button {
+ background: transparent;
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.range-start,
+.range-end {
+ background: var(--rs-color-background-neutral-secondary);
+}
+
+/* A half-open range has one endpoint and no band to join, so it keeps the
+ plain selected pill instead of a flat edge. */
+.range-start:not(.range-end) {
+ border-start-start-radius: var(--rs-radius-5);
+ border-end-start-radius: var(--rs-radius-5);
+ border-start-end-radius: 0;
+ border-end-end-radius: 0;
+}
+
+.range-end:not(.range-start) {
+ border-start-end-radius: var(--rs-radius-5);
+ border-end-end-radius: var(--rs-radius-5);
+ border-start-start-radius: 0;
+ border-end-start-radius: 0;
+}
+
+.range-start .day-button,
+.range-end .day-button {
+ background: var(--rs-color-background-accent-emphasis);
+ color: var(--rs-color-foreground-base-emphasis);
+ border-radius: var(--rs-radius-5);
+}
+
+.range-start .day-button[data-today]::after,
+.range-end .day-button[data-today]::after {
+ background-color: var(--rs-color-foreground-base-emphasis);
+}
+
+/* Two fields side by side, sharing the trigger's width. */
+.range-fields {
+ display: flex;
+ align-items: center;
+ gap: var(--rs-space-3);
+}
diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx
index 63a35258d..4ddc84acf 100644
--- a/packages/raystack/components/calendar-preview/index.tsx
+++ b/packages/raystack/components/calendar-preview/index.tsx
@@ -4,6 +4,9 @@ export type { CalendarPreviewContentProps } from './calendar-preview-content';
export type {
CalendarPreviewChangeDetails,
CalendarPreviewChangeReason,
+ CalendarPreviewDateRange,
+ CalendarPreviewDraftRange,
+ CalendarPreviewField,
CalendarPreviewOpenChangeDetails
} from './calendar-preview-context';
export type { CalendarPreviewDaysProps } from './calendar-preview-days';
diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx
index d45b24943..c9b5ea89f 100644
--- a/packages/raystack/components/calendar-preview/use-calendar.tsx
+++ b/packages/raystack/components/calendar-preview/use-calendar.tsx
@@ -1,12 +1,14 @@
'use client';
import { useCalendarPreviewContext } from './calendar-preview-context';
+import type { CalendarPreviewValue } from './calendar-preview-root';
import type { CalendarPreviewScale } from './lib/scale';
export interface UseCalendarReturn {
- value: Date | null;
- /** Commit a day, or clear with `null`. Emits `onValueChange`. */
- setValue: (value: Date | null) => void;
+ /* Holds a range at `selection='range'`. */
+ value: CalendarPreviewValue;
+ /** Commit a day or a range, or clear with `null`. Emits `onValueChange`. */
+ setValue: (value: CalendarPreviewValue) => void;
/* Read-only until the scale switcher lands in phase 5. Exposing a setter
now would be a public API we cannot take back if the switcher reshapes
it; adding one later is additive. */
@@ -23,18 +25,23 @@ export interface UseCalendarReturn {
*/
export function useCalendar(): UseCalendarReturn {
const { value, setValue, scale, month, setMonth, isDateUnavailable } =
- useCalendarPreviewContext('useCalendar');
+ useCalendarPreviewContext('useCalendar');
return {
value,
/* A null commit is a clear, and the day acted on is the day being
cleared. Reporting `'select'` with `new Date()` broke the context's
documented promise that `toDate()` is the day acted on — it handed back
- today, which is a day nobody touched. */
+ today, which is a day nobody touched. `occasion` is one day either way,
+ so a range reports the day it starts on. */
setValue: next =>
next === null
- ? setValue(null, 'clear', value ?? new Date())
- : setValue(next, 'select', next),
+ ? setValue(
+ null,
+ 'clear',
+ (value instanceof Date ? value : value?.from) ?? new Date()
+ )
+ : setValue(next, 'select', next instanceof Date ? next : next.from),
scale,
month,
setMonth,
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 0dc65b745..c747b1d79 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -25,16 +25,25 @@ export {
type CalendarPreviewCaptionProps,
type CalendarPreviewChangeDetails,
type CalendarPreviewChangeReason,
+ type CalendarPreviewContentProps,
+ type CalendarPreviewDateRange,
type CalendarPreviewDayProps,
type CalendarPreviewDaysProps,
+ type CalendarPreviewDraftRange,
+ type CalendarPreviewField,
type CalendarPreviewFooterProps,
type CalendarPreviewGridProps,
type CalendarPreviewHeaderProps,
+ type CalendarPreviewInputInvalidReason,
+ type CalendarPreviewInputProps,
+ type CalendarPreviewInputValidity,
type CalendarPreviewNavProps,
+ type CalendarPreviewOpenChangeDetails,
type CalendarPreviewProps,
type CalendarPreviewResetProps,
type CalendarPreviewScale,
type CalendarPreviewScaleValue,
+ type CalendarPreviewTriggerProps,
type CalendarPreviewWeekdayProps,
type UseCalendarReturn,
useCalendar