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