'
+ );
+ }
+
+ const validate = (date: Date): CalendarValidity => {
+ if (!isWithinBounds(date, minDate, maxDate, timeZone)) {
+ return { valid: false, reason: 'out-of-bounds' };
+ }
+ if (isDateUnavailable?.(date)) {
+ return { valid: false, reason: 'unavailable' };
+ }
+ return { valid: true };
+ };
+
+ const commit = (field: CalendarRangeField, text: string) => {
+ /*
+ * An emptied field clears that endpoint, and is not an error. The old
+ * DatePicker reported empty as invalid, which left no way to clear.
+ */
+ if (text.trim() === '') {
+ reportValidity({ valid: true });
+ setValue({ ...range, [field]: null });
+ return true;
+ }
+
+ /*
+ * The active granularity wins. Only when it cannot read the text do we
+ * scan the granularities on offer, so typing `Q4` in a day field switches
+ * to Quarter rather than failing — and a day-only picker still rejects it.
+ */
+ const visibleYear = getYear(month, timeZone);
+ let parsed = parseForGranularity(
+ text,
+ granularity,
+ format,
+ timeZone,
+ visibleYear
+ );
+ let matched = granularity;
+ if (!parsed) {
+ const across = parseAcrossGranularities(
+ text,
+ granularities,
+ format,
+ timeZone,
+ visibleYear
+ );
+ if (across) {
+ parsed = across.date;
+ matched = across.granularity as typeof granularity;
+ }
+ }
+ if (!parsed) {
+ reportValidity({ valid: false, reason: 'unparseable' });
+ return false;
+ }
+
+ const validity = validate(parsed);
+ reportValidity(validity);
+ if (!validity.valid) return false;
+
+ if (matched !== granularity) setGranularity(matched);
+
+ /*
+ * A typed day carries no time, so it inherits the one this endpoint already
+ * had — otherwise every retype silently discarded whatever `.TimeField` or
+ * a preset had put there, resetting it to midnight.
+ *
+ * Day granularity only. Every other granularity resolves to the first
+ * instant of a period, and `Q4 2024` means the quarter, not 09:30 on the
+ * day it happens to start.
+ */
+ const previous = matched === 'day' ? range[field] : null;
+ const committed = previous
+ ? setTime(
+ parsed,
+ getHours(previous, timeZone),
+ getMinutes(previous, timeZone),
+ timeZone
+ )
+ : parsed;
+
+ const next: DateRangeValue = { ...range, [field]: committed };
+
+ /*
+ * A typed start after the existing end clears the end rather than
+ * swapping the two: swapping silently moves a value into a field the user
+ * did not type in, which reads as the component losing their input.
+ *
+ * By day, deliberately. An instant comparison here would delete the user's
+ * start the moment they typed an end on the same day, because a bare date
+ * is midnight and `.TimeField` had already put 08:00 on the start.
+ */
+ if (next.from && next.to && isAfterDay(next.from, next.to, timeZone)) {
+ if (field === 'from') next.to = null;
+ else next.from = null;
+ } else if (
+ next.from &&
+ next.to &&
+ next.from.getTime() > next.to.getTime()
+ ) {
+ /*
+ * Ordered by day but inverted by instant — the case a day comparison
+ * cannot see, and the one `.TimeField` refuses outright. Here the
+ * inversion is an artefact of the missing time rather than something the
+ * user asked for, so it is resolved instead of refused: a bare end date
+ * reads as "through the end of that day", which is how every other part
+ * of this component reads a midnight bound.
+ */
+ if (field === 'to') {
+ next.to = endOfDay(next.to, timeZone);
+ } else {
+ next.from = startOfDay(next.from, timeZone);
+ }
+ }
+
+ setValue(next, { granularity: matched });
+ // Typing navigates the grid, so the committed day is actually visible.
+ setMonth(parsed);
+ return true;
+ };
+
+ const renderField = (
+ field: CalendarRangeField,
+ committedText: string,
+ props?: FieldInputProps
+ ) => (
+ /*
+ * The slot and the active flag go on a wrapper, not on `Input`. `Input`
+ * spreads `...props` last, so a `data-slot` passed to it would overwrite
+ * its own `data-slot="input"` — a semver-covered name on another
+ * component. The wrapper also gives the active style something with a
+ * border to colour, since `Input`'s border sits on its container.
+ */
+
+ {/*
+ * Merged, not just spread-last: a consumer `onBlur`/`onKeyDown` would
+ * otherwise replace parse-and-commit outright, leaving a field that
+ * accepts text and reports nothing.
+ */}
+ (
+ {
+ ref: field === 'to' ? endRef : undefined,
+ value: draft[field] ?? committedText,
+ placeholder: patternForGranularity(granularity, format),
+ disabled,
+ readOnly: readOnly || lock === field,
+ 'aria-label': field === 'from' ? 'Start date' : 'End date',
+ onFocus: () => setActiveField(field),
+ onChange: (event: ChangeEvent) =>
+ setDraft(current => ({
+ ...current,
+ [field]: event.target.value
+ })),
+ onBlur: () => {
+ if (draft[field] === null) return;
+ commit(field, draft[field] as string);
+ setDraft(current => ({ ...current, [field]: null }));
+ },
+ onKeyDown: (event: KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ const pending = draft[field];
+ if (pending === null) return;
+ const committedOk = commit(field, pending);
+ setDraft(current => ({ ...current, [field]: null }));
+ if (committedOk && field === 'from' && lock !== 'to') {
+ endRef.current?.focus();
+ }
+ }
+ // See `.Input`: ArrowDown is the keyboard's way into a calendar
+ // whose trigger carries no tab stop.
+ if (event.key === 'ArrowDown' && insideTrigger) {
+ event.preventDefault();
+ setOpen(true);
+ }
+ // Two-stage, as a combobox is: revert the text first, dismiss on
+ // the second press.
+ if (event.key === 'Escape' && draft[field] !== null) {
+ event.stopPropagation();
+ setDraft(current => ({ ...current, [field]: null }));
+ }
+ }
+ } as never,
+ (props ?? {}) as never
+ ) as InputProps)}
+ />
+
+ );
+
+ return (
+
+ {renderField('from', committedFrom, startProps)}
+
+ –
+
+ {renderField('to', committedTo, endProps)}
+
+ );
+}
+
+CalendarPreviewRangeInput.displayName = 'CalendarPreview.RangeInput';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
new file mode 100644
index 000000000..9dadc042c
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
@@ -0,0 +1,566 @@
+'use client';
+
+import { Popover as PopoverPrimitive } from '@base-ui/react';
+import { useControlled } from '@base-ui/utils/useControlled';
+import {
+ type ReactNode,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState
+} from 'react';
+import {
+ type CalendarGranularity,
+ type CalendarPreviewContextValue,
+ CalendarPreviewProvider,
+ type CalendarRangeField,
+ type CalendarSelection,
+ type CalendarValidity,
+ type CalendarValue,
+ type DateRangeValue,
+ isSameValue
+} from './calendar-preview-context';
+import { DEFAULT_FORMAT, dayKey, startOfMonth } from './date-adapter';
+
+/**
+ * Accompanies every value change with the granularity that produced it. A
+ * month pick emits the first day of that month, so without this a consumer
+ * cannot tell `1 June` chosen as a day from June chosen as a month — the same
+ * pairing the reference app sends as `startDate` plus `startDateResolution`.
+ */
+export interface CalendarValueChangeDetails {
+ granularity: CalendarGranularity;
+}
+
+export interface CalendarPreviewBaseProps {
+ /** The active granularity (controlled). */
+ granularity?: CalendarGranularity;
+ /** @defaultValue 'day' */
+ defaultGranularity?: CalendarGranularity;
+ onGranularityChange?: (granularity: CalendarGranularity) => void;
+ /**
+ * Granularities the user may switch between. `.GranularityTabs` renders
+ * only when there is more than one.
+ * @defaultValue ['day']
+ */
+ granularities?: CalendarGranularity[];
+
+ /** Whether the popover is open (controlled). */
+ open?: boolean;
+ /** @defaultValue false */
+ defaultOpen?: boolean;
+ onOpenChange?: (open: boolean, details?: { reason?: string }) => void;
+
+ /** The visible month (controlled). Independent of the selected value. */
+ month?: Date;
+ defaultMonth?: Date;
+ onMonthChange?: (month: Date) => void;
+
+ minDate?: Date;
+ maxDate?: Date;
+ /**
+ * Covers the common predicate without learning RDP's matcher DSL.
+ *
+ * Wrap it in `useCallback`: `.MonthGrid` keys its period memo on this, so an
+ * inline predicate rebuilds every cell on every render.
+ */
+ isDateUnavailable?: (date: Date) => boolean;
+
+ /** @defaultValue 'DD MMM YYYY' */
+ format?: string;
+ timeZone?: string;
+ /** @defaultValue 0 */
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
+
+ /**
+ * Reports whether the typed input currently parses and lands in range.
+ * Renders no error UI itself — compose in `Field` for that.
+ */
+ onValidityChange?: (validity: CalendarValidity) => void;
+
+ /**
+ * `'immediate'` fires `onValueChange` on every interaction. `'explicit'`
+ * buffers edits until `.Apply` commits them, which is what makes a footer
+ * with actions expressible.
+ * @defaultValue 'immediate'
+ */
+ commit?: 'immediate' | 'explicit';
+
+ /**
+ * Replaces the caption and the grid with a shimmer and disables every
+ * control, rather than leaving the chrome live while the grid loads.
+ * @defaultValue false
+ */
+ loading?: boolean;
+
+ /** @defaultValue false */
+ disabled?: boolean;
+ /** @defaultValue false */
+ readOnly?: boolean;
+ children?: ReactNode;
+}
+
+export interface CalendarPreviewSingleProps extends CalendarPreviewBaseProps {
+ selection?: 'single';
+ value?: Date | null;
+ defaultValue?: Date | null;
+ onValueChange?: (
+ value: Date | null,
+ details: CalendarValueChangeDetails
+ ) => void;
+}
+
+export interface CalendarPreviewRangeProps extends CalendarPreviewBaseProps {
+ selection: 'range';
+ value?: DateRangeValue | null;
+ defaultValue?: DateRangeValue | null;
+ onValueChange?: (
+ value: DateRangeValue | null,
+ details: CalendarValueChangeDetails
+ ) => void;
+ /**
+ * Holds one endpoint read-only in both the input and the grid, so "fix the
+ * start, pick the end" no longer means disabling the whole picker.
+ */
+ lock?: CalendarRangeField;
+}
+
+export interface CalendarPreviewMultipleProps extends CalendarPreviewBaseProps {
+ selection: 'multiple';
+ value?: Date[];
+ defaultValue?: Date[];
+ onValueChange?: (value: Date[], details: CalendarValueChangeDetails) => void;
+}
+
+export type CalendarPreviewRootProps =
+ | CalendarPreviewSingleProps
+ | CalendarPreviewRangeProps
+ | CalendarPreviewMultipleProps;
+
+/**
+ * The union collapsed into one shape, for internal use only. Reading `props`
+ * as the union directly would narrow `selection` to `'single'`, making the
+ * other arms unreachable inside the body.
+ */
+interface NormalizedRootProps extends CalendarPreviewBaseProps {
+ selection?: CalendarSelection;
+ lock?: CalendarRangeField;
+ value?: CalendarValue;
+ defaultValue?: CalendarValue;
+ onValueChange?: (
+ value: CalendarValue,
+ details: CalendarValueChangeDetails
+ ) => void;
+}
+
+/** The earliest date a value of any selection mode carries, if any. */
+function firstDateIn(value: CalendarValue | undefined): Date | undefined {
+ if (!value) return undefined;
+ if (value instanceof Date) return value;
+ if (Array.isArray(value)) return value[0];
+ return value.from ?? value.to ?? undefined;
+}
+
+export function CalendarPreviewRoot(props: CalendarPreviewRootProps) {
+ const {
+ selection = 'single',
+ granularity: granularityProp,
+ defaultGranularity = 'day',
+ onGranularityChange,
+ granularities,
+ value: valueProp,
+ defaultValue,
+ onValueChange,
+ open: openProp,
+ defaultOpen = false,
+ onOpenChange,
+ month: monthProp,
+ defaultMonth,
+ onMonthChange,
+ lock,
+ commit: commitMode = 'immediate',
+ onValidityChange,
+ minDate,
+ maxDate,
+ isDateUnavailable,
+ format = DEFAULT_FORMAT,
+ timeZone,
+ weekStartsOn = 0,
+ loading = false,
+ disabled: disabledProp = false,
+ readOnly = false,
+ children
+ } = props as NormalizedRootProps;
+
+ /*
+ * Loading disables everything by folding into `disabled` here, once. Asking
+ * each part to check both flags would mean one of them eventually forgetting.
+ */
+ const disabled = disabledProp || loading;
+
+ const [value, setValueUnwrapped] = useControlled({
+ controlled: valueProp,
+ default: defaultValue ?? (selection === 'multiple' ? [] : null),
+ name: 'CalendarPreview',
+ state: 'value'
+ });
+
+ const [open, setOpenUnwrapped] = useControlled({
+ controlled: openProp,
+ default: defaultOpen,
+ name: 'CalendarPreview',
+ state: 'open'
+ });
+
+ /*
+ * The visible month is independent state, but it has to *start* somewhere
+ * sensible: a picker holding a date in another year must not open on today.
+ * Both `DatePicker` and `RangePicker` sync this today; only the mechanism
+ * changes here.
+ */
+ /*
+ * Computed once. `useControlled` reads `default` as the initial value and
+ * warns if it changes, so deriving it from a live `value` on every render
+ * both trips that warning and risks re-initialising the visible month
+ * underneath the user.
+ */
+ const initialMonth = useRef(null);
+ if (initialMonth.current === null) {
+ initialMonth.current = startOfMonth(
+ defaultMonth ?? firstDateIn(valueProp ?? defaultValue) ?? new Date(),
+ timeZone
+ );
+ }
+
+ const [month, setMonthUnwrapped] = useControlled({
+ controlled: monthProp,
+ default: initialMonth.current,
+ name: 'CalendarPreview',
+ state: 'month'
+ });
+
+ const [granularity, setGranularityUnwrapped] =
+ useControlled({
+ controlled: granularityProp,
+ default: defaultGranularity,
+ name: 'CalendarPreview',
+ state: 'granularity'
+ });
+
+ /*
+ * Defaults to just the active granularity, so a single-granularity picker
+ * shows no tabs and the active one is always present in the list.
+ */
+ const offeredGranularities = useMemo(
+ () =>
+ granularities && granularities.length > 0 ? granularities : [granularity],
+ [granularities, granularity]
+ );
+
+ const setGranularity = useCallback(
+ (next: CalendarGranularity) => {
+ setGranularityUnwrapped(next);
+ onGranularityChange?.(next);
+ },
+ [setGranularityUnwrapped, onGranularityChange]
+ );
+
+ /*
+ * Under `commit='explicit'` every part writes here instead of to the real
+ * value, so the popover can be abandoned without the parent ever seeing the
+ * intermediate states. `undefined` means "nothing buffered".
+ */
+ const [buffer, setBuffer] = useState(undefined);
+ // The granularity the buffered value was picked at, so `.Apply` reports it.
+ const [bufferGranularity, setBufferGranularity] = useState<
+ CalendarGranularity | undefined
+ >(undefined);
+
+ const effectiveValue = buffer === undefined ? value : buffer;
+
+ /*
+ * Read through a ref, not closed over. The active granularity is only ever
+ * the fallback for a value change that does not name one, so depending on it
+ * turned `setValue`'s identity over — and with it the whole context object —
+ * every time the tab changed. Reading it at call time is also the more
+ * correct of the two: it cannot be a stale closure.
+ */
+ const granularityRef = useRef(granularity);
+ granularityRef.current = granularity;
+
+ const setValue = useCallback(
+ (next: CalendarValue, details?: { granularity?: string }) => {
+ const resolved = (details?.granularity ??
+ granularityRef.current) as CalendarGranularity;
+ if (commitMode === 'explicit') {
+ setBuffer(next);
+ setBufferGranularity(resolved);
+ return;
+ }
+ setValueUnwrapped(next);
+ onValueChange?.(next, { granularity: resolved });
+ },
+ [commitMode, setValueUnwrapped, onValueChange]
+ );
+
+ const applyValue = useCallback(() => {
+ if (commitMode !== 'explicit' || buffer === undefined) return;
+ setValueUnwrapped(buffer);
+ onValueChange?.(buffer, {
+ granularity: bufferGranularity ?? granularityRef.current
+ });
+ setBuffer(undefined);
+ setBufferGranularity(undefined);
+ }, [commitMode, buffer, bufferGranularity, setValueUnwrapped, onValueChange]);
+
+ const cancelValue = useCallback(() => {
+ setBuffer(undefined);
+ setBufferGranularity(undefined);
+ }, []);
+
+ /*
+ * Revert-to-default. `defaultValue` is read live rather than captured at
+ * mount, so it works for a controlled picker too: there it means "the value
+ * to revert to" rather than "the initial value".
+ */
+ const canReset =
+ defaultValue != null && !isSameValue(effectiveValue, defaultValue);
+
+ const resetValue = useCallback(() => {
+ if (defaultValue == null) return;
+ setValue(defaultValue);
+ }, [defaultValue, setValue]);
+
+ const setOpen = useCallback(
+ (next: boolean, details?: { reason?: string }) => {
+ setOpenUnwrapped(next);
+ onOpenChange?.(next, details);
+ },
+ [setOpenUnwrapped, onOpenChange]
+ );
+
+ const setMonth = useCallback(
+ (next: Date) => {
+ setMonthUnwrapped(next);
+ onMonthChange?.(next);
+ },
+ [setMonthUnwrapped, onMonthChange]
+ );
+
+ /*
+ * The initial month above is computed once, which is right for a mount and
+ * wrong forever after: a value that arrived asynchronously was never shown,
+ * and reopening the popover left the user wherever they had last navigated
+ * rather than back on the selection.
+ *
+ * So the visible month follows the value at exactly two moments, and only
+ * while the consumer is not driving `month` themselves — on the closed → open
+ * transition, and when the value's anchor day changes. Never while the
+ * popover sits open with that anchor unchanged, because then it is the user
+ * navigating and their navigation has to win.
+ *
+ * Every comparison goes through `dayKey`, never `Date` identity: a fresh
+ * `Date` for the same day must not count as a change, or this becomes the
+ * render loop the RFC diagnosed in `DatePicker`.
+ */
+ const anchorDate = firstDateIn(effectiveValue);
+ const anchorKey = anchorDate ? dayKey(anchorDate, timeZone) : null;
+ const previousOpen = useRef(open);
+ const previousAnchorKey = useRef(anchorKey);
+
+ useEffect(() => {
+ const justOpened = open && !previousOpen.current;
+ const anchorChanged = anchorKey !== previousAnchorKey.current;
+ previousOpen.current = open;
+ previousAnchorKey.current = anchorKey;
+
+ if (monthProp !== undefined) return;
+ // Clearing a value must not yank an open calendar back to today.
+ if (!justOpened && !(anchorChanged && anchorDate)) return;
+
+ const target = startOfMonth(
+ anchorDate ?? defaultMonth ?? new Date(),
+ timeZone
+ );
+ /*
+ * Compared as months, not as days. `.Input` and `.Preset` move the month
+ * by handing over the date the user named, mid-month and all; normalising
+ * that here would fire a second `onMonthChange` for one action and report
+ * a month change that nobody can see.
+ */
+ if (
+ dayKey(target, timeZone) ===
+ dayKey(startOfMonth(month, timeZone), timeZone)
+ )
+ return;
+ setMonth(target);
+ }, [
+ open,
+ anchorKey,
+ anchorDate,
+ month,
+ monthProp,
+ defaultMonth,
+ timeZone,
+ setMonth
+ ]);
+
+ const handleOpenChange = useCallback(
+ (next: boolean, eventDetails: PopoverPrimitive.Root.ChangeEventDetails) => {
+ // A disabled picker cannot be opened, only closed.
+ if (next && disabled) return;
+ // Abandoning the surface discards buffered edits; only `.Apply` keeps
+ // them. Closing via `.Apply` clears the buffer before this runs.
+ if (!next) {
+ setBuffer(undefined);
+ setBufferGranularity(undefined);
+ }
+ setOpen(next, { reason: eventDetails?.reason });
+ },
+ [setOpen, disabled]
+ );
+
+ /*
+ * Internal, per State Ownership in the RFC: `.RangeInput` reads it to know
+ * which field is being edited, and `.Grid` to know which endpoint a click
+ * writes. A locked endpoint can never become active.
+ */
+ const [activeFieldState, setActiveFieldState] = useState(
+ lock === 'from' ? 'to' : 'from'
+ );
+
+ const activeField = lock
+ ? lock === 'from'
+ ? 'to'
+ : 'from'
+ : activeFieldState;
+
+ const setActiveField = useCallback(
+ (field: CalendarRangeField) => {
+ if (lock === field) return;
+ setActiveFieldState(field);
+ },
+ [lock]
+ );
+
+ /*
+ * Counted rather than flagged: `.RangeInput` mounts two fields, and a
+ * composition may hold more than one input. Registration happens in a child
+ * effect, so the flag is false for the first commit — irrelevant for the
+ * click-to-open path, which is every real use, and `initialFocus` stays
+ * overridable for a picker that opens already mounted.
+ */
+ const [triggerFieldCount, setTriggerFieldCount] = useState(0);
+
+ const registerTriggerField = useCallback(() => {
+ setTriggerFieldCount(count => count + 1);
+ return () => setTriggerFieldCount(count => count - 1);
+ }, []);
+
+ const reportValidity = useCallback(
+ (validity: CalendarValidity) => onValidityChange?.(validity),
+ [onValidityChange]
+ );
+
+ /*
+ * One context object, so any state change re-renders every part — a month
+ * step re-renders `.Presets`, `.GranularityTabs`, `.TimeField` and
+ * `.Footer` too.
+ *
+ * Splitting stable actions from volatile state was considered and does not
+ * pay here: those parts all read state as well as actions, so they would
+ * still subscribe to the volatile half. The shape that would actually fix it
+ * is a store read through selectors, which is an architecture change rather
+ * than a tuning one, and no part of this component is expensive enough to
+ * render to justify it — `.MonthGrid`, the one that was, now resolves its
+ * cells in a memo. The action identities above are stable, which is the
+ * prerequisite if that day comes.
+ */
+ const contextValue = useMemo(
+ () => ({
+ selection,
+ granularity,
+ setGranularity,
+ granularities: offeredGranularities,
+ value: effectiveValue,
+ setValue,
+ month,
+ setMonth,
+ open,
+ setOpen,
+ commitMode,
+ hasPendingChanges: buffer !== undefined,
+ canReset,
+ resetValue,
+ applyValue,
+ cancelValue,
+ activeField,
+ setActiveField,
+ lock,
+ reportValidity,
+ minDate,
+ maxDate,
+ isDateUnavailable,
+ format,
+ timeZone,
+ weekStartsOn,
+ loading,
+ disabled,
+ readOnly,
+ triggerOwnsFocus: triggerFieldCount > 0,
+ registerTriggerField
+ }),
+ [
+ selection,
+ granularity,
+ setGranularity,
+ offeredGranularities,
+ effectiveValue,
+ setValue,
+ month,
+ setMonth,
+ open,
+ setOpen,
+ commitMode,
+ buffer,
+ canReset,
+ resetValue,
+ applyValue,
+ cancelValue,
+ activeField,
+ setActiveField,
+ lock,
+ reportValidity,
+ minDate,
+ maxDate,
+ isDateUnavailable,
+ format,
+ timeZone,
+ weekStartsOn,
+ loading,
+ disabled,
+ readOnly,
+ triggerFieldCount,
+ registerTriggerField
+ ]
+ );
+
+ /*
+ * `Popover.Root` renders no element, so wrapping unconditionally costs
+ * nothing and keeps dismissal with Base UI even when the composition has no
+ * popover at all (parts rendered outside `.Content` are simply inline).
+ * This is the whole reason `use-picker-popover.ts` has no successor.
+ */
+ return (
+ }
+ >
+
+ {children}
+
+
+ );
+}
+
+CalendarPreviewRoot.displayName = 'CalendarPreview';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx b/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx
new file mode 100644
index 000000000..d61a40aed
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-time-field.tsx
@@ -0,0 +1,274 @@
+'use client';
+
+import { cx } from 'class-variance-authority';
+import { type ComponentProps, useRef, useState } from 'react';
+import { Input } from '../input/input';
+import styles from './calendar-preview.module.css';
+import type {
+ CalendarValidity,
+ DateRangeValue
+} from './calendar-preview-context';
+import { useCalendarPreviewContext } from './calendar-preview-context';
+import {
+ getHours,
+ getMinutes,
+ isWithinTimeBounds,
+ pad,
+ setTime
+} from './date-adapter';
+
+export interface CalendarPreviewTimeFieldProps
+ extends Omit, 'children'> {
+ /**
+ * Minute increment the field snaps to.
+ * @defaultValue 1
+ */
+ step?: number;
+ /**
+ * 24-hour fields, or 12-hour with an AM/PM control.
+ * @defaultValue 24
+ */
+ hourCycle?: 12 | 24;
+}
+
+/**
+ * Hour and minute for the selected date, plus AM/PM under a 12-hour cycle.
+ *
+ * It edits the time of an existing selection rather than creating one: with
+ * nothing selected there is no day to attach a time to, and inventing "today"
+ * would be a silent decision. The fields are empty and disabled until a date
+ * exists.
+ */
+export function CalendarPreviewTimeField({
+ className,
+ step = 1,
+ hourCycle = 24,
+ ...props
+}: CalendarPreviewTimeFieldProps) {
+ const {
+ selection,
+ value,
+ setValue,
+ activeField,
+ lock,
+ timeZone,
+ disabled,
+ readOnly,
+ minDate,
+ maxDate,
+ isDateUnavailable,
+ reportValidity
+ } = useCalendarPreviewContext('TimeField');
+
+ const [draft, setDraft] = useState<{
+ hour: string | null;
+ minute: string | null;
+ }>({ hour: null, minute: null });
+
+ const target = targetDate(selection, value, lock, activeField);
+ const lastTarget = useRef(target?.getTime() ?? null);
+ if (lastTarget.current !== (target?.getTime() ?? null)) {
+ lastTarget.current = target?.getTime() ?? null;
+ if (draft.hour !== null || draft.minute !== null) {
+ setDraft({ hour: null, minute: null });
+ }
+ }
+
+ const editable = !!target && !disabled && !readOnly;
+ const hours24 = target ? getHours(target, timeZone) : 0;
+ const minutes = target ? getMinutes(target, timeZone) : 0;
+ const isPm = hours24 >= 12;
+
+ const displayHour = hourCycle === 12 ? hours24 % 12 || 12 : hours24;
+
+ /*
+ * The same shape `.Input` and `.RangeInput` validate through, but through
+ * bounds that respect a time of day: this is the one writer whose whole job
+ * is the time inside the day, so a plain day comparison would wave through
+ * 23:00 under a `maxDate` of 10:00. Without this the picker had one writer
+ * that ignored its own bounds and one callback that never fired for it.
+ */
+ const validate = (
+ date: Date,
+ nextRange: DateRangeValue | null
+ ): CalendarValidity => {
+ if (!isWithinTimeBounds(date, minDate, maxDate, timeZone)) {
+ return { valid: false, reason: 'out-of-bounds' };
+ }
+ if (isDateUnavailable?.(date)) {
+ return { valid: false, reason: 'unavailable' };
+ }
+ /*
+ * By instant, not by day. `.RangeInput` guards ordering with `isAfterDay`
+ * because it commits whole typed dates; both endpoints of a range can sit
+ * on one day, and moving a time past the other end inverts the range
+ * without any day changing — which no day comparison can see.
+ *
+ * Refused rather than repaired. `.RangeInput` clears the opposite
+ * endpoint, which is right when the user has typed a whole date over a
+ * field, but here they nudged an hour: deleting the other end of their
+ * range in response would throw away far more than they touched, and
+ * swapping would move a value into a field they were not editing.
+ */
+ if (
+ nextRange?.from &&
+ nextRange.to &&
+ nextRange.from.getTime() > nextRange.to.getTime()
+ ) {
+ return { valid: false, reason: 'range-order' };
+ }
+ return { valid: true };
+ };
+
+ const write = (nextHour24: number, nextMinute: number) => {
+ if (!target || !editable) return;
+ const updated = setTime(target, nextHour24, nextMinute, timeZone);
+
+ const field = lock ? (lock === 'from' ? 'to' : 'from') : activeField;
+ const nextRange =
+ selection === 'range'
+ ? {
+ ...((value as DateRangeValue | null) ?? { from: null, to: null }),
+ [field]: updated
+ }
+ : null;
+
+ const validity = validate(updated, nextRange);
+ reportValidity(validity);
+ // The draft is cleared by the caller either way, so a rejected edit snaps
+ // the field back to the committed time rather than leaving it stranded.
+ if (!validity.valid) return;
+
+ if (nextRange) {
+ setValue(nextRange);
+ return;
+ }
+ if (selection === 'multiple') {
+ const current = (value as Date[]) ?? [];
+ setValue(
+ current.map(item =>
+ item.getTime() === target.getTime() ? updated : item
+ )
+ );
+ return;
+ }
+ setValue(updated);
+ };
+
+ const commitHour = (text: string) => {
+ const parsed = Number.parseInt(text, 10);
+ if (Number.isNaN(parsed)) return;
+ const max = hourCycle === 12 ? 12 : 23;
+ const min = hourCycle === 12 ? 1 : 0;
+ if (parsed < min || parsed > max) return;
+ const next24 = hourCycle === 12 ? (parsed % 12) + (isPm ? 12 : 0) : parsed;
+ write(next24, minutes);
+ };
+
+ const commitMinute = (text: string) => {
+ const parsed = Number.parseInt(text, 10);
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 59) return;
+ /*
+ * Clamped: an unclamped round sends 59 with step 15 to 60, and dayjs's
+ * `.minute(60)` rolls into the next hour — so validation rejected >59 two
+ * lines above and the snap then produced one anyway.
+ */
+ write(hours24, Math.min(59, Math.round(parsed / step) * step));
+ };
+
+ const field = (
+ part: 'hour' | 'minute',
+ display: string,
+ commit: (text: string) => void
+ ) => (
+
+ setDraft(current => ({ ...current, [part]: event.target.value }))
+ }
+ onBlur={() => {
+ if (draft[part] === null) return;
+ commit(draft[part] as string);
+ setDraft(current => ({ ...current, [part]: null }));
+ }}
+ onKeyDown={event => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ if (draft[part] === null) return;
+ commit(draft[part] as string);
+ setDraft(current => ({ ...current, [part]: null }));
+ }
+ if (event.key === 'Escape') {
+ setDraft(current => ({ ...current, [part]: null }));
+ }
+ }}
+ />
+ );
+
+ return (
+
+ {field('hour', target ? pad(displayHour) : '', commitHour)}
+
+ :
+
+ {field('minute', target ? pad(minutes) : '', commitMinute)}
+ {hourCycle === 12 && (
+
+ {(['AM', 'PM'] as const).map(label => {
+ const pressed = label === (isPm ? 'PM' : 'AM');
+ return (
+
+ );
+ })}
+
+ )}
+
+ );
+}
+
+CalendarPreviewTimeField.displayName = 'CalendarPreview.TimeField';
+
+/** The date whose time this field edits, per selection mode. */
+function targetDate(
+ selection: string,
+ value: unknown,
+ lock: 'from' | 'to' | undefined,
+ activeField: 'from' | 'to'
+): Date | null {
+ if (selection === 'range') {
+ const range = value as DateRangeValue | null;
+ if (!range) return null;
+ const field = lock ? (lock === 'from' ? 'to' : 'from') : activeField;
+ return range[field] ?? null;
+ }
+ if (selection === 'multiple') {
+ const list = (value as Date[]) ?? [];
+ return list[list.length - 1] ?? null;
+ }
+ return (value as Date | null) ?? null;
+}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
new file mode 100644
index 000000000..587f398dd
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
@@ -0,0 +1,89 @@
+'use client';
+
+import { Popover as PopoverPrimitive } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import styles from './calendar-preview.module.css';
+import {
+ CalendarPreviewTriggerScope,
+ useCalendarPreviewContext
+} from './calendar-preview-context';
+
+export interface CalendarPreviewTriggerProps
+ extends PopoverPrimitive.Trigger.Props {}
+
+/**
+ * Anchors the popover. Renders a `div`, not a `
);
default:
diff --git a/packages/raystack/components/popover/__tests__/surface-routing.test.tsx b/packages/raystack/components/popover/__tests__/surface-routing.test.tsx
new file mode 100644
index 000000000..090ce8370
--- /dev/null
+++ b/packages/raystack/components/popover/__tests__/surface-routing.test.tsx
@@ -0,0 +1,71 @@
+import { render } from '@testing-library/react';
+import { createRef } from 'react';
+import { describe, expect, it } from 'vitest';
+import { CalendarPreview } from '../../calendar-preview/calendar-preview';
+import { Popover } from '../popover';
+
+// Asserts every prop the pre-extraction implementations routed by hand.
+describe('surface prop routing survives the extraction', () => {
+ it('Popover.Content routes each prop to the element it used to', () => {
+ const ref = createRef