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 00389ae18..df204e56e 100644
--- a/apps/www/src/content/docs/components/calendar-preview/demo.ts
+++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts
@@ -309,3 +309,103 @@ export const pickerDemo = {
}
]
};
+
+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: '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 cce168abf..c14e8a371 100644
--- a/apps/www/src/content/docs/components/calendar-preview/index.mdx
+++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx
@@ -12,6 +12,7 @@ import {
gridDemo,
dateInfoDemo,
pickerDemo,
+ rangeDemo,
} from "./demo.ts";
@@ -223,6 +224,39 @@ 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.
+
+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.
+
+
+
## Accessibility
- Arrow keys move between days; the focused cell carries `data-draft` until it is committed
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..745f145ff
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx
@@ -0,0 +1,245 @@
+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());
+ });
+});
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
index 6f33c50ce..e41d26e95 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
@@ -14,6 +14,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;
@@ -70,6 +88,29 @@ export interface CalendarPreviewContextValue {
disabled: boolean;
readOnly: boolean;
formatValue: (value: Date | ScaleValue, scale: Scale) => 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;
+ /**
+ * 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 47d35c777..1b2c0ea33 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx
@@ -111,15 +111,16 @@ export function CalendarPreviewGrid({
}: CalendarPreviewGridProps) {
const {
value,
- setValue,
+ selection,
+ selectDay,
+ draft,
month,
setMonth,
isDateUnavailable,
today,
timeZone,
clearable,
- disabled,
- readOnly
+ disabled
} = useCalendarPreviewContext('CalendarPreview.Grid');
const days = useCalendarPreviewDaysContext();
const setBusy = days?.setBusy;
@@ -158,9 +159,11 @@ export function CalendarPreviewGrid({
[components, months]
);
- const handleSelect = (selected: Date | undefined, triggerDate: Date) => {
- if (readOnly || disabled) return;
- setValue(selected ?? null, selected ? 'select' : 'clear', triggerDate);
+ /* 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. */
+ const handleSelect = (_selected: unknown, triggerDate: Date) => {
+ selectDay(triggerDate);
};
/* `mode`, `required`, `selected` and `onSelect` stay on the elements below:
@@ -191,12 +194,20 @@ export function CalendarPreviewGrid({
return (
- {clearable ? (
+ {selection === 'range' ? (
+
+ ) : clearable ? (
) : (
@@ -204,7 +215,7 @@ export function CalendarPreviewGrid({
{...base}
mode='single'
required
- selected={value ?? undefined}
+ selected={(value as Date | null) ?? undefined}
onSelect={handleSelect}
/>
)}
@@ -330,6 +341,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,
@@ -419,6 +433,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 6550ff021..cc5c40eb6 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';
@@ -16,6 +17,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;
}
const VALID: CalendarPreviewInputValidity = { valid: true };
@@ -28,11 +34,13 @@ const VALID: CalendarPreviewInputValidity = { valid: true };
* popover, which blurs and therefore commits too.
*/
export function CalendarPreviewInput({
- placeholder = 'Select date',
+ field = 'start',
+ placeholder,
trailingIcon = ,
onValidityChange,
onKeyDown,
onBlur,
+ onFocus,
className,
readOnly: readOnlyProp,
...props
@@ -49,11 +57,27 @@ export function CalendarPreviewInput({
clearable,
today,
disabled,
- readOnly
+ readOnly,
+ selection,
+ selectDay,
+ 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);
const report = (next: CalendarPreviewInputValidity) => {
@@ -88,38 +112,58 @@ export function CalendarPreviewInput({
};
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;
+ /* A typed endpoint goes through the same machine a clicked one does, so
+ the two cannot disagree about what completes a range. */
+ if (isRange) selectDay(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}
aria-invalid={lastReported.current.valid ? undefined : 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-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
index 67af6a640..bfd572f4d 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
@@ -1,15 +1,19 @@
'use client';
import { mergeProps, Popover, useRender } from '@base-ui/react';
+import { createChangeEventDetails } from '@base-ui/react/internals/createBaseUIEventDetails';
import { REASONS } from '@base-ui/react/internals/reasons';
import { useControlled } from '@base-ui/utils/useControlled';
import { cx } from 'class-variance-authority';
-import { useCallback, useMemo, useRef } from 'react';
+import { useCallback, useMemo, useRef, useState } from 'react';
import styles from './calendar-preview.module.css';
import {
type CalendarPreviewChangeDetails,
type CalendarPreviewChangeReason,
type CalendarPreviewContextValue,
+ type CalendarPreviewDateRange,
+ type CalendarPreviewDraftRange,
+ type CalendarPreviewField,
type CalendarPreviewOpenChangeDetails,
CalendarPreviewProvider
} from './calendar-preview-context';
@@ -25,20 +29,59 @@ import { periodOf, type Scale, type ScaleValue } from './lib/scale';
const DEFAULT_YEAR_SPAN = 10;
+function isRange(value: unknown): value is CalendarPreviewDateRange {
+ return value != null && typeof value === 'object' && 'from' in value;
+}
+
+/* The day the view should open on, whichever selection shape the value is. */
+function monthAnchor(value: CalendarPreviewValue): Date | undefined {
+ if (!value) return undefined;
+ return isRange(value) ? value.from : value;
+}
+
/* `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'> {
+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;
+}
+
+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;
+}
+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 */
@@ -125,6 +168,7 @@ export function defaultFormatValue(
}
export function CalendarPreviewRoot({
+ selection = 'single',
value: valueProp,
defaultValue = null,
onValueChange,
@@ -153,7 +197,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',
@@ -162,7 +215,7 @@ export function CalendarPreviewRoot({
const [month, setMonthUnwrapped] = useControlled({
controlled: monthProp,
- default: defaultMonth ?? defaultValue ?? today,
+ default: defaultMonth ?? monthAnchor(defaultValue) ?? today,
name: 'CalendarPreview',
state: 'month'
});
@@ -186,18 +239,18 @@ export function CalendarPreviewRoot({
const setValue = useCallback(
(
- next: Date | null,
+ next: CalendarPreviewValue,
reason: CalendarPreviewChangeReason,
occasion: Date
) => {
setValueUnwrapped(next);
- onValueChange?.(next, {
+ emit?.(next, {
reason,
period: periodOf(occasion, scale),
toDate: () => occasion
});
},
- [setValueUnwrapped, onValueChange, scale]
+ [setValueUnwrapped, emit, scale]
);
const [open, setOpenUnwrapped] = useControlled({
@@ -207,10 +260,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(
@@ -218,7 +272,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;
}
@@ -239,6 +294,82 @@ 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
+ ]
+ );
+
const reset = useCallback(() => {
if (!defaultDate) return;
setValue(defaultDate, 'select', defaultDate);
@@ -267,10 +398,17 @@ 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,
+ draft: draft ?? (isRange(value) ? value : null),
+ activeField,
+ setActiveField,
+ fieldReadOnly,
+ setFieldReadOnly,
open,
setOpen,
shouldIgnoreFocusOpen,
@@ -294,6 +432,12 @@ export function CalendarPreviewRoot({
[
value,
setValue,
+ selection,
+ selectDay,
+ draft,
+ activeField,
+ fieldReadOnly,
+ setFieldReadOnly,
open,
setOpen,
shouldIgnoreFocusOpen,
diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css
index c5e2716be..502159f54 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview.module.css
+++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css
@@ -463,3 +463,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 ac50dab4f..9b83afa74 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/index.tsx b/packages/raystack/index.tsx
index 41e68661a..aff716c65 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -25,8 +25,11 @@ export {
type CalendarPreviewCaptionProps,
type CalendarPreviewChangeDetails,
type CalendarPreviewChangeReason,
+ type CalendarPreviewDateRange,
type CalendarPreviewDayProps,
type CalendarPreviewDaysProps,
+ type CalendarPreviewDraftRange,
+ type CalendarPreviewField,
type CalendarPreviewFooterProps,
type CalendarPreviewGridProps,
type CalendarPreviewHeaderProps,