From 9f19513cfab0a600f0176c504317df8a01a7c516 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 21 May 2026 15:53:51 +0530 Subject: [PATCH 1/6] fix(calendar): tz-aware day keys, picker reactivity, and deprecated prop cleanup --- apps/www/package.json | 2 +- apps/www/src/app/examples/page.tsx | 22 +- .../playground/calendar-examples.tsx | 4 +- .../content/docs/components/calendar/demo.ts | 12 +- .../content/docs/components/calendar/props.ts | 152 +++++++- .../calendar/__tests__/calendar.test.tsx | 28 +- .../__tests__/date-picker.runtime.test.tsx | 59 +++ .../calendar/__tests__/date-picker.test.tsx | 355 ++++++++++++++++++ .../calendar/__tests__/range-picker.test.tsx | 291 ++++++++++++++ .../raystack/components/calendar/calendar.tsx | 30 +- .../components/calendar/date-picker.tsx | 188 +++++----- .../raystack/components/calendar/index.tsx | 1 + .../components/calendar/range-picker.tsx | 117 ++++-- .../components/calendar/use-picker-popover.ts | 140 +++++++ packages/raystack/index.tsx | 8 +- packages/raystack/package.json | 2 +- pnpm-lock.yaml | 20 +- 17 files changed, 1225 insertions(+), 206 deletions(-) create mode 100644 packages/raystack/components/calendar/__tests__/date-picker.runtime.test.tsx create mode 100644 packages/raystack/components/calendar/__tests__/date-picker.test.tsx create mode 100644 packages/raystack/components/calendar/__tests__/range-picker.test.tsx create mode 100644 packages/raystack/components/calendar/use-picker-popover.ts diff --git a/apps/www/package.json b/apps/www/package.json index 375602515..817675695 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -17,7 +17,7 @@ "@raystack/apsara": "workspace:*", "@types/mdast": "^4.0.4", "class-variance-authority": "^0.7.1", - "dayjs": "^1.11.11", + "dayjs": "^1.11.20", "fumadocs-core": "16.0.7", "fumadocs-docgen": "^1.3.8", "fumadocs-mdx": "13.0.5", diff --git a/apps/www/src/app/examples/page.tsx b/apps/www/src/app/examples/page.tsx index eee7efbef..18e9e9c79 100644 --- a/apps/www/src/app/examples/page.tsx +++ b/apps/www/src/app/examples/page.tsx @@ -281,10 +281,7 @@ const Page = () => { disabled: { before: dayjs().add(3, 'month').toDate(), after: dayjs().add(3, 'year').toDate() - }, - mode: 'single', - required: true, - selected: new Date() + } }} inputProps={{ size: 'small' @@ -302,15 +299,7 @@ const Page = () => { } calendarProps={{ captionLayout: 'dropdown', - mode: 'range', - required: true, - selected: { - from: dayjs('2027-11-15').toDate(), - to: dayjs('2027-12-10').toDate() - }, numberOfMonths: 2, - fromYear: 2024, - toYear: 2027, startMonth: dayjs('2024-01-01').toDate(), endMonth: dayjs('2027-12-01').toDate(), defaultMonth: dayjs('2027-11-01').toDate() @@ -335,10 +324,7 @@ const Page = () => { @@ -1978,7 +1964,7 @@ const Page = () => { @@ -2514,7 +2500,7 @@ const Page = () => { No data available There are no users in the system. Create your first diff --git a/apps/www/src/components/playground/calendar-examples.tsx b/apps/www/src/components/playground/calendar-examples.tsx index ff3fb0f78..3f59619fb 100644 --- a/apps/www/src/components/playground/calendar-examples.tsx +++ b/apps/www/src/components/playground/calendar-examples.tsx @@ -8,8 +8,8 @@ export function CalendarExamples() { - - + + ); diff --git a/apps/www/src/content/docs/components/calendar/demo.ts b/apps/www/src/content/docs/components/calendar/demo.ts index 2117f781d..b94c82905 100644 --- a/apps/www/src/content/docs/components/calendar/demo.ts +++ b/apps/www/src/content/docs/components/calendar/demo.ts @@ -32,7 +32,7 @@ export const calendarDemo = { }, { name: 'With Dropdowns', - code: `` + code: `` } ] }; @@ -58,14 +58,8 @@ export const rangePickerDemo = { to: new Date(2024, 0, 15) }} calendarProps={{ - mode: "range", - required: true, - selected: { - from: new Date(2024, 0, 1), - to: new Date(2024, 0, 15) - }, - fromMonth: new Date(2024, 0, 1), - toMonth: new Date(2024, 11, 31), + startMonth: new Date(2024, 0, 1), + endMonth: new Date(2024, 11, 31), }} > {({ startDate, endDate }) => ( diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index 504a087f6..51bf9c6af 100644 --- a/apps/www/src/content/docs/components/calendar/props.ts +++ b/apps/www/src/content/docs/components/calendar/props.ts @@ -2,15 +2,100 @@ import { InputProps } from '../input/props'; import { PopoverContentProps } from '../popover/props'; export interface CalendarProps { - /** Number of months to display */ + /** + * Number of months to render side-by-side. + * @example numberOfMonths={2} + */ numberOfMonths?: number; - /** Layout for the month caption (e.g., "dropdown") */ - captionLayout?: string; + /** + * Caption layout: plain label or month/year dropdown(s). + * @example captionLayout="dropdown" + */ + captionLayout?: 'label' | 'dropdown' | 'dropdown-months' | 'dropdown-years'; + + /** + * Earliest navigable month (was `fromYear` / `fromMonth` — both deprecated). + * Bounds the chevrons and the year dropdown. + * @example startMonth={new Date(2020, 0)} + */ + startMonth?: Date; + + /** + * Latest navigable month (was `toYear` / `toMonth` — both deprecated). + * Bounds the chevrons and the year dropdown. + * @example endMonth={new Date(2030, 11)} + */ + endMonth?: Date; + + /** Initial visible month (uncontrolled). */ + defaultMonth?: Date; + + /** Controlled visible month — pair with `onMonthChange`. */ + month?: Date; + + /** Fires when the visible month changes (chevron paging, dropdown). */ + onMonthChange?: (month: Date) => void; + + /** + * Selection mode. + * @defaultValue "single" + */ + mode?: 'single' | 'multiple' | 'range'; + + /** Currently selected date(s). Shape depends on `mode`. */ + selected?: Date | Date[] | { from: Date; to?: Date }; + + /** Fires when the user picks a date. */ + onSelect?: (selected: Date | Date[] | { from: Date; to?: Date }) => void; + + /** Day on which the week starts. 0 = Sunday, 1 = Monday, …, 6 = Saturday. */ + weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; + + /** Show week numbers in a leading column. */ + showWeekNumber?: boolean; + + /** + * Disabled dates. Accepts a Date, an array, a matcher object + * (e.g. `{ before: date }`, `{ after: date }`, `{ from, to }`), or a + * predicate. + */ + disabled?: + | Date + | Date[] + | { before?: Date; after?: Date; from?: Date; to?: Date } + | ((date: Date) => boolean); + + /** + * Hidden dates (was `fromDate` / `toDate` — both deprecated; use + * `hidden={{ before: date }}` / `hidden={{ after: date }}`). + * Same matcher options as `disabled`. + */ + hidden?: + | Date + | Date[] + | { before?: Date; after?: Date; from?: Date; to?: Date } + | ((date: Date) => boolean); - /** Boolean to show loading state */ + /** + * Boolean to show loading state. + * @example loadingData={true} + */ loadingData?: boolean; + /** + * Enable tooltips on dates that have a matching `tooltipMessages` entry. + * @defaultValue false + */ + showTooltip?: boolean; + + /** + * Tooltip text keyed by date string in `"dd-MM-yyyy"` format. Lookup is + * timezone-aware when `timeZone` is set. + * @example tooltipMessages={{ "15-01-2024": "Holiday" }} + */ + tooltipMessages?: Record; + /** * Custom React components to render above each date. * Can be either: @@ -30,9 +115,18 @@ export interface CalendarProps { | Record | ((date: Date) => React.ReactNode | null); + /** Fires when the year/month dropdown opens (only with `captionLayout="dropdown"`). */ + onDropdownOpen?: () => void; + /** Boolean to show days from previous/next months */ showOutsideDays?: boolean; + /** Footer rendered below the month grid. */ + footer?: React.ReactNode; + + /** Per-element class name overrides passed through to DayPicker. */ + classNames?: Record; + /** Additional CSS class names */ className?: string; @@ -56,14 +150,18 @@ export interface RangePickerProps { */ dateFormat?: string; - /** Callback function when date range is selected */ - onSelect?: (range: { from: Date; to: Date }) => void; + /** + * Fires on every step of range selection — not only on the completed range. + * Both fields are optional during partial selection; gate on `range.to` if + * you only want completed ranges. + */ + onSelect?: (range: { from?: Date; to?: Date }) => void; - /** Initial date range value */ - defaultValue?: { from: Date; to: Date }; + /** Initial (uncontrolled) date range. */ + defaultValue?: { from?: Date; to?: Date }; - /** Controlled date range value */ - value?: { from: Date; to: Date }; + /** Controlled date range. */ + value?: { from?: Date; to?: Date }; /** Props for customizing the calendar */ calendarProps?: CalendarProps; @@ -74,8 +172,21 @@ export interface RangePickerProps { endDate?: InputProps; }; - /** Render prop for custom trigger */ - children?: React.ReactNode; + /** + * Render prop or custom trigger. Pass a function to render a custom trigger + * receiving the formatted `startDate` / `endDate` strings, or a ReactNode to + * replace the default inputs entirely. + * + * @example + * + * {({ startDate, endDate }) => ( + * + * )} + * + */ + children?: + | React.ReactNode + | ((props: { startDate: string; endDate: string }) => React.ReactNode); /** * Boolean to show/hide calendar icon @@ -120,8 +231,21 @@ export interface DatePickerProps { /** Props for customizing the calendar */ calendarProps?: CalendarProps; - /** Render prop for custom trigger */ - children?: React.ReactNode; + /** + * Render prop or custom trigger. Pass a function to render a custom trigger + * receiving the formatted `selectedDate` string, or a ReactNode to replace + * the default input entirely. + * + * @example + * + * {({ selectedDate }) => ( + * + * )} + * + */ + children?: + | React.ReactNode + | ((props: { selectedDate: string }) => React.ReactNode); /** * Boolean to show/hide calendar icon diff --git a/packages/raystack/components/calendar/__tests__/calendar.test.tsx b/packages/raystack/components/calendar/__tests__/calendar.test.tsx index 7cf7b1422..4ecd26c29 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 + /* + * Renders for Sundays if any are visible; querySelector returns null + * otherwise. Test just exercises the function-based path. + */ const sundayInfo = container.querySelector('[data-testid="sunday-info"]'); - // Test passes if function approach works (may or may not find Sunday depending on month) 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..8aaf6365d --- /dev/null +++ b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx @@ -0,0 +1,355 @@ +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('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('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('commits a valid date and fires onSelect once typing reaches a complete match', () => { + const onSelect = vi.fn(); + render(); + + const input = screen.getByPlaceholderText( + 'Select date' + ) as HTMLInputElement; + + // Partial input must not commit + fireEvent.change(input, { target: { value: '15/06' } }); + expect(onSelect).not.toHaveBeenCalled(); + + // Full valid input does not throw and clears error state + fireEvent.change(input, { target: { value: '15/06/2025' } }); + expect(input.getAttribute('aria-invalid')).not.toBe('true'); + }); + }); + + 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.test.tsx b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx new file mode 100644 index 000000000..b4f44e2f5 --- /dev/null +++ b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx @@ -0,0 +1,291 @@ +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('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..a4f7201e9 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 dayjs from 'dayjs'; +import timezonePlugin from 'dayjs/plugin/timezone'; +import utcPlugin from 'dayjs/plugin/utc'; import { ChangeEvent, ReactNode, useEffect, useState } from 'react'; -import { - DayPicker, - DayPickerProps, - DropdownProps, - dateLib -} from 'react-day-picker'; +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; @@ -138,10 +144,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..ab678b40e 100644 --- a/packages/raystack/components/calendar/date-picker.tsx +++ b/packages/raystack/components/calendar/date-picker.tsx @@ -4,27 +4,30 @@ 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 { isValidElement, useEffect, useMemo, 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); interface DatePickerProps { dateFormat?: string; inputProps?: InputProps; - calendarProps?: PropsSingleRequired & PropsBase; + /* + * `mode` is owned by the picker; `selected`/`onSelect`/`required` aren't in + * PropsBase so they're already unreachable. + */ + calendarProps?: Omit & CalendarPropsExtended; onSelect?: (date: Date) => void; value?: Date; children?: @@ -39,129 +42,107 @@ export function DatePicker({ dateFormat = 'DD/MM/YYYY', inputProps, calendarProps, - value = new Date(), + value: valueProp, onSelect = () => {}, children, showCalendarIcon = true, timeZone, popoverProps }: DatePickerProps) { - const [showCalendar, setShowCalendar] = useState(false); + /* + * Stabilise the "no value" default — a fresh `new Date()` per render would + * flip the sync effect's timestamp dep across 1ms boundaries and loop. + */ + const value = useMemo(() => valueProp ?? new Date(), [valueProp]); + const [selectedDate, setSelectedDate] = useState(value); const [error, setError] = useState(); + // biome-ignore lint/correctness/useExhaustiveDependencies: compare on timestamp, not Date identity + useEffect(() => { + setSelectedDate(value); + }, [value?.getTime()]); + const formattedDate = 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. + */ + const [viewMonth, setViewMonth] = useState(selectedDate); + + // 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. + useEffect(() => { + if (popover.isOpen) setViewMonth(selectedDate); + }, [popover.isOpen, selectedDate]); - document.removeEventListener('mouseup', handleMouseDown); + function closePicker() { + popover.disengage(); + const committed = dayjs(selectedDateRef.current).format(dateFormat); + setInputValue(committed); + setError(undefined); + if (!error) onSelect(dayjs(committed).toDate()); } - const handleSelect = (day: Date) => { + function handleSelect(day: Date) { setSelectedDate(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,11 +160,11 @@ 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} /> ); @@ -198,27 +179,36 @@ export function DatePicker({ ); return ( - + {trigger}} /> diff --git a/packages/raystack/components/calendar/index.tsx b/packages/raystack/components/calendar/index.tsx index 697f683ca..89058fc74 100644 --- a/packages/raystack/components/calendar/index.tsx +++ b/packages/raystack/components/calendar/index.tsx @@ -1,3 +1,4 @@ +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..76a1929cc 100644 --- a/packages/raystack/components/calendar/range-picker.tsx +++ b/packages/raystack/components/calendar/range-picker.tsx @@ -3,20 +3,30 @@ 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 { + isValidElement, + 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'; interface RangePickerProps { dateFormat?: string; inputsProps?: { startDate?: InputProps; endDate?: InputProps }; - calendarProps?: PropsRangeRequired & PropsBase; + /* + * `mode` is owned by the picker; `selected`/`onSelect`/`required` aren't in + * PropsBase so they're already unreachable. + */ + calendarProps?: Omit & CalendarPropsExtended; onSelect?: (date: DateRange) => void; pickerGroupClassName?: string; value?: DateRange; @@ -38,10 +48,11 @@ export function RangePicker({ calendarProps, onSelect = () => {}, 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, @@ -52,10 +63,25 @@ export function RangePicker({ const [showCalendar, setShowCalendar] = useState(false); 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 + ); - const selectedRange = value ?? internalValue; + /* + * Sync visible month when controlled `value.from` changes externally + * (form reset, preset buttons, sync-from-URL). + */ + // biome-ignore lint/correctness/useExhaustiveDependencies: compare on timestamp, not Date identity + useEffect(() => { + if (value?.from) setCurrentMonth(value.from); + }, [value?.from?.getTime()]); + + // 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,8 +90,10 @@ 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). + */ const computedDefaultMonth = useMemo(() => { let month = currentMonth; if (calendarProps?.endMonth) { @@ -95,39 +123,43 @@ export function RangePicker({ [showCalendar] ); - // 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. `setInternalValue` runs even when controlled — wasted render. + */ 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); - + if (newField !== currentRangeField) setCurrentRangeField(newField); setInternalValue(newRange); onSelect(newRange); + if (shouldClose) setShowCalendar(false); }; const defaultTrigger = ( @@ -177,11 +209,20 @@ export function RangePicker({ side={popoverProps?.side ?? 'top'} > 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; + // Programmatic close — does NOT fire `onOutsideClick`. + disengage: () => void; +} + +/* + * Popover machinery shared by the date pickers (DatePicker only today — + * RangePicker's inputs are `readOnly` so it doesn't need this). + * + * 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]); + + const handleInputFocus = useCallback(() => { + if (isEngagedRef.current) return; + setIsOpen(true); + }, []); + + const handleInputBlur = useCallback( + (event: React.FocusEvent) => { + if (isEngagedRef.current) { + // Engaged: blur is either outside (close) or into popover (no-op). + const el = event.relatedTarget as HTMLElement | null; + if (el && isElementOutside(el)) onOutsideClickRef.current(); + } else { + // First blur arms outside-click and selects text for type-to-overwrite. + engage(); + setTimeout(() => inputRef.current?.select()); + } + }, + [isElementOutside, engage] + ); + + const onOpenChange = useCallback((open?: boolean) => { + // Ignore dropdown-triggered changes and re-focus while already open. + if ( + !isDropdownOpenRef.current && + !(isEngagedRef.current && isOpenRef.current) + ) { + setIsOpen(Boolean(open)); + } + isDropdownOpenRef.current = false; + }, []); + + const markDropdownOpen = useCallback(() => { + isDropdownOpenRef.current = true; + }, []); + + return { + isOpen, + setIsOpen, + inputRef, + contentRef, + handleInputFocus, + handleInputBlur, + onOpenChange, + markDropdownOpen, + disengage + }; +} diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 3e177e22f..14b27d295 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -10,7 +10,13 @@ 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, + 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: From d4878ce4cdf9341ebd3d011256b914f5c7f16261 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Thu, 21 May 2026 17:23:45 +0530 Subject: [PATCH 2/6] fix(calendar): code-review follow-ups and audit fixes --- .../content/docs/components/calendar/demo.ts | 6 +- .../__tests__/range-picker.runtime.test.tsx | 30 ++++++++ .../raystack/components/calendar/calendar.tsx | 16 +++- .../components/calendar/date-picker.tsx | 57 +++++++++----- .../raystack/components/calendar/index.tsx | 1 + .../components/calendar/range-picker.tsx | 77 ++++++++++++------- .../components/calendar/use-picker-popover.ts | 25 +++++- packages/raystack/index.tsx | 1 + 8 files changed, 157 insertions(+), 56 deletions(-) create mode 100644 packages/raystack/components/calendar/__tests__/range-picker.runtime.test.tsx diff --git a/apps/www/src/content/docs/components/calendar/demo.ts b/apps/www/src/content/docs/components/calendar/demo.ts index b94c82905..33f43606f 100644 --- a/apps/www/src/content/docs/components/calendar/demo.ts +++ b/apps/www/src/content/docs/components/calendar/demo.ts @@ -10,7 +10,7 @@ export const preview = { { name: 'Range Picker', code: ` - ` + ` }, { name: 'Date Picker', @@ -76,11 +76,11 @@ export const datePickerDemo = { tabs: [ { name: 'Basic', - code: `` + code: `` }, { name: 'Without Calendar Icon', - code: `` + code: `` }, { name: 'Custom Trigger', 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/calendar.tsx b/packages/raystack/components/calendar/calendar.tsx index a4f7201e9..24ed5442e 100644 --- a/packages/raystack/components/calendar/calendar.tsx +++ b/packages/raystack/components/calendar/calendar.tsx @@ -5,7 +5,7 @@ import { cva, cx } from 'class-variance-authority'; import dayjs from 'dayjs'; import timezonePlugin from 'dayjs/plugin/timezone'; import utcPlugin from 'dayjs/plugin/utc'; -import { ChangeEvent, ReactNode, useEffect, useState } from 'react'; +import { ChangeEvent, ReactNode, useEffect, useRef, useState } from 'react'; import { DayPicker, DayPickerProps, DropdownProps } from 'react-day-picker'; import { IconButton } from '../icon-button'; @@ -53,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) { diff --git a/packages/raystack/components/calendar/date-picker.tsx b/packages/raystack/components/calendar/date-picker.tsx index ab678b40e..d1a21f583 100644 --- a/packages/raystack/components/calendar/date-picker.tsx +++ b/packages/raystack/components/calendar/date-picker.tsx @@ -6,7 +6,7 @@ import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; -import { isValidElement, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { PropsBase } from 'react-day-picker'; import { Input } from '../input'; import { InputProps } from '../input/input'; @@ -69,9 +69,13 @@ export function DatePicker({ /* * Separate from `selectedDate` so chevron/dropdown nav doesn't rewrite the - * committed date — only day-clicks (`onSelect`) do. + * committed date — only day-clicks (`onSelect`) do. Initial month honors + * `calendarProps.defaultMonth` when set; otherwise falls back to the + * selected date. */ - const [viewMonth, setViewMonth] = useState(selectedDate); + const [viewMonth, setViewMonth] = useState( + calendarProps?.defaultMonth ?? selectedDate + ); // Mirror for reading inside the outside-click callback closure. const selectedDateRef = useRef(selectedDate); @@ -90,17 +94,29 @@ export function DatePicker({ onOutsideClick: () => closePicker() }); - // Reset the visible month on open or external selection change. + /* + * 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(selectedDate); - }, [popover.isOpen, selectedDate]); + if (popover.isOpen) { + setViewMonth(calendarProps?.defaultMonth ?? selectedDate); + } + }, [popover.isOpen, selectedDate, calendarProps?.defaultMonth]); function closePicker() { popover.disengage(); - const committed = dayjs(selectedDateRef.current).format(dateFormat); - setInputValue(committed); + const committedDate = selectedDateRef.current; + setInputValue(dayjs(committedDate).format(dateFormat)); setError(undefined); - if (!error) onSelect(dayjs(committed).toDate()); + /* + * 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). + */ + if (!error) onSelect(committedDate); } function handleSelect(day: Date) { @@ -169,20 +185,23 @@ export function DatePicker({ /> ); - 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}
} /> 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( @@ -92,19 +101,18 @@ export function RangePicker({ /* * Ensures two months are visible even when the current month is the last - * allowed month (endMonth). + * 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( @@ -115,12 +123,12 @@ export function RangePicker({ } else { setCurrentRangeField('to'); } - if (showCalendar) { + if (popover.isOpen) { e.preventDefault(); e.stopPropagation(); } }, - [showCalendar] + [popover.isOpen] ); /* @@ -130,8 +138,9 @@ export function RangePicker({ * B2. from, after -> commit `to`, close * C. from && to -> restart * `onSelect` fires on every step; consumers gate on `range.to` for completed - * ranges. `setInternalValue` runs even when controlled — wasted render. + * ranges. */ + const isControlled = value !== undefined; const handleSelect = (_: DateRange, selectedDay: Date) => { const { from, to } = selectedRange; let newRange: DateRange; @@ -157,9 +166,10 @@ export function RangePicker({ } if (newField !== currentRangeField) setCurrentRangeField(newField); - setInternalValue(newRange); + // Only update internal state when uncontrolled — controlled consumers own `value`. + if (!isControlled) setInternalValue(newRange); onSelect(newRange); - if (shouldClose) setShowCalendar(false); + if (shouldClose) popover.disengage(); }; const defaultTrigger = ( @@ -173,7 +183,7 @@ export function RangePicker({ value={startDate} readOnly data-range-field='start' - data-active={showCalendar && currentRangeField === 'from'} + data-active={popover.isOpen && currentRangeField === 'from'} onClick={onTriggerClick} /> @@ -186,24 +196,32 @@ export function RangePicker({ 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; + /* + * 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 only today — - * RangePicker's inputs are `readOnly` so it doesn't need this). + * 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 @@ -91,6 +100,17 @@ export function usePickerPopover({ 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); @@ -135,6 +155,7 @@ export function usePickerPopover({ handleInputBlur, onOpenChange, markDropdownOpen, + engage, disengage }; } diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 14b27d295..21a104544 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -15,6 +15,7 @@ export { type CalendarProps, type CalendarPropsExtended, DatePicker, + type DateRange, RangePicker } from './components/calendar'; export { Callout } from './components/callout'; From 7626467fec367109def036b0457d9aa1a06a20f0 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 22 May 2026 00:17:26 +0530 Subject: [PATCH 3/6] fix(calendar): code-review follow-ups --- .../content/docs/components/calendar/props.ts | 17 +++++- .../calendar/__tests__/date-picker.test.tsx | 61 +++++++++++++++++++ .../components/calendar/date-picker.tsx | 20 +++--- 3 files changed, 85 insertions(+), 13 deletions(-) diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index 51bf9c6af..170f2ce8f 100644 --- a/apps/www/src/content/docs/components/calendar/props.ts +++ b/apps/www/src/content/docs/components/calendar/props.ts @@ -166,7 +166,11 @@ export interface RangePickerProps { /** Props for customizing the calendar */ calendarProps?: CalendarProps; - /** Props for customizing the inputs */ + /** + * Props for customizing the inputs. Event handlers (`onChange`, `onFocus`, + * `onBlur`, `onKeyUp`) are not forwarded — use the picker's `onSelect` for + * value changes. + */ inputsProps?: { startDate?: InputProps; endDate?: InputProps; @@ -219,12 +223,19 @@ export interface DatePickerProps { */ dateFormat?: string; - /** Props for customizing the input */ + /** + * Props for customizing the input. Event handlers (`onChange`, `onFocus`, + * `onBlur`, `onKeyUp`) are not forwarded — use the picker's `onSelect` for + * value changes. + */ inputProps?: InputProps; - /** Initial date value */ + /** Controlled date value. Pair with `onSelect`. */ value?: Date; + /** Initial (uncontrolled) date value. Ignored if `value` is set. */ + defaultValue?: Date; + /** Callback function when date is selected */ onSelect?: (date: Date) => void; diff --git a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx index 8aaf6365d..962d75294 100644 --- a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx +++ b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx @@ -151,6 +151,67 @@ describe('DatePicker', () => { }); }); + 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 diff --git a/packages/raystack/components/calendar/date-picker.tsx b/packages/raystack/components/calendar/date-picker.tsx index d1a21f583..2f1a8d266 100644 --- a/packages/raystack/components/calendar/date-picker.tsx +++ b/packages/raystack/components/calendar/date-picker.tsx @@ -6,7 +6,7 @@ import dayjs from 'dayjs'; import customParseFormat from 'dayjs/plugin/customParseFormat'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import isSameOrBefore from 'dayjs/plugin/isSameOrBefore'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { PropsBase } from 'react-day-picker'; import { Input } from '../input'; import { InputProps } from '../input/input'; @@ -30,6 +30,7 @@ interface DatePickerProps { calendarProps?: Omit & CalendarPropsExtended; onSelect?: (date: Date) => void; value?: Date; + defaultValue?: Date; children?: | React.ReactNode | ((props: { selectedDate: string }) => React.ReactNode); @@ -43,25 +44,24 @@ export function DatePicker({ inputProps, calendarProps, value: valueProp, + defaultValue, onSelect = () => {}, children, showCalendarIcon = true, timeZone, popoverProps }: DatePickerProps) { - /* - * Stabilise the "no value" default — a fresh `new Date()` per render would - * flip the sync effect's timestamp dep across 1ms boundaries and loop. - */ - const value = useMemo(() => valueProp ?? new Date(), [valueProp]); - - const [selectedDate, setSelectedDate] = useState(value); + // Initial value: controlled prop > defaultValue (uncontrolled init) > today. + const [selectedDate, setSelectedDate] = useState( + valueProp ?? defaultValue ?? new Date() + ); const [error, setError] = useState(); + // Sync only when controlled — uncontrolled mode keeps its own state. // biome-ignore lint/correctness/useExhaustiveDependencies: compare on timestamp, not Date identity useEffect(() => { - setSelectedDate(value); - }, [value?.getTime()]); + if (valueProp !== undefined) setSelectedDate(valueProp); + }, [valueProp?.getTime()]); const formattedDate = dayjs(selectedDate).format(dateFormat); From ccd664a1ba35eb2519976db214f91a87a1953c85 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 22 May 2026 01:41:52 +0530 Subject: [PATCH 4/6] fix(calendar): slotProps migration, reviewer fixes, demo split --- .../content/docs/components/calendar/demo.ts | 73 +++++++++++++++++-- .../docs/components/calendar/index.mdx | 55 ++++++-------- .../content/docs/components/calendar/props.ts | 43 ++++++++--- .../calendar/__tests__/date-picker.test.tsx | 38 +++++++++- .../calendar/__tests__/range-picker.test.tsx | 42 +++++++++++ .../components/calendar/date-picker.tsx | 42 ++++++++--- .../components/calendar/range-picker.tsx | 66 +++++++++++++---- .../components/calendar/use-picker-popover.ts | 37 +++++++--- 8 files changed, 303 insertions(+), 93 deletions(-) diff --git a/apps/www/src/content/docs/components/calendar/demo.ts b/apps/www/src/content/docs/components/calendar/demo.ts index 33f43606f..87339520d 100644 --- a/apps/www/src/content/docs/components/calendar/demo.ts +++ b/apps/www/src/content/docs/components/calendar/demo.ts @@ -10,7 +10,7 @@ export const preview = { { name: 'Range Picker', code: ` - ` + ` }, { name: 'Date Picker', @@ -19,12 +19,19 @@ export const preview = { ] }; +// Layout & appearance: how the calendar looks and what chrome it renders. export const calendarDemo = { type: 'code', tabs: [ { name: 'Basic', - code: `` + code: `` }, { name: 'With Loading', @@ -32,7 +39,55 @@ export const calendarDemo = { }, { name: 'With Dropdowns', - code: `` + code: `` + }, + { + name: 'With Footer', + code: `` + } + ] +}; + +// Data & behavior: tooltips, disabled days, timezone, controlled navigation. +export const calendarBehaviorDemo = { + type: 'code', + tabs: [ + { + name: 'With Tooltips', + code: `` + }, + { + name: 'With Disabled Dates', + code: `` + }, + { + name: 'With Timezone', + code: `` + }, + { + name: 'Controlled Month', + code: ` console.log('Visible month:', month)} + numberOfMonths={2} +/>` } ] }; @@ -57,9 +112,11 @@ export const rangePickerDemo = { from: new Date(2024, 0, 1), to: new Date(2024, 0, 15) }} - calendarProps={{ - startMonth: new Date(2024, 0, 1), - endMonth: new Date(2024, 11, 31), + slotProps={{ + calendar: { + startMonth: new Date(2024, 0, 1), + endMonth: new Date(2024, 11, 31), + }, }} > {({ startDate, endDate }) => ( @@ -76,11 +133,11 @@ export const datePickerDemo = { tabs: [ { name: 'Basic', - code: `` + code: `` }, { name: 'Without Calendar Icon', - code: `` + code: `` }, { name: 'Custom Trigger', diff --git a/apps/www/src/content/docs/components/calendar/index.mdx b/apps/www/src/content/docs/components/calendar/index.mdx index 317e3cba7..4d71faeb4 100644 --- a/apps/www/src/content/docs/components/calendar/index.mdx +++ b/apps/www/src/content/docs/components/calendar/index.mdx @@ -9,6 +9,7 @@ import { datePickerDemo, rangePickerDemo, calendarDemo, + calendarBehaviorDemo, dateInfoDemo, } from "./demo.ts"; @@ -38,13 +39,13 @@ Renders a standalone calendar for date selection. ### RangePicker -The RangePicker supports customizing the popover behavior using the `popoverProps` prop. +The RangePicker exposes its slots — `startInput`, `endInput`, `calendar`, `popover` — through the `slotProps` prop. ### DatePicker -The DatePicker supports customizing the popover behavior using the `popoverProps` prop. +The DatePicker exposes its slots — `input`, `calendar`, `popover` — through the `slotProps` prop. @@ -52,11 +53,21 @@ The DatePicker supports customizing the popover behavior using the `popoverProps ### Calendar -Choose between different variants to convey different meanings or importance levels. Default variant is `accent`. +#### Layout & appearance + +Number of months, dropdown nav, loading state, footer. -#### Custom Date Information +#### Behavior & data + +Tooltips, disabled days, timezone, controlled month navigation. + + + +> The **Controlled Month** tab shows the `month` / `onMonthChange` API. In a real app, hold `month` in parent state and update it from `onMonthChange`: `const [month, setMonth] = useState(...); `. The demo uses a fixed date + logging callback, so the calendar stays pinned because no parent state advances on chevron clicks. + +#### Custom date information You can display custom components above each date using the `dateInfo` prop. The keys should be date strings in `"dd-MM-yyyy"` format, and the values are React components that will be rendered above the date number. @@ -64,39 +75,19 @@ You can display custom components above each date using the `dateInfo` prop. The ### Range Picker -The Range Picker component allows selecting a date range with the following behaviors: - -1. When selecting two different dates: - - The UI will show the exact selected dates - - The callback will return the start and end date as selected - ```tsx - // Example: If user selects April 1st and April 10th, 2025 - // UI will show: April 1st, 2025 - April 10th, 2025 - // Callback will return: - { - "from": "2025-03-31T18:30:00.000Z", - "to": "2025-04-09T18:30:00.000Z" - } - ``` - -2. When clicking the same date twice: - - The UI will show the same date for both start and end - - The callback will return the start and end date as selected - ```tsx - // Example: If user clicks April 1st, 2025 twice - // UI will show: April 1st, 2025 - April 1st, 2025 - // Callback will return: - { - "from": "2025-03-31T18:30:00.000Z", - "to": "2025-03-31T18:30:00.000Z" - } - ``` +Selects a date range across two inputs. The grid uses a state machine that branches on the current `from` / `to`: + +- **Empty** — first click sets `from` and advances focus to the end input. +- **Only `from` set** — clicking a later date completes the range and closes the popover; clicking an earlier date resets `from`. +- **Both set** — clicking again restarts: the new date becomes `from`, `to` clears. + +`onSelect` fires on every step (the shape is `{ from?: Date; to?: Date }` — partial during interaction). Gate on `range.to` if you only want the completed-range event. ### Date Picker -Badges can include an icon to provide additional visual context. By default there is no icon. +Single-date selection with a typable input. The popover opens on focus; pressing Enter, blurring, or clicking outside commits the value and fires `onSelect`. diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index 170f2ce8f..33a7eb350 100644 --- a/apps/www/src/content/docs/components/calendar/props.ts +++ b/apps/www/src/content/docs/components/calendar/props.ts @@ -163,14 +163,24 @@ export interface RangePickerProps { /** Controlled date range. */ value?: { from?: Date; to?: Date }; - /** Props for customizing the calendar */ - calendarProps?: CalendarProps; - /** - * Props for customizing the inputs. Event handlers (`onChange`, `onFocus`, - * `onBlur`, `onKeyUp`) are not forwarded — use the picker's `onSelect` for - * value changes. + * Props for each picker slot. When both this and the legacy + * `inputsProps` / `calendarProps` / `popoverProps` are set, `slotProps` wins. + * + * Input event handlers (`onChange`, `onFocus`, `onBlur`, `onKeyUp`) are not + * forwarded — use `onSelect` for value changes. */ + slotProps?: { + startInput?: InputProps; + endInput?: InputProps; + calendar?: CalendarProps; + popover?: PopoverContentProps; + }; + + /** @deprecated Use `slotProps.calendar` instead. */ + calendarProps?: CalendarProps; + + /** @deprecated Use `slotProps.startInput` / `slotProps.endInput` instead. */ inputsProps?: { startDate?: InputProps; endDate?: InputProps; @@ -207,7 +217,7 @@ export interface RangePickerProps { */ timeZone?: string; - /** Props for customizing the popover */ + /** @deprecated Use `slotProps.popover` instead. */ popoverProps?: PopoverContentProps; } @@ -224,10 +234,19 @@ export interface DatePickerProps { dateFormat?: string; /** - * Props for customizing the input. Event handlers (`onChange`, `onFocus`, - * `onBlur`, `onKeyUp`) are not forwarded — use the picker's `onSelect` for - * value changes. + * Props for each picker slot. When both this and the legacy + * `inputProps` / `calendarProps` / `popoverProps` are set, `slotProps` wins. + * + * Input event handlers (`onChange`, `onFocus`, `onBlur`, `onKeyUp`) are not + * forwarded — use `onSelect` for value changes. */ + slotProps?: { + input?: InputProps; + calendar?: CalendarProps; + popover?: PopoverContentProps; + }; + + /** @deprecated Use `slotProps.input` instead. */ inputProps?: InputProps; /** Controlled date value. Pair with `onSelect`. */ @@ -239,7 +258,7 @@ export interface DatePickerProps { /** Callback function when date is selected */ onSelect?: (date: Date) => void; - /** Props for customizing the calendar */ + /** @deprecated Use `slotProps.calendar` instead. */ calendarProps?: CalendarProps; /** @@ -270,6 +289,6 @@ export interface DatePickerProps { */ timeZone?: string; - /** Props for customizing the popover */ + /** @deprecated Use `slotProps.popover` instead. */ popoverProps?: PopoverContentProps; } diff --git a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx index 962d75294..d31911535 100644 --- a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx +++ b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx @@ -74,6 +74,35 @@ describe('DatePicker', () => { }); }); + 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 @@ -353,7 +382,7 @@ describe('DatePicker', () => { } }); - it('commits a valid date and fires onSelect once typing reaches a complete match', () => { + it('does not fire onSelect while typing — partial stays uncommitted, valid waits for commit (Enter/blur/outside-click)', () => { const onSelect = vi.fn(); render(); @@ -361,13 +390,16 @@ describe('DatePicker', () => { 'Select date' ) as HTMLInputElement; - // Partial input must not commit + // Partial input is not even parseable — no commit. fireEvent.change(input, { target: { value: '15/06' } }); expect(onSelect).not.toHaveBeenCalled(); - // Full valid input does not throw and clears error state + // 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(); }); }); diff --git a/packages/raystack/components/calendar/__tests__/range-picker.test.tsx b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx index b4f44e2f5..39016c57b 100644 --- a/packages/raystack/components/calendar/__tests__/range-picker.test.tsx +++ b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx @@ -117,6 +117,48 @@ describe('RangePicker', () => { }); }); + 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( diff --git a/packages/raystack/components/calendar/date-picker.tsx b/packages/raystack/components/calendar/date-picker.tsx index 2f1a8d266..fe9a8c23c 100644 --- a/packages/raystack/components/calendar/date-picker.tsx +++ b/packages/raystack/components/calendar/date-picker.tsx @@ -20,14 +20,32 @@ 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; - inputProps?: InputProps; - /* - * `mode` is owned by the picker; `selected`/`onSelect`/`required` aren't in - * PropsBase so they're already unreachable. + /** + * Props for each picker slot. When both this and the legacy + * `inputProps`/`calendarProps`/`popoverProps` are set, `slotProps` wins. */ - calendarProps?: Omit & CalendarPropsExtended; + slotProps?: DatePickerSlotProps; + /** @deprecated Use `slotProps.input` instead. */ + inputProps?: InputProps; + /** @deprecated Use `slotProps.calendar` instead. */ + calendarProps?: DatePickerCalendarSlot; + /** @deprecated Use `slotProps.popover` instead. */ + popoverProps?: PopoverContentProps; onSelect?: (date: Date) => void; value?: Date; defaultValue?: Date; @@ -36,21 +54,25 @@ interface DatePickerProps { | ((props: { selectedDate: string }) => React.ReactNode); showCalendarIcon?: boolean; timeZone?: string; - popoverProps?: PopoverContentProps; } export function DatePicker({ dateFormat = 'DD/MM/YYYY', - inputProps, - calendarProps, + slotProps, + inputProps: legacyInputProps, + calendarProps: legacyCalendarProps, + popoverProps: legacyPopoverProps, value: valueProp, defaultValue, onSelect = () => {}, children, showCalendarIcon = true, - timeZone, - popoverProps + timeZone }: DatePickerProps) { + // 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) > today. const [selectedDate, setSelectedDate] = useState( valueProp ?? defaultValue ?? new Date() diff --git a/packages/raystack/components/calendar/range-picker.tsx b/packages/raystack/components/calendar/range-picker.tsx index 3faf53a10..daa09c4d6 100644 --- a/packages/raystack/components/calendar/range-picker.tsx +++ b/packages/raystack/components/calendar/range-picker.tsx @@ -14,14 +14,33 @@ 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; - inputsProps?: { startDate?: InputProps; endDate?: InputProps }; - /* - * `mode` is owned by the picker; `selected`/`onSelect`/`required` aren't in - * PropsBase so they're already unreachable. + /** + * Props for each picker slot. When both this and the legacy + * `inputsProps`/`calendarProps`/`popoverProps` are set, `slotProps` wins. */ - calendarProps?: Omit & CalendarPropsExtended; + slotProps?: RangePickerSlotProps; + /** @deprecated Use `slotProps.startInput` / `slotProps.endInput` instead. */ + inputsProps?: { startDate?: InputProps; endDate?: InputProps }; + /** @deprecated Use `slotProps.calendar` instead. */ + calendarProps?: RangePickerCalendarSlot; + /** @deprecated Use `slotProps.popover` instead. */ + popoverProps?: PopoverContentProps; onSelect?: (date: DateRange) => void; pickerGroupClassName?: string; value?: DateRange; @@ -32,15 +51,16 @@ interface RangePickerProps { showCalendarIcon?: boolean; footer?: React.ReactNode; timeZone?: string; - popoverProps?: PopoverContentProps; } type RangeFields = keyof DateRange; export function RangePicker({ dateFormat = 'DD/MM/YYYY', - inputsProps = {}, - calendarProps, + slotProps, + inputsProps: legacyInputsProps = {}, + calendarProps: legacyCalendarProps, + popoverProps: legacyPopoverProps, onSelect = () => {}, value, /* @@ -52,9 +72,19 @@ export function RangePicker({ children, showCalendarIcon = true, footer, - timeZone, - popoverProps + timeZone }: RangePickerProps) { + // 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 @@ -81,12 +111,17 @@ export function RangePicker({ /* * Sync visible month when controlled `value.from` changes externally - * (form reset, preset buttons, sync-from-URL). + * (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 (value?.from) setCurrentMonth(value.from); - }, [value?.from?.getTime()]); + if (isControlled) setCurrentMonth(value.from); + }, [valueFromTime, isControlled]); // Empty-range fallback so downstream `.from`/`.to` reads don't need guards. const selectedRange: DateRange = value ?? @@ -140,7 +175,6 @@ export function RangePicker({ * `onSelect` fires on every step; consumers gate on `range.to` for completed * ranges. */ - const isControlled = value !== undefined; const handleSelect = (_: DateRange, selectedDay: Date) => { const { from, to } = selectedRange; let newRange: DateRange; @@ -179,7 +213,7 @@ export function RangePicker({ placeholder='Select start date' trailingIcon={showCalendarIcon ? : undefined} className={styles.datePickerInput} - {...(inputsProps.startDate ?? {})} + {...startInputProps} value={startDate} readOnly data-range-field='start' @@ -192,7 +226,7 @@ export function RangePicker({ placeholder='Select end date' trailingIcon={showCalendarIcon ? : undefined} className={styles.datePickerInput} - {...(inputsProps.endDate ?? {})} + {...endInputProps} value={endDate} readOnly data-range-field='end' diff --git a/packages/raystack/components/calendar/use-picker-popover.ts b/packages/raystack/components/calendar/use-picker-popover.ts index ed3a9a508..fd4b12667 100644 --- a/packages/raystack/components/calendar/use-picker-popover.ts +++ b/packages/raystack/components/calendar/use-picker-popover.ts @@ -118,28 +118,41 @@ export function usePickerPopover({ 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). - const el = event.relatedTarget as HTMLElement | null; if (el && isElementOutside(el)) onOutsideClickRef.current(); - } else { - // First blur arms outside-click and selects text for type-to-overwrite. - engage(); - setTimeout(() => inputRef.current?.select()); + 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) => { - // Ignore dropdown-triggered changes and re-focus while already open. - if ( - !isDropdownOpenRef.current && - !(isEngagedRef.current && isOpenRef.current) - ) { - setIsOpen(Boolean(open)); + // 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; } - isDropdownOpenRef.current = false; + /* + * 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(() => { From 3d3ac21162c6c21738abad5c3f67d69d1ca31fb2 Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 22 May 2026 02:43:50 +0530 Subject: [PATCH 5/6] fix(calendar): no value selected for datepicker --- .../content/docs/components/calendar/demo.ts | 98 ++++++++++--------- .../docs/components/calendar/index.mdx | 2 - .../content/docs/components/calendar/props.ts | 11 ++- .../calendar/__tests__/calendar.test.tsx | 6 +- .../calendar/__tests__/date-picker.test.tsx | 38 +++++++ .../components/calendar/date-picker.tsx | 41 +++++--- .../components/calendar/range-picker.tsx | 2 +- 7 files changed, 132 insertions(+), 66 deletions(-) diff --git a/apps/www/src/content/docs/components/calendar/demo.ts b/apps/www/src/content/docs/components/calendar/demo.ts index 87339520d..68f699c9d 100644 --- a/apps/www/src/content/docs/components/calendar/demo.ts +++ b/apps/www/src/content/docs/components/calendar/demo.ts @@ -26,12 +26,12 @@ export const calendarDemo = { { name: 'Basic', code: `` + numberOfMonths={2} + defaultMonth={new Date(2025, 5, 1)} + showWeekNumber={false} + weekStartsOn={1} + showOutsideDays={false} + />` }, { name: 'With Loading', @@ -40,16 +40,16 @@ export const calendarDemo = { { name: 'With Dropdowns', code: `` + captionLayout="dropdown" + startMonth={new Date(2020, 0)} + endMonth={new Date(2030, 11)} + />` }, { name: 'With Footer', code: `` + footer="Select any date to view details" + />` } ] }; @@ -61,21 +61,21 @@ export const calendarBehaviorDemo = { { name: 'With Tooltips', code: `` + showTooltip + tooltipMessages={{ + '15-06-2026': 'Holiday', + '20-06-2026': 'Team off-site', + }} + />` }, { name: 'With Disabled Dates', code: `` + disabled={{ + before: new Date(), + after: new Date(2026, 11, 31), + }} + />` }, { name: 'With Timezone', @@ -83,11 +83,22 @@ export const calendarBehaviorDemo = { }, { name: 'Controlled Month', - code: ` console.log('Visible month:', month)} - numberOfMonths={2} -/>` + code: ` +/* + In a real app, hold \`month\` in parent state and update it from + \`onMonthChange\`: + + const [month, setMonth] = useState(new Date(2025, 5, 1)); + + + This demo uses a fixed date + logging callback, so the calendar stays + pinned to June 2025 because no parent state advances on chevron clicks. +*/ + console.log('Visible month:', month)} + numberOfMonths={2} + />` } ] }; @@ -158,22 +169,21 @@ export const dateInfoDemo = { tabs: [ { name: 'With Date Info', - code: ` - - - 25% - - ) - }} - />` + code: ` + + 25% + + ) + }} + />` } ] }; diff --git a/apps/www/src/content/docs/components/calendar/index.mdx b/apps/www/src/content/docs/components/calendar/index.mdx index 4d71faeb4..73f98759b 100644 --- a/apps/www/src/content/docs/components/calendar/index.mdx +++ b/apps/www/src/content/docs/components/calendar/index.mdx @@ -65,8 +65,6 @@ Tooltips, disabled days, timezone, controlled month navigation. -> The **Controlled Month** tab shows the `month` / `onMonthChange` API. In a real app, hold `month` in parent state and update it from `onMonthChange`: `const [month, setMonth] = useState(...); `. The demo uses a fixed date + logging callback, so the calendar stays pinned because no parent state advances on chevron clicks. - #### Custom date information You can display custom components above each date using the `dateInfo` prop. The keys should be date strings in `"dd-MM-yyyy"` format, and the values are React components that will be rendered above the date number. diff --git a/apps/www/src/content/docs/components/calendar/props.ts b/apps/www/src/content/docs/components/calendar/props.ts index 33a7eb350..ffd7b05f7 100644 --- a/apps/www/src/content/docs/components/calendar/props.ts +++ b/apps/www/src/content/docs/components/calendar/props.ts @@ -249,10 +249,17 @@ export interface DatePickerProps { /** @deprecated Use `slotProps.input` instead. */ inputProps?: InputProps; - /** Controlled date value. Pair with `onSelect`. */ + /** + * Controlled date value. Pair with `onSelect`. Omit (along with + * `defaultValue`) to start the picker in an unselected state — the + * input shows its placeholder until the user selects a date. + */ value?: Date; - /** Initial (uncontrolled) date value. Ignored if `value` is set. */ + /** + * Initial (uncontrolled) date value. Ignored if `value` is set. Omit to + * start unselected. + */ defaultValue?: Date; /** Callback function when date is selected */ diff --git a/packages/raystack/components/calendar/__tests__/calendar.test.tsx b/packages/raystack/components/calendar/__tests__/calendar.test.tsx index 4ecd26c29..4e33ae419 100644 --- a/packages/raystack/components/calendar/__tests__/calendar.test.tsx +++ b/packages/raystack/components/calendar/__tests__/calendar.test.tsx @@ -216,10 +216,10 @@ describe('Calendar', () => { ); /* - * Renders for Sundays if any are visible; querySelector returns null - * otherwise. Test just exercises the function-based path. + * Renders for Sundays if any are visible. Test just exercises the + * function-based path — actual presence depends on which days the + * current month surfaces. */ - const sundayInfo = container.querySelector('[data-testid="sunday-info"]'); expect(container).toBeInTheDocument(); }); diff --git a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx index d31911535..93fc6e462 100644 --- a/packages/raystack/components/calendar/__tests__/date-picker.test.tsx +++ b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx @@ -180,6 +180,44 @@ describe('DatePicker', () => { }); }); + 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(); diff --git a/packages/raystack/components/calendar/date-picker.tsx b/packages/raystack/components/calendar/date-picker.tsx index fe9a8c23c..9e9506ac9 100644 --- a/packages/raystack/components/calendar/date-picker.tsx +++ b/packages/raystack/components/calendar/date-picker.tsx @@ -64,7 +64,7 @@ export function DatePicker({ popoverProps: legacyPopoverProps, value: valueProp, defaultValue, - onSelect = () => {}, + onSelect = () => undefined, children, showCalendarIcon = true, timeZone @@ -73,9 +73,13 @@ export function DatePicker({ const inputProps = { ...legacyInputProps, ...slotProps?.input }; const calendarProps = { ...legacyCalendarProps, ...slotProps?.calendar }; const popoverProps = { ...legacyPopoverProps, ...slotProps?.popover }; - // Initial value: controlled prop > defaultValue (uncontrolled init) > today. - const [selectedDate, setSelectedDate] = useState( - valueProp ?? defaultValue ?? new Date() + /* + * 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(); @@ -85,18 +89,19 @@ export function DatePicker({ if (valueProp !== undefined) setSelectedDate(valueProp); }, [valueProp?.getTime()]); - const formattedDate = dayjs(selectedDate).format(dateFormat); + const formattedDate = selectedDate + ? dayjs(selectedDate).format(dateFormat) + : ''; 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` when set; otherwise falls back to the - * selected date. + * `calendarProps.defaultMonth`, then the selected date, then today. */ const [viewMonth, setViewMonth] = useState( - calendarProps?.defaultMonth ?? selectedDate + calendarProps?.defaultMonth ?? selectedDate ?? new Date() ); // Mirror for reading inside the outside-click callback closure. @@ -123,27 +128,33 @@ export function DatePicker({ */ useEffect(() => { if (popover.isOpen) { - setViewMonth(calendarProps?.defaultMonth ?? selectedDate); + setViewMonth(calendarProps?.defaultMonth ?? selectedDate ?? new Date()); } }, [popover.isOpen, selectedDate, calendarProps?.defaultMonth]); function closePicker() { popover.disengage(); const committedDate = selectedDateRef.current; - setInputValue(dayjs(committedDate).format(dateFormat)); + 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) onSelect(committedDate); + if (!error && committedDate) onSelect(committedDate); } - function 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); popover.disengage(); } @@ -241,8 +252,10 @@ export function DatePicker({ /* * Must stay after spread: `required` is the discriminator for * RDP's prop union, and a widened value would break the narrowing. + * `required={false}` is intentional — the picker now supports an + * unselected initial state when no value/defaultValue is passed. */ - required={true} + required={false} timeZone={timeZone} onDropdownOpen={popover.markDropdownOpen} mode='single' diff --git a/packages/raystack/components/calendar/range-picker.tsx b/packages/raystack/components/calendar/range-picker.tsx index daa09c4d6..b413af66c 100644 --- a/packages/raystack/components/calendar/range-picker.tsx +++ b/packages/raystack/components/calendar/range-picker.tsx @@ -61,7 +61,7 @@ export function RangePicker({ inputsProps: legacyInputsProps = {}, calendarProps: legacyCalendarProps, popoverProps: legacyPopoverProps, - onSelect = () => {}, + onSelect = () => undefined, value, /* * No inline default — the state machine's "first click sets `from`" branch From 3689f042cf2cc42c7da3a40d5ffe4cd174503e3a Mon Sep 17 00:00:00 2001 From: Shreyag02 Date: Fri, 22 May 2026 03:12:24 +0530 Subject: [PATCH 6/6] fix(calendar): changelog --- packages/raystack/CHANGELOG.md | 136 +++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/raystack/CHANGELOG.md b/packages/raystack/CHANGELOG.md index 70a583759..3b2d5a403 100644 --- a/packages/raystack/CHANGELOG.md +++ b/packages/raystack/CHANGELOG.md @@ -1,5 +1,141 @@ # @raystack/apsara +## 0.49.0 + +### Calendar / DatePicker / RangePicker improvements (PR #819) + +A coordinated overhaul of the three calendar surfaces — `Calendar`, +`DatePicker`, `RangePicker` — that had drifted apart on behavior, +defaults, and exposed API. 16 P0/P1 bugs fixed, a new `slotProps` +API added, and the three legacy prop names (`inputProps`, +`inputsProps`, `calendarProps`, `popoverProps`) marked +`@deprecated` for a one-release window. + +#### New features + +- **`slotProps` API** on both pickers — consolidates the per-slot + configuration into a single, consistent prop shape: + - `DatePicker`: `slotProps={{ input?, calendar?, popover? }}` + - `RangePicker`: `slotProps={{ startInput?, endInput?, calendar?, popover? }}` + - Legacy `inputProps`/`inputsProps`/`calendarProps`/`popoverProps` + still work; when both are set, `slotProps` wins. +- **`DatePicker.defaultValue`** added — pair with controlled `value` + for the standard React controlled/uncontrolled pattern. +- **Unselected initial state** on `DatePicker` — omitting both + `value` and `defaultValue` now starts the picker empty; the + "Select date" placeholder is honored. `onSelect` stays typed + `(date: Date) => void` and only fires with a defined date. +- **Public types** — `CalendarProps`, `CalendarPropsExtended`, and + `DateRange` re-exported from `@raystack/apsara`. + +#### Bug fixes + +- **DatePicker `TypeError` on every keystroke** (P0 hotfix) — + `dayjs.extend(isSameOrAfter)` and `isSameOrBefore` were missing; + bounds checks threw on each input. +- **Future dates no longer silently rejected** — the hardcoded + `isSameOrBefore(dayjs())` ceiling is gone; bounds come from + `calendarProps.startMonth` / `endMonth`. +- **`value` prop is reactive** on both pickers — form resets, preset + buttons, and URL-driven changes now propagate to the input. +- **Month navigation no longer mutates selection** on `DatePicker` — + visible month tracked separately from selected date. +- **`calendarProps` overrides respected** on both pickers — type + widened to `Omit & CalendarPropsExtended`. +- **Strict format parsing** for typed input on `DatePicker` — + single digits no longer commit "Jan 5 2001"-style V8 fallbacks. +- **`calendarProps.defaultMonth` honored** on every open. +- **`Calendar.mode` no longer forced** away from consumer overrides. +- **Popover machinery extracted** into shared `usePickerPopover` + hook — RangePicker gains the year/month dropdown carve-out plus + outside-click handling. +- **RangePicker: `{today, today}` default removed** — uncontrolled + picker now correctly shows the placeholder until the first + interaction. +- **RangePicker state machine rewritten** — branches on actual + `from`/`to` state (A/B1/B2/C) rather than which input is active; + resolves cases where the machine got stuck. +- **RangePicker controlled-mode wasted renders** eliminated — + `setInternalValue` skipped when `value` is set. +- **RangePicker `onSelect` typing** corrected to `{from?, to?}` — + matches the runtime `DateRange` shape. +- **Calendar tz-aware `dateKey`** for `tooltipMessages` / `dateInfo` + lookups — UTC-day grids in non-UTC browsers no longer miss + messages keyed at UTC midnight. +- **Calendar `onDropdownOpen` re-fire** fixed — ref-based mirror so + the effect depends only on `open`; parent callback identity churn + no longer re-fires. + +#### Code-review and audit follow-ups + +- `usePickerPopover`: document mouseup listener cleaned up on unmount. +- `usePickerPopover`: `handleInputBlur` closes immediately when the + first blur moves focus outside (keyboard-Tab path). +- `usePickerPopover`: `onOpenChange` now lets explicit close requests + (Escape, trigger toggle) through; only redundant re-opens are + suppressed. +- `DatePicker.closePicker`: emits the committed `Date` directly + instead of round-tripping through `dayjs(formattedString).toDate()` + (which could mis-parse non-ISO formats like `DD/MM/YYYY`). +- Picker trigger always renders as `
` 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