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