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,