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..742eefc17 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}
+ />`
}
]
};
@@ -43,6 +109,24 @@ export const rangePickerDemo = {
name: 'Basic',
code: ``
},
+ {
+ name: 'Disabled',
+ code: `
+ /* Disabling either input gates the whole picker — the shared popover would otherwise let users rewrite the "disabled" side through the calendar grid. To fix one side, constrain the calendar via \`calendarProps\` instead. */
+
+ `
+ },
+ {
+ name: 'Disabled Dates',
+ code: `
+ /* Pass a matcher to \`slotProps.calendar.disabled\` to block specific dates in the grid (here: weekends). The inputs stay interactive; the calendar refuses the disabled dates. */
+
+ `
+ },
{
name: 'Without Calendar Icon',
code: ``
@@ -57,15 +141,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 +162,27 @@ export const datePickerDemo = {
tabs: [
{
name: 'Basic',
- code: ``
+ code: ``
+ },
+ {
+ name: 'Disabled',
+ code: ``
+ },
+ {
+ name: 'Disabled Dates',
+ code: `
+ /* Pass a matcher to \`slotProps.calendar.disabled\` to block specific dates in the grid (here: every date before today). The input stays interactive; the calendar refuses the disabled dates. */
+
+ `
},
{
name: 'Without Calendar Icon',
- code: ``
+ code: ``
},
{
name: 'Custom Trigger',
@@ -107,22 +203,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..2750c464e 100644
--- a/packages/raystack/CHANGELOG.md
+++ b/packages/raystack/CHANGELOG.md
@@ -1,5 +1,159 @@
# @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. 18 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.
+- **`disabled` on input now also gates the popover** on both pickers
+ — the trailing calendar icon renders as a sibling `
` to the
+ ``, so its clicks bubbled to `Popover.Trigger` and opened
+ the calendar even when the input was `disabled`. RangePicker
+ treats either input disabled as fully disabled (partial-disable
+ would let the shared range state machine rewrite the "disabled"
+ side through the grid; constrain via `calendarProps` for
+ fix-one-side use cases).
+- **`dateInfo` icons render correctly when their day is selected** —
+ the `.dayInfo svg { fill: emphasis }` rule was overriding
+ `fill="none"` on stroke-based icons (lucide) and filling the
+ outline paths solid. `color` alone now carries the selected style
+ via `currentColor` for both stroke- and fill-based icon libraries.
+
+#### 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`.
+- New **Disabled** and **Disabled Dates** tabs on both picker
+ demos. The RangePicker **Disabled** demo includes an inline
+ comment explaining the any-disabled gating rule and points at
+ `calendarProps` for partial-lock use cases.
+
+#### Tests
+
+47 new tests across 4 new files:
+- `date-picker.test.tsx` (31 tests) — slotProps, defaultValue,
+ unselected state, single-fire onSelect, strict parsing, bounds,
+ month navigation, calendarProps surface, disabled-state gating.
+- `date-picker.runtime.test.tsx` (4 tests) — mount/unmount loops
+ with `captionLayout='dropdown'`.
+- `range-picker.test.tsx` (19 tests) — slotProps, state machine
+ (A/B1/B2/C), value→currentMonth sync, calendarProps surface,
+ disabled-state gating (both-disabled and partial-disabled paths).
+- `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..0a0b5e9ad
--- /dev/null
+++ b/packages/raystack/components/calendar/__tests__/date-picker.test.tsx
@@ -0,0 +1,558 @@
+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('disabled state', () => {
+ /*
+ * The trailing CalendarIcon renders as a sibling `
` to the actual
+ * ``, so its clicks bubble to the `Popover.Trigger` wrapper even
+ * when the input is `disabled`. Without the gate inside DatePicker, the
+ * popover would open on icon click despite the disabled input.
+ */
+ function getTrailingIcon(input: HTMLElement) {
+ /*
+ * Base UI's InputPrimitive also stamps `data-disabled` on the
+ * `` itself, so a plain `[data-disabled]` selector matches
+ * the input first. The outer Input wrapper sets it to "true"; use
+ * the value form to land on the wrapper.
+ */
+ const wrapper = input.closest(
+ '[data-disabled="true"]'
+ ) as HTMLElement | null;
+ expect(wrapper).not.toBeNull();
+ const icon = wrapper!.querySelector(
+ '[aria-hidden="true"]'
+ ) as HTMLElement | null;
+ expect(icon).not.toBeNull();
+ return icon!;
+ }
+
+ it('does not open the popover when the trailing icon is clicked (legacy inputProps.disabled)', () => {
+ calendarCalls.list.length = 0;
+ render();
+
+ const input = screen.getByPlaceholderText('Select date');
+ fireEvent.click(getTrailingIcon(input));
+
+ // Popover never opened → Calendar never rendered.
+ expect(calendarCalls.list).toEqual([]);
+ });
+
+ it('does not open the popover when the trailing icon is clicked (slotProps.input.disabled)', () => {
+ calendarCalls.list.length = 0;
+ render();
+
+ const input = screen.getByPlaceholderText('Select date');
+ fireEvent.click(getTrailingIcon(input));
+
+ expect(calendarCalls.list).toEqual([]);
+ });
+
+ it('forwards disabled to the underlying input', () => {
+ render();
+ const input = screen.getByPlaceholderText(
+ 'Select date'
+ ) as HTMLInputElement;
+ expect(input.disabled).toBe(true);
+ });
+
+ /*
+ * Sanity check: without `disabled`, the same trailing-icon click path
+ * does open the popover. Guards against a future change that
+ * accidentally swallows trigger clicks for everyone.
+ */
+ it('still opens the popover via the trailing icon when not disabled', () => {
+ calendarCalls.list.length = 0;
+ render();
+
+ const input = screen.getByPlaceholderText('Select date');
+ const wrapper = input.parentElement!.parentElement as HTMLElement;
+ const icon = wrapper.querySelector('[aria-hidden="true"]') as HTMLElement;
+ fireEvent.click(icon);
+
+ expect(calendarCalls.list.length).toBeGreaterThan(0);
+ });
+ });
+
+ 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..e1a58acf4
--- /dev/null
+++ b/packages/raystack/components/calendar/__tests__/range-picker.test.tsx
@@ -0,0 +1,453 @@
+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');
+ });
+ });
+
+ describe('disabled state', () => {
+ /*
+ * Each input's trailing CalendarIcon renders as a sibling `
` to
+ * its ``, so its clicks bubble to the shared `Popover.Trigger`
+ * even when the input is `disabled`. The picker is treated as
+ * fully disabled only when **both** inputs are disabled (matches the
+ * common "disable the whole range field" usage).
+ */
+ function getTrailingIconWithin(input: HTMLElement) {
+ /*
+ * Base UI's InputPrimitive also stamps `data-disabled` on the
+ * `` itself, so a plain `[data-disabled]` selector matches
+ * the input first. The outer Input wrapper sets it to "true"; use
+ * the value form to land on the wrapper.
+ */
+ const wrapper = input.closest(
+ '[data-disabled="true"]'
+ ) as HTMLElement | null;
+ expect(wrapper).not.toBeNull();
+ const icon = wrapper!.querySelector(
+ '[aria-hidden="true"]'
+ ) as HTMLElement | null;
+ expect(icon).not.toBeNull();
+ return icon!;
+ }
+
+ it('does not open the popover when both inputs are disabled (legacy inputsProps)', () => {
+ calendarCalls.list.length = 0;
+ render(
+
+ );
+
+ const startInput = screen.getByPlaceholderText('Select start date');
+ const endInput = screen.getByPlaceholderText('Select end date');
+ fireEvent.click(getTrailingIconWithin(startInput));
+ fireEvent.click(getTrailingIconWithin(endInput));
+
+ expect(calendarCalls.list).toEqual([]);
+ });
+
+ it('does not open the popover when both inputs are disabled (slotProps)', () => {
+ calendarCalls.list.length = 0;
+ render(
+
+ );
+
+ const startInput = screen.getByPlaceholderText('Select start date');
+ const endInput = screen.getByPlaceholderText('Select end date');
+ fireEvent.click(getTrailingIconWithin(startInput));
+ fireEvent.click(getTrailingIconWithin(endInput));
+
+ expect(calendarCalls.list).toEqual([]);
+ });
+
+ /*
+ * Partial-disable (one input disabled, the other enabled) also gates
+ * the popover. Without this, the enabled input's click would open the
+ * shared popover and the calendar's range state machine would happily
+ * rewrite the "disabled" field through the grid — defeating the
+ * disabled intent. Consumers who need to fix one side and pick the
+ * other should constrain the calendar via `calendarProps`.
+ */
+ it('does not open the popover when only the start input is disabled', () => {
+ calendarCalls.list.length = 0;
+ render();
+
+ // Try every path: enabled-side click, enabled-side focus, and the
+ // trailing-icon click on the disabled side.
+ const startInput = screen.getByPlaceholderText('Select start date');
+ const endInput = screen.getByPlaceholderText('Select end date');
+ fireEvent.click(endInput);
+ fireEvent.focus(endInput);
+ fireEvent.click(getTrailingIconWithin(startInput));
+
+ expect(calendarCalls.list).toEqual([]);
+ });
+
+ it('does not open the popover when only the end input is disabled', () => {
+ calendarCalls.list.length = 0;
+ render();
+
+ const startInput = screen.getByPlaceholderText('Select start date');
+ const endInput = screen.getByPlaceholderText('Select end date');
+ fireEvent.click(startInput);
+ fireEvent.focus(startInput);
+ fireEvent.click(getTrailingIconWithin(endInput));
+
+ expect(calendarCalls.list).toEqual([]);
+ });
+
+ it('forwards disabled to both underlying inputs', () => {
+ render(
+
+ );
+ const startInput = screen.getByPlaceholderText(
+ 'Select start date'
+ ) as HTMLInputElement;
+ const endInput = screen.getByPlaceholderText(
+ 'Select end date'
+ ) as HTMLInputElement;
+ expect(startInput.disabled).toBe(true);
+ expect(endInput.disabled).toBe(true);
+ });
+ });
+});
diff --git a/packages/raystack/components/calendar/calendar.module.css b/packages/raystack/components/calendar/calendar.module.css
index 34b0d88f3..6fa3fc589 100644
--- a/packages/raystack/components/calendar/calendar.module.css
+++ b/packages/raystack/components/calendar/calendar.module.css
@@ -84,7 +84,7 @@
display: flex;
justify-content: center;
align-items: center;
- border: var(--rs-space-1) solid transparent;
+ border: 1px solid transparent;
margin-bottom: var(--rs-space-1);
}
@@ -93,7 +93,7 @@
height: var(--rs-space-10, 40px);
flex-shrink: 0;
border-radius: var(--rs-radius-5);
- border: var(--rs-space-1) solid var(--rs-color-border-accent-emphasis-hover);
+ border: 1px solid var(--rs-color-border-accent-emphasis-hover);
background-color: transparent;
}
@@ -274,11 +274,6 @@
color: var(--rs-color-foreground-base-emphasis);
}
-.selected:not(.rangeMiddle) .dayInfo svg {
- color: var(--rs-color-foreground-base-emphasis);
- fill: var(--rs-color-foreground-base-emphasis);
-}
-
.dayNumber {
display: flex;
align-items: center;
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..da311dabf 100644
--- a/packages/raystack/components/calendar/date-picker.tsx
+++ b/packages/raystack/components/calendar/date-picker.tsx
@@ -4,164 +4,200 @@ 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 };
+ /*
+ * Gate the popover when the input is disabled — the trailing icon
+ * renders as a sibling `
` to the ``, so its clicks bubble
+ * to `Popover.Trigger` even when the input itself is `disabled`.
+ */
+ const isDisabled = !!inputProps.disabled;
+ /*
+ * 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 +215,66 @@ 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..051b03202 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,89 @@ 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 };
+ /*
+ * Gate the popover whenever either input is disabled. Partial-disable
+ * leaks: the range state machine rewrites both `from` and `to` regardless
+ * of which input was clicked, and the trailing icon's click bubbles to
+ * `Popover.Trigger` even when the input is disabled. For "fix one side,
+ * pick the other", constrain the calendar via `calendarProps` instead.
+ */
+ const isDisabled = !!startInputProps.disabled || !!endInputProps.disabled;
+ /*
+ * 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 +142,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 +166,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 +221,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 +234,63 @@ 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: