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 f35dfb867..00389ae18 100644
--- a/apps/www/src/content/docs/components/calendar-preview/demo.ts
+++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts
@@ -230,3 +230,82 @@ export const dateInfoDemo = {
}
]
};
+
+export const pickerDemo = {
+ 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: 'With Field',
+ code: `
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Custom trigger',
+ code: `
+ } />
+
+
+
+ `
+ }
+ ]
+};
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 e0bf02336..cce168abf 100644
--- a/apps/www/src/content/docs/components/calendar-preview/index.mdx
+++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx
@@ -11,6 +11,7 @@ import {
boundsDemo,
gridDemo,
dateInfoDemo,
+ pickerDemo,
} from "./demo.ts";
@@ -88,6 +89,18 @@ Step the view one month. Never disabled by `minDate` or `maxDate` — bounds lim
Restores `defaultDate`. Renders only when there is something to restore.
+### CalendarPreview.Trigger
+
+Anchors the popover and owns opening it. Renders the formatted value, or the placeholder, when given no children — wrap an `.Input` in it for a typeable field. Never renders a `button`, so the control inside stays focusable. Takes `render`, `className` and `ref`.
+
+### CalendarPreview.Content
+
+The portaled popover surface. Takes `Popover.Content` props — `side`, `align`, `sideOffset` and the rest — and flips above the trigger on collision.
+
+### CalendarPreview.Input
+
+
+
### CalendarPreview.Footer
The row below the calendar. A bare string is wrapped in `Text`; anything else renders as given.
@@ -113,6 +126,9 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| Slot | Element |
|------|---------|
| `calendar-preview` | The root, a column wrapping the parts |
+| `calendar-preview-trigger` | The popover anchor |
+| `calendar-preview-content` | The portaled popover surface |
+| `calendar-preview-input` | The typeable date field |
| `calendar-preview-days` | The day view surface |
| `calendar-preview-header` | The header row, single-month layout |
| `calendar-preview-month-header` | One month's header, when several months are shown |
@@ -186,6 +202,27 @@ Outside days are **off by default**, so a grid ends on the last day of its month
`` turns the caption into a filled chip that opens two adjacent scrolling columns. It is a plain popover of buttons, not a `Select` — picking from either column moves the view and never selects a value.
+### Date picker
+
+The date picker is not a separate export — it is this composition:
+
+```tsx
+
+
+
+
+
+
+
+
+```
+
+The popover opens when the input takes focus. Enter, blur and an outside click all commit — there is no Apply button. Dismissal is Base UI's, so escape and outside press behave like every other popover in the library.
+
+"Without calendar icon" is composition rather than a prop: pass `trailingIcon={null}` to `.Input`.
+
+
+
## Accessibility
- Arrow keys move between days; the focused cell carries `data-draft` until it is committed
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 298693923..204531a32 100644
--- a/apps/www/src/content/docs/components/calendar-preview/props.ts
+++ b/apps/www/src/content/docs/components/calendar-preview/props.ts
@@ -142,3 +142,30 @@ export interface CalendarPreviewGridProps {
/** Cover the grid with a skeleton and stop navigation. */
loading?: boolean;
}
+
+export interface CalendarPreviewInputProps {
+ /**
+ * Placeholder shown when there is no value.
+ * @default "Select date"
+ */
+ placeholder?: string;
+
+ /**
+ * Icon at the end of the field. Pass `null` for a picker with no calendar
+ * glyph — that variant is composition, not a prop.
+ * @default
+ */
+ trailingIcon?: ReactNode;
+
+ /**
+ * Called when the typed text starts or stops being a usable date.
+ * @example onValidityChange={({ valid, reason }) => setError(reason)}
+ */
+ onValidityChange?: (validity: {
+ valid: boolean;
+ reason?: 'unparseable' | 'out-of-bounds' | 'unavailable';
+ }) => void;
+
+ /** Read and navigable, but not typeable. */
+ readOnly?: boolean;
+}
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 70c45b231..57d2942d4 100644
--- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
+++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx
@@ -915,6 +915,7 @@ describe('CalendarPreview public surface', () => {
expect(partNames.sort()).toEqual(
[
'Caption',
+ 'Content',
'Day',
'Days',
'Footer',
@@ -922,7 +923,9 @@ describe('CalendarPreview public surface', () => {
'Header',
'NextMonth',
'PrevMonth',
+ 'Input',
'Reset',
+ 'Trigger',
'Weekday'
].sort()
);
diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx
new file mode 100644
index 000000000..77a182a54
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx
@@ -0,0 +1,312 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { 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 renderPicker(props = {}, inputProps = {}) {
+ const utils = render(
+
+
+
+
+
+
+
+
+ );
+ const input = getSlot(
+ utils.container,
+ 'calendar-preview-input'
+ ) as HTMLInputElement;
+ return { ...utils, input };
+}
+
+const isOpen = () =>
+ getSlot(document.body, 'calendar-preview-content') !== null;
+
+describe('CalendarPreview picker composition', () => {
+ it('renders a trigger and a typeable input, and no button', () => {
+ const { container, input } = renderPicker();
+ expect(getSlot(container, 'calendar-preview-trigger')).toBeInTheDocument();
+ expect(input).toBeInTheDocument();
+ /* The trigger wraps a control, and a control inside a button is not
+ focusable on its own. */
+ expect(getSlot(container, 'calendar-preview-trigger')?.tagName).not.toBe(
+ 'BUTTON'
+ );
+ });
+
+ it('opens on focus', () => {
+ const { input } = renderPicker();
+ expect(isOpen()).toBe(false);
+ fireEvent.focus(input);
+ expect(isOpen()).toBe(true);
+ });
+
+ it('reports a single open when focus opens it', () => {
+ const onOpenChange = vi.fn();
+ const { input } = renderPicker({ onOpenChange });
+ fireEvent.focus(input);
+ expect(onOpenChange).toHaveBeenCalledTimes(1);
+ expect(onOpenChange.mock.calls[0][0]).toBe(true);
+ /* Base UI's own reason, forwarded rather than re-declared. */
+ expect(onOpenChange.mock.calls[0][1].reason).toBe('trigger-focus');
+ });
+
+ it('stays open after focusing — it does not immediately re-close', () => {
+ const onOpenChange = vi.fn();
+ const { input } = renderPicker({ onOpenChange });
+ fireEvent.focus(input);
+ expect(isOpen()).toBe(true);
+ expect(onOpenChange.mock.calls.filter(call => call[0] === false)).toEqual(
+ []
+ );
+ });
+
+ it('mounts no Select anywhere, open or closed', () => {
+ const { input } = renderPicker();
+ const count = () =>
+ document.body.querySelectorAll(
+ 'select,[role="combobox"],[role="listbox"],[data-slot^="select"]'
+ ).length;
+ expect(count()).toBe(0);
+ fireEvent.focus(input);
+ expect(count()).toBe(0);
+ });
+
+ it('honours a controlled open', () => {
+ render(
+
+
+
+
+
+
+
+
+ );
+ expect(isOpen()).toBe(true);
+ });
+
+ it('never opens while disabled', () => {
+ const onOpenChange = vi.fn();
+ const { input } = renderPicker({ disabled: true, onOpenChange });
+ fireEvent.focus(input);
+ expect(isOpen()).toBe(false);
+ expect(onOpenChange).not.toHaveBeenCalled();
+ });
+});
+
+describe('CalendarPreview.Input commit', () => {
+ it('commits on Enter', () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ onValueChange });
+ fireEvent.change(input, { target: { value: '20/05/2027' } });
+ expect(onValueChange).not.toHaveBeenCalled();
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2027, 4, 20));
+ expect(onValueChange.mock.calls[0][1].reason).toBe('input');
+ });
+
+ it('commits on blur', () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ onValueChange });
+ fireEvent.change(input, { target: { value: '20/05/2027' } });
+ fireEvent.blur(input);
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2027, 4, 20));
+ });
+
+ it('commits on an outside click, through the blur it causes', () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ onValueChange });
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: '20/05/2027' } });
+ fireEvent.blur(input, { relatedTarget: document.body });
+ fireEvent.pointerDown(document.body);
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2027, 4, 20));
+ });
+
+ it('emits nothing while typing', () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ onValueChange });
+ for (const text of ['2', '20', '20/', '20/0', '20/05', '20/05/2027']) {
+ fireEvent.change(input, { target: { value: text } });
+ }
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('keeps partial input visible instead of committing it', () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ onValueChange });
+ fireEvent.change(input, { target: { value: '2' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(input.value).toBe('2');
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['20/05/2027', new Date(2027, 4, 20)],
+ ['5/5/2027', new Date(2027, 4, 5)],
+ ['2027-05-20', new Date(2027, 4, 20)]
+ ])('accepts %s at day scale', (text, expected) => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ onValueChange });
+ fireEvent.change(input, { target: { value: text } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange.mock.calls[0][0]).toEqual(expected);
+ });
+
+ /* Coarser scales parse, but have nowhere to go until the scale switcher
+ lands, so they must not commit a day the user never typed. */
+ it('refuses a coarser scale until the scale views ship', () => {
+ const onValueChange = vi.fn();
+ const onValidityChange = vi.fn();
+ const { input } = renderPicker({ onValueChange }, { onValidityChange });
+ fireEvent.change(input, { target: { value: 'May 2027' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange).not.toHaveBeenCalled();
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'unparseable'
+ });
+ });
+
+ it('clears on an emptied field when clearable', () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({
+ defaultValue: new Date(2026, 7, 20),
+ onValueChange
+ });
+ fireEvent.change(input, { target: { value: '' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange.mock.calls[0][0]).toBeNull();
+ expect(onValueChange.mock.calls[0][1].reason).toBe('clear');
+ });
+
+ it('renders the committed value through formatValue', () => {
+ const { input } = renderPicker({
+ defaultValue: new Date(2026, 7, 20),
+ formatValue: () => 'CUSTOM'
+ });
+ expect(input.value).toBe('CUSTOM');
+ });
+
+ it('shows the placeholder when there is no value', () => {
+ const { input } = renderPicker();
+ expect(input.value).toBe('');
+ expect(input).toHaveAttribute('placeholder', 'Select date');
+ });
+});
+
+describe('CalendarPreview.Input validity', () => {
+ it('reports unparseable text', () => {
+ const onValidityChange = vi.fn();
+ const { input } = renderPicker({}, { onValidityChange });
+ fireEvent.change(input, { target: { value: 'not a date' } });
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'unparseable'
+ });
+ });
+
+ it('does not re-fire on consecutive invalid keystrokes', () => {
+ const onValidityChange = vi.fn();
+ const { input } = renderPicker({}, { onValidityChange });
+ fireEvent.change(input, { target: { value: 'no' } });
+ fireEvent.change(input, { target: { value: 'nop' } });
+ fireEvent.change(input, { target: { value: 'nope' } });
+ expect(onValidityChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports a date outside the bounds separately from an unavailable one', () => {
+ const onValidityChange = vi.fn();
+ const { input } = renderPicker(
+ { minDate: new Date(2026, 7, 10), maxDate: new Date(2026, 7, 20) },
+ { onValidityChange }
+ );
+ fireEvent.change(input, { target: { value: '01/08/2026' } });
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'out-of-bounds'
+ });
+ });
+
+ it('reports a day the consumer rejected as unavailable', () => {
+ const onValidityChange = vi.fn();
+ const { input } = renderPicker(
+ { isDateUnavailable: (date: Date) => date.getDate() === 12 },
+ { onValidityChange }
+ );
+ fireEvent.change(input, { target: { value: '12/08/2026' } });
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'unavailable'
+ });
+ });
+
+ it('recovers to valid once the text parses again', () => {
+ const onValidityChange = vi.fn();
+ const { input } = renderPicker({}, { onValidityChange });
+ fireEvent.change(input, { target: { value: 'nope' } });
+ fireEvent.change(input, { target: { value: '20/05/2027' } });
+ expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true });
+ });
+
+ it('does not commit an out-of-bounds date', () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ minDate: new Date(2026, 7, 10) });
+ fireEvent.change(input, { target: { value: '01/08/2026' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('commits nothing while readOnly', () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ readOnly: true, onValueChange });
+ fireEvent.change(input, { target: { value: '20/05/2027' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+});
+
+describe('CalendarPreview.Trigger content', () => {
+ it('renders the formatted value when given no children', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent(
+ '20/08/2026'
+ );
+ });
+
+ it('renders the placeholder when there is no value', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent(
+ 'Pick a day'
+ );
+ });
+
+ it('lets a consumer replace the element through render', () => {
+ render(
+
+ } />
+
+ );
+ expect(screen.getByText('Select date')).toHaveAttribute(
+ 'data-custom',
+ 'true'
+ );
+ });
+});
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-content.tsx b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx
new file mode 100644
index 000000000..27972ca07
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx
@@ -0,0 +1,32 @@
+import { cx } from 'class-variance-authority';
+import type { ComponentProps } from 'react';
+import { Popover } from '../popover';
+import styles from './calendar-preview.module.css';
+
+export type CalendarPreviewContentProps = ComponentProps<
+ typeof Popover.Content
+>;
+
+/**
+ * The portaled popover surface.
+ *
+ * Dismissal is Base UI's: outside press, escape and focus-out are all handled
+ * by `Popover.Root`, so nothing in this directory listens on the document.
+ */
+export function CalendarPreviewContent({
+ className,
+ children,
+ ...props
+}: CalendarPreviewContentProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+CalendarPreviewContent.displayName = 'CalendarPreview.Content';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
index 73d73e484..6f33c50ce 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
@@ -1,5 +1,6 @@
'use client';
+import type { Popover } from '@base-ui/react';
import { createContext, type ReactNode, useContext } from 'react';
import type { DayKey } from './date-adapter';
import type { Scale, ScaleValue } from './lib/scale';
@@ -11,6 +12,8 @@ export type CalendarPreviewChangeReason =
| 'clear'
| 'scale';
+export type CalendarPreviewOpenChangeDetails = Popover.Root.ChangeEventDetails;
+
export interface CalendarPreviewChangeDetails {
/** What caused the change. */
reason: CalendarPreviewChangeReason;
@@ -33,6 +36,19 @@ export interface CalendarPreviewContextValue {
reason: CalendarPreviewChangeReason,
occasion: Date
) => void;
+ /** Whether the popover is open. Always `false` for an inline calendar. */
+ open: boolean;
+ /**
+ * Base UI's own details, forwarded rather than re-declared, so `reason` stays
+ * the typed union Base UI narrows on.
+ */
+ setOpen: (open: boolean, details: CalendarPreviewOpenChangeDetails) => void;
+ /**
+ * Whether `.Trigger` must swallow the next focus-open, because the close it
+ * would undo was an Escape or a press on the trigger itself. Reads and
+ * clears. Tracks the last close reason, never the open state.
+ */
+ shouldIgnoreFocusOpen: () => boolean;
/** Read even when `value` is controlled. */
defaultDate: Date | undefined;
/** A value reset — it never moves the view. */
@@ -44,6 +60,10 @@ export interface CalendarPreviewContextValue {
scale: Scale;
setScale: (scale: Scale) => void;
isDateUnavailable: (date: Date) => boolean;
+ /* Separate from `isDateUnavailable`, which folds them together: `.Input`
+ reports which of the two rejected a typed date. */
+ minDate: Date | undefined;
+ maxDate: Date | undefined;
today: Date;
timeZone: string | undefined;
clearable: boolean;
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx
new file mode 100644
index 000000000..6550ff021
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx
@@ -0,0 +1,146 @@
+import { cx } from 'class-variance-authority';
+import { type ComponentProps, useRef, useState } from 'react';
+import { CalendarIcon } from '~/icons';
+import { Input } from '../input';
+import styles from './calendar-preview.module.css';
+import { useCalendarPreviewContext } from './calendar-preview-context';
+import { dayKey, parseKey } from './date-adapter';
+import { parseScaleInput } from './lib/parse';
+
+export type CalendarPreviewInputValidity = {
+ valid: boolean;
+ reason?: 'unparseable' | 'out-of-bounds' | 'unavailable';
+};
+
+export interface CalendarPreviewInputProps
+ extends Omit, 'value' | 'defaultValue'> {
+ /** Called when the typed text starts or stops being a usable date. */
+ onValidityChange?: (validity: CalendarPreviewInputValidity) => void;
+}
+
+const VALID: CalendarPreviewInputValidity = { valid: true };
+
+/**
+ * The typed date field.
+ *
+ * It never touches open state — `.Trigger` owns that. Typing sets a draft and
+ * emits nothing; Enter and blur commit, and Base UI's outside press closes the
+ * popover, which blurs and therefore commits too.
+ */
+export function CalendarPreviewInput({
+ placeholder = 'Select date',
+ trailingIcon = ,
+ onValidityChange,
+ onKeyDown,
+ onBlur,
+ className,
+ readOnly: readOnlyProp,
+ ...props
+}: CalendarPreviewInputProps) {
+ const {
+ value,
+ setValue,
+ formatValue,
+ scale,
+ isDateUnavailable,
+ minDate,
+ maxDate,
+ timeZone,
+ clearable,
+ today,
+ disabled,
+ readOnly
+ } = useCalendarPreviewContext('CalendarPreview.Input');
+
+ /* Null means "show the committed value"; a string is the user's draft. */
+ const [draft, setDraft] = useState(null);
+ const lastReported = useRef(VALID);
+
+ const report = (next: CalendarPreviewInputValidity) => {
+ if (
+ next.valid === lastReported.current.valid &&
+ next.reason === lastReported.current.reason
+ ) {
+ return;
+ }
+ lastReported.current = next;
+ onValidityChange?.(next);
+ };
+
+ const resolve = (text: string): CalendarPreviewInputValidity | Date => {
+ const parsed = parseScaleInput(text);
+ /* Coarser scales parse today but have nowhere to go until the scale
+ switcher lands, so they read as unparseable rather than committing a day
+ the user did not type. */
+ if (!parsed || parsed.scale !== 'day') {
+ return { valid: false, reason: 'unparseable' };
+ }
+ const date = parseKey(parsed.date);
+ const key = dayKey(date, timeZone);
+ if (
+ (minDate && key < dayKey(minDate, timeZone)) ||
+ (maxDate && key > dayKey(maxDate, timeZone))
+ ) {
+ return { valid: false, reason: 'out-of-bounds' };
+ }
+ if (isDateUnavailable(date)) return { valid: false, reason: 'unavailable' };
+ return date;
+ };
+
+ const commit = () => {
+ if (draft === null) return;
+ const text = draft.trim();
+ if (text === '') {
+ if (clearable && value) setValue(null, 'clear', today);
+ setDraft(null);
+ report(VALID);
+ return;
+ }
+ const resolved = resolve(text);
+ if (resolved instanceof Date) {
+ setValue(resolved, 'input', resolved);
+ setDraft(null);
+ report(VALID);
+ }
+ };
+
+ const inert = disabled || readOnly || readOnlyProp;
+
+ return (
+ {
+ if (inert) return;
+ setDraft(text);
+ if (text.trim() === '') {
+ report(VALID);
+ return;
+ }
+ const resolved = resolve(text);
+ report(resolved instanceof Date ? VALID : resolved);
+ }}
+ onKeyDown={event => {
+ onKeyDown?.(event);
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ commit();
+ }
+ }}
+ onBlur={event => {
+ onBlur?.(event);
+ commit();
+ }}
+ {...props}
+ />
+ );
+}
+
+CalendarPreviewInput.displayName = 'CalendarPreview.Input';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
index c4fb9dcd2..67af6a640 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
@@ -1,14 +1,16 @@
'use client';
-import { mergeProps, useRender } from '@base-ui/react';
+import { mergeProps, Popover, useRender } from '@base-ui/react';
+import { REASONS } from '@base-ui/react/internals/reasons';
import { useControlled } from '@base-ui/utils/useControlled';
import { cx } from 'class-variance-authority';
-import { useCallback, useMemo } from 'react';
+import { useCallback, useMemo, useRef } from 'react';
import styles from './calendar-preview.module.css';
import {
type CalendarPreviewChangeDetails,
type CalendarPreviewChangeReason,
type CalendarPreviewContextValue,
+ type CalendarPreviewOpenChangeDetails,
CalendarPreviewProvider
} from './calendar-preview-context';
import {
@@ -37,6 +39,16 @@ export interface CalendarPreviewProps
details: CalendarPreviewChangeDetails
) => void;
+ /** Whether the popover is open (controlled). Ignored by an inline calendar. */
+ open?: boolean;
+ /** @defaultValue false */
+ defaultOpen?: boolean;
+ /** Base UI's typed details, forwarded unchanged. */
+ onOpenChange?: (
+ open: boolean,
+ details: CalendarPreviewOpenChangeDetails
+ ) => void;
+
/** The first month the grid displays (controlled). */
month?: Date;
/**
@@ -116,6 +128,9 @@ export function CalendarPreviewRoot({
value: valueProp,
defaultValue = null,
onValueChange,
+ open: openProp,
+ defaultOpen = false,
+ onOpenChange,
month: monthProp,
defaultMonth,
onMonthChange,
@@ -185,6 +200,40 @@ export function CalendarPreviewRoot({
[setValueUnwrapped, onValueChange, scale]
);
+ const [open, setOpenUnwrapped] = useControlled({
+ controlled: openProp,
+ default: defaultOpen,
+ name: 'CalendarPreview',
+ 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. */
+ const focusOpenBlocked = useRef(false);
+
+ const setOpen = useCallback(
+ (next: boolean, details: CalendarPreviewOpenChangeDetails) => {
+ if (
+ !next &&
+ (details.reason === REASONS.escapeKey ||
+ details.reason === REASONS.triggerPress)
+ ) {
+ focusOpenBlocked.current = true;
+ }
+ setOpenUnwrapped(next);
+ onOpenChange?.(next, details);
+ },
+ [setOpenUnwrapped, onOpenChange]
+ );
+
+ const shouldIgnoreFocusOpen = useCallback(() => {
+ if (!focusOpenBlocked.current) return false;
+ focusOpenBlocked.current = false;
+ return true;
+ }, []);
+
const setScale = useCallback(
(next: Scale) => setScaleUnwrapped(next),
[setScaleUnwrapped]
@@ -222,6 +271,9 @@ export function CalendarPreviewRoot({
() => ({
value,
setValue,
+ open,
+ setOpen,
+ shouldIgnoreFocusOpen,
defaultDate,
reset,
month,
@@ -230,6 +282,8 @@ export function CalendarPreviewRoot({
scale,
setScale,
isDateUnavailable,
+ minDate,
+ maxDate,
today,
timeZone,
clearable,
@@ -240,6 +294,9 @@ export function CalendarPreviewRoot({
[
value,
setValue,
+ open,
+ setOpen,
+ shouldIgnoreFocusOpen,
defaultDate,
reset,
month,
@@ -248,6 +305,8 @@ export function CalendarPreviewRoot({
scale,
setScale,
isDateUnavailable,
+ minDate,
+ maxDate,
today,
timeZone,
clearable,
@@ -277,11 +336,15 @@ export function CalendarPreviewRoot({
)
});
+ /* Base UI owns dismissal — outside press, escape and focus-out all come from
+ `Popover.Root`, which is why no file here has an outside-click listener. */
return (
}
>
- {element}
+
+ {element}
+
);
}
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..d1ea944e6
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
@@ -0,0 +1,97 @@
+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 { 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';
+
+export interface CalendarPreviewTriggerProps
+ extends useRender.ComponentProps<'div'> {
+ /** Shown when there is no value and no children. */
+ placeholder?: string;
+}
+
+/**
+ * Anchors the popover and owns opening it.
+ *
+ * Base UI has no focus-to-open option, so this is a handler — but it is the
+ * only one, and it lives here rather than on `.Input`. Two guards keep it from
+ * fighting Base UI, both verified against real browser input:
+ *
+ * - during a pointer press, `useClick` is already going to open the popover,
+ * so opening here too produced open/close/open;
+ * - when focus arrives back from the popup, the popover has just been
+ * dismissed — reopening on that made Escape impossible to use.
+ *
+ * Neither guard touches dismissal, which stays entirely Base UI's.
+ *
+ * Renders a `div`, never a `button`: it wraps an `.Input` in the picker
+ * composition, and a control inside a button is not focusable on its own.
+ */
+export function CalendarPreviewTrigger({
+ placeholder = 'Select date',
+ className,
+ children,
+ render,
+ ref,
+ ...props
+}: CalendarPreviewTriggerProps) {
+ const {
+ value,
+ formatValue,
+ scale,
+ setOpen,
+ shouldIgnoreFocusOpen,
+ disabled,
+ readOnly
+ } = 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. */
+ const pressing = useRef(false);
+
+ /* One cast at the boundary: Base UI types its trigger for the `button` it
+ renders by default, and this one is always a `div`. Consumer props stay
+ last, inside the merge. */
+ const triggerProps = {
+ nativeButton: false,
+ disabled,
+ render: render ?? ,
+ ref,
+ ...mergeProps<'div'>(
+ {
+ className: cx(styles.trigger, className),
+ 'data-slot': 'calendar-preview-trigger',
+ 'data-scale': scale,
+ onPointerDown: () => {
+ pressing.current = true;
+ },
+ onPointerUp: () => {
+ pressing.current = false;
+ },
+ onFocus: (event: FocusEvent) => {
+ if (disabled || readOnly || pressing.current) return;
+ if (shouldIgnoreFocusOpen()) return;
+ setOpen(
+ true,
+ createChangeEventDetails(
+ REASONS.triggerFocus,
+ event.nativeEvent,
+ event.currentTarget
+ )
+ );
+ }
+ } as useRender.ComponentProps<'div'>,
+ props
+ )
+ } as ComponentProps;
+
+ return (
+
+ {children ?? (value ? formatValue(value, scale) : placeholder)}
+
+ );
+}
+
+CalendarPreviewTrigger.displayName = 'CalendarPreview.Trigger';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css
index 020732e38..c5e2716be 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview.module.css
+++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css
@@ -442,3 +442,24 @@
padding: var(--rs-space-3);
margin-top: var(--rs-space-2);
}
+
+/* The trigger is a plain box: it wraps `.Input`, which draws its own field. */
+.trigger {
+ display: inline-flex;
+ align-items: center;
+}
+
+.trigger[data-disabled] {
+ cursor: not-allowed;
+}
+
+/* Wider than the trigger and centred on it, per the frames. `.Days` brings its
+ own padding, so the surface adds none. */
+.content {
+ padding: 0;
+ width: max-content;
+}
+
+.input {
+ width: 100%;
+}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx
index fec4ab37d..6717b31df 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx
@@ -1,6 +1,7 @@
'use client';
import { CalendarPreviewCaption } from './calendar-preview-caption';
+import { CalendarPreviewContent } from './calendar-preview-content';
import { CalendarPreviewDays } from './calendar-preview-days';
import { CalendarPreviewFooter } from './calendar-preview-footer';
import {
@@ -13,10 +14,15 @@ import {
CalendarPreviewNextMonth,
CalendarPreviewPrevMonth
} from './calendar-preview-header';
+import { CalendarPreviewInput } from './calendar-preview-input';
import { CalendarPreviewReset } from './calendar-preview-reset';
import { CalendarPreviewRoot } from './calendar-preview-root';
+import { CalendarPreviewTrigger } from './calendar-preview-trigger';
export const CalendarPreview = Object.assign(CalendarPreviewRoot, {
+ Trigger: CalendarPreviewTrigger,
+ Content: CalendarPreviewContent,
+ Input: CalendarPreviewInput,
Days: CalendarPreviewDays,
Header: CalendarPreviewHeader,
PrevMonth: CalendarPreviewPrevMonth,
diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx
index 3dc499780..ac50dab4f 100644
--- a/packages/raystack/components/calendar-preview/index.tsx
+++ b/packages/raystack/components/calendar-preview/index.tsx
@@ -1,8 +1,10 @@
export { CalendarPreview } from './calendar-preview';
export type { CalendarPreviewCaptionProps } from './calendar-preview-caption';
+export type { CalendarPreviewContentProps } from './calendar-preview-content';
export type {
CalendarPreviewChangeDetails,
- CalendarPreviewChangeReason
+ CalendarPreviewChangeReason,
+ CalendarPreviewOpenChangeDetails
} from './calendar-preview-context';
export type { CalendarPreviewDaysProps } from './calendar-preview-days';
export type { CalendarPreviewFooterProps } from './calendar-preview-footer';
@@ -15,7 +17,12 @@ export type {
CalendarPreviewHeaderProps,
CalendarPreviewNavProps
} from './calendar-preview-header';
+export type {
+ CalendarPreviewInputProps,
+ CalendarPreviewInputValidity
+} from './calendar-preview-input';
export type { CalendarPreviewResetProps } from './calendar-preview-reset';
export type { CalendarPreviewProps } from './calendar-preview-root';
+export type { CalendarPreviewTriggerProps } from './calendar-preview-trigger';
export type { Scale, ScaleValue } from './lib/scale';
export { type UseCalendarReturn, useCalendar } from './use-calendar';