` with `nativeButton={false}`
+ to avoid Base UI's button-nesting warning when consumers pass a
+ button element.
+- RangePicker `computedDefaultMonth`: short-circuits when
+ `currentMonth` is undefined (was passing `dayjs(undefined)` → "now"
+ and falsely matching `endMonth`).
+- RangePicker controlled-clear `value.from` sync now unpins the
+ calendar on parent reset.
+
+#### Deprecations (one-release window)
+
+- `DatePicker.inputProps` → `slotProps.input`
+- `DatePicker.calendarProps` → `slotProps.calendar`
+- `DatePicker.popoverProps` → `slotProps.popover`
+- `RangePicker.inputsProps` → `slotProps.startInput` / `slotProps.endInput`
+- `RangePicker.calendarProps` → `slotProps.calendar`
+- `RangePicker.popoverProps` → `slotProps.popover`
+
+All marked `@deprecated` via JSDoc; IDEs surface the replacement.
+Old props still work — `slotProps` wins when both are set.
+
+#### Docs
+
+- Calendar docs page split into **Layout & appearance**
+ (Basic / Loading / Dropdowns / Footer) and **Behavior & data**
+ (Tooltips / Disabled / Timezone / Controlled Month) demo blocks.
+- `RangePicker` and `DatePicker` prose rewritten to describe actual
+ behavior (state machine, typed input commit semantics).
+- `CalendarProps` surface fully documented (previously only a
+ subset).
+- Migration notes for deprecated `fromYear` / `toYear` /
+ `fromMonth` / `toMonth` / `fromDate` / `toDate` props folded into
+ the docs for `startMonth` / `endMonth` / `hidden`.
+- Showcase demos migrated to `slotProps`.
+
+#### Tests
+
+38 new tests across 4 new files:
+- `date-picker.test.tsx` (27 tests) — slotProps, defaultValue,
+ unselected state, single-fire onSelect, strict parsing, bounds,
+ month navigation, calendarProps surface.
+- `date-picker.runtime.test.tsx` (4 tests) — mount/unmount loops
+ with `captionLayout='dropdown'`.
+- `range-picker.test.tsx` (14 tests) — slotProps, state machine
+ (A/B1/B2/C), value→currentMonth sync, calendarProps surface.
+- `range-picker.runtime.test.tsx` (2 tests) — mount/unmount loops.
+
+Plus regression tests added to the existing `calendar.test.tsx`
+for the tz-aware `dateKey` fix.
+
+#### Internal
+
+- New shared hook `use-picker-popover.ts` — encapsulates open/close
+ state, outside-click listener, and the year/month dropdown
+ carve-out.
+- `dayjs` bumped to `^1.11.20` (was `^1.11.11`) for the strict-parse
+ + tz plugins.
+
+
## 0.11.3
### Patch Changes
diff --git a/packages/raystack/components/calendar/__tests__/calendar.test.tsx b/packages/raystack/components/calendar/__tests__/calendar.test.tsx
index 7cf7b1422..4e33ae419 100644
--- a/packages/raystack/components/calendar/__tests__/calendar.test.tsx
+++ b/packages/raystack/components/calendar/__tests__/calendar.test.tsx
@@ -64,6 +64,27 @@ describe('Calendar', () => {
const grid = screen.getByRole('grid');
expect(grid).toBeInTheDocument();
});
+
+ /*
+ * Regression: pre-fix the lookup key was formatted in the user's local
+ * zone, so UTC days were missed when the browser was in a non-UTC zone.
+ * Tested via `dateInfo` (renders inline) rather than `tooltipMessages`
+ * (which only updates aria-describedby on hover).
+ */
+ it('matches dateInfo by tz-aware day key when timeZone is set', () => {
+ render(
+
+ );
+
+ const day15Button = screen.getByRole('button', {
+ name: /June 15(?:st|nd|rd|th)?,?\s*2025/i
+ });
+ expect(day15Button.textContent).toContain('INFO-15');
+ });
});
describe('Month Navigation', () => {
@@ -194,10 +215,11 @@ describe('Calendar', () => {
/>
);
- // Should render for Sundays if any are visible in current month
- // The querySelector will return null if not found, which is fine
- const sundayInfo = container.querySelector('[data-testid="sunday-info"]');
- // Test passes if function approach works (may or may not find Sunday depending on month)
+ /*
+ * Renders for Sundays if any are visible. Test just exercises the
+ * function-based path — actual presence depends on which days the
+ * current month surfaces.
+ */
expect(container).toBeInTheDocument();
});
diff --git a/packages/raystack/components/calendar/__tests__/date-picker.runtime.test.tsx b/packages/raystack/components/calendar/__tests__/date-picker.runtime.test.tsx
new file mode 100644
index 000000000..bf3149725
--- /dev/null
+++ b/packages/raystack/components/calendar/__tests__/date-picker.runtime.test.tsx
@@ -0,0 +1,59 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { DatePicker } from '../date-picker';
+
+/*
+ * Real-Calendar runtime regression tests. The main date-picker.test.tsx mocks
+ * Calendar for prop assertions; this file uses the real Calendar to catch
+ * mount/unmount loops in Base UI internals.
+ *
+ * Covers regressions:
+ * - `value = new Date()` default creating a fresh Date per render and
+ * looping the value-sync effect (fixed via useMemo in date-picker.tsx).
+ * - Unstable `onOpenChange` identity causing Base UI store re-subscribe
+ * loops on mount (fixed via ref-based `isOpen` in use-picker-popover.ts).
+ * - Base UI Select.Trigger forkRef cleanup looping on unmount when
+ * captionLayout='dropdown' mounts Selects (resolved alongside the above).
+ */
+
+describe('DatePicker runtime loops', () => {
+ it('plain DatePicker open via focus does not throw', () => {
+ expect(() => {
+ const { unmount } = render(
);
+ fireEvent.focus(screen.getByPlaceholderText('Select date'));
+ unmount();
+ }).not.toThrow();
+ });
+
+ it('DatePicker with captionLayout=dropdown open via focus does not throw on mount', () => {
+ expect(() => {
+ render(
);
+ fireEvent.focus(screen.getByPlaceholderText('Select date'));
+ }).not.toThrow();
+ });
+
+ it('plain DatePicker click + unmount does not throw', () => {
+ expect(() => {
+ const { unmount } = render(
);
+ const input = screen.getByPlaceholderText('Select date');
+ fireEvent.click(input);
+ unmount();
+ }).not.toThrow();
+ });
+
+ /*
+ * Previously triggered the Base UI Select.Trigger forkRef cleanup loop.
+ * Passes in jsdom after the value-default useMemo + stable onOpenChange
+ * fixes. Real-browser verification still recommended before re-enabling
+ * captionLayout='dropdown' as the default.
+ */
+ it('DatePicker with captionLayout=dropdown click + unmount does not throw', () => {
+ expect(() => {
+ const { unmount } = render(
+
+ );
+ fireEvent.click(screen.getByPlaceholderText('Select date'));
+ unmount();
+ }).not.toThrow();
+ });
+});
diff --git a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx
new file mode 100644
index 000000000..93fc6e462
--- /dev/null
+++ b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx
@@ -0,0 +1,486 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import dayjs from 'dayjs';
+import type { ReactElement } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { DatePicker } from '../date-picker';
+
+/*
+ * Hoisted store + Calendar mock. Tests that assert Calendar props read
+ * `calendarCalls.list`; tests that render Calendar visually must override
+ * this mock per-test.
+ */
+const calendarCalls = vi.hoisted(() => ({
+ list: [] as Array
>
+}));
+
+vi.mock('../calendar', async () => {
+ const actual =
+ await vi.importActual('../calendar');
+ return {
+ ...actual,
+ Calendar: (props: Record) => {
+ calendarCalls.list.push(props);
+ return null;
+ }
+ };
+});
+
+function renderWithCalendarSpy(ui: ReactElement) {
+ calendarCalls.list.length = 0;
+ render(ui);
+ // Focus opens the popover without relying on Calendar contents.
+ fireEvent.focus(screen.getByPlaceholderText('Select date'));
+ return calendarCalls.list;
+}
+
+describe('DatePicker', () => {
+ describe('calendarProps surface', () => {
+ /*
+ * Two regressions on the picker's `calendarProps` type:
+ * - Original type required `mode`/`selected`/`onSelect`, which the picker
+ * overrides after the spread — consumer values were silently ignored.
+ * - CalendarPropsExtended fields (tooltipMessages, dateInfo, loadingData,
+ * showTooltip) were unreachable because the type didn't include them.
+ */
+ it('accepts CalendarPropsExtended fields via calendarProps without type cast', () => {
+ // The compile-time check is the real test — failing it stops compilation.
+ expect(() =>
+ render(
+
+ )
+ ).not.toThrow();
+ });
+
+ /*
+ * captionLayout='dropdown' is not the default because mounting Selects
+ * inside the popover triggers a Base UI Select.Trigger ref-cleanup loop
+ * on unmount. Consumers can still opt in.
+ */
+ it('passes consumer-provided captionLayout through to Calendar', () => {
+ const calls = renderWithCalendarSpy(
+
+ );
+ const lastCall = calls[calls.length - 1];
+ expect(lastCall.captionLayout).toBe('dropdown');
+ });
+ });
+
+ describe('slotProps surface', () => {
+ it('forwards slotProps.calendar to the Calendar slot', () => {
+ const calls = renderWithCalendarSpy(
+
+ );
+ const last = calls[calls.length - 1];
+ expect(last.captionLayout).toBe('dropdown');
+ });
+
+ it('slotProps.calendar wins over the deprecated calendarProps', () => {
+ const calls = renderWithCalendarSpy(
+
+ );
+ const last = calls[calls.length - 1];
+ expect(last.captionLayout).toBe('dropdown');
+ });
+
+ it('forwards slotProps.input to the Input slot', () => {
+ render(
+
+ );
+ const input = screen.getByPlaceholderText('Select date');
+ expect(input.getAttribute('aria-label')).toBe('pick-day');
+ });
+ });
+
+ describe('month navigation does not mutate selection', () => {
+ /*
+ * Earlier the Calendar's `month` and `onMonthChange` were both wired to
+ * `selectedDate`, so chevrons and the dropdown silently rewrote the
+ * selection. The view-month is now in separate state.
+ */
+ it('chevron / onMonthChange does not change selected', async () => {
+ const initial = new Date(2026, 0, 15); // 15 Jan 2026
+ const calls = renderWithCalendarSpy();
+
+ const firstCall = calls[calls.length - 1];
+ expect((firstCall.selected as Date).getTime()).toBe(initial.getTime());
+ expect((firstCall.month as Date).getTime()).toBe(initial.getTime());
+
+ // Simulate chevron click: Calendar calls onMonthChange with the new month.
+ const newViewMonth = new Date(2026, 1, 1);
+ const onMonthChange = firstCall.onMonthChange as (m: Date) => void;
+ await new Promise(resolve => {
+ // Act-equivalent flush: spy captures the next render's props.
+ onMonthChange(newViewMonth);
+ setTimeout(resolve, 0);
+ });
+
+ const afterPaging = calls[calls.length - 1];
+ expect((afterPaging.selected as Date).getTime()).toBe(initial.getTime());
+ expect((afterPaging.month as Date).getTime()).toBe(
+ newViewMonth.getTime()
+ );
+ });
+
+ it('selecting a date via the calendar updates both selected and month', async () => {
+ const initial = new Date(2026, 0, 15);
+ const onSelect = vi.fn();
+ const calls = renderWithCalendarSpy(
+
+ );
+
+ const firstCall = calls[calls.length - 1];
+ const calendarOnSelect = firstCall.onSelect as (d: Date) => void;
+
+ const newSelection = new Date(2026, 1, 10); // 10 Feb 2026
+ await new Promise(resolve => {
+ calendarOnSelect(newSelection);
+ setTimeout(resolve, 0);
+ });
+
+ const afterSelect = calls[calls.length - 1];
+ expect((afterSelect.selected as Date).getTime()).toBe(
+ newSelection.getTime()
+ );
+ /*
+ * month follows selection via the open-time sync effect (picker re-opens
+ * on next mount; while open, the click also closes the popover).
+ */
+ expect(onSelect).toHaveBeenCalledWith(newSelection);
+ });
+ });
+
+ describe('value prop is reactive', () => {
+ /*
+ * Earlier the picker only used `value` as the initial useState seed, so
+ * later parent updates (form reset, "today" buttons, URL-driven changes)
+ * no-op'd on the displayed input.
+ */
+ it('updates the displayed value when the value prop changes', () => {
+ const { rerender } = render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ expect(input.value).toBe('01/01/2026');
+
+ rerender();
+ expect(input.value).toBe('15/06/2026');
+ });
+ });
+
+ describe('unselected initial state (CLD-3195 #4)', () => {
+ it('renders with empty input + placeholder when no value or defaultValue is provided', () => {
+ render();
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ expect(input.value).toBe('');
+ });
+
+ it('does not fire onSelect on close when nothing was ever selected', () => {
+ const onSelect = vi.fn();
+ render();
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ // Open + close without selecting.
+ fireEvent.focus(input);
+ fireEvent.keyUp(input, { code: 'Enter' });
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('passes selected=undefined to Calendar when picker has no value', () => {
+ const calls = renderWithCalendarSpy();
+ const last = calls[calls.length - 1];
+ expect(last.selected).toBeUndefined();
+ });
+
+ it('falls back month to today when no value/defaultValue/calendarProps.defaultMonth', () => {
+ const calls = renderWithCalendarSpy();
+ const last = calls[calls.length - 1];
+ const month = last.month as Date;
+ const today = new Date();
+ // Month should be in this calendar month (today's month/year).
+ expect(month.getMonth()).toBe(today.getMonth());
+ expect(month.getFullYear()).toBe(today.getFullYear());
+ });
+ });
+
+ describe('defaultValue (uncontrolled)', () => {
+ it('initializes from defaultValue when value is not provided', () => {
+ render();
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ expect(input.value).toBe('15/06/2024');
+ });
+
+ it('value prop takes precedence over defaultValue', () => {
+ render(
+
+ );
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ expect(input.value).toBe('01/01/2025');
+ });
+
+ it('defaultValue is only honored at mount, not on later rerenders', () => {
+ const { rerender } = render(
+
+ );
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ expect(input.value).toBe('15/06/2024');
+
+ // Changing defaultValue after mount should NOT update the input
+ // (uncontrolled semantics).
+ rerender();
+ expect(input.value).toBe('15/06/2024');
+ });
+ });
+
+ describe('onSelect fires once per commit', () => {
+ /*
+ * Regression for CLD-3195 #25: each commit path (calendar click, Enter,
+ * outside click) should fire onSelect exactly once with a single canonical
+ * Date.
+ */
+ it('Enter after typing a valid date fires onSelect exactly once', () => {
+ const onSelect = vi.fn();
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: '15/06/2025' } });
+ fireEvent.keyUp(input, { code: 'Enter' });
+
+ expect(onSelect).toHaveBeenCalledTimes(1);
+ const calledWith = onSelect.mock.calls[0][0] as Date;
+ expect(dayjs(calledWith).format('DD/MM/YYYY')).toBe('15/06/2025');
+ });
+ });
+
+ describe('typed-input bounds checking', () => {
+ /*
+ * Earlier `handleInputChange` compared the typed date against
+ * `isSameOrBefore(dayjs())`, silently rejecting future dates. The grid
+ * let you click them, so typing and clicking disagreed. Future dates
+ * are now allowed; bounds come from `startMonth` / `endMonth`.
+ */
+ it('accepts a future date when no calendarProps bounds are set', () => {
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ // Pick a date far in the future to avoid clock skew in tests.
+ fireEvent.change(input, { target: { value: '15/06/2099' } });
+ expect(input.getAttribute('aria-invalid')).not.toBe('true');
+ });
+
+ it('accepts a future date within endMonth', () => {
+ render(
+
+ );
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: '15/06/2099' } });
+ expect(input.getAttribute('aria-invalid')).not.toBe('true');
+ });
+
+ it('rejects a date past endMonth', () => {
+ render(
+
+ );
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: '15/06/2099' } });
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ });
+
+ it('rejects a date before startMonth', () => {
+ render(
+
+ );
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: '15/06/2020' } });
+ expect(input.getAttribute('aria-invalid')).toBe('true');
+ });
+ });
+
+ describe('typed input parsing', () => {
+ /*
+ * Earlier `handleInputChange` sniffed a format from the input string
+ * (`/` vs `-`) and fell through to `undefined` for shorter input.
+ * `dayjs('5', undefined)` fell back to `new Date('5')` (Jan 5 2001 in
+ * V8), so single-digit input committed a 2001 date and the input
+ * visibly snapped. Now uses `dateFormat` with strict parsing.
+ */
+ it('does not commit partial single-digit input as a date', () => {
+ const onSelect = vi.fn();
+ const initial = new Date(2026, 4, 20); // 20/05/2026
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+
+ // Simulate select-all + type "5"
+ fireEvent.change(input, { target: { value: '5' } });
+
+ // Input must NOT have jumped to a 2001 date.
+ expect(input.value).not.toMatch(/2001/);
+ // onSelect must not have been called with a 2001 date.
+ for (const call of onSelect.mock.calls) {
+ const arg = call[0] as Date;
+ expect(dayjs(arg).year()).not.toBe(2001);
+ }
+ });
+
+ it('does not commit partial multi-char input that V8 would lenient-parse', () => {
+ const initial = new Date(2026, 4, 20);
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+
+ // "01" -> new Date("01") is Jan 1 2001 in V8. Must not commit.
+ fireEvent.change(input, { target: { value: '01' } });
+ expect(input.value).not.toMatch(/2001/);
+ });
+
+ it('accepts a fully-typed valid date matching dateFormat', () => {
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+
+ fireEvent.change(input, { target: { value: '15/06/2025' } });
+ // The change handler accepted it — error attr stays unset.
+ expect(input.getAttribute('aria-invalid')).not.toBe('true');
+ });
+
+ /*
+ * Follow-up regression: pre-fix the input was bound to a derived
+ * `formattedDate`, so partial typed values were overwritten on the next
+ * render and typing felt broken — only paste of the full string worked.
+ */
+ it('keeps typed characters visible while typing a full date one char at a time', () => {
+ const onSelect = vi.fn();
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+
+ const steps = [
+ '1',
+ '15',
+ '15/',
+ '15/0',
+ '15/06',
+ '15/06/',
+ '15/06/2',
+ '15/06/20',
+ '15/06/202',
+ '15/06/2025'
+ ];
+
+ for (const step of steps) {
+ fireEvent.change(input, { target: { value: step } });
+ expect(input.value).toBe(step);
+ }
+ });
+
+ it('does not fire onSelect while typing — partial stays uncommitted, valid waits for commit (Enter/blur/outside-click)', () => {
+ const onSelect = vi.fn();
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+
+ // Partial input is not even parseable — no commit.
+ fireEvent.change(input, { target: { value: '15/06' } });
+ expect(onSelect).not.toHaveBeenCalled();
+
+ // Full valid input parses internally but still doesn't fire onSelect —
+ // commit only happens via Enter / blur / outside-click (see the
+ // dedicated single-fire test below for that path).
+ fireEvent.change(input, { target: { value: '15/06/2025' } });
+ expect(input.getAttribute('aria-invalid')).not.toBe('true');
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('typing does not throw on bounds checks', () => {
+ it('does not throw on input change when only startMonth/endMonth are provided', () => {
+ /*
+ * Earlier `handleInputChange` called `isSameOrAfter` / `isSameOrBefore`
+ * but only `customParseFormat` was extended. Every keystroke threw
+ * `dayjs(...).isSameOrBefore is not a function`, React swallowed the
+ * update, and the input snapped back after one character.
+ */
+ render(
+
+ );
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+
+ expect(() => {
+ fireEvent.change(input, { target: { value: '15/06/2025' } });
+ }).not.toThrow();
+ });
+
+ it('does not throw on input change with no calendar bounds', () => {
+ /*
+ * Covers the no-bounds path — past regression had an unconditional
+ * `isSameOrBefore(dayjs())` that threw without the plugin extended.
+ */
+ render();
+
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+
+ expect(() => {
+ fireEvent.change(input, { target: { value: '01/01/2020' } });
+ }).not.toThrow();
+ });
+ });
+});
diff --git a/packages/raystack/components/calendar/__tests__/range-picker.runtime.test.tsx b/packages/raystack/components/calendar/__tests__/range-picker.runtime.test.tsx
new file mode 100644
index 000000000..90d659909
--- /dev/null
+++ b/packages/raystack/components/calendar/__tests__/range-picker.runtime.test.tsx
@@ -0,0 +1,30 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import { RangePicker } from '../range-picker';
+
+/*
+ * Real-Calendar runtime regression tests for RangePicker. Mirrors
+ * date-picker.runtime.test.tsx — uses the real Calendar to catch
+ * mount/unmount loops in Base UI internals after RangePicker adopted
+ * usePickerPopover.
+ */
+
+describe('RangePicker runtime loops', () => {
+ it('plain RangePicker click + unmount does not throw', () => {
+ expect(() => {
+ const { unmount } = render();
+ fireEvent.click(screen.getByPlaceholderText('Select start date'));
+ unmount();
+ }).not.toThrow();
+ });
+
+ it('RangePicker with captionLayout=dropdown click + unmount does not throw', () => {
+ expect(() => {
+ const { unmount } = render(
+
+ );
+ fireEvent.click(screen.getByPlaceholderText('Select start date'));
+ unmount();
+ }).not.toThrow();
+ });
+});
diff --git a/packages/raystack/components/calendar/__tests__/range-picker.test.tsx b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx
new file mode 100644
index 000000000..39016c57b
--- /dev/null
+++ b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx
@@ -0,0 +1,333 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import type { ReactElement } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { RangePicker } from '../range-picker';
+
+/*
+ * Hoisted Calendar spy. Tests assert prop wiring (Calendar has its own
+ * suite). To drive Calendar callbacks, read `calendarCalls.list` and invoke
+ * the captured handlers.
+ */
+const calendarCalls = vi.hoisted(() => ({
+ list: [] as Array>
+}));
+
+vi.mock('../calendar', async () => {
+ const actual =
+ await vi.importActual('../calendar');
+ return {
+ ...actual,
+ Calendar: (props: Record) => {
+ calendarCalls.list.push(props);
+ return null;
+ }
+ };
+});
+
+function openPopoverAndCaptureCalendar(ui: ReactElement) {
+ calendarCalls.list.length = 0;
+ const result = render(ui);
+ const startInput = screen.getByPlaceholderText('Select start date');
+ fireEvent.click(startInput);
+ return { ...result, calls: calendarCalls.list };
+}
+
+function latestCalendarOnSelect(): (range: unknown, day: Date) => void {
+ const last = calendarCalls.list[calendarCalls.list.length - 1];
+ return last.onSelect as (range: unknown, day: Date) => void;
+}
+
+describe('RangePicker', () => {
+ describe('no default range', () => {
+ /*
+ * Earlier `defaultValue` defaulted to `{from: today, to: today}`, so an
+ * uncontrolled picker always rendered pre-filled with today and the
+ * placeholders never showed.
+ */
+ it('renders with empty inputs when neither value nor defaultValue is provided', () => {
+ render();
+
+ const startInput = screen.getByPlaceholderText(
+ 'Select start date'
+ ) as HTMLInputElement;
+ const endInput = screen.getByPlaceholderText(
+ 'Select end date'
+ ) as HTMLInputElement;
+
+ expect(startInput.value).toBe('');
+ expect(endInput.value).toBe('');
+ });
+
+ it('honors an explicit defaultValue', () => {
+ render(
+
+ );
+
+ const startInput = screen.getByPlaceholderText(
+ 'Select start date'
+ ) as HTMLInputElement;
+ const endInput = screen.getByPlaceholderText(
+ 'Select end date'
+ ) as HTMLInputElement;
+
+ expect(startInput.value).toBe('01/01/2026');
+ expect(endInput.value).toBe('15/01/2026');
+ });
+
+ it('does not crash when value is undefined and no defaultValue', () => {
+ expect(() => {
+ render();
+ }).not.toThrow();
+ });
+ });
+
+ describe('Regression: calendarProps surface', () => {
+ it('accepts CalendarPropsExtended fields via calendarProps', () => {
+ expect(() =>
+ render(
+
+ )
+ ).not.toThrow();
+ });
+
+ /*
+ * #19a (default captionLayout='dropdown') reverted: surfaced a Base UI
+ * Select.Trigger unmount ref loop. Consumers can still opt in.
+ */
+ it('passes consumer-provided captionLayout through to Calendar', () => {
+ const { calls } = openPopoverAndCaptureCalendar(
+
+ );
+ const last = calls[calls.length - 1];
+ expect(last.captionLayout).toBe('dropdown');
+ });
+ });
+
+ describe('slotProps surface', () => {
+ it('forwards slotProps.calendar to the Calendar slot', () => {
+ const { calls } = openPopoverAndCaptureCalendar(
+
+ );
+ const last = calls[calls.length - 1];
+ expect(last.captionLayout).toBe('dropdown');
+ });
+
+ it('slotProps.calendar wins over the deprecated calendarProps', () => {
+ const { calls } = openPopoverAndCaptureCalendar(
+
+ );
+ const last = calls[calls.length - 1];
+ expect(last.captionLayout).toBe('dropdown');
+ });
+
+ it('forwards slotProps.startInput / endInput to each input', () => {
+ render(
+
+ );
+ expect(
+ screen
+ .getByPlaceholderText('Select start date')
+ .getAttribute('aria-label')
+ ).toBe('pick-from');
+ expect(
+ screen
+ .getByPlaceholderText('Select end date')
+ .getAttribute('aria-label')
+ ).toBe('pick-to');
+ });
+ });
+
+ describe('Regression: value prop syncs currentMonth', () => {
+ it('passes the new value.from as the visible month when value changes', () => {
+ const { rerender } = openPopoverAndCaptureCalendar(
+
+ );
+
+ const firstMonth = calendarCalls.list[calendarCalls.list.length - 1]
+ .month as Date;
+ expect(firstMonth.getMonth()).toBe(0); // January
+
+ rerender(
+
+ );
+
+ const updatedMonth = calendarCalls.list[calendarCalls.list.length - 1]
+ .month as Date;
+ expect(updatedMonth.getMonth()).toBe(5); // June
+ });
+
+ it('does not crash when value is undefined', () => {
+ const { rerender } = render(
+
+ );
+
+ expect(() => {
+ rerender();
+ }).not.toThrow();
+ });
+ });
+
+ describe('Regression: state machine', () => {
+ /*
+ * State machine branches on actual from/to state:
+ * A. empty -> set from, advance
+ * B1. from set, click before -> reset
+ * B2. from set, click after/equal -> set to, close
+ * C. both set -> restart
+ */
+
+ it('A: empty range, click sets from and leaves popover open', () => {
+ openPopoverAndCaptureCalendar();
+
+ const calendarOnSelect = latestCalendarOnSelect();
+ act(() => {
+ calendarOnSelect({}, new Date(2026, 0, 15));
+ });
+
+ const startInput = screen.getByPlaceholderText(
+ 'Select start date'
+ ) as HTMLInputElement;
+ const endInput = screen.getByPlaceholderText(
+ 'Select end date'
+ ) as HTMLInputElement;
+ expect(startInput.value).toBe('15/01/2026');
+ expect(endInput.value).toBe('');
+
+ /*
+ * Popover still open -> Calendar still mounted -> spy keeps recording.
+ * If the popover had closed, there'd be no Calendar to re-record on
+ * the next interaction.
+ */
+ const callsAfter = calendarCalls.list.length;
+ expect(callsAfter).toBeGreaterThan(0);
+
+ // Focus moved to 'to'.
+ expect(endInput.getAttribute('data-active')).toBe('true');
+ });
+
+ it('B2: from set, click after commits the range and closes', () => {
+ const onSelect = vi.fn();
+ openPopoverAndCaptureCalendar(
+
+ );
+
+ const calendarOnSelect = latestCalendarOnSelect();
+ const callsBefore = calendarCalls.list.length;
+
+ act(() => {
+ calendarOnSelect({}, new Date(2026, 0, 20));
+ });
+
+ const startInput = screen.getByPlaceholderText(
+ 'Select start date'
+ ) as HTMLInputElement;
+ const endInput = screen.getByPlaceholderText(
+ 'Select end date'
+ ) as HTMLInputElement;
+ expect(startInput.value).toBe('15/01/2026');
+ expect(endInput.value).toBe('20/01/2026');
+
+ /*
+ * Popover should have closed -> Calendar unmounted. A tight call-count
+ * assertion would be brittle (React's final commit may queue more);
+ * instead assert data-active returned to 'from' — only the B2 close
+ * path does that.
+ */
+ const callsAfter = calendarCalls.list.length;
+ expect(callsAfter).toBeGreaterThanOrEqual(callsBefore);
+ expect(startInput.getAttribute('data-active')).toBe('false');
+
+ expect(onSelect).toHaveBeenLastCalledWith({
+ from: new Date(2026, 0, 15),
+ to: new Date(2026, 0, 20)
+ });
+ });
+
+ it('B1: from set, click before resets from and leaves popover open', () => {
+ openPopoverAndCaptureCalendar(
+
+ );
+
+ const calendarOnSelect = latestCalendarOnSelect();
+ act(() => {
+ calendarOnSelect({}, new Date(2026, 0, 10));
+ });
+
+ const startInput = screen.getByPlaceholderText(
+ 'Select start date'
+ ) as HTMLInputElement;
+ const endInput = screen.getByPlaceholderText(
+ 'Select end date'
+ ) as HTMLInputElement;
+ expect(startInput.value).toBe('10/01/2026');
+ expect(endInput.value).toBe(''); // to cleared
+ expect(endInput.getAttribute('data-active')).toBe('true'); // still on 'to'
+ });
+
+ it('C: both set, click restarts with new from and clears to', () => {
+ openPopoverAndCaptureCalendar(
+
+ );
+
+ const calendarOnSelect = latestCalendarOnSelect();
+ act(() => {
+ calendarOnSelect({}, new Date(2026, 1, 1));
+ });
+
+ const startInput = screen.getByPlaceholderText(
+ 'Select start date'
+ ) as HTMLInputElement;
+ const endInput = screen.getByPlaceholderText(
+ 'Select end date'
+ ) as HTMLInputElement;
+ expect(startInput.value).toBe('01/02/2026');
+ expect(endInput.value).toBe('');
+ expect(endInput.getAttribute('data-active')).toBe('true');
+ });
+ });
+});
diff --git a/packages/raystack/components/calendar/calendar.tsx b/packages/raystack/components/calendar/calendar.tsx
index e21bf8664..24ed5442e 100644
--- a/packages/raystack/components/calendar/calendar.tsx
+++ b/packages/raystack/components/calendar/calendar.tsx
@@ -2,13 +2,11 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons';
import { cva, cx } from 'class-variance-authority';
-import { ChangeEvent, ReactNode, useEffect, useState } from 'react';
-import {
- DayPicker,
- DayPickerProps,
- DropdownProps,
- dateLib
-} from 'react-day-picker';
+import dayjs from 'dayjs';
+import timezonePlugin from 'dayjs/plugin/timezone';
+import utcPlugin from 'dayjs/plugin/utc';
+import { ChangeEvent, ReactNode, useEffect, useRef, useState } from 'react';
+import { DayPicker, DayPickerProps, DropdownProps } from 'react-day-picker';
import { IconButton } from '../icon-button';
import { Select } from '../select';
@@ -16,13 +14,21 @@ import { Skeleton } from '../skeleton';
import { Tooltip } from '../tooltip';
import styles from './calendar.module.css';
+// `timezone` plugin depends on `utc`.
+dayjs.extend(utcPlugin);
+dayjs.extend(timezonePlugin);
+
interface OnDropdownOpen {
onDropdownOpen?: VoidFunction;
}
-interface CalendarPropsExtended {
+export interface CalendarPropsExtended {
showTooltip?: boolean;
tooltipMessages?: Record;
+ /*
+ * Record keys are tz-aware `DD-MM-YYYY`; function form gets raw `day.date`
+ * (consumer must apply `timeZone` themselves to stay consistent).
+ */
dateInfo?: Record | ((date: Date) => ReactNode | null);
loadingData?: boolean;
timeZone?: string;
@@ -47,9 +53,19 @@ function DropDown({
}: DropDownComponentProps) {
const [open, setOpen] = useState(false);
+ /*
+ * Mirror the callback into a ref so the effect depends only on `open` —
+ * parents that re-create `onDropdownOpen` per render would otherwise cause
+ * a re-fire on every parent render where `open` is true.
+ */
+ const onDropdownOpenRef = useRef(onDropdownOpen);
+ useEffect(() => {
+ onDropdownOpenRef.current = onDropdownOpen;
+ });
+
useEffect(() => {
- if (open && onDropdownOpen) onDropdownOpen();
- }, [open, onDropdownOpen]);
+ if (open) onDropdownOpenRef.current?.();
+ }, [open]);
function handleChange(value: string) {
if (onChange) {
@@ -138,10 +154,16 @@ export const Calendar = function ({
),
DayButton: props => {
const { day, ...buttonProps } = props;
- const dateKey = dateLib.format(day.date, 'dd-MM-yyyy');
+ /*
+ * Format in the picker's zone so the key matches the rendered day
+ * (otherwise UTC-day grids miss messages keyed at UTC midnight when
+ * the browser is in a non-UTC zone).
+ */
+ const dateKey = timeZone
+ ? dayjs(day.date).tz(timeZone).format('DD-MM-YYYY')
+ : dayjs(day.date).format('DD-MM-YYYY');
const message = tooltipMessages[dateKey];
- // Support both object and function for dateInfo
const dateComponent =
typeof dateInfo === 'function'
? dateInfo(day.date)
diff --git a/packages/raystack/components/calendar/date-picker.tsx b/packages/raystack/components/calendar/date-picker.tsx
index 607a811a2..9e9506ac9 100644
--- a/packages/raystack/components/calendar/date-picker.tsx
+++ b/packages/raystack/components/calendar/date-picker.tsx
@@ -4,164 +4,194 @@ import { CalendarIcon } from '@radix-ui/react-icons';
import { cx } from 'class-variance-authority';
import dayjs from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
-import {
- isValidElement,
- useCallback,
- useEffect,
- useRef,
- useState
-} from 'react';
-import { PropsBase, PropsSingleRequired } from 'react-day-picker';
+import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
+import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
+import { useEffect, useRef, useState } from 'react';
+import { PropsBase } from 'react-day-picker';
import { Input } from '../input';
import { InputProps } from '../input/input';
import { Popover } from '../popover';
import { PopoverContentProps } from '../popover/popover';
-import { Calendar } from './calendar';
+import { Calendar, type CalendarPropsExtended } from './calendar';
import styles from './calendar.module.css';
+import { usePickerPopover } from './use-picker-popover';
dayjs.extend(customParseFormat);
+dayjs.extend(isSameOrAfter);
+dayjs.extend(isSameOrBefore);
+
+/*
+ * Picker-specific calendar surface. `mode` is owned by the picker; the other
+ * forced keys (`selected`/`onSelect`/`required`) aren't in `PropsBase` so
+ * they're already unreachable.
+ */
+type DatePickerCalendarSlot = Omit & CalendarPropsExtended;
+
+interface DatePickerSlotProps {
+ input?: InputProps;
+ calendar?: DatePickerCalendarSlot;
+ popover?: PopoverContentProps;
+}
interface DatePickerProps {
dateFormat?: string;
+ /**
+ * Props for each picker slot. When both this and the legacy
+ * `inputProps`/`calendarProps`/`popoverProps` are set, `slotProps` wins.
+ */
+ slotProps?: DatePickerSlotProps;
+ /** @deprecated Use `slotProps.input` instead. */
inputProps?: InputProps;
- calendarProps?: PropsSingleRequired & PropsBase;
+ /** @deprecated Use `slotProps.calendar` instead. */
+ calendarProps?: DatePickerCalendarSlot;
+ /** @deprecated Use `slotProps.popover` instead. */
+ popoverProps?: PopoverContentProps;
onSelect?: (date: Date) => void;
value?: Date;
+ defaultValue?: Date;
children?:
| React.ReactNode
| ((props: { selectedDate: string }) => React.ReactNode);
showCalendarIcon?: boolean;
timeZone?: string;
- popoverProps?: PopoverContentProps;
}
export function DatePicker({
dateFormat = 'DD/MM/YYYY',
- inputProps,
- calendarProps,
- value = new Date(),
- onSelect = () => {},
+ slotProps,
+ inputProps: legacyInputProps,
+ calendarProps: legacyCalendarProps,
+ popoverProps: legacyPopoverProps,
+ value: valueProp,
+ defaultValue,
+ onSelect = () => undefined,
children,
showCalendarIcon = true,
- timeZone,
- popoverProps
+ timeZone
}: DatePickerProps) {
- const [showCalendar, setShowCalendar] = useState(false);
- const [selectedDate, setSelectedDate] = useState(value);
+ // Merge legacy props with slotProps; slotProps wins when both are set.
+ const inputProps = { ...legacyInputProps, ...slotProps?.input };
+ const calendarProps = { ...legacyCalendarProps, ...slotProps?.calendar };
+ const popoverProps = { ...legacyPopoverProps, ...slotProps?.popover };
+ /*
+ * Initial value: controlled prop > defaultValue (uncontrolled init) >
+ * undefined. With both omitted the picker starts unselected so the
+ * "Select date" placeholder is honest.
+ */
+ const [selectedDate, setSelectedDate] = useState(
+ valueProp ?? defaultValue
+ );
const [error, setError] = useState();
- const formattedDate = dayjs(selectedDate).format(dateFormat);
+ // Sync only when controlled — uncontrolled mode keeps its own state.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: compare on timestamp, not Date identity
+ useEffect(() => {
+ if (valueProp !== undefined) setSelectedDate(valueProp);
+ }, [valueProp?.getTime()]);
+
+ const formattedDate = selectedDate
+ ? dayjs(selectedDate).format(dateFormat)
+ : '';
- const isDropdownOpenRef = useRef(false);
- const inputRef = useRef(null);
- const contentRef = useRef(null);
- const isInputFocused = useRef(false);
+ const [inputValue, setInputValue] = useState(formattedDate);
+
+ /*
+ * Separate from `selectedDate` so chevron/dropdown nav doesn't rewrite the
+ * committed date — only day-clicks (`onSelect`) do. Initial month honors
+ * `calendarProps.defaultMonth`, then the selected date, then today.
+ */
+ const [viewMonth, setViewMonth] = useState(
+ calendarProps?.defaultMonth ?? selectedDate ?? new Date()
+ );
+
+ // Mirror for reading inside the outside-click callback closure.
const selectedDateRef = useRef(selectedDate);
useEffect(() => {
selectedDateRef.current = selectedDate;
}, [selectedDate]);
- const isElementOutside = useCallback((el: HTMLElement) => {
- return (
- !isDropdownOpenRef.current && // Month and Year dropdown from Date picker
- !inputRef.current?.contains(el) && // Input
- !contentRef.current?.contains(el)
- );
- }, []);
-
- const handleMouseDown = useCallback(
- (event: MouseEvent) => {
- const el = event.target as HTMLElement | null;
- if (el && isElementOutside(el)) removeEventListeners();
- },
- [isElementOutside]
- );
-
- function registerEventListeners() {
- isInputFocused.current = true;
- document.addEventListener('mouseup', handleMouseDown);
- }
-
- function removeEventListeners(skipUpdate = false) {
- isInputFocused.current = false;
- setShowCalendar(false);
+ // Sync the input when the committed date changes from a non-typing source.
+ useEffect(() => {
+ setInputValue(formattedDate);
+ }, [formattedDate]);
- const updatedVal = dayjs(selectedDateRef.current).format(dateFormat);
+ // Hook owns open/close, outside-click, and the year/month dropdown carve-out.
+ const popover = usePickerPopover({
+ onOutsideClick: () => closePicker()
+ });
- if (inputRef.current) inputRef.current.value = updatedVal;
- if (!error && !skipUpdate) onSelect(dayjs(updatedVal).toDate());
+ /*
+ * Reset the visible month on open or external selection change. Honor
+ * `calendarProps.defaultMonth` so consumers controlling the initial view
+ * see it every time the picker opens, not only on first mount.
+ */
+ useEffect(() => {
+ if (popover.isOpen) {
+ setViewMonth(calendarProps?.defaultMonth ?? selectedDate ?? new Date());
+ }
+ }, [popover.isOpen, selectedDate, calendarProps?.defaultMonth]);
- document.removeEventListener('mouseup', handleMouseDown);
+ function closePicker() {
+ popover.disengage();
+ const committedDate = selectedDateRef.current;
+ setInputValue(committedDate ? dayjs(committedDate).format(dateFormat) : '');
+ setError(undefined);
+ /*
+ * Emit the committed Date directly. Going through
+ * `dayjs(formattedString).toDate()` re-parses the formatted string without
+ * a format spec, which falls back to native `Date` parsing and can shift
+ * non-ISO formats (e.g. DD/MM/YYYY → wrong Date).
+ *
+ * Skip when nothing was ever selected — `onSelect` is typed
+ * `(date: Date) => void` so we don't fire with `undefined`.
+ */
+ if (!error && committedDate) onSelect(committedDate);
}
- const handleSelect = (day: Date) => {
+ function handleSelect(day: Date | undefined) {
setSelectedDate(day);
- onSelect(day);
+ // RDP can hand us `undefined` when `required={false}` and the user
+ // clicks the currently-selected day (deselect). Only forward defined
+ // dates to consumer `onSelect` — keeps the prop type narrow.
+ if (day) onSelect(day);
setError(undefined);
- removeEventListeners(true);
- };
-
- function onDropdownOpen() {
- isDropdownOpenRef.current = true;
- }
-
- function onOpenChange(open?: boolean) {
- if (
- !isDropdownOpenRef.current &&
- !(isInputFocused.current && showCalendar)
- ) {
- setShowCalendar(Boolean(open));
- }
-
- isDropdownOpenRef.current = false;
- }
-
- function handleInputFocus() {
- if (isInputFocused.current) return;
- if (!showCalendar) setShowCalendar(true);
- }
-
- function handleInputBlur(event: React.FocusEvent) {
- if (isInputFocused.current) {
- const el = event.relatedTarget as HTMLElement | null;
- if (el && isElementOutside(el)) removeEventListeners();
- } else {
- registerEventListeners();
- setTimeout(() => inputRef.current?.select());
- }
+ popover.disengage();
}
function handleKeyUp(event: React.KeyboardEvent) {
- if (event.code === 'Enter' && inputRef.current) {
- inputRef.current.blur();
- removeEventListeners();
+ if (event.code === 'Enter' && popover.inputRef.current) {
+ popover.inputRef.current.blur();
+ closePicker();
}
}
function handleInputChange(event: React.ChangeEvent) {
const { value } = event.target;
+ setInputValue(value);
- const format = value.includes('/')
- ? 'DD/MM/YYYY'
- : value.includes('-')
- ? 'DD-MM-YYYY'
- : undefined;
- const date = dayjs(value, format);
+ const date = dayjs(value, dateFormat, true);
const isValidDate = date.isValid();
+ /*
+ * RDP treats `startMonth`/`endMonth` as months — compare against month
+ * bounds so any day inside the boundary month is accepted.
+ */
const isAfter =
calendarProps?.startMonth !== undefined
- ? dayjs(date).isSameOrAfter(calendarProps.startMonth)
+ ? date.isSameOrAfter(dayjs(calendarProps.startMonth).startOf('month'))
: true;
const isBefore =
calendarProps?.endMonth !== undefined
- ? dayjs(date).isSameOrBefore(calendarProps.endMonth)
+ ? date.isSameOrBefore(dayjs(calendarProps.endMonth).endOf('month'))
: true;
- const isValid =
- isValidDate && isAfter && isBefore && dayjs(date).isSameOrBefore(dayjs());
+ /*
+ * No upper-bound on "future": the grid lets users click future days, so
+ * typing and clicking should agree.
+ */
+ const isValid = isValidDate && isAfter && isBefore;
if (isValid) {
setSelectedDate(date.toDate());
@@ -179,46 +209,60 @@ export function DatePicker({
className={styles.datePickerInput}
trailingIcon={showCalendarIcon ? : undefined}
{...inputProps}
- ref={inputRef}
- value={formattedDate}
+ ref={popover.inputRef}
+ value={inputValue}
onChange={handleInputChange}
- onFocus={handleInputFocus}
- onBlur={handleInputBlur}
+ onFocus={popover.handleInputFocus}
+ onBlur={popover.handleInputBlur}
onKeyUp={handleKeyUp}
/>
);
- const trigger =
- typeof children === 'function' ? (
- children({ selectedDate: formattedDate })
- ) : children ? (
- {children}
- ) : (
- {defaultTrigger}
- );
+ /*
+ * Always wrap the trigger in a `` so the rendered outer element is
+ * never a `
}
+ nativeButton={false}
+ render={
{triggerContent}
}
/>
diff --git a/packages/raystack/components/calendar/index.tsx b/packages/raystack/components/calendar/index.tsx
index 697f683ca..b9b02d6d0 100644
--- a/packages/raystack/components/calendar/index.tsx
+++ b/packages/raystack/components/calendar/index.tsx
@@ -1,3 +1,5 @@
+export type { DateRange } from 'react-day-picker';
+export type { CalendarProps, CalendarPropsExtended } from './calendar';
export { Calendar } from './calendar';
export { DatePicker } from './date-picker';
export { RangePicker } from './range-picker';
diff --git a/packages/raystack/components/calendar/range-picker.tsx b/packages/raystack/components/calendar/range-picker.tsx
index 03bc0d89f..b413af66c 100644
--- a/packages/raystack/components/calendar/range-picker.tsx
+++ b/packages/raystack/components/calendar/range-picker.tsx
@@ -3,20 +3,44 @@
import { CalendarIcon } from '@radix-ui/react-icons';
import { cx } from 'class-variance-authority';
import dayjs from 'dayjs';
-import { isValidElement, useCallback, useMemo, useState } from 'react';
-import { DateRange, PropsBase, PropsRangeRequired } from 'react-day-picker';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { DateRange, PropsBase } from 'react-day-picker';
import { Flex } from '../flex';
import { Input } from '../input';
import { InputProps } from '../input/input';
import { Popover } from '../popover';
import { PopoverContentProps } from '../popover/popover';
-import { Calendar } from './calendar';
+import { Calendar, type CalendarPropsExtended } from './calendar';
import styles from './calendar.module.css';
+import { usePickerPopover } from './use-picker-popover';
+
+/*
+ * Picker-specific calendar surface. `mode` is owned by the picker; the other
+ * forced keys (`selected`/`onSelect`/`required`) aren't in `PropsBase` so
+ * they're already unreachable.
+ */
+type RangePickerCalendarSlot = Omit
& CalendarPropsExtended;
+
+interface RangePickerSlotProps {
+ startInput?: InputProps;
+ endInput?: InputProps;
+ calendar?: RangePickerCalendarSlot;
+ popover?: PopoverContentProps;
+}
interface RangePickerProps {
dateFormat?: string;
+ /**
+ * Props for each picker slot. When both this and the legacy
+ * `inputsProps`/`calendarProps`/`popoverProps` are set, `slotProps` wins.
+ */
+ slotProps?: RangePickerSlotProps;
+ /** @deprecated Use `slotProps.startInput` / `slotProps.endInput` instead. */
inputsProps?: { startDate?: InputProps; endDate?: InputProps };
- calendarProps?: PropsRangeRequired & PropsBase;
+ /** @deprecated Use `slotProps.calendar` instead. */
+ calendarProps?: RangePickerCalendarSlot;
+ /** @deprecated Use `slotProps.popover` instead. */
+ popoverProps?: PopoverContentProps;
onSelect?: (date: DateRange) => void;
pickerGroupClassName?: string;
value?: DateRange;
@@ -27,35 +51,81 @@ interface RangePickerProps {
showCalendarIcon?: boolean;
footer?: React.ReactNode;
timeZone?: string;
- popoverProps?: PopoverContentProps;
}
type RangeFields = keyof DateRange;
export function RangePicker({
dateFormat = 'DD/MM/YYYY',
- inputsProps = {},
- calendarProps,
- onSelect = () => {},
+ slotProps,
+ inputsProps: legacyInputsProps = {},
+ calendarProps: legacyCalendarProps,
+ popoverProps: legacyPopoverProps,
+ onSelect = () => undefined,
value,
- defaultValue = {
- to: new Date(),
- from: new Date()
- },
+ /*
+ * No inline default — the state machine's "first click sets `from`" branch
+ * needs an empty range to fire.
+ */
+ defaultValue,
pickerGroupClassName,
children,
showCalendarIcon = true,
footer,
- timeZone,
- popoverProps
+ timeZone
}: RangePickerProps) {
- const [showCalendar, setShowCalendar] = useState(false);
+ // Merge legacy props with slotProps; slotProps wins when both are set.
+ const startInputProps = {
+ ...legacyInputsProps.startDate,
+ ...slotProps?.startInput
+ };
+ const endInputProps = {
+ ...legacyInputsProps.endDate,
+ ...slotProps?.endInput
+ };
+ const calendarProps = { ...legacyCalendarProps, ...slotProps?.calendar };
+ const popoverProps = { ...legacyPopoverProps, ...slotProps?.popover };
+ /*
+ * Hook owns open/close, outside-click dismissal, and the year/month
+ * dropdown carve-out. Inputs stay `readOnly`, so we arm the listener on
+ * open (click-to-open path) instead of on input blur (typed-input path).
+ */
+ const popover = usePickerPopover({
+ onOutsideClick: () => popover.disengage()
+ });
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies: engage/disengage are stable
+ useEffect(() => {
+ if (popover.isOpen) popover.engage();
+ else popover.disengage();
+ }, [popover.isOpen]);
+
const [currentRangeField, setCurrentRangeField] =
useState('from');
- const [internalValue, setInternalValue] = useState(value ?? defaultValue);
- const [currentMonth, setCurrentMonth] = useState(internalValue?.from);
+ const [internalValue, setInternalValue] = useState(
+ value ?? defaultValue
+ );
+ const [currentMonth, setCurrentMonth] = useState(
+ internalValue?.from
+ );
+
+ /*
+ * Sync visible month when controlled `value.from` changes externally
+ * (form reset, preset buttons, sync-from-URL). Sync runs whenever
+ * `value` is defined — including when `value.from` is cleared — so a
+ * parent reset (`setValue({ from: undefined })`) actually unpins the
+ * calendar. Uncontrolled mode (value === undefined) skips entirely.
+ */
+ const valueFromTime = value?.from?.getTime();
+ const isControlled = value !== undefined;
+ // biome-ignore lint/correctness/useExhaustiveDependencies: compare on timestamp, not Date identity
+ useEffect(() => {
+ if (isControlled) setCurrentMonth(value.from);
+ }, [valueFromTime, isControlled]);
- const selectedRange = value ?? internalValue;
+ // Empty-range fallback so downstream `.from`/`.to` reads don't need guards.
+ const selectedRange: DateRange = value ??
+ internalValue ?? { from: undefined };
const startDate = selectedRange.from
? dayjs(selectedRange.from).format(dateFormat)
@@ -64,19 +134,20 @@ export function RangePicker({
? dayjs(selectedRange.to).format(dateFormat)
: '';
- // Ensures two months are visible even when
- // current month is the last allowed month (endMonth).
+ /*
+ * Ensures two months are visible even when the current month is the last
+ * allowed month (endMonth). Skips when `currentMonth` is undefined —
+ * `dayjs(undefined)` returns "now" and would falsely match `endMonth` if
+ * endMonth happens to be the current month, forcing the calendar away
+ * from its own default.
+ */
const computedDefaultMonth = useMemo(() => {
- let month = currentMonth;
- if (calendarProps?.endMonth) {
- const endMonth = dayjs(calendarProps.endMonth);
- const fromMonth = dayjs(currentMonth);
-
- if (fromMonth.isSame(endMonth, 'month')) {
- month = endMonth.subtract(1, 'month').toDate();
- }
+ if (!currentMonth || !calendarProps?.endMonth) return currentMonth;
+ const endMonth = dayjs(calendarProps.endMonth);
+ if (dayjs(currentMonth).isSame(endMonth, 'month')) {
+ return endMonth.subtract(1, 'month').toDate();
}
- return month;
+ return currentMonth;
}, [currentMonth, calendarProps?.endMonth]);
const onTriggerClick = useCallback(
@@ -87,47 +158,52 @@ export function RangePicker({
} else {
setCurrentRangeField('to');
}
- if (showCalendar) {
+ if (popover.isOpen) {
e.preventDefault();
e.stopPropagation();
}
},
- [showCalendar]
+ [popover.isOpen]
);
- // Handle date selection with custom logic
+ /*
+ * State machine branches on `from`/`to`, not the focused input:
+ * A. !from -> set `from`, advance to 'to'
+ * B1. from, before -> reset
+ * B2. from, after -> commit `to`, close
+ * C. from && to -> restart
+ * `onSelect` fires on every step; consumers gate on `range.to` for completed
+ * ranges.
+ */
const handleSelect = (_: DateRange, selectedDay: Date) => {
- let newRange = { ...selectedRange };
- let newCurrentRangeField = currentRangeField;
-
- if (currentRangeField === 'from') {
- // If selecting start date and it's after the current end date
- if (
- selectedRange?.to &&
- dayjs(selectedDay).isAfter(dayjs(selectedRange.to))
- ) {
+ const { from, to } = selectedRange;
+ let newRange: DateRange;
+ let newField: RangeFields = 'to';
+ let shouldClose = false;
+
+ if (!from) {
+ // A: empty -> set from, advance
+ newRange = { from: selectedDay };
+ } else if (!to) {
+ if (dayjs(selectedDay).isBefore(dayjs(from))) {
+ // B1: click before from -> reset
newRange = { from: selectedDay };
- newCurrentRangeField = 'to';
} else {
- newRange.from = selectedDay;
- if (!selectedRange?.to) newCurrentRangeField = 'to';
+ // B2: complete range -> close
+ newRange = { from, to: selectedDay };
+ newField = 'from';
+ shouldClose = true;
}
} else {
- // If selecting end date and it's before the current start date
- if (
- selectedRange?.from &&
- dayjs(selectedDay).isBefore(dayjs(selectedRange.from))
- ) {
- newRange = { from: selectedDay };
- newCurrentRangeField = 'to';
- } else newRange.to = selectedDay;
+ // C: both set -> restart
+ newRange = { from: selectedDay };
}
- if (newCurrentRangeField !== currentRangeField)
- setCurrentRangeField(newCurrentRangeField);
-
- setInternalValue(newRange);
+ if (newField !== currentRangeField) setCurrentRangeField(newField);
+ // Only update internal state when uncontrolled — controlled consumers own `value`.
+ if (!isControlled) setInternalValue(newRange);
onSelect(newRange);
+ if (shouldClose) popover.disengage();
};
const defaultTrigger = (
@@ -137,11 +213,11 @@ export function RangePicker({
placeholder='Select start date'
trailingIcon={showCalendarIcon ? : undefined}
className={styles.datePickerInput}
- {...(inputsProps.startDate ?? {})}
+ {...startInputProps}
value={startDate}
readOnly
data-range-field='start'
- data-active={showCalendar && currentRangeField === 'from'}
+ data-active={popover.isOpen && currentRangeField === 'from'}
onClick={onTriggerClick}
/>
@@ -150,39 +226,57 @@ export function RangePicker({
placeholder='Select end date'
trailingIcon={showCalendarIcon ? : undefined}
className={styles.datePickerInput}
- {...(inputsProps.endDate ?? {})}
+ {...endInputProps}
value={endDate}
readOnly
data-range-field='end'
- data-active={showCalendar && currentRangeField === 'to'}
+ data-active={popover.isOpen && currentRangeField === 'to'}
onClick={onTriggerClick}
/>
);
- const trigger =
+ /*
+ * Always wrap the trigger in a `` so the rendered outer element is
+ * never a `
}
+ nativeButton={false}
+ render={
{triggerContent}
}
/>
void;
+}
+
+export interface UsePickerPopoverReturn {
+ isOpen: boolean;
+ setIsOpen: React.Dispatch>;
+ inputRef: React.RefObject;
+ contentRef: React.RefObject;
+ handleInputFocus: () => void;
+ handleInputBlur: (event: React.FocusEvent) => void;
+ onOpenChange: (open?: boolean) => void;
+ /*
+ * Pass as Calendar's `onDropdownOpen` so the year/month dropdown isn't
+ * treated as an outside click.
+ */
+ markDropdownOpen: () => void;
+ /*
+ * Arm the outside-click listener. DatePicker engages on first input blur
+ * (typed-input pattern). Click-to-open consumers (e.g. RangePicker with
+ * readOnly inputs) should engage on open via `useEffect`.
+ */
+ engage: () => void;
+ // Programmatic close — does NOT fire `onOutsideClick`.
+ disengage: () => void;
+}
+
+/*
+ * Popover machinery shared by the date pickers.
+ *
+ * DatePicker drives engagement off input focus/blur (typed-input pattern).
+ * RangePicker (readOnly inputs) drives engagement off `isOpen` via a useEffect
+ * that calls `engage()` / `disengage()`.
+ *
+ * Why custom instead of Base UI's dismissal: Calendar's `captionLayout='dropdown'`
+ * renders Selects inside the popover; their portals look "outside" to a naive
+ * dismiss handler. The hook carves that out via `markDropdownOpen`.
+ *
+ * `onOpenChange` reads `isOpen` via ref so its identity stays stable —
+ * Base UI's store subscriber re-binds on identity change, which caused an
+ * updateStoreInstance loop on mount.
+ */
+export function usePickerPopover({
+ onOutsideClick
+}: UsePickerPopoverOptions): UsePickerPopoverReturn {
+ const [isOpen, setIsOpen] = useState(false);
+
+ const inputRef = useRef(null);
+ const contentRef = useRef(null);
+
+ // True once focused-in and the outside-click listener is armed.
+ const isEngagedRef = useRef(false);
+
+ /*
+ * True while the Calendar's year/month dropdown is open — its clicks
+ * are not "outside".
+ */
+ const isDropdownOpenRef = useRef(false);
+
+ // Mirror for `onOpenChange` stability (see header comment).
+ const isOpenRef = useRef(isOpen);
+ useEffect(() => {
+ isOpenRef.current = isOpen;
+ });
+
+ // Mirror so the mouseup listener doesn't re-bind every render.
+ const onOutsideClickRef = useRef(onOutsideClick);
+ useEffect(() => {
+ onOutsideClickRef.current = onOutsideClick;
+ });
+
+ const isElementOutside = useCallback((el: HTMLElement) => {
+ return (
+ !isDropdownOpenRef.current &&
+ !inputRef.current?.contains(el) &&
+ !contentRef.current?.contains(el)
+ );
+ }, []);
+
+ const handleMouseDown = useCallback(
+ (event: MouseEvent) => {
+ const el = event.target as HTMLElement | null;
+ if (el && isElementOutside(el)) onOutsideClickRef.current();
+ },
+ [isElementOutside]
+ );
+
+ const engage = useCallback(() => {
+ isEngagedRef.current = true;
+ document.addEventListener('mouseup', handleMouseDown);
+ }, [handleMouseDown]);
+
+ const disengage = useCallback(() => {
+ isEngagedRef.current = false;
+ setIsOpen(false);
+ document.removeEventListener('mouseup', handleMouseDown);
+ }, [handleMouseDown]);
+
+ /*
+ * Safety net: if the component unmounts while engaged (or `handleMouseDown`
+ * identity changes mid-life), strip the document listener so stale
+ * `onOutsideClickRef` invocations can't fire.
+ */
+ useEffect(() => {
+ return () => {
+ document.removeEventListener('mouseup', handleMouseDown);
+ };
+ }, [handleMouseDown]);
+
+ const handleInputFocus = useCallback(() => {
+ if (isEngagedRef.current) return;
+ setIsOpen(true);
+ }, []);
+
+ const handleInputBlur = useCallback(
+ (event: React.FocusEvent) => {
+ const el = event.relatedTarget as HTMLElement | null;
+ if (isEngagedRef.current) {
+ // Engaged: blur is either outside (close) or into popover (no-op).
+ if (el && isElementOutside(el)) onOutsideClickRef.current();
+ return;
+ }
+ // Not yet engaged. If the user tab'd straight to an outside element,
+ // close immediately — otherwise keyboard users get stuck with the
+ // popover open until they mouse-click somewhere.
+ if (el && isElementOutside(el)) {
+ onOutsideClickRef.current();
+ return;
+ }
+ // First blur arms outside-click and selects text for type-to-overwrite.
+ engage();
+ setTimeout(() => inputRef.current?.select());
+ },
+ [isElementOutside, engage]
+ );
+
+ const onOpenChange = useCallback((open?: boolean) => {
+ // Year/month dropdown opening inside the popover triggers an open-change
+ // we don't want; swallow it and consume the flag.
+ if (isDropdownOpenRef.current) {
+ isDropdownOpenRef.current = false;
+ return;
+ }
+ /*
+ * Suppress only redundant *re-open* events fired by focus/click handlers
+ * while the picker is already engaged + open. Explicit close requests
+ * (Escape key, trigger toggle, programmatic) must always go through, or
+ * users get stuck with no way to close.
+ */
+ if (open === true && isEngagedRef.current && isOpenRef.current) return;
+ setIsOpen(Boolean(open));
+ }, []);
+
+ const markDropdownOpen = useCallback(() => {
+ isDropdownOpenRef.current = true;
+ }, []);
+
+ return {
+ isOpen,
+ setIsOpen,
+ inputRef,
+ contentRef,
+ handleInputFocus,
+ handleInputBlur,
+ onOpenChange,
+ markDropdownOpen,
+ engage,
+ disengage
+ };
+}
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 3e177e22f..21a104544 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -10,7 +10,14 @@ export { Badge } from './components/badge';
export { Box } from './components/box';
export { Breadcrumb } from './components/breadcrumb';
export { Button } from './components/button';
-export { Calendar, DatePicker, RangePicker } from './components/calendar';
+export {
+ Calendar,
+ type CalendarProps,
+ type CalendarPropsExtended,
+ DatePicker,
+ type DateRange,
+ RangePicker
+} from './components/calendar';
export { Callout } from './components/callout';
export { Checkbox } from './components/checkbox';
export { Chip } from './components/chip';
diff --git a/packages/raystack/package.json b/packages/raystack/package.json
index f26c641f4..c3faea698 100644
--- a/packages/raystack/package.json
+++ b/packages/raystack/package.json
@@ -122,7 +122,7 @@
"@tanstack/table-core": "^8.9.2",
"class-variance-authority": "^0.7.1",
"color": "^5.0.0",
- "dayjs": "^1.11.11",
+ "dayjs": "^1.11.20",
"prism-react-renderer": "^2.4.1",
"react-day-picker": "^9.6.7"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d56606f33..8eaec5781 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,8 +57,8 @@ importers:
specifier: ^0.7.1
version: 0.7.1
dayjs:
- specifier: ^1.11.11
- version: 1.11.11
+ specifier: ^1.11.20
+ version: 1.11.20
fumadocs-core:
specifier: 16.0.7
version: 16.0.7(@types/react@19.2.2)(lucide-react@0.548.0(react@19.2.1))(next@16.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1))(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
@@ -203,8 +203,8 @@ importers:
specifier: ^5.0.0
version: 5.0.0
dayjs:
- specifier: ^1.11.11
- version: 1.11.11
+ specifier: ^1.11.20
+ version: 1.11.20
prism-react-renderer:
specifier: ^2.4.1
version: 2.4.1(react@19.2.1)
@@ -4611,8 +4611,8 @@ packages:
date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
- dayjs@1.11.11:
- resolution: {integrity: sha512-okzr3f11N6WuqYtZSvm+F776mB41wRZMhKP+hc34YdW+KmtYYK9iqvHSwo2k9FEH3fhGXvOPV6yz2IcSrfRUDg==}
+ dayjs@1.11.20:
+ resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==}
debug@4.3.5:
resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==}
@@ -10502,8 +10502,6 @@ snapshots:
'@parcel/logger': 2.12.0
'@parcel/utils': 2.12.0
lmdb: 2.8.5
- transitivePeerDependencies:
- - '@swc/helpers'
'@parcel/cache@2.9.2(@parcel/core@2.12.0)':
dependencies:
@@ -12181,7 +12179,7 @@ snapshots:
class-variance-authority: 0.7.1
cmdk: 1.1.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
color: 5.0.0
- dayjs: 1.11.11
+ dayjs: 1.11.20
prism-react-renderer: 2.4.1(react@19.2.1)
radix-ui: 1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
react: 19.2.1
@@ -12704,7 +12702,7 @@ snapshots:
'@testing-library/dom@10.4.0':
dependencies:
'@babel/code-frame': 7.26.2
- '@babel/runtime': 7.28.6
+ '@babel/runtime': 7.29.2
'@types/aria-query': 5.0.4
aria-query: 5.3.0
chalk: 4.1.2
@@ -13821,7 +13819,7 @@ snapshots:
date-fns@4.1.0: {}
- dayjs@1.11.11: {}
+ dayjs@1.11.20: {}
debug@4.3.5:
dependencies: