From bcc77ec6678d00a97f1db739073cef899de21da4 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Sat, 5 Sep 2026 14:47:13 +0530 Subject: [PATCH] feat: CalendarPreview date picker composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 3 of 7. Adds `.Trigger`, `.Content` and `.Input`, and the root's `open` / `defaultOpen` / `onOpenChange` forwarding Base UI's own typed details. The picker is not an export — it is these parts composed, and the composition lives in the docs. Focus-to-open could not arrive the way the RFC assumed. Base UI 1.7.0 has no `openOnFocus`: `Popover.Trigger` wires only `useClick` and hover, and `useFocus` is unexported floating-ui internals. So focus-to-open is a handler — but a single one, on `.Trigger`, reporting through Base UI's own `trigger-focus` reason. `.Input` never touches open state. Driving real Chrome over CDP with trusted input showed that handler alone reproducing the exact race the rewrite exists to kill: a click gave `trigger-focus` then `trigger-press` closing it then `trigger-focus` again, and Escape closed and instantly reopened because Base UI hands focus back to the trigger. Synthetic DOM events had reported all of this as passing, which is the jsdom-shaped false negative the RFC warns about. Two guards fix it, both taken from floating-ui's own `useFocus`: skip the focus-open while a pointer press is in flight, since `useClick` is already going to open it; and skip the one focus that follows a close caused by Escape or a press on the trigger. The first tracks the pointer, the second the last close reason — neither mirrors open state, and neither touches dismissal, which stays entirely Base UI's. No file in `calendar-preview/` listens on the document. `.Input` parses with `parseScaleInput` and renders through the root's `formatValue`. Typing emits nothing; Enter, blur and the blur an outside click causes all commit. Coarser scales parse but are refused until the scale views land, rather than committing a day the user never typed. Validity is reported through `onValidityChange`, which needs to tell a bound from a consumer rejection, so the root now carries `minDate` and `maxDate` on its context alongside the predicate that folds them. Verified with real browser input: 1 click opens: true, single trigger-press, no flicker 2 escape closes: true, no reopen 3 Tab focus opens: true, single trigger-focus 4 outside press closes: true, via focus-out 5 Enter commits: Thu May 20 2027 6 outside-click commits: Tue Feb 01 2028 7 selects mounted: 0 Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/components/calendar-preview/demo.ts | 79 +++++ .../components/calendar-preview/index.mdx | 37 +++ .../docs/components/calendar-preview/props.ts | 27 ++ .../__tests__/calendar-preview.test.tsx | 3 + .../__tests__/picker.test.tsx | 312 ++++++++++++++++++ .../calendar-preview-content.tsx | 32 ++ .../calendar-preview-context.tsx | 20 ++ .../calendar-preview-input.tsx | 146 ++++++++ .../calendar-preview-root.tsx | 69 +++- .../calendar-preview-trigger.tsx | 97 ++++++ .../calendar-preview.module.css | 21 ++ .../calendar-preview/calendar-preview.tsx | 6 + .../components/calendar-preview/index.tsx | 9 +- 13 files changed, 854 insertions(+), 4 deletions(-) create mode 100644 packages/raystack/components/calendar-preview/__tests__/picker.test.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-content.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-input.tsx create mode 100644 packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx 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';