diff --git a/apps/www/src/components/demo/demo-playground.tsx b/apps/www/src/components/demo/demo-playground.tsx
index ae4b8314d..d501e6329 100644
--- a/apps/www/src/components/demo/demo-playground.tsx
+++ b/apps/www/src/components/demo/demo-playground.tsx
@@ -36,7 +36,13 @@ const getInitialProps = (
const value =
(searchParams && searchParams.get(key)) ?? initialValue ?? defaultValue;
- initialProps[key] = type === 'checkbox' ? value === 'true' : value;
+ /* Only a search param arrives as a string; comparing a real boolean to 'true' unchecks it. */
+ initialProps[key] =
+ type === 'checkbox'
+ ? typeof value === 'string'
+ ? value === 'true'
+ : Boolean(value)
+ : value;
});
return initialProps;
};
diff --git a/apps/www/src/components/demo/demo.tsx b/apps/www/src/components/demo/demo.tsx
index ba84e8c1d..9a308189d 100644
--- a/apps/www/src/components/demo/demo.tsx
+++ b/apps/www/src/components/demo/demo.tsx
@@ -63,11 +63,7 @@ import { DemoProps } from './types';
export default function Demo(props: DemoProps) {
const {
data,
- // `...Apsara` carries the 32 icons Apsara publishes, so none of those needs
- // its own entry, and nothing below may repeat one of their keys, because a
- // later key shadows the spread. A demo that needs any other glyph names a
- // lucide component from the block above and sizes it at the call site,
- // which is exactly what an application does.
+ // Nothing below may repeat an Apsara icon key: a later key shadows the spread.
scope = {
...Apsara,
DataViewTableDemo,
diff --git a/apps/www/src/content/docs/components/calendar-preview/demo.ts b/apps/www/src/content/docs/components/calendar-preview/demo.ts
index d7a2fe5ff..e7b290e2a 100644
--- a/apps/www/src/content/docs/components/calendar-preview/demo.ts
+++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts
@@ -3,8 +3,6 @@
import type { ComponentPropsType } from '@/components/demo/types';
import { getPropsString } from '@/lib/utils';
-/* The grid props drive the playground rather than the root's, because they
- are what visibly changes: the root's state props need a value to show. */
export const getCode = (props: ComponentPropsType) => {
return `
@@ -46,11 +44,11 @@ export const playground = {
getCode
};
-export const preview = {
+export const calendarDemo = {
type: 'code',
tabs: [
{
- name: 'Inline',
+ name: 'Default',
code: ``
@@ -62,123 +60,207 @@ export const preview = {
`
},
{
- name: 'Month + year',
+ name: 'Monday first',
code: `
-
-
-
-
-
-
+
+
+
+ `
+ },
+ {
+ name: 'Week numbers',
+ code: `
+
+
+
+
+ `
+ },
+ {
+ name: 'Outside days',
+ code: `
+
+
+ `
}
]
};
-export const compositionDemo = {
+export const pickerDemo = {
type: 'code',
tabs: [
{
- name: 'Default header',
+ name: 'Basic',
code: `
-
+
+
+
+
+
+ `
},
{
- name: 'Custom caption',
- code: `
-
-
- Delivery date
-
-
-
-
-
-
+ name: 'Custom trigger',
+ code: `
+ } />
+
+
+ `
},
{
- name: 'With footer',
+ name: 'No icon',
code: `
-
- Dates are inclusive
+
+
+
+
+
+
+ `
+ }
+ ]
+};
+
+export const rangeDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Basic',
+ code: `
+
+
+
+
+
+
+
+
+ `
},
{
- name: 'Node footer',
- code: `
-
-
+ name: 'Read-only start',
+ code: `
+
- Beta
- Times are UTC
+
+
-
+
+
+
+ `
}
]
};
-export const resetDemo = {
+export const periodsDemo = {
type: 'code',
tabs: [
{
- name: 'Reset',
+ name: 'All scales',
code: `
-
+ `
},
{
- name: 'Nothing to restore',
+ name: 'In a popover',
code: `
-
+
+
+
+ `
},
{
- name: 'Range',
+ name: 'Year range',
code: `
-
+ `
},
{
- name: 'Clear the selection',
- code: `
-
+ name: 'Month',
+ code: `
+ `
},
{
- name: 'No defaultDate',
- code: `
-
+ name: 'Quarter',
+ code: `
+ `
+ },
+ {
+ name: 'Trailing value',
+ code: `function CalendarPreviewTrailingExample() {
+ const scales = ['day', 'month', 'quarter', 'halfYear', 'year'];
+ const [start, setStart] = React.useState({ date: '2026-07-01', scale: 'quarter' });
+ const [end, setEnd] = React.useState({ date: '2026-09-30', scale: 'quarter' });
+
+ return (
+
+
+
+ }
+ nativeButton
+ placeholder="Add start date"
+ />
+
+
+
+
+
+ →
+
+
+ }
+ nativeButton
+ placeholder="Add end date"
+ />
+
+
+
+
+
+
+
+ Emitted: {start.date} → {end.date}
+
+
+ );
+}`
}
]
};
-export const boundsDemo = {
+export const limitsDemo = {
type: 'code',
tabs: [
{
@@ -191,7 +273,7 @@ export const boundsDemo = {
`
},
{
- name: 'Min and max',
+ name: 'Min/max',
code: ``
},
{
- name: 'Read only',
+ name: 'Bounded periods',
code: `
-
+ `
}
]
};
-export const gridDemo = {
+export const statesDemo = {
type: 'code',
tabs: [
- {
- name: 'Outside days',
- code: `
-
-
-
-
- `
- },
- {
- name: 'Week numbers',
- code: `
-
-
-
-
- `
- },
- {
- name: 'Monday first',
- code: `
-
-
-
-
- `
- },
- {
- name: 'Loading',
- code: `
-
-
-
-
- `
- }
- ]
-};
-
-export const dateInfoDemo = {
- type: 'code',
- tabs: [
- {
- name: 'Date info',
- code: `
-
-
-
- date.getDate() % 7 === 0 ? (
- $
- ) : null
- }
- />
-
- `
- },
- {
- name: 'Tooltips',
- code: `
-
-
-
- date.getDay() === 0 ? 'Weekend rate applies' : null
- }
- />
-
- `
- }
- ]
-};
-
-export const pickerDemo = {
- type: 'code',
- tabs: [
- {
- name: 'Basic',
- code: `
-
-
-
-
-
-
- `
- },
{
name: 'Disabled',
code: `
@@ -325,104 +320,47 @@ export const pickerDemo = {
`
},
{
- name: 'Disabled dates',
+ name: 'Read only',
code: ` date.getDay() === 0 || date.getDay() === 6}
+ defaultValue={new Date(2024, 3, 17)}
+ readOnly
>
-
-
-
-
-
-
+ `
},
{
- name: 'Without calendar icon',
+ name: 'Loading',
code: `
-
-
-
-
-
-
- `
- },
- {
- name: 'With Field',
- code: `
-
-
-
-
-
-
-
-
- `
- },
- {
- name: 'Reset',
- code: `
-
-
-
-
-
-
+
+
+
+ `
- },
+ }
+ ]
+};
+
+export const validationDemo = {
+ type: 'code',
+ tabs: [
{
name: 'Invalid input',
- code: `
-function CalendarPreviewInvalidExample() {
- const [defaultError, setDefaultError] = React.useState();
- const [customError, setCustomError] = React.useState();
-
- const bounds = {
- defaultMonth: new Date(2024, 3, 1),
- minDate: new Date(2024, 3, 1),
- maxDate: new Date(2024, 3, 30)
- };
+ code: `function CalendarPreviewInvalidExample() {
+ const [error, setError] = React.useState();
return (
-
-
-
+
+
+ setDefaultError(message)}
- />
-
-
-
-
-
-
-
-
-
-
- setCustomError(message)}
+ errorMessages={{ unparseable: 'Use DD MMM YYYY' }}
+ onValidityChange={({ message }) => setError(message)}
/>
@@ -435,202 +373,175 @@ function CalendarPreviewInvalidExample() {
}`
},
{
- name: 'Custom trigger',
+ name: 'Custom messages',
code: `
- } />
+
+
+ `
+ },
+ {
+ name: 'With Field',
+ code: `
+
+
+
+
+
+
+
+
+
+
+ `
}
]
};
-export const rangeDemo = {
+export const resetDemo = {
type: 'code',
tabs: [
{
- name: 'Basic',
- code: `
-
-
-
-
-
-
-
-
-
- `
- },
- {
- name: 'Disabled',
- code: `
-
-
-
-
-
-
-
-
-
+ name: 'Reset to date',
+ code: `
+ `
},
{
- name: 'Disabled dates',
+ name: 'Reset range',
code: ` date.getDay() === 0 || date.getDay() === 6}
+ defaultDate={{ from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) }}
+ defaultValue={{ from: new Date(2024, 3, 3), to: new Date(2024, 3, 7) }}
>
-
-
-
-
-
-
-
-
-
+ `
},
{
- name: 'Without calendar icon',
- code: `
-
-
-
-
-
-
-
-
-
+ name: 'Clear',
+ code: `
+ `
},
{
- name: 'Read-only start',
+ name: 'Nothing to restore',
code: `
-
-
-
-
-
-
-
-
-
+ `
},
{
- name: 'Reset',
+ name: 'No defaultDate',
code: `
-
+
+ `
+ }
+ ]
+};
+
+export const customisingDemo = {
+ type: 'code',
+ tabs: [
+ {
+ name: 'Caption',
+ code: `
+
+
+ Delivery date
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Month/year dropdown',
+ code: `
+
+
+
+
+
+
+
+
+ `
+ },
+ {
+ name: 'Footer',
+ code: `
+
+ Dates are inclusive
+ `
+ },
+ {
+ name: 'Node footer',
+ code: `
+
+
-
-
+ Beta
+ Times are UTC
-
-
-
-
+ `
},
{
- name: 'Invalid input',
- code: `
-function CalendarPreviewRangeInvalidExample() {
- const [defaultError, setDefaultError] = React.useState();
- const [customError, setCustomError] = React.useState();
-
- const range = {
- selection: 'range',
- defaultMonth: new Date(2024, 3, 1),
- defaultValue: { from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) }
- };
-
- return (
-
-
-
-
-
- setDefaultError(message)}
- />
- setDefaultError(message)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- setCustomError(message)}
- />
- setCustomError(message)}
- />
-
-
-
-
-
-
-
-
- );
-}`
+ name: 'Date info',
+ code: `
+
+
+
+ date.getDate() % 7 === 0 ? (
+ $
+ ) : null
+ }
+ />
+
+ `
},
{
- name: 'Custom trigger',
- code: `
- }>
- 10 Apr – 20 Apr
-
-
-
-
+ name: 'Tooltips',
+ code: `
+
+
+
+ date.getDay() === 0 ? 'Weekend rate applies' : null
+ }
+ />
+ `
}
]
diff --git a/apps/www/src/content/docs/components/calendar-preview/index.mdx b/apps/www/src/content/docs/components/calendar-preview/index.mdx
index 2199cff04..aa5226cfa 100644
--- a/apps/www/src/content/docs/components/calendar-preview/index.mdx
+++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx
@@ -5,155 +5,323 @@ source: packages/raystack/components/calendar-preview
---
import {
- preview,
playground,
- compositionDemo,
- resetDemo,
- boundsDemo,
- gridDemo,
- dateInfoDemo,
+ calendarDemo,
pickerDemo,
rangeDemo,
+ periodsDemo,
+ limitsDemo,
+ statesDemo,
+ validationDemo,
+ resetDemo,
+ customisingDemo,
} from "./demo.ts";
-
-
-
- Later RFC 005 phases may still reshape props, slots and `useCalendar()`'s
- return, so pin an exact version if you adopt it now. `Calendar` and
- `DatePicker` remain the supported choice.
-
-
-## Playground
+A calendar that owns its selection and view state, composed from parts you mount only as deep as you need.
-## Anatomy
-
-Every part renders its own default, so composition is opt-in depth:
-
```tsx
import { CalendarPreview } from '@raystack/apsara'
+```
+
+## Usage
+```tsx
```
-Expanded, the day view is a header and a grid:
+## Examples
+
+### Calendar
+
+The day view. Every layout option lives on `.Grid`, so two grids can differ.
+
+
+
+### Date picker
+
+A trigger wrapping an `.Input`, with the day view in a popover.
+
+
+
+### Range picker
+
+`selection="range"` turns clicks into endpoints. Give each `.Input` a `field`.
+
+
+
+### Time periods
+
+`scales` selects at granularities coarser than a day.
+
+
+
+### Limits
+
+`minDate`, `maxDate` and `isDateUnavailable` disable cells.
+
+
+ None of them clamps navigation — the chevrons and the scroller still reach any month. `isDateUnavailable` is day scale only; period cells are bounded by `minDate` and `maxDate` instead.
+
+
+
+
+### States
+
+`disabled` makes the whole calendar inert; `readOnly` keeps it focusable.
+
+
+
+### Validation
+
+Typed dates are checked on every keystroke, and a date that fails is never committed.
+
+
+ Blurring or pressing Enter on a date that does not resolve leaves the typed text in the field. The field stays marked invalid but now shows something other than the committed value — read the value from `onValueChange`, never from the input's text.
+
+
+
+
+### Reset
+
+Restores `defaultDate`. Stays visible but disabled when there is nothing to restore.
+
+
+ `defaultDate={null}` clears the selection and reports `reason: 'clear'`; omitting it hides the button.
+
+
+
+
+### Customising
+
+Children replace the content a part computes from context.
+
+
+
+## Anatomy
```tsx
-
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
```
-`.Grid` renders the day cells itself and takes no children. `.Day` and `.Weekday` are
-not written inside it — they are overrides, passed through `components`:
+`.Reset` appears twice because exactly one of the two renders: `.Header` holds it at day scale, `.Body` at every coarser scale, where there is no `.Header`.
+
+Every part renders its own default, so none of this is required. `.Grid` renders the day cells itself and takes no children — `.Day` and `.Weekday` are overrides passed through `components`:
```tsx
```
-Children override the content a part computes from context, so
-`Q3 2024` replaces the month label.
-
-## API Reference
+## API reference
### CalendarPreview
-The root. Owns the selected value and the visible month, provides both to every part, and renders a column that hugs its content. Also takes `render`, `className` and `ref`.
+The root. Owns the selected value and the visible month. Also takes `render`, `className` and `ref`.
-### CalendarPreview.Days
+### CalendarPreview.Trigger
-The day view — a header and a grid. Hugs its content rather than reserving a fixed height.
+Anchors the popover and owns opening it. Renders the formatted value, or the placeholder, when given no children.
-
+
-### CalendarPreview.Caption
+### CalendarPreview.Content
-The month label above the grid, and optionally the trigger for the month and year scroller.
+The portaled popover surface. Takes `Popover.Content` props — `side`, `align`, `sideOffset` — and flips above the trigger on collision.
-
+### CalendarPreview.Input
+
+The typeable date field.
+
+
+
+### CalendarPreview.Body
+
+The popup body: label, input, scale switcher and the view for the active scale.
+
+
+
+### CalendarPreview.Days
+
+The day view — a header and a grid.
+
+
### CalendarPreview.Grid
-The day grid. Layout and per-day data live here rather than on the root, so a calendar with two grids can configure them independently.
+The day grid. Layout and per-day data live here rather than on the root.
### CalendarPreview.Header
-The row above the grid. Composes `.Caption`, `.Reset`, `.PrevMonth` and `.NextMonth` when given no children. Takes `render`, `className` and `ref`.
+The row above the grid. Composes `.Caption`, `.Reset`, `.PrevMonth` and `.NextMonth` when given no children.
+### CalendarPreview.Caption
+
+The month label above the grid, and optionally the trigger for the month and year scroller.
+
+
+
### CalendarPreview.PrevMonth / CalendarPreview.NextMonth
-Step the view one month. Never disabled by `minDate` or `maxDate` — bounds limit selection, not navigation.
+Step the view one month.
### CalendarPreview.Reset
-Restores `defaultDate`, reporting `reason: 'reset'`. Rendered whenever `defaultDate`
-is set, and disabled once the value already equals it — it stays mounted rather than
-disappearing, so activating it does not send focus to the page body or shift the nav
-buttons sideways. It carries `data-restored` while there is nothing to restore.
+Restores `defaultDate`, reporting `reason: 'reset'`. Carries `data-restored` while there is nothing to restore.
-### CalendarPreview.Trigger
+### CalendarPreview.Scales / CalendarPreview.Scale
-Anchors the popover and owns opening it. Renders the formatted value, or the placeholder, when given no children — wrap an `.Input` in it for a typeable field. Never renders a `button`, so the control inside stays focusable. Takes `render`, `className` and `ref`.
+The scale switcher, built on Apsara `Tabs`. Renders nothing when only one scale is offered. `.Scale` is only needed to relabel or reorder.
-### CalendarPreview.Content
+
-The portaled popover surface. Takes `Popover.Content` props — `side`, `align`, `sideOffset` and the rest — and flips above the trigger on collision.
+### CalendarPreview.Panel
-### CalendarPreview.Input
+The view container. Mounts all five views; each gates on the active scale itself.
-
+### CalendarPreview.Months / .Quarters / .HalfYears / .Years
+
+Year-grouped period lists at 3, 4, 2 and 1 columns, opening on the active year.
+
+### CalendarPreview.Label / CalendarPreview.Separator
+
+The field label above the input, and the rule between the switcher and the view. `.Label` renders nothing without children.
### CalendarPreview.Footer
The row below the calendar. A bare string is wrapped in `Text`; anything else renders as given.
-It needs no container of its own: the root renders a column that hugs its content, so `.Days` and `.Footer` stack whatever the surrounding layout does.
-
### useCalendar
-Reads the enclosing root's state, for building parts the library does not ship. Deliberately narrow:
+Reads the enclosing root's state, for building parts the library does not ship. Throws outside a `CalendarPreview`, naming the part that asked.
```tsx
import { useCalendar } from '@raystack/apsara'
-const { value, setValue, scale, month, setMonth, isDateUnavailable } = useCalendar()
+const { value, setValue, scale, draft, scaleDraft, month, setMonth, isDateUnavailable } =
+ useCalendar()
```
-Calling it outside a `CalendarPreview` throws, naming the part that asked. `scale` is
-read-only for now — the setter arrives with the scale switcher in a later phase.
+
-`setValue(null)` clears the selection and reports `reason: 'clear'`, carrying the day
-that was cleared as `details.toDate()`.
+#### details (onValueChange)
-
-### Slots
+## Behaviour
+
+### Value shape
+
+| `scales` | `value` |
+|---|---|
+| omitted, or `'day'` | `Date` |
+| any other scale, or any array | `ScaleValue` |
+
+```ts
+interface ScaleValue { date: 'YYYY-MM-DD'; scale: Scale }
+```
+
+`date` is stored as `YYYY-MM-DD` so lexicographic order is chronological order. It is never what you see — every trigger, input and annotation renders through `formatValue`.
+
+### Drafting
+
+| Action | Result |
+|---|---|
+| Switch scale | moves the view, sets a draft, emits nothing |
+| Click a cell, or press Enter | commits the draft |
+| Escape | drops the draft, restores the input from `value` |
+| Range, one endpoint | stays internal; the grid styles the track from it |
+
+### Range clicks
+
+| State | A click does |
+|---|---|
+| Nothing selected | sets `from`, moves focus to the end field |
+| `from` only, later day | completes the range and emits |
+| `from` only, earlier day | that day becomes the new `from` |
+| Complete range | restarts — the new day is `from` |
+
+`onValueChange` fires on a complete range or not at all. Typing is stricter than clicking: an endpoint that crosses its partner is rejected as `out-of-order` rather than restarting.
+
+
+ A read-only endpoint with no value makes the range unsatisfiable — the free endpoint sets, the range never completes, and nothing emits. Give a read-only endpoint a value.
+
+
+### Validation reasons
+
+| `reason` | Means |
+|---|---|
+| `unparseable` | The text is not a date the input could read at all |
+| `out-of-bounds` | A real date, outside `minDate` / `maxDate` |
+| `unavailable` | A real date in range that `isDateUnavailable` rejected |
+| `out-of-order` | Range only — the endpoint crossed its partner |
+
+`onValidityChange` fires only when validity changes, and carries a ready-to-render `message` — `undefined` while valid, which is what [Field](/docs/components/field)'s `error` wants. Override per reason with `errorMessages`.
+
+### trailingValue
+
+Emits a period's last day rather than its first, and is month-end correct. It changes the value, not the formatting. Availability tests the date a period would produce, here with `minDate={15 Jul 2026}`:
+
+| Period | A start field emits | An end field emits | Start | End |
+|---|---|---|---|---|
+| H1 2026 | 1 Jan | 30 Jun | disabled | disabled |
+| July 2026 | 1 Jul | 31 Jul | disabled | available |
+| Q3 2026 | 1 Jul | 30 Sep | disabled | available |
+
+A start/end pair is two roots, not `selection="range"` — each end has its own `scales` and `trailingValue`, and they can hold different scales.
+
+## Styling
+
+
+Slots and state attributes
Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot):
@@ -172,6 +340,7 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| `calendar-preview-caption-popup` | The month and year scroller (when `dropdown` is open) |
| `calendar-preview-caption-months` | The month column of the scroller |
| `calendar-preview-caption-month` | One month in the scroller |
+| `calendar-preview-caption-divider` | The rule between the scroller's two columns |
| `calendar-preview-caption-years` | The year column of the scroller |
| `calendar-preview-caption-year` | One year in the scroller |
| `calendar-preview-reset` | The reset button |
@@ -189,10 +358,20 @@ Every rendered part carries a stable `data-slot` attribute for [styling and test
| `calendar-preview-day-number` | The day number inside a day button |
| `calendar-preview-day-info` | Content above the number (when `dateInfo` resolves) |
| `calendar-preview-day-tooltip` | The tooltip shown on hover |
+| `calendar-preview-body` | The popup body |
+| `calendar-preview-label` | The field label |
+| `calendar-preview-scales` | The scale switcher |
+| `calendar-preview-scale` | One scale chip |
+| `calendar-preview-separator` | The rule below the switcher |
+| `calendar-preview-panel` | The view container |
+| `calendar-preview-months` / `-quarters` / `-half-years` / `-years` | One period list |
+| `calendar-preview-period-group` | One year's block inside a period list |
+| `calendar-preview-period-year` | The year heading, on every view but `.Years` |
+| `calendar-preview-period` | One period cell |
| `calendar-preview-footer` | The footer row |
| `calendar-preview-footer-text` | The `Text` wrapping a string footer |
-Day cells also carry their state, so a stylesheet can target it without a class:
+Day cells also carry their state:
| Attribute | Set when |
|------|------|
@@ -203,197 +382,32 @@ Day cells also carry their state, so a stylesheet can target it without a class:
| `data-outside` | The day belongs to an adjacent month |
| `data-scale` | The granularity the value is committed at |
-## Examples
-
-### Composition
-
-Each part renders a default; children replace it.
-
-
-
-### Reset
-
-`.Reset` restores `defaultDate` and **leaves the visible month alone** — it is a value reset, not a view reset. It renders whenever `defaultDate` is set, and goes disabled once there is nothing left to restore rather than unmounting: removing the focused element would strand a keyboard user, and dropping a child from the header would shift both nav buttons sideways every time the value crossed the default.
-
-`defaultDate` follows the selection. At `selection="range"` it takes a range, and both edges have to match before the button counts as restored:
-
-```tsx
-
-```
-
-`defaultDate` is a separate prop from `defaultValue` because `defaultValue` is ignored once `value` is passed. Keying the reset off its own prop is what makes it work for a controlled calendar.
-
-`defaultDate={null}` is a default of **nothing selected**, so the button clears the day and reports `reason: 'clear'`. Omitting the prop is the different case: the part has no job and renders nothing.
-
-
-
-### Selection bounds
-
-`minDate`, `maxDate` and `isDateUnavailable` disable cells. **None of them clamps navigation** — the chevrons and the scroller still reach any month. Bounds compare whole calendar days, so a `minDate` carrying a time of day still leaves its own day selectable.
-
-
-
-### Grid layout
-
-Outside days are **off by default**, so a grid ends on the last day of its month with the leading cells blank.
-
-
-
-### Date information and tooltips
-
-`dateInfo` and `tooltipMessages` are functions of the date, not records keyed by a formatted string. `dateInfo` content renders above the day number; today's dot sits below it, so the two never collide.
-
-
-
-### Month and year scroller
-
-`` turns the caption into a filled chip that opens two adjacent scrolling columns. It is a plain popover of buttons, not a `Select` — picking from either column moves the view and never selects a value.
-
-### Date picker
-
-The date picker is not a separate export — it is this composition:
-
-```tsx
-
-
-
-
-
-
-
-
-```
-
-The popover opens when the input takes focus. Enter, blur and an outside click all commit — there is no Apply button. Dismissal is Base UI's, so escape and outside press behave like every other popover in the library.
-
-"Without calendar icon" is composition rather than a prop: pass `trailingIcon={null}` to `.Input`.
-
-
-
-### Range selection
-
-`selection="range"` turns clicks into endpoints. Give each `.Input` a `field`:
-
-```tsx
-
-
-
-
-
-
-
-
-
-```
-
-**`onValueChange` fires on a complete range or not at all.** `to` is not nullable, so there is no partial `{ from?, to? }` to gate on. The half-built range stays internal — the grid styles the track from it, but nothing is emitted until the second endpoint lands.
-
-The click machine:
-
-| State | A click does |
-|---|---|
-| Nothing selected | sets `from`, moves focus to the end field |
-| `from` only, later day | completes the range, emits, closes the popover |
-| `from` only, earlier day | that day becomes the new `from` |
-| Complete range | restarts — the new day is `from`, and the value stays at the previous range until the new one completes |
-
-Completing asks the popover to close through `onOpenChange`, so a consumer holding `open` open is not fought.
-
-**Typing is stricter than clicking.** A click means "the next endpoint", so an earlier day restarts
-the range, as the table above says. Typing names the field it lands in, so an endpoint that crosses
-its partner is rejected instead: `onValidityChange` reports `out-of-order`, the field goes red, and
-nothing is emitted. Two endpoints on the same day are a valid range.
-
-```tsx
- setError(message)}
-/>
-```
-
-Instead of a `lock` prop, mark one endpoint's `.Input` as `readOnly` — the grid will not rewrite it. **A read-only endpoint with no value makes the range unsatisfiable:** the free endpoint sets, the range never completes, and nothing emits. Give a read-only endpoint a value.
-
-
-### Invalid typed dates
-
-Typing is checked on every keystroke, and a date that fails is **never committed** — `onValueChange`
-does not fire and the previous value stands.
-
-`.Input` marks itself `aria-invalid` and `data-invalid`, and `data-invalid` is what
-[Input](/docs/components/input) paints its error border from, so the field turns red on its own with
-nothing wired up.
-
-`onValidityChange` carries a ready-to-render `message`, so a message under the field is one line —
-it is `undefined` while valid, which is exactly what [Field](/docs/components/field)'s `error` wants:
-
-```tsx
-
-
-
- setError(message)} />
-
-
-
-
-
-
-```
-
-The default is a flat **"Invalid input"** for most reasons. It stays deliberately vague because only
-you know the field's bounds — the component cannot say *which* dates would be accepted without
-inventing wording it has no basis for.
+
-`out-of-order` is the exception, and gets a real default: it needs no knowledge of your bounds, only
-of which endpoint was typed.
-
-Override it with `errorMessages`, per reason. Anything left out keeps the default, so wording one
-reason does not mean restating the rest:
+## Accessibility
-```tsx
- setError(message)}
-/>
-```
+- Arrow keys move between days; the focused cell carries `data-draft` until it is committed
+- `readOnly` is conveyed with `aria-readonly` on the grid and `aria-disabled` on each day, and the grid stays focusable and arrow-navigable — unlike `disabled`
+- Each grid is labelled with its month, so the caption is not the only announcement
+- Nav buttons carry `aria-label`, and the scroller's columns are labelled groups
+- Selected and unavailable days are announced through their native button state
-The reason is also on the payload if you would rather branch on it yourself:
+## Notes
-| `reason` | Means |
-|----------|-------|
-| `unparseable` | The text is not a date the input could read at all |
-| `out-of-bounds` | A real date, outside `minDate` / `maxDate` |
-| `unavailable` | A real date in range that `isDateUnavailable` rejected |
-| `out-of-order` | Range only — the endpoint crossed its partner |
+**Performance.** `dateInfo`, `tooltipMessages` and `isDateUnavailable` are functions, so an inline arrow re-renders every day cell. Wrap them in `useCallback` or hoist them out.
-It fires only when validity *changes*, not on every keystroke, so it is safe to drive state with.
-
-
- Blurring or pressing Enter on a date that does not resolve leaves the typed text in the field
- rather than discarding what was typed. The field stays marked invalid, but it now shows something
- other than the committed value — so read the value from `onValueChange`, never from the input's
- text.
-
+**Localization.** English only — month and weekday names come from date-fns' `en-US`, and the nav, reset and caption labels are hardcoded. `timeZone` is unaffected.
## Migrating from Calendar
-`CalendarPreview` is not a drop-in replacement. Two props keep their names and change
-their meaning, so they are the ones to check first — neither produces a type error in
-every case, and both fail quietly.
+Not a drop-in replacement. Two props keep their names and change their meaning:
| Prop | On `Calendar` | On `CalendarPreview` |
|------|---------------|----------------------|
-| `disabled` | A day matcher — `disabled={{ before: today }}` blocks those days | A boolean that makes the **whole calendar** inert. Use `isDateUnavailable` or `minDate` / `maxDate` for days |
+| `disabled` | A day matcher | A boolean that makes the whole calendar inert. Use `isDateUnavailable` or `minDate` / `maxDate` for days |
| `showOutsideDays` | Defaults to `true` | Defaults to `false` |
-The rest are renames. Most follow the repo's conventions (`onValueChange`, `loading`, a
-boolean `disabled`), which is why the names moved rather than the behaviour:
+The rest are renames:
| `Calendar` | `CalendarPreview` |
|-----------|-------------------|
@@ -407,12 +421,7 @@ boolean `disabled`), which is why the names moved rather than the behaviour:
| `footer` prop | `` part |
| `captionLayout="dropdown"` | `` |
-Two things have **no replacement** yet: the record forms of `dateInfo` and
-`tooltipMessages` (both are functions here), and the `classNames` escape hatch — style
-through the `data-slot` attributes in the table above instead.
-
-Slot names changed too, so a stylesheet written against `Calendar` needs a second set of
-selectors rather than an edit:
+Slot names changed too:
| `Calendar` slot | `CalendarPreview` slot |
|-----------------|------------------------|
@@ -421,25 +430,4 @@ selectors rather than an edit:
| `calendar-month-grid` | `calendar-preview-weeks` |
| `calendar-nav-previous` | `calendar-preview-prev-month` |
-## Performance
-
-`dateInfo`, `tooltipMessages` and `isDateUnavailable` are functions rather than records,
-so the grid cannot tell a changed rule from a re-created one. Passing an inline arrow
-re-renders every day cell on every render of the surrounding component. Wrap them in
-`useCallback`, or hoist them out of the component, whenever the calendar is inside
-anything that re-renders often.
-
-## Localization
-
-English only for now. There is no `locale` prop: month and weekday names come from
-date-fns' default `en-US`, and the nav, reset and caption labels are hardcoded strings.
-`timeZone` is unaffected — a calendar can render in any zone, in English. Localization
-is tracked against RFC 005 rather than patched in per-part.
-
-## Accessibility
-
-- Arrow keys move between days; the focused cell carries `data-draft` until it is committed
-- `readOnly` is conveyed with `aria-readonly` on the grid and `aria-disabled` on each day, and the grid stays focusable and arrow-navigable — unlike `disabled`
-- Each grid is labelled with its month, so the caption is not the only announcement
-- Nav buttons carry `aria-label`, and the scroller's columns are labelled groups
-- Selected and unavailable days are announced through their native button state
+Two things have no replacement: the record forms of `dateInfo` and `tooltipMessages` (both are functions here), and the `classNames` escape hatch — style through `data-slot` instead.
diff --git a/apps/www/src/content/docs/components/calendar-preview/props.ts b/apps/www/src/content/docs/components/calendar-preview/props.ts
index 57fdcdda5..ca71b6958 100644
--- a/apps/www/src/content/docs/components/calendar-preview/props.ts
+++ b/apps/www/src/content/docs/components/calendar-preview/props.ts
@@ -1,26 +1,68 @@
import { ReactNode } from 'react';
+type Scale = 'day' | 'month' | 'quarter' | 'halfYear' | 'year';
+
export interface CalendarPreviewProps {
- /** The selected day (controlled). */
- value?: Date | null;
+ /**
+ * Whether the grid picks one day or a span. A range is two edges or nothing;
+ * the half-built state stays internal.
+ * @default "single"
+ */
+ selection?: 'single' | 'range';
- /** The initially selected day (uncontrolled). */
- defaultValue?: Date | null;
+ /**
+ * The selected value (controlled). Its shape follows the root: a `Date` by
+ * default, `{ from, to }` at `selection="range"`, and `{ date, scale }` once
+ * `scales` offers anything beyond `"day"` — `date` is a timeless
+ * `"YYYY-MM-DD"`.
+ */
+ value?:
+ | Date
+ | { from: Date; to: Date }
+ | { date: string; scale: Scale }
+ | null;
+
+ /** The initial value (uncontrolled). Same shape as `value`. */
+ defaultValue?:
+ | Date
+ | { from: Date; to: Date }
+ | { date: string; scale: Scale }
+ | null;
/**
- * Called when a day is committed or cleared. `details.toDate()` returns the
- * day acted on even when `value` is `null`.
+ * Called when a value is committed or cleared, with the same shape as
+ * `value`. `details.toDate()` returns the day acted on even when the value is
+ * `null`. A range fires on a complete range or not at all.
* @example onValueChange={(value, details) => console.log(details.reason)}
*/
onValueChange?: (
- value: Date | null,
+ value:
+ | Date
+ | { from: Date; to: Date }
+ | { date: string; scale: Scale }
+ | null,
details: {
- reason: 'select' | 'input' | 'clear' | 'scale';
+ reason: 'select' | 'input' | 'clear' | 'reset' | 'scale';
period: { start: string; end: string };
toDate: () => Date;
}
) => void;
+ /** Whether the popover is open (controlled). Ignored by an inline calendar. */
+ open?: boolean;
+
+ /** @default false */
+ defaultOpen?: boolean;
+
+ /**
+ * Called when the popover opens or closes. `details` is Base UI's own,
+ * forwarded unchanged, so `details.reason` stays the union it narrows on.
+ */
+ onOpenChange?: (
+ open: boolean,
+ details: { reason?: string; event?: Event }
+ ) => void;
+
/** The first month the grid displays (controlled). */
month?: Date;
@@ -34,22 +76,28 @@ export interface CalendarPreviewProps {
onMonthChange?: (month: Date) => void;
/**
- * The years the caption's year column offers.
+ * The years the period views and the caption's year column offer. Passing it
+ * replaces the default, so a bound outside it stays unreachable.
* Defaults to ten years either side of `today`, widened to cover any bound.
*/
yearRange?: { from: number; to: number };
/**
- * Earliest selectable day, inclusive. Never clamps navigation.
+ * Earliest selectable day, inclusive. Never clamps navigation. A period is
+ * tested against the day it would emit, so `trailingValue` moves the answer:
+ * bounded at 15 July, Q3 is rejected for a start field and allowed for an end
+ * field.
* @example minDate={new Date(2024, 3, 17)}
*/
minDate?: Date;
- /** Latest selectable day, inclusive. Never clamps navigation. */
+ /** Latest selectable day, inclusive. Tested as `minDate` is. */
maxDate?: Date;
/**
- * Reject individual days, on top of `minDate` / `maxDate`.
+ * Reject individual days, on top of `minDate` / `maxDate`. Day scale only —
+ * period cells never call it, and are bounded by `minDate` / `maxDate`
+ * against the day they would emit.
* @example isDateUnavailable={date => date.getDay() === 0}
*/
isDateUnavailable?: (date: Date) => boolean;
@@ -57,10 +105,60 @@ export interface CalendarPreviewProps {
/**
* The day `.Reset` restores. Read even when `value` is controlled, which
* `defaultValue` is not. `null` is a default of nothing selected, so
- * `.Reset` clears; omitting the prop renders no button at all. Takes a
- * range at `selection="range"`.
+ * `.Reset` clears; omitting the prop renders no button at all. It follows
+ * the selection: a range at `selection="range"`, a period at a coarser
+ * scale.
*/
- defaultDate?: Date | { from: Date; to: Date } | null;
+ defaultDate?:
+ | Date
+ | { from: Date; to: Date }
+ | { date: string; scale: Scale }
+ | null;
+
+ /**
+ * Renders a value for display — every trigger, input and annotation goes
+ * through it. The root passes `timeZone` through as the third argument, so a
+ * formatter that reads calendar fields off the `Date` must use it or it will
+ * render the neighbouring day. Defaults to `DD MMM YYYY` at day scale, and
+ * the period's own shorthand above it.
+ * @example formatValue={(value, scale, timeZone) => format(value, timeZone)}
+ */
+ formatValue?: (
+ value: Date | { date: string; scale: Scale },
+ scale: Scale,
+ timeZone?: string
+ ) => string;
+
+ /**
+ * The granularities this root offers. One entry hides the switcher; anything
+ * beyond `"day"` moves the value to `{ date, scale }`.
+ *
+ * The array form takes the scale-aware arm whatever it holds, so
+ * `scales={['day']}` types the value as `{ date, scale }` while the bare
+ * string `scales="day"` keeps it a `Date`. TypeScript cannot read an array's
+ * contents, so the two spellings of a day-only calendar are not equivalent —
+ * pass the string unless you want the period shape.
+ * @default "day"
+ * @example scales={['day', 'month', 'quarter']}
+ */
+ scales?: Scale | Scale[];
+
+ /** The scale the picker opens on. Defaults to the first of `scales`. */
+ defaultScale?: Scale;
+
+ /** The active scale (controlled). */
+ scale?: Scale;
+
+ /** Called when the switcher moves. */
+ onScaleChange?: (scale: Scale) => void;
+
+ /**
+ * Whether a period emits its last day rather than its first — an end field
+ * wants 31 July from "July 2026", a start field wants the 1st. It changes the
+ * value, not the formatting.
+ * @default false
+ */
+ trailingValue?: boolean;
/**
* The zone the grid reads days in. Forwarded to the grid; the component does
@@ -78,7 +176,8 @@ export interface CalendarPreviewProps {
today?: Date;
/**
- * Whether clicking the selected day deselects it.
+ * Whether clicking the selected day deselects it. Day scale only — clicking
+ * an already-selected period re-commits it rather than clearing.
* @default true
*/
clearable?: boolean;
@@ -178,6 +277,25 @@ export interface CalendarPreviewNavProps {
className?: string;
}
+export interface CalendarPreviewBodyProps {
+ /** The field label, passed to `.Label`. Omitted, no label renders. */
+ label?: ReactNode;
+
+ /**
+ * Whether the field carries the calendar glyph.
+ * @default false
+ */
+ showIcon?: boolean;
+}
+
+export interface CalendarPreviewScaleProps {
+ /** Which scale this chip selects. Required — a chip addresses one scale. */
+ value: Scale;
+
+ /** Merged with the part's own classes. */
+ className?: string;
+}
+
export interface CalendarPreviewFooterProps {
/** Merged with the part's own classes. */
className?: string;
@@ -190,15 +308,37 @@ export interface CalendarPreviewResetProps {
/** What the enclosing root exposes to a custom part. */
export interface UseCalendarReturn {
- /** The committed day, or null. */
- value: Date | null;
-
- /** Commit a day, or clear with `null`. Emits `onValueChange`. */
- setValue: (value: Date | null) => void;
+ /**
+ * The committed value, or null. A day, a range at `selection="range"`, or a
+ * period at a coarser scale — whichever shape this root holds.
+ */
+ value:
+ | Date
+ | { from: Date; to: Date }
+ | { date: string; scale: Scale }
+ | null;
+
+ /** Commit a value, or clear with `null`. Emits `onValueChange`. */
+ setValue: (
+ value:
+ | Date
+ | { from: Date; to: Date }
+ | { date: string; scale: Scale }
+ | null
+ ) => void;
- /** The granularity the value is committed at. Read-only until phase 5. */
+ /**
+ * The granularity the value is committed at. Read-only — switching scale is
+ * `.Scales` and `.Scale`, which take `render` for custom chrome.
+ */
scale: 'day' | 'month' | 'quarter' | 'halfYear' | 'year';
+ /** The range mid-build, at `selection="range"`. Never emitted. */
+ draft: { from?: Date; to?: Date } | null;
+
+ /** The period a scale switch is holding, uncommitted. Never emitted. */
+ scaleDraft: { date: string; scale: Scale } | null;
+
/** The first month currently displayed. */
month: Date;
@@ -221,9 +361,38 @@ export interface CalendarPreviewChangeDetails {
toDate: () => Date;
}
+export interface CalendarPreviewTriggerProps {
+ /**
+ * Shown when there is no value and no children.
+ * @default "Select date"
+ */
+ placeholder?: string;
+
+ /**
+ * Replaces the rendered element. The trigger is a `div` by default, so a
+ * control inside it stays focusable.
+ * @default
+ */
+ render?: ReactNode;
+
+ /**
+ * Whether `render` produces a native `
);
expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent(
- '20/08/2026'
+ '20 Aug 2026'
);
});
@@ -493,3 +493,388 @@ describe('CalendarPreview.Trigger content', () => {
);
});
});
+
+describe('CalendarPreview.Trigger is an anchor around a field', () => {
+ it('drops the button role and the tab stop when it wraps an input', () => {
+ const { container } = renderPicker();
+ const trigger = getSlot(container, 'calendar-preview-trigger');
+ expect(trigger).not.toHaveAttribute('role', 'button');
+ expect(trigger).toHaveAttribute('tabindex', '-1');
+ });
+
+ it('keeps both when it wraps only a label', () => {
+ const { container } = render(
+
+
+
+
+
+
+ );
+ const trigger = getSlot(container, 'calendar-preview-trigger');
+ expect(trigger).toHaveAttribute('role', 'button');
+ expect(trigger).not.toHaveAttribute('tabindex', '-1');
+ });
+
+ it('opens on a pointer press, which no longer races the focus handler', () => {
+ const { input } = renderPicker();
+ fireEvent.pointerDown(input);
+ fireEvent.focus(input);
+ fireEvent.pointerUp(input);
+ fireEvent.click(input);
+ expect(isOpen()).toBe(true);
+ });
+
+ it('stays open when a press moves between two fields of a range', () => {
+ const { container } = render(
+
+
+
+
+
+
+
+
+
+ );
+ const [start, end] = getAllSlots(container, 'calendar-preview-input');
+ fireEvent.focus(start);
+ expect(isOpen()).toBe(true);
+ fireEvent.pointerDown(end);
+ fireEvent.focus(end);
+ fireEvent.pointerUp(end);
+ fireEvent.click(end);
+ expect(isOpen()).toBe(true);
+ });
+});
+
+describe('CalendarPreview.Input drops a rejected draft on an outside write', () => {
+ it('clears the text and the invalid state when a day is clicked', () => {
+ const onValidityChange = vi.fn();
+ const { container, input } = renderPicker({}, { onValidityChange });
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'not a date' } });
+ expect(input).toHaveAttribute('data-invalid');
+
+ const cell = getAllSlots(document.body, 'calendar-preview-day').find(
+ one =>
+ getSlot(one, 'calendar-preview-day-number')?.textContent === '12' &&
+ !one.hasAttribute('data-outside')
+ ) as HTMLElement;
+ fireEvent.click(cell);
+
+ expect(input.value).toBe('12 Aug 2026');
+ expect(input).not.toHaveAttribute('data-invalid');
+ expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true });
+ expect(container).toBeTruthy();
+ });
+
+ it('leaves a draft alone while the value has not moved', () => {
+ const { input } = renderPicker();
+ fireEvent.focus(input);
+ fireEvent.change(input, { target: { value: 'not a date' } });
+ fireEvent.change(input, { target: { value: 'still not' } });
+ expect(input.value).toBe('still not');
+ });
+});
+
+describe('CalendarPreview.Trigger and the focus a dismissal gives back', () => {
+ const pressOutside = () => {
+ fireEvent.pointerDown(document.body);
+ fireEvent.mouseDown(document.body);
+ fireEvent.click(document.body);
+ };
+
+ it('does not reopen on the focus an outside press hands back', () => {
+ const onOpenChange = vi.fn();
+ const { input } = renderPicker({ onOpenChange });
+ /* Real focus, so the close can see the trigger still holding it. */
+ input.focus();
+ fireEvent.focus(input);
+ expect(isOpen()).toBe(true);
+
+ pressOutside();
+ expect(isOpen()).toBe(false);
+ const calls = onOpenChange.mock.calls;
+ expect(calls[calls.length - 1][1].reason).toBe('outside-press');
+
+ fireEvent.focus(input);
+ expect(isOpen()).toBe(false);
+ });
+
+ /* jsdom ignores `focus({ preventScroll })`, without which the assertion cannot fail. */
+ const withPreventScroll = () => {
+ const focus = HTMLElement.prototype.focus;
+ HTMLElement.prototype.focus = function patched(options?: FocusOptions) {
+ void options?.preventScroll;
+ return focus.call(this);
+ };
+ return () => {
+ HTMLElement.prototype.focus = focus;
+ };
+ };
+
+ const pressOutsideOff = (input: HTMLInputElement) => {
+ fireEvent.pointerDown(document.body);
+ input.blur();
+ fireEvent.mouseDown(document.body);
+ fireEvent.click(document.body);
+ };
+
+ it('leaves focus where an outside press put it', async () => {
+ const restore = withPreventScroll();
+ try {
+ const { input } = renderPicker();
+ input.focus();
+ fireEvent.focus(input);
+ expect(isOpen()).toBe(true);
+
+ pressOutsideOff(input);
+ expect(isOpen()).toBe(false);
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(document.activeElement).not.toBe(input);
+ } finally {
+ restore();
+ }
+ });
+
+ it('gives focus back to the input on Escape', async () => {
+ const { input } = renderPicker();
+ input.focus();
+ fireEvent.focus(input);
+ expect(isOpen()).toBe(true);
+
+ fireEvent.keyDown(input, { key: 'Escape' });
+ expect(isOpen()).toBe(false);
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(document.activeElement).toBe(input);
+ });
+
+ it('releases the guard on the next press when no focus comes back', () => {
+ const { input } = renderPicker();
+ fireEvent.focus(input);
+ pressOutside();
+ expect(isOpen()).toBe(false);
+
+ fireEvent.pointerDown(input);
+ fireEvent.focus(input);
+ expect(isOpen()).toBe(true);
+ });
+});
+
+describe('CalendarPreview.Trigger beside a Body that owns the input', () => {
+ const composition = (
+ <>
+
+
+
+
+ >
+ );
+
+ it('stays a button while an input it does not own is mounted', () => {
+ const { container } = render(
+
+ {composition}
+
+ );
+ expect(
+ getSlot(document.body, 'calendar-preview-input')
+ ).toBeInTheDocument();
+
+ const trigger = getSlot(container, 'calendar-preview-trigger');
+ expect(trigger).toHaveAttribute('role', 'button');
+ expect(trigger).not.toHaveAttribute('tabindex', '-1');
+ });
+
+ it('still gives up the role for an input of its own', () => {
+ const { container } = renderPicker();
+ expect(getSlot(container, 'calendar-preview-trigger')).not.toHaveAttribute(
+ 'role',
+ 'button'
+ );
+ });
+});
+
+describe('CalendarPreview picker props the review left open', () => {
+ it('composes a consumer onValueChange rather than replacing it', () => {
+ const onInputValueChange = vi.fn();
+ const onValueChange = vi.fn();
+ const { input } = renderPicker(
+ { onValueChange },
+ { onValueChange: onInputValueChange }
+ );
+
+ fireEvent.change(input, { target: { value: '20/05/2027' } });
+ expect(onInputValueChange).toHaveBeenCalled();
+ expect(onInputValueChange.mock.calls[0][0]).toBe('20/05/2027');
+
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2027, 4, 20));
+ });
+
+ it('re-judges drafted text when the bounds move under it', () => {
+ const { input, rerender } = renderPicker({
+ minDate: new Date(2026, 7, 10)
+ });
+ fireEvent.change(input, { target: { value: '05/08/2026' } });
+ expect(input).toHaveAttribute('data-invalid');
+
+ rerender(
+
+
+
+
+
+
+
+
+ );
+
+ expect(input.value).toBe('05/08/2026');
+ expect(input).not.toHaveAttribute('data-invalid');
+ });
+
+ it('reports the recovered validity to the consumer', () => {
+ const onValidityChange = vi.fn();
+ const { input, rerender } = renderPicker(
+ { maxDate: new Date(2026, 7, 10) },
+ { onValidityChange }
+ );
+ fireEvent.change(input, { target: { value: '20/08/2026' } });
+ expect(onValidityChange).toHaveBeenLastCalledWith({
+ valid: false,
+ reason: 'out-of-bounds',
+ message: 'Invalid input'
+ });
+
+ rerender(
+
+
+
+
+
+
+
+
+ );
+
+ expect(onValidityChange).toHaveBeenLastCalledWith({ valid: true });
+ });
+
+ it('gives a trigger with no input a tab stop of its own', () => {
+ const { container } = render(
+
+
+
+
+
+
+ );
+ const trigger = getSlot(container, 'calendar-preview-trigger');
+ expect(trigger).toHaveAttribute('role', 'button');
+ expect(trigger).toHaveAttribute('tabindex', '0');
+ });
+
+ it('keeps a trigger around an input out of the tab order', () => {
+ const { container } = renderPicker();
+ expect(getSlot(container, 'calendar-preview-trigger')).toHaveAttribute(
+ 'tabindex',
+ '-1'
+ );
+ });
+});
+
+describe('CalendarPreview.Content initial focus', () => {
+ const settle = () => new Promise(resolve => setTimeout(resolve, 100));
+
+ it('leaves focus on the field the popover opened from', async () => {
+ const { input } = renderPicker();
+ input.focus();
+ fireEvent.focus(input);
+ await settle();
+
+ expect(isOpen()).toBe(true);
+ expect(document.activeElement).toBe(input);
+ });
+
+ it('still takes typed text once it is open', async () => {
+ const onValueChange = vi.fn();
+ const { input } = renderPicker({ onValueChange });
+ input.focus();
+ fireEvent.focus(input);
+ await settle();
+
+ fireEvent.change(input, { target: { value: '20/05/2027' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2027, 4, 20));
+ });
+
+ it('keeps both range fields reachable, so a range fills by keyboard', async () => {
+ const utils = render(
+
+
+
+
+
+
+
+
+
+ );
+ const [start, end] = getAllSlots(
+ utils.container,
+ 'calendar-preview-input'
+ ) as HTMLInputElement[];
+
+ start.focus();
+ fireEvent.focus(start);
+ await settle();
+ expect(document.activeElement).toBe(start);
+
+ fireEvent.change(start, { target: { value: '10 Aug 2026' } });
+ fireEvent.keyDown(start, { key: 'Enter' });
+ fireEvent.change(end, { target: { value: '20 Aug 2026' } });
+ fireEvent.keyDown(end, { key: 'Enter' });
+
+ expect(start.value).toBe('10 Aug 2026');
+ expect(end.value).toBe('20 Aug 2026');
+ });
+
+ it('still moves focus into the popup when the trigger wraps no input', async () => {
+ const utils = render(
+
+
+
+
+
+
+ );
+ const trigger = getSlot(
+ utils.container,
+ 'calendar-preview-trigger'
+ ) as HTMLElement;
+
+ trigger.focus();
+ await settle();
+ expect(document.activeElement).not.toBe(trigger);
+ expect(
+ getSlot(document.body, 'calendar-preview-content')?.contains(
+ document.activeElement
+ )
+ ).toBe(true);
+ });
+});
diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx
index 7255d864f..27ef9c0c2 100644
--- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx
+++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx
@@ -55,7 +55,6 @@ describe('CalendarPreview range machine', () => {
fireEvent.click(day(container, '20'));
fireEvent.click(day(container, '10'));
expect(onValueChange).not.toHaveBeenCalled();
- /* The earlier day became the new start, so a later click completes. */
fireEvent.click(day(container, '15'));
expect(onValueChange.mock.calls[0][0]).toEqual({
from: new Date(2026, 7, 10),
@@ -128,30 +127,53 @@ describe('CalendarPreview range inputs', () => {
expect(end).toHaveAttribute('placeholder', 'Select end date');
});
+ /* `Input` paints `data-active` as focus, so a shut popover must mark nothing. */
+ it('marks no endpoint while the popover is shut', () => {
+ const { container } = renderRange({}, picker);
+ const [start, end] = inputs(container);
+ expect(start).not.toHaveAttribute('data-active');
+ expect(end).not.toHaveAttribute('data-active');
+ });
+
it('advances the active endpoint to the end after the first click', () => {
const { container } = renderRange({}, picker);
const [start, end] = inputs(container);
+
+ fireEvent.focus(start);
expect(start).toHaveAttribute('data-active', 'true');
expect(end).not.toHaveAttribute('data-active');
- fireEvent.focus(start);
fireEvent.click(day(document.body, '10'));
expect(end).toHaveAttribute('data-active', 'true');
expect(start).not.toHaveAttribute('data-active');
});
+ /* An inline range has no popover to open, so it is always live. */
+ it('marks the active endpoint with no trigger in the tree', () => {
+ const { container } = renderRange(
+ {},
+ <>
+
+
+
+ >
+ );
+ const [start, end] = inputs(container);
+ expect(start).toHaveAttribute('data-active', 'true');
+ expect(end).not.toHaveAttribute('data-active');
+ });
+
it('shows each endpoint in its own field', () => {
const { container } = renderRange({}, picker);
fireEvent.focus(inputs(container)[0]);
fireEvent.click(day(document.body, '10'));
fireEvent.click(day(document.body, '20'));
const [start, end] = inputs(container);
- expect(start.value).toBe('10/08/2026');
- expect(end.value).toBe('20/08/2026');
+ expect(start.value).toBe('10 Aug 2026');
+ expect(end.value).toBe('20 Aug 2026');
});
- /* `lock` is gone: a read-only endpoint is one read-only `.Input`. */
it('never lets a grid click rewrite a read-only endpoint', () => {
const onValueChange = vi.fn();
const { container } = renderRange(
@@ -170,14 +192,12 @@ describe('CalendarPreview range inputs', () => {
>
);
fireEvent.focus(inputs(container)[1]);
- /* A click that would restart the range has to rewrite `from`, which is
- read-only, so nothing moves. */
fireEvent.click(day(document.body, '5'));
expect(onValueChange).not.toHaveBeenCalled();
});
});
-describe('CalendarPreview range auto-close', () => {
+describe('CalendarPreview range completion leaves the popover open', () => {
const picker = (
<>
@@ -193,54 +213,54 @@ describe('CalendarPreview range auto-close', () => {
const isOpen = () =>
getSlot(document.body, 'calendar-preview-content') !== null;
- it('closes through onOpenChange when the range completes', () => {
- const onOpenChange = vi.fn();
- const { container } = renderRange({ onOpenChange }, picker);
+ const open = (container: HTMLElement) =>
fireEvent.focus(
getAllSlots(container, 'calendar-preview-input')[0] as HTMLElement
);
- expect(isOpen()).toBe(true);
- fireEvent.click(day(document.body, '10'));
+ it('stays open when the range completes', () => {
+ const onOpenChange = vi.fn();
+ const { container } = renderRange({ onOpenChange }, picker);
+ open(container);
expect(isOpen()).toBe(true);
+ fireEvent.click(day(document.body, '10'));
fireEvent.click(day(document.body, '20'));
- expect(isOpen()).toBe(false);
- expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything());
+ expect(isOpen()).toBe(true);
+ expect(onOpenChange.mock.calls.filter(call => call[0] === false)).toEqual(
+ []
+ );
});
- /* Completing a range hands focus back to the trigger, and an unguarded
- focus handler reopens the popover on the way out. jsdom does not restore
- focus the way a browser does, so this asserts the guard rather than the
- symptom: the close must be the last thing that happens. */
- it('does not reopen on the focus that follows an auto-close', () => {
- const onOpenChange = vi.fn();
- const { container } = renderRange({ onOpenChange }, picker);
- const [start] = getAllSlots(
- container,
- 'calendar-preview-input'
- ) as HTMLElement[];
- fireEvent.focus(start);
-
+ it('takes a second range without a second trip to the trigger', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({ onValueChange }, picker);
+ open(container);
fireEvent.click(day(document.body, '10'));
fireEvent.click(day(document.body, '20'));
- expect(isOpen()).toBe(false);
- /* The browser returns focus to the trigger here. */
- fireEvent.focus(start);
- expect(isOpen()).toBe(false);
- const calls = onOpenChange.mock.calls;
- expect(calls[calls.length - 1][0]).toBe(false);
+ fireEvent.click(day(document.body, '5'));
+ fireEvent.click(day(document.body, '8'));
+ expect(isOpen()).toBe(true);
+ expect(onValueChange).toHaveBeenCalledTimes(2);
+ expect(onValueChange.mock.calls[1][0]).toEqual({
+ from: new Date(2026, 7, 5),
+ to: new Date(2026, 7, 8)
+ });
});
- /* Completing a range asks to close; a consumer holding `open` open wins. */
- it('does not fight a controlled open', () => {
- const onOpenChange = vi.fn();
- renderRange({ open: true, onOpenChange }, picker);
+ it('still dismisses on Escape once the range is complete', () => {
+ const { container } = renderRange({}, picker);
+ open(container);
fireEvent.click(day(document.body, '10'));
fireEvent.click(day(document.body, '20'));
expect(isOpen()).toBe(true);
- expect(onOpenChange).toHaveBeenLastCalledWith(false, expect.anything());
+
+ fireEvent.keyDown(
+ getSlot(document.body, 'calendar-preview-content') as HTMLElement,
+ { key: 'Escape' }
+ );
+ expect(isOpen()).toBe(false);
});
});
@@ -268,8 +288,6 @@ describe('CalendarPreview range parts that read the value', () => {
>
);
- /* `.Days` renders `.Header` renders `.Reset`, so this is the default
- composition — it threw on `dayKey(range)` before the shape guard. */
it('renders the default composition with a range value and a defaultDate', () => {
expect(() =>
renderRange({ defaultValue: RANGE, defaultDate: RANGE.from })
@@ -300,11 +318,10 @@ describe('CalendarPreview range parts that read the value', () => {
defaultDate: RANGE
});
const reset = getSlot(container, 'calendar-preview-reset') as HTMLElement;
- expect(reset).toBeDisabled();
+ expect(reset).toHaveAttribute('aria-disabled', 'true');
expect(reset).toHaveAttribute('data-restored');
});
- /* Both edges have to match — a shared start is not a restored range. */
it('is not restored when only one edge matches the default', () => {
const { container } = renderRange({
defaultValue: RANGE,
@@ -313,7 +330,6 @@ describe('CalendarPreview range parts that read the value', () => {
expect(getSlot(container, 'calendar-preview-reset')).not.toBeDisabled();
});
- /* Clearing is shape-agnostic, so a `null` default keeps working. */
it('keeps .Reset for a null defaultDate, and clears the range', () => {
const onValueChange = vi.fn();
const { container } = renderRange({
@@ -333,8 +349,8 @@ describe('CalendarPreview range parts that read the value', () => {
);
const trigger = getSlot(container, 'calendar-preview-trigger');
- expect(trigger?.textContent).toContain('10/08/2026');
- expect(trigger?.textContent).toContain('20/08/2026');
+ expect(trigger?.textContent).toContain('10 Aug 2026');
+ expect(trigger?.textContent).toContain('20 Aug 2026');
});
it('edits the end without disturbing the start', () => {
@@ -345,8 +361,8 @@ describe('CalendarPreview range parts that read the value', () => {
);
const [start, end] = inputs(container);
typeAndCommit(end, '25/08/2026');
- expect(start.value).toBe('10/08/2026');
- expect(end.value).toBe('25/08/2026');
+ expect(start.value).toBe('10 Aug 2026');
+ expect(end.value).toBe('25 Aug 2026');
expect(onValueChange).toHaveBeenCalledWith(
{ from: RANGE.from, to: new Date(2026, 7, 25) },
expect.objectContaining({ reason: 'input' })
@@ -361,8 +377,8 @@ describe('CalendarPreview range parts that read the value', () => {
);
const [start, end] = inputs(container);
typeAndCommit(start, '05/08/2026');
- expect(start.value).toBe('05/08/2026');
- expect(end.value).toBe('20/08/2026');
+ expect(start.value).toBe('05 Aug 2026');
+ expect(end.value).toBe('20 Aug 2026');
expect(onValueChange).toHaveBeenCalledWith(
{ from: new Date(2026, 7, 5), to: RANGE.to },
expect.objectContaining({ reason: 'input' })
@@ -432,7 +448,7 @@ describe('CalendarPreview range order validation', () => {
message: 'End date cannot be before the start date'
});
expect(onValueChange).not.toHaveBeenCalled();
- expect(start.value).toBe('10/08/2026');
+ expect(start.value).toBe('10 Aug 2026');
expect(end).toHaveAttribute('data-invalid');
});
@@ -497,7 +513,6 @@ describe('CalendarPreview range order validation', () => {
);
});
- /* The grid keeps its restart rule — only typing is strict. */
it('still lets a grid click restart the range from an earlier day', () => {
const onValueChange = vi.fn();
const { container } = renderRange({ defaultValue: RANGE, onValueChange });
@@ -506,3 +521,290 @@ describe('CalendarPreview range order validation', () => {
expect(day(container, '5')).toHaveAttribute('data-selected');
});
});
+
+describe('CalendarPreview range with a read-only start', () => {
+ const COMMITTED = { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) };
+
+ function renderFixedStart(props = {}, endProps = {}) {
+ return render(
+
+
+
+
+
+
+
+ );
+ }
+
+ it('moves the end against the committed start', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderFixedStart({ onValueChange });
+ fireEvent.click(day(container, '25'));
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ from: new Date(2026, 7, 10),
+ to: new Date(2026, 7, 25)
+ });
+ });
+
+ it('refuses a day before the fixed start rather than restarting there', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderFixedStart({ onValueChange });
+ fireEvent.click(day(container, '5'));
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('writes nothing when both endpoints are read-only', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderFixedStart(
+ { onValueChange },
+ { readOnly: true }
+ );
+ fireEvent.click(day(container, '25'));
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('leaves the ordinary range machine alone when nothing is read-only', () => {
+ const onValueChange = vi.fn();
+ const { container } = render(
+
+
+
+
+
+
+
+ );
+ fireEvent.click(day(container, '25'));
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+});
+
+describe('CalendarPreview range and days it may not cover', () => {
+ const blocked = (date: Date) => date.getDate() === 15;
+
+ it('restarts instead of completing over a blocked day', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({
+ onValueChange,
+ isDateUnavailable: blocked
+ });
+ fireEvent.click(day(container, '10'));
+ fireEvent.click(day(container, '20'));
+ expect(onValueChange).not.toHaveBeenCalled();
+
+ fireEvent.click(day(container, '22'));
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ from: new Date(2026, 7, 20),
+ to: new Date(2026, 7, 22)
+ });
+ });
+
+ it('completes a range that clears the blocked day', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderRange({
+ onValueChange,
+ isDateUnavailable: blocked
+ });
+ fireEvent.click(day(container, '16'));
+ fireEvent.click(day(container, '20'));
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('rejects a typed endpoint whose span is blocked', () => {
+ const onValidityChange = vi.fn();
+ const { container } = renderRange(
+ { isDateUnavailable: blocked },
+ <>
+
+
+
+
+
+ >
+ );
+ fireEvent.click(day(container, '10'));
+
+ const end = getAllSlots(container, 'calendar-preview-input')[1];
+ fireEvent.change(end, { target: { value: '20 Aug 2026' } });
+ const calls = onValidityChange.mock.calls;
+ const last = calls[calls.length - 1][0];
+ expect(last.valid).toBe(false);
+ expect(last.reason).toBe('unavailable');
+ });
+});
+
+describe('CalendarPreview range emptying one field', () => {
+ const RANGE = { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) };
+
+ function renderFields(props = {}) {
+ const utils = renderRange(
+ { defaultValue: RANGE, ...props },
+ <>
+
+
+
+
+
+ >
+ );
+ const [start, end] = getAllSlots(
+ utils.container,
+ 'calendar-preview-input'
+ ) as HTMLInputElement[];
+ return { ...utils, start, end };
+ }
+
+ const empty = (input: HTMLInputElement) => {
+ fireEvent.change(input, { target: { value: '' } });
+ fireEvent.blur(input);
+ };
+
+ it('keeps the partner endpoint in its field', () => {
+ const { start, end } = renderFields();
+ empty(start);
+ expect(start.value).toBe('');
+ expect(end.value).toBe('20 Aug 2026');
+ });
+
+ it('emits null once the range can no longer be formed', () => {
+ const onValueChange = vi.fn();
+ const { start } = renderFields({ onValueChange });
+ empty(start);
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange.mock.calls[0][0]).toBeNull();
+ });
+
+ it('completes again from a grid click without losing the kept endpoint', () => {
+ const onValueChange = vi.fn();
+ const { container, start } = renderFields({ onValueChange });
+ empty(start);
+ fireEvent.click(day(container, '12'));
+ const calls = onValueChange.mock.calls;
+ expect(calls[calls.length - 1][0]).toEqual({
+ from: new Date(2026, 7, 12),
+ to: new Date(2026, 7, 20)
+ });
+ });
+
+ it('restarts when the click crosses the kept endpoint', () => {
+ const onValueChange = vi.fn();
+ const { container, start } = renderFields({ onValueChange });
+ empty(start);
+ fireEvent.click(day(container, '25'));
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves both fields alone when clearable is off', () => {
+ const { start, end } = renderFields({ clearable: false });
+ empty(start);
+ expect(start.value).toBe('10 Aug 2026');
+ expect(end.value).toBe('20 Aug 2026');
+ });
+});
+
+describe('CalendarPreview range endpoints the review left open', () => {
+ function renderFields(props = {}) {
+ const utils = renderRange(
+ props,
+ <>
+
+
+
+
+
+ >
+ );
+ const [start, end] = getAllSlots(
+ utils.container,
+ 'calendar-preview-input'
+ ) as HTMLInputElement[];
+ return { ...utils, start, end };
+ }
+
+ const type = (input: HTMLInputElement, text: string) => {
+ fireEvent.change(input, { target: { value: text } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ };
+
+ it('keeps a typed end in the end field when there is no start yet', () => {
+ const { start, end } = renderFields();
+ type(end, '20 Aug 2026');
+ expect(end.value).toBe('20 Aug 2026');
+ expect(start.value).toBe('');
+ });
+
+ it('completes from a typed end once the start arrives', () => {
+ const onValueChange = vi.fn();
+ const { container, end } = renderFields({ onValueChange });
+ type(end, '20 Aug 2026');
+ fireEvent.click(day(container, '10'));
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ from: new Date(2026, 7, 10),
+ to: new Date(2026, 7, 20)
+ });
+ });
+
+ it('clears a crossing end once the start moves behind it', () => {
+ const { container, end } = renderFields();
+ fireEvent.click(day(container, '10'));
+ fireEvent.change(end, { target: { value: '05 Aug 2026' } });
+ expect(end).toHaveAttribute('data-invalid');
+
+ fireEvent.click(day(container, '1'));
+ expect(end.value).toBe('05 Aug 2026');
+ expect(end).not.toHaveAttribute('data-invalid');
+ });
+
+ it('drops a half-built draft when the consumer moves the value', () => {
+ const { container, rerender, start, end } = renderFields({
+ value: null
+ });
+ fireEvent.click(day(container, '10'));
+ expect(start.value).toBe('10 Aug 2026');
+
+ rerender(
+
+
+
+
+
+
+
+ );
+
+ expect(start.value).toBe('03 Aug 2026');
+ expect(end.value).toBe('07 Aug 2026');
+ });
+
+ it('keeps the draft that emptying one field leaves behind', () => {
+ const { start, end } = renderFields({
+ defaultValue: { from: new Date(2026, 7, 10), to: new Date(2026, 7, 20) }
+ });
+ fireEvent.change(start, { target: { value: '' } });
+ fireEvent.blur(start);
+ expect(start.value).toBe('');
+ expect(end.value).toBe('20 Aug 2026');
+ });
+});
diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx
new file mode 100644
index 000000000..75b1b9ed7
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx
@@ -0,0 +1,1237 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { useState } from 'react';
+import { describe, expect, it, vi } from 'vitest';
+import { getAllSlots, getSlot } from '~/test-utils/data-slots';
+import { CalendarPreview } from '../calendar-preview';
+import type { Scale } from '../lib/scale';
+
+const TODAY = new Date(2026, 7, 15);
+const ALL: Scale[] = ['day', 'month', 'quarter', 'halfYear', 'year'];
+
+function renderBody(props = {}) {
+ return render(
+
+
+
+ );
+}
+
+const period = (container: HTMLElement, label: string, year = 2026) => {
+ const group = getAllSlots(container, 'calendar-preview-period-group').find(
+ node => node.getAttribute('data-year') === String(year)
+ );
+ if (!group) throw new Error(`no year group ${year}`);
+ const match = getAllSlots(group, 'calendar-preview-period').find(
+ cell => cell.textContent === label
+ );
+ if (!match) throw new Error(`no period cell ${label} in ${year}`);
+ return match;
+};
+
+const switchTo = (container: HTMLElement, scale: Scale) => {
+ const chip = getAllSlots(container, 'calendar-preview-scale').find(
+ node => node.getAttribute('data-scale') === scale
+ );
+ fireEvent.click(chip as HTMLElement);
+};
+
+describe('CalendarPreview scale switching', () => {
+ it('emits nothing on a scale switch — it only drafts', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({ onValueChange });
+ switchTo(container, 'quarter');
+ expect(onValueChange).not.toHaveBeenCalled();
+ switchTo(container, 'year');
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('emits once a period is picked', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({ onValueChange });
+ switchTo(container, 'quarter');
+ fireEvent.click(period(container, 'Q3'));
+ expect(onValueChange).toHaveBeenCalledTimes(1);
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2026-07-01',
+ scale: 'quarter'
+ });
+ });
+
+ it('reports the scale it moved to', () => {
+ const onScaleChange = vi.fn();
+ const { container } = renderBody({ onScaleChange });
+ switchTo(container, 'month');
+ expect(onScaleChange).toHaveBeenCalledWith('month');
+ });
+});
+
+describe('CalendarPreview trailingValue', () => {
+ it.each([
+ ['month', 'Aug', '2026-08-01', '2026-08-31'],
+ ['quarter', 'Q3', '2026-07-01', '2026-09-30'],
+ ['halfYear', 'H2', '2026-07-01', '2026-12-31'],
+ ['year', '2026', '2026-01-01', '2026-12-31']
+ ] as const)('flips the emitted edge for %s', (scale, label, lead, trail) => {
+ for (const [trailing, expected] of [
+ [false, lead],
+ [true, trail]
+ ] as const) {
+ const onValueChange = vi.fn();
+ const { container, unmount } = renderBody({
+ onValueChange,
+ trailingValue: trailing
+ });
+ switchTo(container, scale);
+ fireEvent.click(period(container, label));
+ expect(onValueChange.mock.calls[0][0]).toEqual({ date: expected, scale });
+ unmount();
+ }
+ });
+
+ it('is month-end correct in a leap February', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({
+ onValueChange,
+ trailingValue: true,
+ today: new Date(2028, 1, 10),
+ yearRange: { from: 2028, to: 2028 }
+ });
+ switchTo(container, 'month');
+ fireEvent.click(period(container, 'Feb', 2028));
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2028-02-29',
+ scale: 'month'
+ });
+ });
+});
+
+describe('CalendarPreview availability differs by field', () => {
+ const bounded = { minDate: new Date(2026, 6, 15), today: TODAY };
+
+ it.each([
+ ['quarter', 'Q3'],
+ ['month', 'Jul']
+ ] as const)('disables %s for a start field and allows it for an end field', (scale, label) => {
+ const start = renderBody({ ...bounded, trailingValue: false });
+ switchTo(start.container, scale);
+ expect(period(start.container, label)).toBeDisabled();
+ start.unmount();
+
+ const end = renderBody({ ...bounded, trailingValue: true });
+ switchTo(end.container, scale);
+ expect(period(end.container, label)).not.toBeDisabled();
+ });
+
+ it('disables H1 2026 for an end field, which would emit 30 June', () => {
+ const { container } = renderBody({ ...bounded, trailingValue: true });
+ switchTo(container, 'halfYear');
+ expect(period(container, 'H1')).toBeDisabled();
+ expect(period(container, 'H2')).not.toBeDisabled();
+ });
+
+ it('allows July and Q3 for an end field, because they emit after the bound', () => {
+ const { container } = renderBody({ ...bounded, trailingValue: true });
+ switchTo(container, 'month');
+ expect(period(container, 'Jul')).not.toBeDisabled();
+ switchTo(container, 'quarter');
+ expect(period(container, 'Q3')).not.toBeDisabled();
+ });
+
+ it('shows out-of-bounds periods rather than hiding them', () => {
+ const { container } = renderBody({
+ maxDate: new Date(2026, 7, 31),
+ today: TODAY
+ });
+ switchTo(container, 'month');
+ expect(period(container, 'Dec')).toBeInTheDocument();
+ expect(period(container, 'Dec')).toBeDisabled();
+ });
+});
+
+describe('CalendarPreview periods ignore the time zone', () => {
+ it.each([
+ ['Pacific/Niue'],
+ ['Pacific/Kiritimati'],
+ ['UTC']
+ ])('commits the period that was clicked in %s', timeZone => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({
+ timeZone,
+ defaultScale: 'month',
+ onValueChange
+ });
+ fireEvent.click(period(container, 'Aug'));
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2026-08-01',
+ scale: 'month'
+ });
+ });
+
+ it('marks the clicked quarter, not its neighbour, west of UTC', () => {
+ const { container } = renderBody({
+ timeZone: 'Pacific/Niue',
+ defaultScale: 'quarter',
+ value: { date: '2026-07-01', scale: 'quarter' }
+ });
+ expect(period(container, 'Q3')).toHaveAttribute('data-selected');
+ expect(period(container, 'Q2')).not.toHaveAttribute('data-selected');
+ });
+});
+
+describe('CalendarPreview opens at the committed scale', () => {
+ it('mounts the period view, not the day grid, for a committed period', () => {
+ const { container } = renderBody({
+ value: { date: '2026-07-01', scale: 'quarter' }
+ });
+ expect(getSlot(container, 'calendar-preview-days')).toBeNull();
+ expect(getSlot(container, 'calendar-preview-quarters')).not.toBeNull();
+ expect(period(container, 'Q3')).toHaveAttribute('data-selected');
+ });
+
+ it('prefers an explicit defaultScale over the value', () => {
+ const { container } = renderBody({
+ value: { date: '2026-07-01', scale: 'quarter' },
+ defaultScale: 'day'
+ });
+ expect(getSlot(container, 'calendar-preview-days')).not.toBeNull();
+ });
+});
+
+describe('CalendarPreview.Reset is reachable at every scale', () => {
+ const QUARTER = { date: '2026-07-01', scale: 'quarter' } as const;
+
+ it('restores from a period view', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({
+ defaultDate: QUARTER,
+ value: { date: '2026-10-01', scale: 'quarter' },
+ onValueChange
+ });
+ const reset = getSlot(container, 'calendar-preview-reset') as HTMLElement;
+ expect(reset).not.toBeNull();
+ fireEvent.click(reset);
+ expect(onValueChange).toHaveBeenCalledWith(
+ QUARTER,
+ expect.objectContaining({ reason: 'reset' })
+ );
+ });
+
+ it.each([
+ ['day'],
+ ['quarter']
+ ] as const)('mounts exactly one reset at %s scale', scale => {
+ const { container } = renderBody({
+ defaultScale: scale,
+ defaultValue: { date: '2026-08-20', scale: 'day' },
+ defaultDate: { date: '2026-08-10', scale: 'day' }
+ });
+ expect(getAllSlots(container, 'calendar-preview-reset')).toHaveLength(1);
+ });
+});
+
+describe('CalendarPreview reads a period at its own scale', () => {
+ it('agrees between .Trigger and .Input on a committed period', () => {
+ const { container } = render(
+
+
+
+
+ );
+ expect(getSlot(container, 'calendar-preview-trigger')?.textContent).toBe(
+ 'Q3 2026'
+ );
+ expect(
+ (getSlot(container, 'calendar-preview-input') as HTMLInputElement).value
+ ).toBe('Q3 2026');
+ });
+});
+
+describe('CalendarPreview settles the scale out loud', () => {
+ const inputValue = (container: HTMLElement) =>
+ (getSlot(container, 'calendar-preview-input') as HTMLInputElement).value;
+ const pressEscape = (container: HTMLElement) =>
+ fireEvent.keyDown(
+ getSlot(container, 'calendar-preview-body') as HTMLElement,
+ {
+ key: 'Escape'
+ }
+ );
+
+ it('reports the scale a dropped draft settles back on', () => {
+ const onScaleChange = vi.fn();
+ const { container } = renderBody({
+ value: { date: '2026-08-20', scale: 'day' },
+ onScaleChange
+ });
+ switchTo(container, 'quarter');
+ expect(onScaleChange).toHaveBeenLastCalledWith('quarter');
+ pressEscape(container);
+ expect(onScaleChange).toHaveBeenLastCalledWith('day');
+ });
+
+ /* The raw setter left a controlled switcher stuck on the draft. */
+ it('moves a controlled scale back when the draft is dropped', () => {
+ function Controlled() {
+ const [scale, setScale] = useState('day');
+ return (
+
+
+
+ );
+ }
+ const { container } = render();
+ switchTo(container, 'quarter');
+ expect(inputValue(container)).toBe('Q3 2026');
+ pressEscape(container);
+ expect(inputValue(container)).toBe('20 Aug 2026');
+ });
+});
+
+describe('CalendarPreview settles the scale once when Escape both drops and closes', () => {
+ function renderPicker(props = {}) {
+ return render(
+
+
+
+
+
+
+
+
+ );
+ }
+
+ it('reports the settled scale once', () => {
+ const onScaleChange = vi.fn();
+ const { container } = renderPicker({
+ value: { date: '2026-08-20', scale: 'day' },
+ onScaleChange
+ });
+ fireEvent.focus(
+ getSlot(container, 'calendar-preview-input') as HTMLElement
+ );
+ switchTo(document.body, 'quarter');
+ onScaleChange.mockClear();
+
+ fireEvent.keyDown(
+ getSlot(document.body, 'calendar-preview-body') as HTMLElement,
+ { key: 'Escape' }
+ );
+ expect(onScaleChange).toHaveBeenCalledTimes(1);
+ expect(onScaleChange).toHaveBeenCalledWith('day');
+ });
+});
+
+describe('CalendarPreview settles the view on what a typed date commits', () => {
+ const input = (container: HTMLElement) =>
+ getSlot(container, 'calendar-preview-input') as HTMLInputElement;
+
+ const marked = (container: HTMLElement) =>
+ getAllSlots(container, 'calendar-preview-period')
+ .filter(cell => cell.hasAttribute('data-selected'))
+ .map(cell => cell.textContent);
+
+ it('moves to day scale when a day is typed on a period view', () => {
+ const onScaleChange = vi.fn();
+ const { container } = renderBody({
+ defaultScale: 'quarter',
+ onScaleChange
+ });
+
+ fireEvent.change(input(container), { target: { value: '2 May 2026' } });
+ fireEvent.keyDown(input(container), { key: 'Enter' });
+
+ expect(onScaleChange).toHaveBeenLastCalledWith('day');
+ expect(getSlot(container, 'calendar-preview')).toHaveAttribute(
+ 'data-scale',
+ 'day'
+ );
+ });
+
+ it('moves to the typed period and marks its cell', () => {
+ const { container } = renderBody({ defaultScale: 'day' });
+
+ fireEvent.change(input(container), { target: { value: 'Q2 2026' } });
+ fireEvent.keyDown(input(container), { key: 'Enter' });
+
+ expect(getSlot(container, 'calendar-preview')).toHaveAttribute(
+ 'data-scale',
+ 'quarter'
+ );
+ expect(marked(container)).toEqual(['Q2']);
+ });
+});
+
+describe('CalendarPreview.Scales', () => {
+ it('renders nothing when only one scale is offered', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(getSlot(container, 'calendar-preview-scales')).toBeNull();
+ });
+
+ it('renders one chip per offered scale', () => {
+ const { container } = renderBody();
+ expect(getAllSlots(container, 'calendar-preview-scale')).toHaveLength(5);
+ });
+});
+
+describe('CalendarPreview period views mount alone', () => {
+ it.each([
+ ['quarter', CalendarPreview.Quarters, 'calendar-preview-quarters'],
+ ['month', CalendarPreview.Months, 'calendar-preview-months'],
+ ['halfYear', CalendarPreview.HalfYears, 'calendar-preview-half-years'],
+ ['year', CalendarPreview.Years, 'calendar-preview-years']
+ ] as const)('%s renders with no other view in the tree', (scale, View, slot) => {
+ const { container } = render(
+
+
+
+ );
+ expect(getSlot(container, slot)).toBeInTheDocument();
+ expect(getSlot(container, 'calendar-preview-grid')).toBeNull();
+ });
+
+ it('gates on the active scale, so the others stay unmounted', () => {
+ const { container } = renderBody({ defaultScale: 'quarter' });
+ expect(getSlot(container, 'calendar-preview-quarters')).toBeInTheDocument();
+ expect(getSlot(container, 'calendar-preview-months')).toBeNull();
+ expect(getSlot(container, 'calendar-preview-grid')).toBeNull();
+ });
+});
+
+describe('CalendarPreview.Trigger annotation', () => {
+ it.each([
+ ['day', '2026-07-02', '02 Jul 2026'],
+ ['month', '2026-06-01', 'Jun 2026'],
+ ['quarter', '2026-07-01', 'Q3 2026'],
+ ['halfYear', '2026-01-01', 'H1 2026'],
+ ['year', '2025-01-01', '2025']
+ ] as const)('formats %s with no popover open', (scale, date, expected) => {
+ const { container } = render(
+
+
+
+ );
+ expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent(
+ expected
+ );
+ expect(getSlot(document.body, 'calendar-preview-content')).toBeNull();
+ });
+
+ it('shows the empty state when there is no value', () => {
+ render(
+
+
+
+ );
+ expect(screen.getByText('Add start date')).toBeInTheDocument();
+ });
+});
+
+describe('CalendarPreview.Input at scale', () => {
+ const input = (container: HTMLElement) =>
+ getSlot(container, 'calendar-preview-input') as HTMLInputElement;
+
+ it('advertises the formats it accepts', () => {
+ const { container } = renderBody();
+ expect(input(container)).toHaveAttribute(
+ 'placeholder',
+ 'Try: 15 Aug 2026, Aug 2026, Q3 2026'
+ );
+ });
+
+ /* A hardcoded list suggested a day format to a field that rejects one. */
+ it('suggests only the scales the root offers', () => {
+ const { container } = renderBody({ scales: ['month', 'quarter', 'year'] });
+ expect(input(container)).toHaveAttribute(
+ 'placeholder',
+ 'Try: Aug 2026, Q3 2026, 2026'
+ );
+ });
+
+ it('suggests the single scale a one-scale root takes', () => {
+ const { container } = renderBody({ scales: 'quarter' });
+ expect(input(container)).toHaveAttribute('placeholder', 'Try: Q3 2026');
+ });
+
+ it('every suggestion is a format the field accepts', () => {
+ const { container } = renderBody({ scales: ['month', 'quarter', 'year'] });
+ const suggestions = (
+ input(container).getAttribute('placeholder') ?? ''
+ ).replace('Try: ', '');
+
+ for (const text of suggestions.split(', ')) {
+ fireEvent.change(input(container), { target: { value: text } });
+ expect(input(container)).not.toHaveAttribute('aria-invalid');
+ }
+ });
+
+ it('suggests parseable formats under a custom formatValue', () => {
+ const { container } = renderBody({
+ scales: ['month', 'quarter', 'year'],
+ formatValue: () => 'custom'
+ });
+ expect(input(container)).toHaveAttribute(
+ 'placeholder',
+ 'Try: Aug 2026, Q3 2026, 2026'
+ );
+ });
+
+ it('moves the scale to match what was typed', () => {
+ const onValueChange = vi.fn();
+ const onScaleChange = vi.fn();
+ const { container } = renderBody({ onValueChange, onScaleChange });
+ fireEvent.change(input(container), { target: { value: 'Q4 2026' } });
+ fireEvent.keyDown(input(container), { key: 'Enter' });
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2026-10-01',
+ scale: 'quarter'
+ });
+ });
+
+ it('refuses a scale this root does not offer', () => {
+ const { container } = render(
+
+
+
+ );
+ fireEvent.change(input(container), { target: { value: 'Q4 2026' } });
+ expect(getSlot(container, 'calendar-preview-input')).toHaveAttribute(
+ 'aria-invalid'
+ );
+ });
+
+ it('drops the draft on Escape and falls back to the value', () => {
+ const { container } = renderBody({
+ value: { date: '2026-08-20', scale: 'day' }
+ });
+ expect(input(container).value).toBe('20 Aug 2026');
+
+ switchTo(container, 'quarter');
+ expect(input(container).value).toBe('Q3 2026');
+
+ fireEvent.keyDown(
+ getSlot(container, 'calendar-preview-body') as HTMLElement,
+ {
+ key: 'Escape'
+ }
+ );
+ expect(input(container).value).toBe('20 Aug 2026');
+ });
+});
+
+describe('CalendarPreview change details at scale', () => {
+ const input = (container: HTMLElement) =>
+ getSlot(container, 'calendar-preview-input') as HTMLInputElement;
+
+ it('hands back the produced date through toDate()', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({ onValueChange, trailingValue: true });
+ switchTo(container, 'quarter');
+ fireEvent.click(period(container, 'Q3'));
+ const details = onValueChange.mock.calls[0][1];
+ expect(typeof details.toDate).toBe('function');
+ expect(details.toDate()).toEqual(new Date(2026, 8, 30));
+ });
+
+ it('reports the period of the scale that was committed, not the view', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({ onValueChange });
+ switchTo(container, 'month');
+ fireEvent.click(period(container, 'Aug'));
+ expect(onValueChange.mock.calls[0][1].period).toEqual({
+ start: '2026-08-01',
+ end: '2026-08-31'
+ });
+ });
+
+ it('reports the typed scale period, not the scale still on screen', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({ onValueChange });
+ fireEvent.change(input(container), { target: { value: 'Q4 2026' } });
+ fireEvent.keyDown(input(container), { key: 'Enter' });
+ expect(onValueChange.mock.calls[0][1].period).toEqual({
+ start: '2026-10-01',
+ end: '2026-12-31'
+ });
+ });
+});
+
+describe('CalendarPreview scale anchors on the visible month', () => {
+ it('drafts from the view month rather than today', () => {
+ const onValueChange = vi.fn();
+ const { container } = render(
+
+
+
+ );
+ switchTo(container, 'quarter');
+ fireEvent.click(period(container, 'Q1', 2030));
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2030-01-01',
+ scale: 'quarter'
+ });
+ });
+
+ it('opens the period list on the view month year', () => {
+ const { container } = render(
+
+
+
+ );
+ switchTo(container, 'month');
+ expect(period(container, 'Jan', 2030)).toBeInTheDocument();
+ expect(period(container, 'Jan', 2026)).toBeInTheDocument();
+ });
+
+ it('still follows the value when there is one', () => {
+ const onValueChange = vi.fn();
+ const { container } = render(
+
+
+
+ );
+ switchTo(container, 'quarter');
+ fireEvent.click(period(container, 'Q2', 2027));
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2027-04-01',
+ scale: 'quarter'
+ });
+ });
+});
+
+describe('CalendarPreview commits a day at one shape', () => {
+ const typeDay = (container: HTMLElement) => {
+ const input = getSlot(
+ container,
+ 'calendar-preview-input'
+ ) as HTMLInputElement;
+ fireEvent.change(input, { target: { value: '15 Aug 2026' } });
+ fireEvent.keyDown(input, { key: 'Enter' });
+ };
+
+ it('types a day as a ScaleValue on a scale-aware root', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({ onValueChange });
+ typeDay(container);
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2026-08-15',
+ scale: 'day'
+ });
+ });
+
+ it("types a day as a ScaleValue under scales={['day']}", () => {
+ const onValueChange = vi.fn();
+ const { container } = render(
+
+
+
+ );
+ typeDay(container);
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2026-08-15',
+ scale: 'day'
+ });
+ });
+
+ it('keeps a plain root on Date', () => {
+ const onValueChange = vi.fn();
+ const { container } = render(
+
+
+
+
+ );
+ typeDay(container);
+ expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2026, 7, 15));
+ });
+});
+
+describe('CalendarPreview at day scale on a scale-aware root', () => {
+ const dayCell = (container: HTMLElement, day: string) => {
+ const match = getAllSlots(container, 'calendar-preview-day').find(
+ cell =>
+ getSlot(cell, 'calendar-preview-day-number')?.textContent === day &&
+ !cell.hasAttribute('data-outside')
+ );
+ if (!match) throw new Error(`no cell for day ${day}`);
+ return match;
+ };
+
+ it('marks the day the value carries', () => {
+ const { container } = renderBody({
+ value: { date: '2026-08-20', scale: 'day' }
+ });
+ expect(dayCell(container, '20')).toHaveAttribute('data-selected');
+ });
+
+ it('commits a clicked day as a period at day scale', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({ onValueChange });
+ fireEvent.click(dayCell(container, '20'));
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2026-08-20',
+ scale: 'day'
+ });
+ });
+
+ it('keeps a bare Date for a day-only root', () => {
+ const onValueChange = vi.fn();
+ const { container } = render(
+
+
+
+ );
+ fireEvent.click(dayCell(container, '20'));
+ expect(onValueChange.mock.calls[0][0]).toEqual(new Date(2026, 7, 20));
+ });
+
+ it('labels a childless .Trigger with the period, at its own scale', () => {
+ const { container } = render(
+
+
+
+ );
+ expect(getSlot(container, 'calendar-preview-trigger')?.textContent).toBe(
+ 'Q3 2026'
+ );
+ });
+});
+
+describe('CalendarPreview.Reset at scale', () => {
+ const QUARTER = { date: '2026-07-01', scale: 'quarter' } as const;
+ const reset = (container: HTMLElement) =>
+ getSlot(container, 'calendar-preview-reset') as HTMLElement;
+
+ it('renders while the value differs from the period default', () => {
+ const { container } = renderBody({
+ defaultDate: QUARTER,
+ value: { date: '2026-10-01', scale: 'quarter' }
+ });
+ expect(reset(container)).toBeInTheDocument();
+ expect(reset(container)).not.toBeDisabled();
+ });
+
+ it('is not restored when only the day matches', () => {
+ const { container } = renderBody({
+ defaultDate: QUARTER,
+ value: { date: '2026-07-01', scale: 'month' }
+ });
+ expect(reset(container)).not.toBeDisabled();
+ });
+
+ it('stays mounted but inert once the day and the scale both match', () => {
+ const { container } = renderBody({
+ defaultDate: QUARTER,
+ value: QUARTER
+ });
+ expect(reset(container)).toHaveAttribute('aria-disabled', 'true');
+ expect(reset(container)).toHaveAttribute('data-restored');
+ });
+
+ it('restores the day and the scale together', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({
+ defaultDate: QUARTER,
+ defaultValue: { date: '2026-08-20', scale: 'day' },
+ onValueChange
+ });
+
+ fireEvent.click(reset(container));
+ expect(onValueChange).toHaveBeenCalledWith(
+ QUARTER,
+ expect.objectContaining({ reason: 'reset' })
+ );
+ expect(getSlot(container, 'calendar-preview-days')).toBeNull();
+ expect(
+ (getSlot(container, 'calendar-preview-input') as HTMLInputElement).value
+ ).toBe('Q3 2026');
+ });
+});
+
+describe('CalendarPreview period cells name their year', () => {
+ it('puts the year in a quarter cell name', () => {
+ const { container } = renderBody({ defaultScale: 'quarter' });
+ expect(period(container, 'Q3', 2026)).toHaveAttribute(
+ 'aria-label',
+ 'Q3 2026'
+ );
+ expect(period(container, 'Q3', 2027)).toHaveAttribute(
+ 'aria-label',
+ 'Q3 2027'
+ );
+ });
+
+ it('puts the year in a month cell name', () => {
+ const { container } = renderBody({ defaultScale: 'month' });
+ expect(period(container, 'Jan', 2030)).toHaveAttribute(
+ 'aria-label',
+ 'Jan 2030'
+ );
+ });
+
+ it('leaves a year cell named by itself', () => {
+ const { container } = renderBody({ defaultScale: 'year' });
+ expect(period(container, '2026', 2026)).toHaveAttribute(
+ 'aria-label',
+ '2026'
+ );
+ });
+});
+
+describe('CalendarPreview drops a draft to the scale it started from', () => {
+ const pressEscape = (container: HTMLElement) =>
+ fireEvent.keyDown(
+ getSlot(container, 'calendar-preview-body') as HTMLElement,
+ { key: 'Escape' }
+ );
+
+ it('restores defaultScale rather than the first offered scale', () => {
+ const onScaleChange = vi.fn();
+ const { container } = render(
+
+
+
+ );
+ switchTo(container, 'month');
+ expect(onScaleChange).toHaveBeenLastCalledWith('month');
+ pressEscape(container);
+ expect(onScaleChange).toHaveBeenLastCalledWith('year');
+ });
+
+ it('comes back to the start of the run, not a scale passed through it', () => {
+ const onScaleChange = vi.fn();
+ const { container } = renderBody({ defaultScale: 'year', onScaleChange });
+ switchTo(container, 'month');
+ switchTo(container, 'quarter');
+ pressEscape(container);
+ expect(onScaleChange).toHaveBeenLastCalledWith('year');
+ });
+
+ it('forgets the run once a period is committed', () => {
+ const onScaleChange = vi.fn();
+ const { container } = renderBody({ defaultScale: 'day', onScaleChange });
+ switchTo(container, 'quarter');
+ fireEvent.click(period(container, 'Q3'));
+ onScaleChange.mockClear();
+ pressEscape(container);
+ expect(onScaleChange).not.toHaveBeenCalledWith('day');
+ });
+});
+
+describe('CalendarPreview.Input reads the root clock', () => {
+ const input = (container: HTMLElement) =>
+ getSlot(container, 'calendar-preview-input') as HTMLInputElement;
+
+ it('resolves a bare period in the root year, not the wall clock', () => {
+ const onValueChange = vi.fn();
+ const FAR = new Date(2030, 0, 1);
+ const { container } = render(
+
+
+
+ );
+ fireEvent.change(input(container), { target: { value: 'Q4' } });
+ fireEvent.keyDown(input(container), { key: 'Enter' });
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2030-10-01',
+ scale: 'quarter'
+ });
+ });
+
+ it('commits a typed period at the trailing edge of an end root', () => {
+ const onValueChange = vi.fn();
+ const { container } = render(
+
+
+
+ );
+ fireEvent.change(input(container), { target: { value: 'Q4 2026' } });
+ fireEvent.keyDown(input(container), { key: 'Enter' });
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2026-12-31',
+ scale: 'quarter'
+ });
+ });
+
+ it('respects a bound that only the trailing edge clears', () => {
+ const onValueChange = vi.fn();
+ const { container } = render(
+
+
+
+ );
+ fireEvent.change(input(container), { target: { value: 'Q3 2026' } });
+ fireEvent.keyDown(input(container), { key: 'Enter' });
+ expect(onValueChange.mock.calls[0][0]).toEqual({
+ date: '2026-09-30',
+ scale: 'quarter'
+ });
+ });
+});
+
+describe('CalendarPreview formatValue sees the root time zone', () => {
+ it('passes it as the third argument', () => {
+ const formatValue = vi.fn(() => 'formatted');
+ renderBody({
+ formatValue,
+ timeZone: 'Pacific/Niue',
+ value: { date: '2026-08-20', scale: 'day' }
+ });
+ expect(formatValue).toHaveBeenCalledWith(
+ expect.anything(),
+ 'day',
+ 'Pacific/Niue'
+ );
+ });
+});
+
+describe('CalendarPreview drops a scale draft when the popover closes', () => {
+ function renderScalePicker(props = {}) {
+ const utils = render(
+
+
+
+
+
+
+
+
+
+ );
+ const input = getSlot(
+ utils.container,
+ 'calendar-preview-input'
+ ) as HTMLInputElement;
+ return { ...utils, input };
+ }
+
+ it('restores the field and the scale when Escape closes a bare panel', () => {
+ const { container, input } = renderScalePicker({
+ value: { date: '2026-08-20', scale: 'day' }
+ });
+ fireEvent.focus(input);
+ switchTo(document.body, 'quarter');
+ expect(input.value).toBe('Q3 2026');
+
+ fireEvent.keyDown(
+ getSlot(document.body, 'calendar-preview-content') as HTMLElement,
+ { key: 'Escape' }
+ );
+ expect(input.value).toBe('20 Aug 2026');
+ expect(getSlot(container, 'calendar-preview')).toHaveAttribute(
+ 'data-scale',
+ 'day'
+ );
+ });
+
+ it('keeps a committed period, and a later Escape does not undo it', () => {
+ const { container, input } = renderScalePicker();
+ fireEvent.focus(input);
+ switchTo(document.body, 'quarter');
+ fireEvent.click(period(document.body, 'Q3'));
+ expect(input.value).toBe('Q3 2026');
+
+ fireEvent.keyDown(
+ getSlot(document.body, 'calendar-preview-content') as HTMLElement,
+ { key: 'Escape' }
+ );
+ expect(input.value).toBe('Q3 2026');
+ expect(getSlot(container, 'calendar-preview')).toHaveAttribute(
+ 'data-scale',
+ 'quarter'
+ );
+ });
+});
+
+describe('CalendarPreview.Scales honours the order it was given', () => {
+ const labels = (container: HTMLElement) =>
+ getAllSlots(container, 'calendar-preview-scale').map(node =>
+ node.getAttribute('data-scale')
+ );
+
+ it('shows them in the order listed, not a canonical one', () => {
+ const { container } = renderBody({ scales: ['year', 'day', 'quarter'] });
+ expect(labels(container)).toEqual(['year', 'day', 'quarter']);
+ });
+
+ it('takes the first listed as the default scale', () => {
+ const { container } = renderBody({ scales: ['quarter', 'day'] });
+ expect(getSlot(container, 'calendar-preview')).toHaveAttribute(
+ 'data-scale',
+ 'quarter'
+ );
+ });
+
+ it('drops a repeat rather than rendering it twice', () => {
+ const { container } = renderBody({ scales: ['day', 'month', 'day'] });
+ expect(labels(container)).toEqual(['day', 'month']);
+ });
+});
+
+describe('CalendarPreview.Scale announces which scale is active', () => {
+ it('marks the active one pressed and the others not', () => {
+ const { container } = render(
+
+
+
+
+ );
+ const [day, quarter] = getAllSlots(container, 'calendar-preview-scale');
+ expect(day).toHaveAttribute('aria-pressed', 'false');
+ expect(quarter).toHaveAttribute('aria-pressed', 'true');
+ });
+});
+
+describe('CalendarPreview scale switches round-trip', () => {
+ const caption = (container: HTMLElement) =>
+ getSlot(container, 'calendar-preview-caption')?.textContent;
+ const field = (container: HTMLElement) =>
+ (getSlot(container, 'calendar-preview-input') as HTMLInputElement).value;
+
+ it('restores the committed day after a trip through a coarser scale', () => {
+ const { container } = renderBody({
+ defaultValue: { date: '2026-08-15', scale: 'day' }
+ });
+ expect(field(container)).toBe('15 Aug 2026');
+ expect(caption(container)).toBe('Aug 2026');
+
+ switchTo(container, 'year');
+ expect(field(container)).toBe('2026');
+
+ switchTo(container, 'day');
+ expect(field(container)).toBe('15 Aug 2026');
+ expect(caption(container)).toBe('Aug 2026');
+ });
+
+ it('leaves an empty field empty, and the view where it was', () => {
+ const { container } = renderBody();
+ switchTo(container, 'year');
+ switchTo(container, 'day');
+ expect(field(container)).toBe('');
+ expect(caption(container)).toBe('Aug 2026');
+ });
+
+ it('reads each scale off the value, not off the scale before it', () => {
+ const { container } = renderBody({
+ defaultValue: { date: '2026-08-15', scale: 'day' }
+ });
+ switchTo(container, 'year');
+ switchTo(container, 'month');
+ expect(field(container)).toBe('Aug 2026');
+ });
+
+ it('emits nothing across the whole round trip', () => {
+ const onValueChange = vi.fn();
+ const { container } = renderBody({
+ defaultValue: { date: '2026-08-15', scale: 'day' },
+ onValueChange
+ });
+ switchTo(container, 'year');
+ switchTo(container, 'quarter');
+ switchTo(container, 'day');
+ expect(onValueChange).not.toHaveBeenCalled();
+ });
+
+ it('puts the month back when the draft is dropped', () => {
+ const { container } = renderBody({
+ defaultValue: { date: '2026-08-15', scale: 'day' }
+ });
+ switchTo(container, 'year');
+ expect(caption(container)).toBeUndefined();
+
+ fireEvent.keyDown(
+ getSlot(container, 'calendar-preview-body') as HTMLElement,
+ {
+ key: 'Escape'
+ }
+ );
+ expect(field(container)).toBe('15 Aug 2026');
+ expect(caption(container)).toBe('Aug 2026');
+ });
+});
+
+describe('CalendarPreview period cells match the value scale', () => {
+ const marked = (container: HTMLElement) =>
+ getAllSlots(container, 'calendar-preview-period')
+ .filter(cell => cell.hasAttribute('data-selected'))
+ .map(cell => cell.getAttribute('aria-label'));
+
+ it('does not light a quarter for a half-year value', () => {
+ const { container } = renderBody({
+ defaultScale: 'quarter',
+ value: { date: '2028-01-01', scale: 'halfYear' }
+ });
+ expect(marked(container)).toEqual([]);
+ });
+
+ it('does not light a quarter for a month or a year value', () => {
+ const month = renderBody({
+ defaultScale: 'quarter',
+ value: { date: '2029-01-01', scale: 'month' }
+ });
+ expect(marked(month.container)).toEqual([]);
+ month.unmount();
+
+ const year = renderBody({
+ defaultScale: 'quarter',
+ value: { date: '2029-01-01', scale: 'year' }
+ });
+ expect(marked(year.container)).toEqual([]);
+ });
+
+ it('still lights the cell whose own scale the value carries', () => {
+ const { container } = renderBody({
+ defaultScale: 'quarter',
+ value: { date: '2029-01-01', scale: 'quarter' }
+ });
+ expect(marked(container)).toEqual(['Q1 2029']);
+ });
+
+ it('still lights the draft a switch produces', () => {
+ const { container } = renderBody({
+ defaultValue: { date: '2026-08-15', scale: 'day' }
+ });
+ switchTo(container, 'quarter');
+ expect(marked(container)).toEqual(['Q3 2026']);
+ });
+});
+
+describe('CalendarPreview period lists drop unreachable years', () => {
+ const groupYears = (container: HTMLElement) =>
+ getAllSlots(container, 'calendar-preview-period-group').map(group =>
+ group.getAttribute('data-year')
+ );
+
+ it('leaves out the years with nothing selectable in them', () => {
+ const { container } = renderBody({
+ minDate: new Date(2026, 6, 15),
+ today: TODAY
+ });
+ switchTo(container, 'month');
+
+ const years = groupYears(container);
+ expect(years).not.toContain('2025');
+ expect(years).not.toContain('2016');
+ expect(years[0]).toBe('2026');
+ expect(years).toContain('2036');
+ });
+
+ it('keeps every cell of a year the bound runs through', () => {
+ const { container } = renderBody({
+ minDate: new Date(2026, 6, 15),
+ today: TODAY
+ });
+ switchTo(container, 'month');
+
+ expect(period(container, 'Jan')).toBeInTheDocument();
+ expect(period(container, 'Jan')).toBeDisabled();
+ expect(period(container, 'Aug')).not.toBeDisabled();
+ });
+
+ it('drops nothing when there are no bounds', () => {
+ const { container } = renderBody({ today: TODAY });
+ switchTo(container, 'quarter');
+ expect(groupYears(container)).toHaveLength(21);
+ });
+
+ it('keeps the dead years when every year is dead', () => {
+ const { container } = renderBody({
+ minDate: new Date(2026, 5, 1),
+ maxDate: new Date(2026, 7, 1),
+ today: TODAY
+ });
+ switchTo(container, 'year');
+
+ const years = groupYears(container);
+ expect(years.length).toBeGreaterThan(0);
+ expect(
+ getAllSlots(container, 'calendar-preview-period').every(cell =>
+ cell.hasAttribute('data-unavailable')
+ )
+ ).toBe(true);
+ });
+});
diff --git a/packages/raystack/components/calendar-preview/__tests__/scale.test.ts b/packages/raystack/components/calendar-preview/__tests__/scale.test.ts
index 5cd4a7d01..3a55cc1b4 100644
--- a/packages/raystack/components/calendar-preview/__tests__/scale.test.ts
+++ b/packages/raystack/components/calendar-preview/__tests__/scale.test.ts
@@ -2,12 +2,12 @@ import { describe, expect, it } from 'vitest';
import {
anchorOf,
- type CalendarPreviewScale,
convertScale,
isAvailable,
isScale,
periodOf,
- SCALES
+ SCALES,
+ type Scale
} from '../lib/scale';
const LEADING = false;
@@ -70,13 +70,6 @@ describe('periodOf', () => {
expect(periodOf(day, 'year')).toEqual(expected);
});
- it('accepts a Date and reads its own calendar day', () => {
- expect(periodOf(new Date(2026, 7, 15), 'month')).toEqual({
- start: '2026-08-01',
- end: '2026-08-31'
- });
- });
-
it.each([
'2026-8-15',
'15/08/2026',
@@ -143,19 +136,14 @@ describe('anchorOf', () => {
});
describe('convertScale — every direction', () => {
- /*
- * The anchor is 15 August 2026, which sits in August, Q3, H2 and 2026. Every
- * cell is the period of the target scale containing that anchor, read at the
- * stated edge.
- */
- const leading: Record = {
+ const leading: Record = {
day: '2026-08-15',
month: '2026-08-01',
quarter: '2026-07-01',
halfYear: '2026-07-01',
year: '2026-01-01'
};
- const trailing: Record = {
+ const trailing: Record = {
day: '2026-08-15',
month: '2026-08-31',
quarter: '2026-09-30',
@@ -269,8 +257,10 @@ describe('convertScale — round trips', () => {
describe('isAvailable', () => {
it('is unbounded when neither bound is given', () => {
- expect(isAvailable('1000-01-01', 'day', LEADING)).toBe(true);
- expect(isAvailable('9999-12-31', 'year', TRAILING)).toBe(true);
+ expect(isAvailable('1000-01-01', 'day', { trailing: LEADING })).toBe(true);
+ expect(isAvailable('9999-12-31', 'year', { trailing: TRAILING })).toBe(
+ true
+ );
});
describe('the RFC table — an end field bounded at 15 July 2026', () => {
@@ -279,26 +269,28 @@ describe('isAvailable', () => {
it('disables H1 2026, which emits 30 June', () => {
expect(periodOf('2026-01-01', 'halfYear').end).toBe('2026-06-30');
- expect(isAvailable('2026-01-01', 'halfYear', trailing, min)).toBe(false);
+ expect(isAvailable('2026-01-01', 'halfYear', { trailing, min })).toBe(
+ false
+ );
});
it('allows July 2026, which emits 31 July', () => {
expect(periodOf('2026-07-01', 'month').end).toBe('2026-07-31');
- expect(isAvailable('2026-07-01', 'month', trailing, min)).toBe(true);
+ expect(isAvailable('2026-07-01', 'month', { trailing, min })).toBe(true);
});
it('allows Q3 2026, which emits 30 September', () => {
expect(periodOf('2026-07-01', 'quarter').end).toBe('2026-09-30');
- expect(isAvailable('2026-07-01', 'quarter', trailing, min)).toBe(true);
+ expect(isAvailable('2026-07-01', 'quarter', { trailing, min })).toBe(
+ true
+ );
});
it('allows August 2026', () => {
- expect(isAvailable('2026-08-01', 'month', trailing, min)).toBe(true);
+ expect(isAvailable('2026-08-01', 'month', { trailing, min })).toBe(true);
});
it('tests the produced date, not the period start', () => {
- /* Every period above starts before the bound; only the produced date
- * separates them. */
for (const [day, scale] of [
['2026-01-01', 'halfYear'],
['2026-07-01', 'month'],
@@ -317,7 +309,7 @@ describe('isAvailable', () => {
['2026-07-01', 'quarter'],
['2026-08-01', 'month']
] as const) {
- expect(isAvailable(day, scale, LEADING, min)).toBe(
+ expect(isAvailable(day, scale, { trailing: LEADING, min })).toBe(
periodOf(day, scale).start >= min
);
}
@@ -325,88 +317,112 @@ describe('isAvailable', () => {
describe('bounds are inclusive at both edges', () => {
it('accepts a day exactly on min', () => {
- expect(isAvailable('2026-07-15', 'day', LEADING, '2026-07-15')).toBe(
- true
- );
+ expect(
+ isAvailable('2026-07-15', 'day', {
+ trailing: LEADING,
+ min: '2026-07-15'
+ })
+ ).toBe(true);
});
it('rejects the day before min', () => {
- expect(isAvailable('2026-07-14', 'day', LEADING, '2026-07-15')).toBe(
- false
- );
+ expect(
+ isAvailable('2026-07-14', 'day', {
+ trailing: LEADING,
+ min: '2026-07-15'
+ })
+ ).toBe(false);
});
it('accepts a day exactly on max', () => {
expect(
- isAvailable('2026-07-15', 'day', LEADING, undefined, '2026-07-15')
+ isAvailable('2026-07-15', 'day', {
+ trailing: LEADING,
+ max: '2026-07-15'
+ })
).toBe(true);
});
it('rejects the day after max', () => {
expect(
- isAvailable('2026-07-16', 'day', LEADING, undefined, '2026-07-15')
+ isAvailable('2026-07-16', 'day', {
+ trailing: LEADING,
+ max: '2026-07-15'
+ })
).toBe(false);
});
it('accepts a period whose produced date lands exactly on max', () => {
expect(
- isAvailable('2026-08-10', 'month', TRAILING, undefined, '2026-08-31')
+ isAvailable('2026-08-10', 'month', {
+ trailing: TRAILING,
+ max: '2026-08-31'
+ })
).toBe(true);
expect(
- isAvailable('2026-08-10', 'month', TRAILING, undefined, '2026-08-30')
+ isAvailable('2026-08-10', 'month', {
+ trailing: TRAILING,
+ max: '2026-08-30'
+ })
).toBe(false);
});
it('accepts a period whose produced date lands exactly on min', () => {
- expect(isAvailable('2026-08-10', 'month', LEADING, '2026-08-01')).toBe(
- true
- );
- expect(isAvailable('2026-08-10', 'month', LEADING, '2026-08-02')).toBe(
- false
- );
+ expect(
+ isAvailable('2026-08-10', 'month', {
+ trailing: LEADING,
+ min: '2026-08-01'
+ })
+ ).toBe(true);
+ expect(
+ isAvailable('2026-08-10', 'month', {
+ trailing: LEADING,
+ min: '2026-08-02'
+ })
+ ).toBe(false);
});
});
it('applies both bounds together', () => {
expect(
- isAvailable('2026-08-15', 'day', LEADING, '2026-01-01', '2026-12-31')
+ isAvailable('2026-08-15', 'day', {
+ trailing: LEADING,
+ min: '2026-01-01',
+ max: '2026-12-31'
+ })
).toBe(true);
expect(
- isAvailable('2025-08-15', 'day', LEADING, '2026-01-01', '2026-12-31')
+ isAvailable('2025-08-15', 'day', {
+ trailing: LEADING,
+ min: '2026-01-01',
+ max: '2026-12-31'
+ })
).toBe(false);
expect(
- isAvailable('2027-08-15', 'day', LEADING, '2026-01-01', '2026-12-31')
+ isAvailable('2027-08-15', 'day', {
+ trailing: LEADING,
+ min: '2026-01-01',
+ max: '2026-12-31'
+ })
).toBe(false);
});
it('can allow a period in a start field and disable it in an end field', () => {
const max = '2026-08-15';
- expect(isAvailable('2026-08-01', 'month', LEADING, undefined, max)).toBe(
+ expect(isAvailable('2026-08-01', 'month', { trailing: LEADING, max })).toBe(
true
);
- expect(isAvailable('2026-08-01', 'month', TRAILING, undefined, max)).toBe(
- false
- );
- });
-
- it('accepts Dates for the value and for either bound', () => {
expect(
- isAvailable(
- new Date(2026, 7, 15),
- 'day',
- LEADING,
- new Date(2026, 0, 1),
- new Date(2026, 11, 31)
- )
- ).toBe(true);
+ isAvailable('2026-08-01', 'month', { trailing: TRAILING, max })
+ ).toBe(false);
});
it('rejects a malformed bound rather than ignoring it', () => {
expect(() =>
- isAvailable('2026-08-15', 'day', LEADING, '15/08/2026')
+ isAvailable('2026-08-15', 'day', { trailing: LEADING, min: '15/08/2026' })
).toThrow(RangeError);
expect(() =>
- isAvailable('2026-08-15', 'day', LEADING, undefined, '2026-13-01')
+ isAvailable('2026-08-15', 'day', { trailing: LEADING, max: '2026-13-01' })
).toThrow(RangeError);
});
});
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx
new file mode 100644
index 000000000..64454b373
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx
@@ -0,0 +1,61 @@
+import { mergeProps, useRender } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import type { ReactNode } from 'react';
+import styles from './calendar-preview.module.css';
+import { useCalendarPreviewContext } from './calendar-preview-context';
+import { CalendarPreviewInput } from './calendar-preview-input';
+import { CalendarPreviewLabel } from './calendar-preview-label';
+import { CalendarPreviewPanel } from './calendar-preview-panel';
+import { CalendarPreviewReset } from './calendar-preview-reset';
+import { CalendarPreviewScales } from './calendar-preview-scales';
+import { CalendarPreviewSeparator } from './calendar-preview-separator';
+
+export interface CalendarPreviewBodyProps
+ extends useRender.ComponentProps<'div'> {
+ label?: ReactNode;
+ /** @defaultValue false */
+ showIcon?: boolean;
+}
+
+export function CalendarPreviewBody({
+ label,
+ showIcon = false,
+ className,
+ children,
+ render,
+ ref,
+ ...props
+}: CalendarPreviewBodyProps) {
+ const { scale, dropDraft } = useCalendarPreviewContext(
+ 'CalendarPreview.Body'
+ );
+
+ return useRender({
+ defaultTagName: 'div',
+ ref,
+ render,
+ props: mergeProps<'div'>(
+ {
+ className: cx(styles.body, className),
+ 'data-slot': 'calendar-preview-body',
+ /* Escape drops the draft on its way to Base UI, which closes on it. */
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (event.key === 'Escape') dropDraft();
+ },
+ children: children ?? (
+ <>
+ {label}
+
+
+ {scale !== 'day' && }
+
+
+ >
+ )
+ } as useRender.ComponentProps<'div'>,
+ props
+ )
+ });
+}
+
+CalendarPreviewBody.displayName = 'CalendarPreview.Body';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx
index 7d841e979..89410c94a 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-caption.tsx
@@ -7,6 +7,9 @@ import {
} from '@base-ui/react';
import { cx } from 'class-variance-authority';
import { type ReactNode, useEffect, useRef } from 'react';
+import { ScrollArea } from '../scroll-area';
+import { Separator } from '../separator';
+import { useThemeInjection } from '../theme/portal';
import styles from './calendar-preview.module.css';
import {
useCalendarPreviewContext,
@@ -19,20 +22,10 @@ import {
shiftMonths
} from './date-adapter';
-/* Two elements, so two prop shapes: a plain caption is a `span`, one that
- opens the scroller is a `button`. */
export type CalendarPreviewCaptionProps =
| ({ dropdown?: false } & useRender.ComponentProps<'span'>)
| ({ dropdown: true } & useRender.ComponentProps<'button'>);
-/**
- * The label above the grid. Children replace it entirely, so
- * `Q3 2026` works.
- *
- * With `dropdown` it opens our own month and year scroller. No `Select` may be
- * mounted here — one is what makes the popover dismissal loop return. Picking
- * moves the view; it never selects a value.
- */
export function CalendarPreviewCaption(props: CalendarPreviewCaptionProps) {
return props.dropdown ? (
@@ -62,7 +55,6 @@ function CaptionLabel({
ref,
...props
}: { dropdown?: false } & useRender.ComponentProps<'span'>) {
- const { scale } = useCalendarPreviewContext('CalendarPreview.Caption');
const label = useCaptionLabel();
return useRender({
@@ -73,7 +65,6 @@ function CaptionLabel({
{
className: cx(styles.caption, className),
'data-slot': 'calendar-preview-caption',
- 'data-scale': scale,
children: children ?? label
} as useRender.ComponentProps<'span'>,
props
@@ -89,9 +80,11 @@ function CaptionDropdown({
ref,
...props
}: { dropdown: true } & useRender.ComponentProps<'button'>) {
- const { month, setMonth, yearRange, scale, disabled } =
- useCalendarPreviewContext('CalendarPreview.Caption');
+ const { month, setMonth, yearRange, disabled } = useCalendarPreviewContext(
+ 'CalendarPreview.Caption'
+ );
const label = useCaptionLabel();
+ const theme = useThemeInjection();
const activeMonth = month.getMonth();
const activeYear = month.getFullYear();
@@ -105,7 +98,6 @@ function CaptionDropdown({
{children ?? label}
-
+ setMonth(monthStart(activeYear, index))
}))}
/>
+ (null);
- /* A twenty-year column otherwise opens scrolled to the wrong end. Optional
- call: jsdom does not implement scrollIntoView. */
+ /* A twenty-year column otherwise opens scrolled to the wrong end. */
useEffect(() => {
activeRef.current?.scrollIntoView?.({ block: 'center' });
}, []);
return (
-
- {options.map(option => (
-
- ))}
-
+
+
+ {options.map(option => (
+
+ ))}
+
+
);
}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-content.tsx b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx
index 27972ca07..22b7aecb3 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-content.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-content.tsx
@@ -2,26 +2,26 @@ import { cx } from 'class-variance-authority';
import type { ComponentProps } from 'react';
import { Popover } from '../popover';
import styles from './calendar-preview.module.css';
+import { useCalendarPreviewContext } from './calendar-preview-context';
export type CalendarPreviewContentProps = ComponentProps<
typeof Popover.Content
>;
-/**
- * The portaled popover surface.
- *
- * Dismissal is Base UI's: outside press, escape and focus-out are all handled
- * by `Popover.Root`, so nothing in this directory listens on the document.
- */
export function CalendarPreviewContent({
className,
children,
...props
}: CalendarPreviewContentProps) {
+ const { triggerHasInput, shouldRestoreFinalFocus } =
+ useCalendarPreviewContext('CalendarPreview.Content');
+
return (
{children}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
index 4e6684607..25543ddb0 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx
@@ -1,14 +1,10 @@
'use client';
import type { Popover } from '@base-ui/react';
-import { createContext, type ReactNode, useContext } from 'react';
+import { createContext, type RefObject, useContext } from 'react';
import type { DayKey } from './date-adapter';
-import type {
- CalendarPreviewScale,
- CalendarPreviewScaleValue
-} from './lib/scale';
+import type { Scale, ScaleValue } from './lib/scale';
-/** What caused a value to change. */
export type CalendarPreviewChangeReason =
| 'select'
| 'input'
@@ -18,73 +14,52 @@ export type CalendarPreviewChangeReason =
export type CalendarPreviewOpenChangeDetails = Popover.Root.ChangeEventDetails;
-/** Which endpoint a range `.Input` addresses. */
export type CalendarPreviewField = 'start' | 'end';
-/**
- * A completed range. Neither edge is nullable: a range that is still being
- * built is a draft, and drafts are never emitted.
- */
export interface CalendarPreviewDateRange {
from: Date;
to: Date;
}
-/** A range mid-build. `to` is absent until the second click lands. */
export interface CalendarPreviewDraftRange {
- from: Date;
+ from?: Date;
to?: Date;
}
export interface CalendarPreviewChangeDetails {
- /** What caused the change. */
reason: CalendarPreviewChangeReason;
- /** Both edges, month-end correct. At day scale they are the same day. */
period: { start: DayKey; end: DayKey };
- /**
- * The day acted on — never null, even when `value` is, so a clear still says
- * which cell the user clicked.
- */
toDate: () => Date;
}
-/* Generic so a later phase's scale-aware arms carry a
- `CalendarPreviewScaleValue` without a second context: stored as `unknown`,
- cast once at the hook boundary. */
-export interface CalendarPreviewContextValue {
- value: Value;
- /** `occasion` is the day acted on, which a cleared `value` cannot carry. */
+export type CalendarPreviewValue =
+ | Date
+ | CalendarPreviewDateRange
+ | ScaleValue
+ | null;
+
+export interface CalendarPreviewContextValue {
+ value: CalendarPreviewValue;
setValue: (
- value: Value,
+ value: CalendarPreviewValue,
reason: CalendarPreviewChangeReason,
occasion: Date
) => void;
- /** Whether the popover is open. Always `false` for an inline calendar. */
open: boolean;
- /**
- * Base UI's own details, forwarded rather than re-declared, so `reason` stays
- * the typed union Base UI narrows on.
- */
setOpen: (open: boolean, details: CalendarPreviewOpenChangeDetails) => void;
- /**
- * Whether `.Trigger` must swallow the next focus-open, because the close it
- * would undo was an Escape or a press on the trigger itself. Reads and
- * clears. Tracks the last close reason, never the open state.
- */
shouldIgnoreFocusOpen: () => boolean;
- /** Read even when `value` is controlled. */
- defaultDate: Date | CalendarPreviewDateRange | null | undefined;
- /** A value reset — it never moves the view. */
+ shouldRestoreFinalFocus: () => boolean;
+ triggerRef: RefObject;
+ triggerHasInput: boolean;
+ setTriggerHasInput: (hasInput: boolean) => void;
+ defaultDate: Date | CalendarPreviewDateRange | ScaleValue | null | undefined;
reset: () => void;
month: Date;
- /** Never clamped by `minDate` / `maxDate`. */
setMonth: (month: Date) => void;
yearRange: { from: number; to: number };
- scale: CalendarPreviewScale;
- setScale: (scale: CalendarPreviewScale) => void;
+ scale: Scale;
+ setScale: (scale: Scale) => void;
isDateUnavailable: (date: Date) => boolean;
- /* Separate from `isDateUnavailable`, which folds them together: `.Input`
- reports which of the two rejected a typed date. */
minDate: Date | undefined;
maxDate: Date | undefined;
today: Date;
@@ -92,89 +67,52 @@ export interface CalendarPreviewContextValue {
clearable: boolean;
disabled: boolean;
readOnly: boolean;
- formatValue: (
- value: Date | CalendarPreviewScaleValue,
- scale: CalendarPreviewScale
- ) => string;
+ formatValue: (value: Date | ScaleValue, scale: Scale) => string;
+
+ scales: readonly Scale[];
+ trailingValue: boolean;
+ scaleDraft: ScaleValue | null;
+ switchScale: (scale: Scale) => void;
+ selectPeriod: (date: Date | string, scale: Scale) => void;
+ dropDraft: () => void;
+ isPeriodAvailable: (date: Date | string, scale: Scale) => boolean;
selection: 'single' | 'range';
- /**
- * Commits a clicked day. Single scale commits it directly; range runs the
- * from/to machine, which lives here because completing a range both writes
- * the value and closes the popover.
- */
selectDay: (date: Date) => void;
- /** Writes one named endpoint, for a typed `.Input`. */
+ commitDay: (date: Date, reason: CalendarPreviewChangeReason) => void;
setEndpoint: (field: CalendarPreviewField, date: Date) => void;
- /**
- * The range as the grid should draw it — the draft while one is being built,
- * the committed value otherwise. Never emitted; the track between endpoints
- * is styled from it.
- */
+ clearEndpoint: (field: CalendarPreviewField) => void;
draft: CalendarPreviewDraftRange | null;
- /** The endpoint the next click fills. `.Input` reads it to show focus. */
activeField: CalendarPreviewField;
setActiveField: (field: CalendarPreviewField) => void;
- /**
- * Which endpoints a `.Input` has declared read-only, so a grid click cannot
- * rewrite one. Registered by the inputs, because `readOnly` is their prop.
- */
fieldReadOnly: Record;
setFieldReadOnly: (field: CalendarPreviewField, readOnly: boolean) => void;
}
-const CalendarPreviewContext =
- createContext | null>(null);
-
-export function CalendarPreviewProvider({
- value,
- children
-}: {
- value: CalendarPreviewContextValue;
- children: ReactNode;
-}) {
- return (
- {children}
- );
-}
+export const CalendarPreviewContext =
+ createContext(null);
-/* `part` is the caller's display name, so the throw points at the element the
- author wrote rather than at this file. */
-export function useCalendarPreviewContext(
+/* `part` is the caller's display name, so the throw points at the author's element. */
+export function useCalendarPreviewContext(
part: string
-): CalendarPreviewContextValue {
+): CalendarPreviewContextValue {
const context = useContext(CalendarPreviewContext);
if (!context) {
throw new Error(`${part} must be used within `);
}
- return context as CalendarPreviewContextValue;
+ return context;
}
-/* `.Days` owns this rather than the root, so two day views in one tree cannot
- disable each other's navigation. */
+/* On `.Days`, so two day views in one tree cannot disable each other's navigation. */
export interface CalendarPreviewDaysContextValue {
numberOfMonths: number;
busy: boolean;
setBusy: (busy: boolean) => void;
}
-const CalendarPreviewDaysContext =
+export const CalendarPreviewDaysContext =
createContext(null);
-export function CalendarPreviewDaysProvider({
- value,
- children
-}: {
- value: CalendarPreviewDaysContextValue;
- children: ReactNode;
-}) {
- return (
-
- {children}
-
- );
-}
-
export function useCalendarPreviewDaysContext(): CalendarPreviewDaysContextValue | null {
return useContext(CalendarPreviewDaysContext);
}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx
index 8d8664ccf..0b8ce30f8 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx
@@ -5,8 +5,8 @@ import { cx } from 'class-variance-authority';
import { useMemo, useState } from 'react';
import styles from './calendar-preview.module.css';
import {
+ CalendarPreviewDaysContext,
type CalendarPreviewDaysContextValue,
- CalendarPreviewDaysProvider,
useCalendarPreviewContext
} from './calendar-preview-context';
import { CalendarPreviewGrid } from './calendar-preview-grid';
@@ -14,15 +14,11 @@ import { CalendarPreviewHeader } from './calendar-preview-header';
export interface CalendarPreviewDaysProps
extends useRender.ComponentProps<'div'> {
- /**
- * How many months the grid shows side by side.
- * @defaultValue 1
- */
+ /** @defaultValue 1 */
numberOfMonths?: number;
}
-/* Owns what the header and grid share, so two day views in one tree cannot
- disable each other's navigation. */
+/* Owns what the header and grid share, so two day views cannot disable each other. */
export function CalendarPreviewDays({
numberOfMonths = 1,
className,
@@ -49,12 +45,10 @@ export function CalendarPreviewDays({
{
className: cx(styles.days, className),
'data-slot': 'calendar-preview-days',
- 'data-scale': scale,
'data-disabled': disabled || undefined,
'data-readonly': readOnly || undefined,
'data-busy': busy || undefined,
- /* Several months caption themselves inside the grid, so a `.Header`
- here would be a second, redundant row. */
+ /* Several months caption themselves inside the grid, so a `.Header` here would duplicate. */
children: children ?? (
<>
{numberOfMonths <= 1 && }
@@ -66,10 +60,12 @@ export function CalendarPreviewDays({
)
});
+ if (scale !== 'day') return null;
+
return (
-
+
{element}
-
+
);
}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx
index da6af402b..1e7c45e66 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-footer.tsx
@@ -8,8 +8,6 @@ import styles from './calendar-preview.module.css';
export type CalendarPreviewFooterProps = ComponentProps;
-/* A bare string is wrapped in `Text` so the common case needs no knowledge of
- the type scale; anything else renders as given. */
export function CalendarPreviewFooter({
className,
children,
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx
index 78154e1e9..64b106a10 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-grid.tsx
@@ -35,23 +35,20 @@ import {
CalendarPreviewNextMonth,
CalendarPreviewPrevMonth
} from './calendar-preview-header';
-import { formatCaptionLabel, formatWeekdayLabel } from './date-adapter';
-
-/* The only file that may import react-day-picker. It runs with
- `hideNavigation` and `captionLayout='label'` so it never mounts a `Select`,
- and the selection props come from root context rather than from
- `CalendarPreviewGridProps` — which is what lets `...props` stay last. */
-/* Split in two on purpose. Every day button and its tooltip wrapper consume
- the day-facing half, so it is memoized — an unstable value there re-renders
- all 42 cells per month on any grid render. The root half carries
- `rootProps`, a fresh rest-spread every render that cannot be memoized
- without going stale; it has exactly one consumer, so its instability costs
- one element instead of 42. */
+import { isScaleValue } from './calendar-preview-root';
+import {
+ formatCaptionLabel,
+ formatWeekdayLabel,
+ parseKey
+} from './date-adapter';
+
+/* The only file that may import react-day-picker, and it never mounts a `Select`. */
interface GridContextValue {
dateInfo?: (date: Date) => ReactNode;
tooltipMessages?: (date: Date) => ReactNode;
showTooltip: boolean;
loading: boolean;
+ showOutsideDays: boolean;
}
interface GridRootContextValue {
@@ -81,48 +78,18 @@ function useGridRootContext(part: string): GridRootContextValue {
export interface CalendarPreviewGridProps
extends useRender.ComponentProps<'div'> {
- /**
- * Always render six week rows, so the grid height never jumps between a
- * 4-, 5- and 6-row month.
- *
- * On by default. Phases 3-4 put this calendar in a popover, where a grid
- * that changes height on navigation resizes the surface under the user's
- * cursor. Opt out with `fixedWeeks={false}` where the calendar is inline
- * and the trailing blank row is not wanted.
- *
- * @defaultValue true
- */
+ /** @defaultValue true */
fixedWeeks?: boolean;
- /**
- * Render the days either side of the month.
- *
- * Off, unlike the current `DatePicker`: reference A ends every grid on the
- * last day of its month and leaves the leading cells blank. The cells are
- * still rendered, so the week rows keep their shape — they are just empty.
- *
- * @defaultValue false
- */
+ /** @defaultValue false */
showOutsideDays?: boolean;
- /** Render a week-number column. */
showWeekNumber?: boolean;
- /** First day of the week, 0 (Sunday) to 6. */
weekStartsOn?: DayPickerProps['weekStartsOn'];
- /** Extra day modifiers, passed through to react-day-picker. */
modifiers?: DayPickerProps['modifiers'];
- /** Override react-day-picker's component slots. */
components?: Partial;
- /**
- * Extra content for a day, rendered above the date number.
- *
- * A function, not a record: the record form keyed cells by a formatted
- * string and silently missed every day once a `timeZone` shifted the key.
- */
dateInfo?: (date: Date) => ReactNode;
- /** Whether day tooltips are shown at all. @defaultValue false */
+ /** @defaultValue false */
showTooltip?: boolean;
- /** The tooltip for a day, or nothing. A function, for the same reason. */
tooltipMessages?: (date: Date) => ReactNode;
- /** Cover the grid with a skeleton and stop navigation. */
loading?: boolean;
}
@@ -140,10 +107,6 @@ export function CalendarPreviewGrid({
className,
render,
ref,
- /* Swallowed, not forwarded: `.Grid` renders the day cells from context, and
- `props` is spread last into the day-picker root, so a stray child would
- win over them and blank the calendar. `.Day` / `.Weekday` are `components`
- overrides, not children. */
children: _children,
...props
}: CalendarPreviewGridProps) {
@@ -164,20 +127,22 @@ export function CalendarPreviewGrid({
const days = useCalendarPreviewDaysContext();
const setBusy = days?.setBusy;
- /* The header is a sibling, so loading has to reach their common parent for
- navigation to go inert with it. */
useEffect(() => {
if (!setBusy) return;
setBusy(loading);
return () => setBusy(false);
}, [loading, setBusy]);
- /* `dateInfo` and `tooltipMessages` are functions, so a consumer passing
- inline arrows still invalidates this every render — which is why the docs
- ask for them to be memoized at the call site. */
+ /* Inline arrows invalidate this every render, which is why the docs ask for memoized ones. */
const gridContext = useMemo(
- () => ({ dateInfo, tooltipMessages, showTooltip, loading }),
- [dateInfo, tooltipMessages, showTooltip, loading]
+ () => ({
+ dateInfo,
+ tooltipMessages,
+ showTooltip,
+ loading,
+ showOutsideDays
+ }),
+ [dateInfo, tooltipMessages, showTooltip, loading, showOutsideDays]
);
const gridRootContext: GridRootContextValue = {
@@ -188,8 +153,12 @@ export function CalendarPreviewGrid({
const months = days?.numberOfMonths ?? 1;
- /* Several months have no single header to caption them, so each month
- captions itself and `.Days` renders no `.Header` above. */
+ const selected = isScaleValue(value)
+ ? parseKey(value.date)
+ : value instanceof Date
+ ? value
+ : undefined;
+
const slots = useMemo(
() => ({
Root: CalendarPreviewGridRoot,
@@ -204,11 +173,6 @@ export function CalendarPreviewGrid({
[components, months]
);
- /* Every click goes to the root, which owns both the single commit and the
- from/to machine — completing a range has to close the popover, and that
- must travel through the root's open state rather than from in here. It
- also keeps the `readOnly` / `disabled` guard in one place, so every path
- in and out of the calendar inherits the same one. */
const handleSelect = useCallback(
(_selected: unknown, triggerDate: Date) => {
selectDay(triggerDate);
@@ -216,9 +180,6 @@ export function CalendarPreviewGrid({
[selectDay]
);
- /* `mode`, `required`, `selected` and `onSelect` stay on the elements below:
- RDP discriminates its union on the literal `required`, which a `boolean`
- cannot narrow, so both arms are written out rather than cast away. */
const base = {
month,
onMonthChange: setMonth,
@@ -252,7 +213,7 @@ export function CalendarPreviewGrid({
{...base}
mode='range'
required={false}
- selected={draft ?? undefined}
+ selected={draft ? { from: draft.from, to: draft.to } : undefined}
onSelect={handleSelect}
/>
) : clearable ? (
@@ -260,7 +221,7 @@ export function CalendarPreviewGrid({
{...base}
mode='single'
required={false}
- selected={(value as Date | null) ?? undefined}
+ selected={selected}
onSelect={handleSelect}
/>
) : (
@@ -268,7 +229,7 @@ export function CalendarPreviewGrid({
{...base}
mode='single'
required
- selected={(value as Date | null) ?? undefined}
+ selected={selected}
onSelect={handleSelect}
/>
)}
@@ -279,8 +240,7 @@ export function CalendarPreviewGrid({
CalendarPreviewGrid.displayName = 'CalendarPreview.Grid';
-/* `` forwards only `className`, `style` and `data-*` to its root,
- so `render`, `ref` and the consumer's props have to land here instead. */
+/* `` forwards only `className`, `style` and `data-*` to its root. */
function CalendarPreviewGridRoot({ rootRef, ...rootProps }: RootProps) {
const {
rootRender,
@@ -313,24 +273,21 @@ function CalendarPreviewWeeks(props: MonthGridProps) {
aria-hidden='true'
>
);
}
-/* Three fixed grid columns rather than spacer elements: the empty nav track is
- still reserved when a month carries no button, so every caption centres on
- its own grid instead of drifting toward the buttonless side. */
function CalendarPreviewMonthCaption({
calendarMonth,
displayIndex,
- /* The class react-day-picker passes here hides the caption, which is what
- the single-month layout wants and this header must not be. */
+ /* The class RDP passes here hides the caption, which this header must not be. */
className: _className,
...props
}: MonthCaptionProps) {
@@ -363,8 +320,6 @@ export interface CalendarPreviewDayProps
extends DayButtonProps,
Pick, 'render' | 'ref'> {}
-/* At day scale the draft is the roving-focus cell — arrowed to, not entered.
- PR 5's scale-switch draft writes the same attribute. */
export function CalendarPreviewDay({
day,
modifiers,
@@ -379,11 +334,7 @@ export function CalendarPreviewDay({
'CalendarPreview.Day'
);
- /* Replacing react-day-picker's `DayButton` also replaces the effect it uses
- to move DOM focus, which lives on that component rather than in the
- library's keyboard handler. Without this, an arrow key moves RDP's focus
- target and the `data-draft` marker while focus stays put — so the next
- Enter commits the day the user navigated away from. */
+ /* Replacing RDP's `DayButton` also replaces the effect that moves DOM focus. */
const buttonRef = useRef(null);
const mergedRef = useMergedRefs(buttonRef, ref);
@@ -402,7 +353,6 @@ export function CalendarPreviewDay({
{
type: 'button',
className: cx(
- styles['day-button'],
info != null && styles['day-button-with-info'],
className
),
@@ -436,23 +386,25 @@ export function CalendarPreviewDay({
)
} as useRender.ComponentProps<'button'>,
props,
- /* After the spread, unlike everything else here. RDP sets
- `aria-disabled` per day and passes `undefined` for an available one,
- which would erase this — and `readOnly` is root state that has to win
- over a per-day value. It is not `disabled`: a read-only grid stays
- focusable and arrow-navigable, which is the whole difference. */
+ /* After the spread: RDP passes `undefined` per available day, which would erase this. */
readOnly
? ({ 'aria-disabled': true } as useRender.ComponentProps<'button'>)
: {}
)
});
- /* The wrapper is unconditional. Two reasons, both measured: a disabled
- button fires no pointer events, so hanging the trigger on the day itself
- hid exactly the tooltip a blocked day needs; and returning `button` bare
- when there is no message changes the element type at that position, which
- tears down the DOM node and drops focus the moment `showTooltip` or a
- per-day message flips. */
+ /* The span, not the day: a disabled button fires no pointer events. */
+ if (!showTooltip) {
+ return (
+
+ {button}
+
+ );
+ }
+
return (
;
+function CalendarPreviewWeekNumber({
+ week,
+ children,
+ ...props
+}: WeekNumberProps) {
+ const { showOutsideDays } = useGridContext('CalendarPreview.Grid');
+ /* A padding row's number counts a week the grid never showed. */
+ const counts = showOutsideDays || week.days.some(day => !day.outside);
+ return (
+
+ {counts ? children : null}
+
+ );
}
function CalendarPreviewWeekNumberHeader(props: WeekNumberHeaderProps) {
return
;
}
-/* Three-letter names from the adapter rather than react-day-picker's
- two-letter default, because the frames spell them `Sun Mon Tue`.
-
- English only, and deliberately so for now: there is no `locale` prop on this
- family yet, so every `format()` call falls back to date-fns' `en-US`, and
- the nav, reset and caption labels are hardcoded literals besides. Adding
- `locale` means threading it through `date-adapter` and adding a label bag
- for the literals — a change to make once, not one to half-make here. */
const GRID_FORMATTERS: DayPickerProps['formatters'] = {
formatWeekdayName: date => formatWeekdayLabel(date)
};
-/* month_caption is hidden, not removed: `.Header` owns the visible caption,
- and RDP still labels each table through it. */
+/* Hidden, not removed: RDP still labels each table through it. */
const GRID_CLASS_NAMES: DayPickerProps['classNames'] = {
months: styles.months,
month: styles.month,
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-header.tsx b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx
index 15f00da58..e33e72fc5 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-header.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-header.tsx
@@ -16,8 +16,7 @@ import { shiftMonths } from './date-adapter';
export type CalendarPreviewHeaderProps = useRender.ComponentProps<'div'>;
-/* Single-month only, and source order is tab order, so the row needs no CSS
- reordering. Several months caption themselves inside `.Grid`. */
+/* Source order is tab order, so the row needs no CSS reordering. */
export function CalendarPreviewHeader({
className,
children,
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx
index ee4d338b5..28c29ad7c 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx
@@ -5,8 +5,15 @@ import { Input } from '../input';
import styles from './calendar-preview.module.css';
import type { CalendarPreviewField } from './calendar-preview-context';
import { useCalendarPreviewContext } from './calendar-preview-context';
-import { dayKey, parseKey } from './date-adapter';
+import {
+ defaultFormatValue,
+ isRange as isRangeValue,
+ isScaleValue
+} from './calendar-preview-root';
+import { useTriggerInput } from './calendar-preview-trigger';
+import { anyDayBetween, dayKey, parseKey } from './date-adapter';
import { parseScaleInput } from './lib/parse';
+import type { Scale } from './lib/scale';
export type CalendarPreviewInputInvalidReason =
| 'unparseable'
@@ -17,37 +24,19 @@ export type CalendarPreviewInputInvalidReason =
export type CalendarPreviewInputValidity = {
valid: boolean;
reason?: CalendarPreviewInputInvalidReason;
- /**
- * The message to show, already resolved against `errorMessages`. Absent
- * while valid, so it can be handed straight to `Field`'s `error`.
- */
message?: string;
};
export interface CalendarPreviewInputProps
extends Omit, 'value' | 'defaultValue'> {
- /** Called when the typed text starts or stops being a usable date. */
onValidityChange?: (validity: CalendarPreviewInputValidity) => void;
- /**
- * Which endpoint this field addresses, at `selection='range'`. Two inputs,
- * each addressable — rather than one bag of props per endpoint.
- */
field?: CalendarPreviewField;
- /**
- * Replaces the message for one or more reasons; anything left out keeps the
- * default. That default is one flat string because only the consumer knows
- * the field's bounds — a built-in message cannot say which dates would be
- * accepted.
- *
- * @defaultValue `'Invalid input'` for every reason
- */
+ /** @defaultValue `'Invalid input'`, except out-of-order, which words itself */
errorMessages?: Partial>;
}
const DEFAULT_INVALID_MESSAGE = 'Invalid input';
-/* The one reason the component can word itself: it needs no knowledge of the
- field's bounds. */
const DEFAULT_OUT_OF_ORDER: Record = {
start: 'Start date cannot be after the end date',
end: 'End date cannot be before the start date'
@@ -55,13 +44,6 @@ const DEFAULT_OUT_OF_ORDER: Record = {
const VALID: CalendarPreviewInputValidity = { valid: true };
-/**
- * The typed date field.
- *
- * It never touches open state — `.Trigger` owns that. Typing sets a draft and
- * emits nothing; Enter and blur commit, and Base UI's outside press closes the
- * popover, which blurs and therefore commits too.
- */
export function CalendarPreviewInput({
field = 'start',
placeholder,
@@ -71,6 +53,7 @@ export function CalendarPreviewInput({
onKeyDown,
onBlur,
onFocus,
+ onValueChange: onValueChangeProp,
className,
readOnly: readOnlyProp,
...props
@@ -88,30 +71,75 @@ export function CalendarPreviewInput({
today,
disabled,
readOnly,
+ scales,
+ scaleDraft,
+ trailingValue,
+ selectPeriod,
+ isPeriodAvailable,
selection,
+ commitDay,
setEndpoint,
+ clearEndpoint,
draft,
activeField,
+ open,
setActiveField,
setFieldReadOnly
} = useCalendarPreviewContext('CalendarPreview.Input');
const isRange = selection === 'range';
- /* The grid has to know which endpoint refuses a write, and `readOnly` is
- this input's prop, so it registers rather than the root guessing. */
+ const trigger = useTriggerInput();
+ useEffect(() => {
+ trigger?.registerInput(true);
+ return () => trigger?.registerInput(false);
+ }, [trigger]);
+
useEffect(() => {
if (!isRange) return;
setFieldReadOnly(field, Boolean(readOnlyProp));
return () => setFieldReadOnly(field, false);
}, [isRange, field, readOnlyProp, setFieldReadOnly]);
- /* Null means "show the committed value"; a string is the user's draft. */
const [text, setText] = useState(null);
- const lastReported = useRef(VALID);
+ const [validity, setValidity] = useState(VALID);
+
+ const committed = useRef(value);
+
+ /* The partner endpoint and the bounds both move while retained text sits there. */
+ const judgedAgainst = useRef([]);
+ useEffect(() => {
+ const partner = isRange
+ ? field === 'start'
+ ? draft?.to
+ : draft?.from
+ : undefined;
+ const next = [
+ partner && dayKey(partner, timeZone),
+ minDate && dayKey(minDate, timeZone),
+ maxDate && dayKey(maxDate, timeZone),
+ isDateUnavailable
+ ];
+ const moved = next.some(
+ (item, index) => item !== judgedAgainst.current[index]
+ );
+ judgedAgainst.current = next;
+ if (!moved || text === null || committed.current !== value) return;
+ const trimmed = text.trim();
+ if (trimmed === '') return;
+ const resolved = resolve(trimmed);
+ report('valid' in resolved ? resolved : VALID);
+ });
+
+ useEffect(() => {
+ if (committed.current === value) return;
+ committed.current = value;
+ setText(null);
+ if (validity.valid) return;
+ setValidity(VALID);
+ onValidityChange?.(VALID);
+ }, [value, validity.valid, onValidityChange]);
- /* Derived from the reason rather than returned alongside it, so the reason
- stays the single source of truth. */
const withMessage = (
validity: CalendarPreviewInputValidity
): CalendarPreviewInputValidity =>
@@ -129,25 +157,34 @@ export function CalendarPreviewInput({
const report = (candidate: CalendarPreviewInputValidity) => {
const next = withMessage(candidate);
if (
- next.valid === lastReported.current.valid &&
- next.reason === lastReported.current.reason &&
- next.message === lastReported.current.message
+ next.valid === validity.valid &&
+ next.reason === validity.reason &&
+ next.message === validity.message
) {
return;
}
- lastReported.current = next;
+ setValidity(next);
onValidityChange?.(next);
};
- const resolve = (text: string): CalendarPreviewInputValidity | Date => {
- const parsed = parseScaleInput(text);
- /* Coarser scales parse today but have nowhere to go until the scale
- switcher lands, so they read as unparseable rather than committing a day
- the user did not type. */
- if (!parsed || parsed.scale !== 'day') {
+ const resolve = (
+ text: string
+ ): CalendarPreviewInputValidity | { date: Date; scale: Scale } => {
+ const parsed = parseScaleInput(text, {
+ referenceDate: today,
+ trailing: trailingValue
+ });
+ if (!parsed || !scales.includes(parsed.scale)) {
return { valid: false, reason: 'unparseable' };
}
const date = parseKey(parsed.date);
+
+ if (parsed.scale !== 'day') {
+ return isPeriodAvailable(date, parsed.scale)
+ ? { date, scale: parsed.scale }
+ : { valid: false, reason: 'out-of-bounds' };
+ }
+
const key = dayKey(date, timeZone);
if (
(minDate && key < dayKey(minDate, timeZone)) ||
@@ -156,9 +193,6 @@ export function CalendarPreviewInput({
return { valid: false, reason: 'out-of-bounds' };
}
if (isDateUnavailable(date)) return { valid: false, reason: 'unavailable' };
- /* The checks above read one date on its own and cannot see the partner. A
- grid click restarts instead of rejecting, on purpose. Equal days are a
- valid range. */
const partner = field === 'start' ? draft?.to : draft?.from;
if (isRange && partner) {
const typed = dayKey(date, timeZone);
@@ -166,23 +200,33 @@ export function CalendarPreviewInput({
if (field === 'start' ? typed > against : typed < against) {
return { valid: false, reason: 'out-of-order' };
}
+ const [lead, trail] =
+ typed < against ? [typed, against] : [against, typed];
+ if (anyDayBetween(lead, trail, isDateUnavailable)) {
+ return { valid: false, reason: 'unavailable' };
+ }
}
- return date;
+ return { date, scale: 'day' };
};
const commit = () => {
if (text === null) return;
const trimmed = text.trim();
if (trimmed === '') {
- if (clearable && value) setValue(null, 'clear', today);
+ if (clearable) {
+ if (isRange) clearEndpoint(field);
+ else if (value) setValue(null, 'clear', today);
+ }
setText(null);
report(VALID);
return;
}
const resolved = resolve(trimmed);
- if (!(resolved instanceof Date)) return;
- if (isRange) setEndpoint(field, resolved);
- else setValue(resolved, 'input', resolved);
+ if ('valid' in resolved) return;
+ if (isRange) setEndpoint(field, resolved.date);
+ else if (resolved.scale !== 'day')
+ selectPeriod(resolved.date, resolved.scale);
+ else commitDay(resolved.date, 'input');
setText(null);
report(VALID);
};
@@ -191,24 +235,38 @@ export function CalendarPreviewInput({
const endpoint = isRange
? ((field === 'start' ? draft?.from : draft?.to) ?? null)
- : (value as Date | null);
- const committedText = endpoint ? formatValue(endpoint, scale) : '';
+ : (scaleDraft ?? (isRangeValue(value) ? null : value));
+ const committedText = endpoint
+ ? formatValue(endpoint, isScaleValue(endpoint) ? endpoint.scale : scale)
+ : '';
+ /* From the scales this root offers and the default formats, not `formatValue`:
+ the parser reads only those, whatever the consumer displays. */
+ const carriesScale = scales.length > 1 || scales[0] !== 'day';
const resolvedPlaceholder =
placeholder ??
- (isRange
- ? field === 'start'
- ? 'Select start date'
- : 'Select end date'
- : 'Select date');
+ (carriesScale
+ ? `Try: ${scales
+ .slice(0, 3)
+ .map(one => defaultFormatValue(today, one, timeZone))
+ .join(', ')}`
+ : isRange
+ ? field === 'start'
+ ? 'Select start date'
+ : 'Select end date'
+ : 'Select date');
return (
{
onFocus?.(event);
if (isRange) setActiveField(field);
@@ -216,16 +274,13 @@ export function CalendarPreviewInput({
trailingIcon={trailingIcon}
disabled={disabled}
readOnly={readOnly || readOnlyProp}
- /* Input paints its error border from `data-invalid`, so marking only
- `aria-invalid` reached assistive tech and left the field looking
- untouched. Spread rather than set to `undefined`: these props land
- after Field's, and an explicit `undefined` erases the invalid state
- Field sets for errors this input knows nothing about. */
- {...(lastReported.current.valid
+ /* Spread, not set: an explicit `undefined` would erase Field's own invalid state. */
+ {...(validity.valid
? {}
: { 'aria-invalid': true, 'data-invalid': true })}
value={text ?? committedText}
- onValueChange={text => {
+ onValueChange={(text, details) => {
+ onValueChangeProp?.(text, details);
if (inert) return;
setText(text);
if (text.trim() === '') {
@@ -233,7 +288,7 @@ export function CalendarPreviewInput({
return;
}
const resolved = resolve(text);
- report(resolved instanceof Date ? VALID : resolved);
+ report('valid' in resolved ? resolved : VALID);
}}
onKeyDown={event => {
onKeyDown?.(event);
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-label.tsx b/packages/raystack/components/calendar-preview/calendar-preview-label.tsx
new file mode 100644
index 000000000..08c898fa9
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-label.tsx
@@ -0,0 +1,31 @@
+import { mergeProps, useRender } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import styles from './calendar-preview.module.css';
+
+export type CalendarPreviewLabelProps = useRender.ComponentProps<'span'>;
+
+export function CalendarPreviewLabel({
+ className,
+ children,
+ render,
+ ref,
+ ...props
+}: CalendarPreviewLabelProps) {
+ const element = useRender({
+ defaultTagName: 'span',
+ ref,
+ render,
+ props: mergeProps<'span'>(
+ {
+ className: cx(styles.label, className),
+ 'data-slot': 'calendar-preview-label',
+ children
+ } as useRender.ComponentProps<'span'>,
+ props
+ )
+ });
+
+ return children == null && render == null ? null : element;
+}
+
+CalendarPreviewLabel.displayName = 'CalendarPreview.Label';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx
new file mode 100644
index 000000000..ad4a16414
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx
@@ -0,0 +1,49 @@
+import { mergeProps, useRender } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import styles from './calendar-preview.module.css';
+import { useCalendarPreviewContext } from './calendar-preview-context';
+import { CalendarPreviewDays } from './calendar-preview-days';
+import {
+ CalendarPreviewHalfYears,
+ CalendarPreviewMonths,
+ CalendarPreviewQuarters,
+ CalendarPreviewYears
+} from './calendar-preview-periods';
+
+export type CalendarPreviewPanelProps = useRender.ComponentProps<'div'>;
+
+/* Each view gates on the scale itself, so `.Quarters` can be mounted alone. */
+export function CalendarPreviewPanel({
+ className,
+ children,
+ render,
+ ref,
+ ...props
+}: CalendarPreviewPanelProps) {
+ const { scale } = useCalendarPreviewContext('CalendarPreview.Panel');
+
+ return useRender({
+ defaultTagName: 'div',
+ ref,
+ render,
+ props: mergeProps<'div'>(
+ {
+ className: cx(styles.panel, className),
+ 'data-slot': 'calendar-preview-panel',
+ 'data-scale': scale,
+ children: children ?? (
+ <>
+
+
+
+
+
+ >
+ )
+ } as useRender.ComponentProps<'div'>,
+ props
+ )
+ });
+}
+
+CalendarPreviewPanel.displayName = 'CalendarPreview.Panel';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx
new file mode 100644
index 000000000..c8256d108
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx
@@ -0,0 +1,238 @@
+import { mergeProps, useRender } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import { useEffect, useMemo, useRef } from 'react';
+import styles from './calendar-preview.module.css';
+import { useCalendarPreviewContext } from './calendar-preview-context';
+import { isScaleValue } from './calendar-preview-root';
+import {
+ type DayKey,
+ dayKey,
+ dayKeyFromParts,
+ monthShortNames,
+ yearOf
+} from './date-adapter';
+import { anchorOf, periodOf, type Scale } from './lib/scale';
+
+export type CalendarPreviewPeriodViewProps = useRender.ComponentProps<'div'>;
+
+interface Cell {
+ key: string;
+ label: string;
+ date: DayKey;
+}
+
+const MONTHS = monthShortNames();
+
+function cellsFor(scale: Scale, year: number): Cell[] {
+ const at = (key: string, label: string, month: number): Cell | null => {
+ const date = dayKeyFromParts(year, month, 1);
+ return date === null ? null : { key, label, date };
+ };
+
+ let cells: (Cell | null)[];
+ if (scale === 'month') {
+ cells = MONTHS.map((label, index) =>
+ at(`${year}-${index}`, label, index + 1)
+ );
+ } else if (scale === 'quarter') {
+ cells = [0, 1, 2, 3].map(q => at(`${year}-q${q}`, `Q${q + 1}`, q * 3 + 1));
+ } else if (scale === 'halfYear') {
+ cells = [0, 1].map(h => at(`${year}-h${h}`, `H${h + 1}`, h * 6 + 1));
+ } else {
+ cells = [at(`${year}`, String(year), 1)];
+ }
+ return cells.filter((cell): cell is Cell => cell !== null);
+}
+
+function PeriodView({
+ scale: viewScale,
+ columns,
+ slot,
+ className,
+ children,
+ render,
+ ref,
+ ...props
+}: CalendarPreviewPeriodViewProps & {
+ scale: Scale;
+ columns: number;
+ slot: string;
+}) {
+ const {
+ scale,
+ scaleDraft,
+ value,
+ yearRange,
+ selectPeriod,
+ isPeriodAvailable,
+ trailingValue,
+ month,
+ timeZone,
+ disabled,
+ readOnly
+ } = useCalendarPreviewContext('CalendarPreview.Periods');
+
+ const years = useMemo(() => {
+ const list: number[] = [];
+ for (let y = yearRange.from; y <= yearRange.to; y += 1) list.push(y);
+ return list;
+ }, [yearRange]);
+
+ const activeYear = yearOf(
+ scaleDraft?.date ??
+ (isScaleValue(value) ? value.date : dayKey(month, timeZone))
+ );
+
+ const selected = scaleDraft ?? (isScaleValue(value) ? value : null);
+ const selectedKey = selected?.scale === viewScale ? selected.date : null;
+
+ const isActive = scale === viewScale;
+
+ /* All five views mount at once, so the inactive ones build no cells. */
+ const groups = useMemo(() => {
+ if (!isActive) return [];
+ const all = years.map(year => ({
+ year,
+ cells: cellsFor(viewScale, year).map(cell => ({
+ ...cell,
+ produced: anchorOf(periodOf(cell.date, viewScale), trailingValue),
+ unavailable: !isPeriodAvailable(cell.date, viewScale)
+ }))
+ }));
+ /* Years outside the bounds would be dead tab stops, unless dropping them empties the panel. */
+ const reachable = all.filter(group =>
+ group.cells.some(cell => !cell.unavailable)
+ );
+ return reachable.length > 0 ? reachable : all;
+ }, [isActive, years, viewScale, trailingValue, isPeriodAvailable]);
+
+ /* On becoming active, not on mount: a mount effect fires with an empty ref. */
+ const activeRef = useRef(null);
+ useEffect(() => {
+ if (!isActive) return;
+ const group = activeRef.current;
+ const list = group?.parentElement;
+ /* The ref lags a render behind `activeYear`. */
+ if (!group || !list || group.dataset.year !== String(activeYear)) return;
+ list.scrollTop +=
+ group.getBoundingClientRect().top - list.getBoundingClientRect().top;
+ }, [isActive, activeYear]);
+
+ const element = useRender({
+ defaultTagName: 'div',
+ ref,
+ render,
+ props: mergeProps<'div'>(
+ {
+ className: cx(styles.periods, className),
+ 'data-slot': slot,
+ children: children ?? (
+ <>
+ {groups.map(({ year, cells }) => (
+
+ ))}
+ >
+ )
+ } as useRender.ComponentProps<'div'>,
+ props
+ )
+ });
+
+ return isActive ? element : null;
+}
+
+export function CalendarPreviewMonths(props: CalendarPreviewPeriodViewProps) {
+ return (
+
+ );
+}
+CalendarPreviewMonths.displayName = 'CalendarPreview.Months';
+
+export function CalendarPreviewQuarters(props: CalendarPreviewPeriodViewProps) {
+ return (
+
+ );
+}
+CalendarPreviewQuarters.displayName = 'CalendarPreview.Quarters';
+
+export function CalendarPreviewHalfYears(
+ props: CalendarPreviewPeriodViewProps
+) {
+ return (
+
+ );
+}
+CalendarPreviewHalfYears.displayName = 'CalendarPreview.HalfYears';
+
+export function CalendarPreviewYears(props: CalendarPreviewPeriodViewProps) {
+ return (
+
+ );
+}
+CalendarPreviewYears.displayName = 'CalendarPreview.Years';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx
index 6a3e0d221..c0a631c63 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-reset.tsx
@@ -6,61 +6,58 @@ import { UndoIcon } from '~/icons';
import { IconButton } from '../icon-button';
import styles from './calendar-preview.module.css';
import { useCalendarPreviewContext } from './calendar-preview-context';
-import type { CalendarPreviewValue } from './calendar-preview-root';
+import { isRange, isScaleValue } from './calendar-preview-root';
import { dayKey } from './date-adapter';
export type CalendarPreviewResetProps = ComponentProps;
-/**
- * Restores `defaultDate` — a day, or a range at range selection — or clears
- * the selection when it is `null`. A value
- * reset, not a view reset — it leaves the
- * visible month alone. Keyed off `defaultDate` rather than `defaultValue` so
- * it still shows under a controlled `value`.
- *
- * With nothing to restore it stays mounted and disabled rather than
- * unmounting: unmounting the focused element sends focus to ``, which
- * strands a keyboard user mid-calendar, and removing a `flex: none` child
- * from the header re-flows both nav buttons sideways every time the value
- * crosses the default.
- */
+/* Unmounting would send focus to `` and drop the nav's `flex: none` child. */
export function CalendarPreviewReset({
className,
children,
onClick,
+ disabled: disabledProp,
...props
}: CalendarPreviewResetProps) {
const { value, defaultDate, reset, disabled, readOnly, timeZone } =
- useCalendarPreviewContext('CalendarPreview.Reset');
+ useCalendarPreviewContext('CalendarPreview.Reset');
- /* No `defaultDate` means the part has no job at all, which is a different
- thing from having nothing to restore right now — `null` is a default. */
if (defaultDate === undefined) return null;
const sameDay = (a: Date, b: Date) =>
dayKey(a, timeZone) === dayKey(b, timeZone);
- /* Both edges have to match: a shared start is not a restored range. */
+ /* A period matches on scale too: the same day at two scales is two values. */
const restored =
defaultDate === null
? value == null
: value != null &&
(defaultDate instanceof Date
? value instanceof Date && sameDay(value, defaultDate)
- : !(value instanceof Date) &&
- sameDay(value.from, defaultDate.from) &&
- sameDay(value.to, defaultDate.to));
+ : isRange(defaultDate)
+ ? isRange(value) &&
+ sameDay(value.from, defaultDate.from) &&
+ sameDay(value.to, defaultDate.to)
+ : isScaleValue(value) &&
+ value.date === defaultDate.date &&
+ value.scale === defaultDate.scale);
+
+ /* `aria-disabled`, not `disabled`: a disabled element cannot hold the focus it was activated with. */
+ const inert = disabled || readOnly || disabledProp;
return (
{
onClick?.(event);
+ /* `aria-disabled` is a claim, not a guard — the press still arrives. */
+ if (restored) return;
reset();
}}
{...props}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
index 533a91be1..54a327ea7 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx
@@ -1,23 +1,24 @@
'use client';
import { mergeProps, Popover, useRender } from '@base-ui/react';
-import { createChangeEventDetails } from '@base-ui/react/internals/createBaseUIEventDetails';
import { REASONS } from '@base-ui/react/internals/reasons';
import { useControlled } from '@base-ui/utils/useControlled';
import { cx } from 'class-variance-authority';
-import { useCallback, useMemo, useRef, useState } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import styles from './calendar-preview.module.css';
import {
type CalendarPreviewChangeDetails,
type CalendarPreviewChangeReason,
+ CalendarPreviewContext,
type CalendarPreviewContextValue,
type CalendarPreviewDateRange,
type CalendarPreviewDraftRange,
type CalendarPreviewField,
type CalendarPreviewOpenChangeDetails,
- CalendarPreviewProvider
+ type CalendarPreviewValue
} from './calendar-preview-context';
import {
+ anyDayBetween,
dayKey,
formatDayLabel,
formatMonthLabel,
@@ -26,171 +27,153 @@ import {
yearOf
} from './date-adapter';
import {
- type CalendarPreviewScale,
- type CalendarPreviewScaleValue,
- periodOf
+ anchorOf,
+ convertScale,
+ isAvailable,
+ isScale,
+ periodOf,
+ type Scale,
+ type ScaleValue
} from './lib/scale';
const DEFAULT_YEAR_SPAN = 10;
-function isRange(value: unknown): value is CalendarPreviewDateRange {
+export function isRange(value: unknown): value is CalendarPreviewDateRange {
return value != null && typeof value === 'object' && 'from' in value;
}
-/* The day the view should open on, whichever selection shape the value is. */
-function monthAnchor(
+export function monthAnchor(
value: CalendarPreviewValue | undefined
): Date | undefined {
if (!value) return undefined;
- return isRange(value) ? value.from : value;
+ if (isRange(value)) return value.from;
+ return value instanceof Date ? value : parseKey(value.date);
}
-/* `defaultValue` is omitted because `HTMLAttributes` already declares it as a
- form value, which is not what it means here. */
-export type CalendarPreviewValue = Date | CalendarPreviewDateRange | null;
+export type { CalendarPreviewValue };
+
+export function isScaleValue(
+ value: CalendarPreviewValue | undefined
+): value is ScaleValue {
+ return value != null && !(value instanceof Date) && 'date' in value;
+}
-/* Selection arms are discriminated on `selection`, so a single-day consumer
- keeps a `Date | null` callback and a range consumer gets a range that has
- both edges. One shared `value` type would widen both. */
interface CalendarPreviewSingleProps {
selection?: 'single';
- /** The selected day (controlled). */
+ scales?: 'day';
value?: Date | null;
- /** The initially selected day (uncontrolled). */
defaultValue?: Date | null;
onValueChange?: (
value: Date | null,
details: CalendarPreviewChangeDetails
) => void;
- /**
- * The day `.Reset` restores, read even when `value` is controlled — which
- * `defaultValue` is not. `null` is a default of *nothing selected*, so
- * `.Reset` clears; omitting it renders no button at all.
- */
+ /** `.Reset`'s target, read even when controlled. `null` clears; omitted hides it. */
defaultDate?: Date | null;
}
interface CalendarPreviewRangeProps {
selection: 'range';
- /** The selected range (controlled). Both edges, or nothing. */
+ scales?: 'day';
value?: CalendarPreviewDateRange | null;
- /** The initial range (uncontrolled). */
defaultValue?: CalendarPreviewDateRange | null;
- /**
- * Fires on a **complete** range or not at all. The half-built state stays
- * internal, so there is no partial `{ from?, to? }` to gate on.
- */
onValueChange?: (
value: CalendarPreviewDateRange | null,
details: CalendarPreviewChangeDetails
) => void;
- /**
- * The range `.Reset` restores, read even when `value` is controlled — which
- * `defaultValue` is not. `null` is a default of *nothing selected*, so
- * `.Reset` clears; omitting it renders no button at all.
- */
+ /** `.Reset`'s target, read even when controlled. `null` clears; omitted hides it. */
defaultDate?: CalendarPreviewDateRange | null;
}
+/* On the SHAPE of `scales`, so `scales={['day']}` takes this arm and `scales='day'` does not. */
+interface CalendarPreviewScaleAwareProps {
+ selection?: 'single';
+ scales: Exclude | Scale[];
+ value?: ScaleValue | null;
+ defaultValue?: ScaleValue | null;
+ onValueChange?: (
+ value: ScaleValue | null,
+ details: CalendarPreviewChangeDetails
+ ) => void;
+ /** `.Reset`'s target, read even when controlled. */
+ defaultDate?: ScaleValue | null;
+}
+
export type CalendarPreviewProps = (
| CalendarPreviewSingleProps
| CalendarPreviewRangeProps
+ | CalendarPreviewScaleAwareProps
) &
CalendarPreviewSharedProps;
interface CalendarPreviewSharedProps
extends Omit, 'defaultValue' | 'onChange'> {
- /** Whether the popover is open (controlled). Ignored by an inline calendar. */
+ /** @defaultValue the first of `scales` */
+ defaultScale?: Scale;
+ scale?: Scale;
+ onScaleChange?: (scale: Scale) => void;
+ /** A period emits its last day, not its first. @defaultValue false */
+ trailingValue?: boolean;
open?: boolean;
/** @defaultValue false */
defaultOpen?: boolean;
- /** Base UI's typed details, forwarded unchanged. */
onOpenChange?: (
open: boolean,
details: CalendarPreviewOpenChangeDetails
) => void;
- /** The first month the grid displays (controlled). */
month?: Date;
- /**
- * The month the grid opens on.
- * @defaultValue the month of `value`, else `today`
- */
+ /** @defaultValue the month of `value`, else `today` */
defaultMonth?: Date;
- /** Called when the view moves. */
onMonthChange?: (month: Date) => void;
/**
- * The years the caption's year column offers.
+ * The years the period views and the caption's year column offer. Passing it
+ * replaces the default, so a bound outside it stays unreachable.
* @defaultValue ten years either side of `today`, widened to cover any bound
*/
yearRange?: { from: number; to: number };
- /** Earliest selectable day, inclusive. Never clamps navigation. */
+ /** Never clamps navigation. A period is tested against the day it emits. */
minDate?: Date;
- /** Latest selectable day, inclusive. Never clamps navigation. */
maxDate?: Date;
- /** Reject individual days. Applied on top of `minDate` / `maxDate`. */
+ /* Day scale only: a day predicate has no single lift to a period. */
isDateUnavailable?: (date: Date) => boolean;
- /**
- * Renders a value for display.
- * @defaultValue `DD/MM/YYYY` at day scale
- */
+ /** @defaultValue `DD MMM YYYY` at day scale, the period's shorthand above it */
formatValue?: (
- value: Date | CalendarPreviewScaleValue,
- scale: CalendarPreviewScale
+ value: Date | ScaleValue,
+ scale: Scale,
+ timeZone?: string
) => string;
/**
- * The zone the grid reads days in. Forwarded to the grid; this family does
- * no conversion of its own (RFC 005).
- *
- * Every `Date` prop and every `Date` handed back is therefore an **instant**,
- * not a calendar day, and the calendar shows the day that instant falls on
- * in this zone. At a far offset that is not the day the local fields spell:
- * with `timeZone="Pacific/Niue"`, a `defaultMonth` of `new Date(2026, 7, 1)`
- * is 31 July there, and the grid opens on July. Build `Date`s for a zoned
- * calendar from a known instant — `new Date(Date.UTC(…))` — rather than from
- * local calendar fields.
- *
- * `onValueChange` receives whatever the grid produced, which is a `TZDate`
- * when this is set. It is a `Date` subclass carrying the same instant, so
- * `getTime()` and comparisons are unaffected; only its field getters read in
- * this zone.
+ * Every `Date` is an instant, not a calendar day, so at a far offset it is
+ * not the day its local fields spell — build them from `Date.UTC`.
*/
timeZone?: string;
- /**
- * Today, injectable so a calendar renders deterministically in tests.
- * @defaultValue `new Date()`
- */
+ /** Injectable so tests render deterministically. @defaultValue `new Date()` */
today?: Date;
- /**
- * Whether clicking the selected day deselects it.
- * @defaultValue true
- */
+ /** Click-to-deselect is day scale only; a period re-commits. @defaultValue true */
clearable?: boolean;
- /**
- * Whether the whole calendar is inert and every day is disabled.
- * @defaultValue false
- */
+ /** @defaultValue false */
disabled?: boolean;
- /**
- * Whether the value can be read and navigated but not changed.
- * @defaultValue false
- */
+ /** @defaultValue false */
readOnly?: boolean;
}
-/* Exported for its tests; `formatValue` replaces it wholesale. */
+function scaleChanged(value: CalendarPreviewValue, next: Scale): boolean {
+ return isScaleValue(value) && value.scale !== next;
+}
+
export function defaultFormatValue(
- value: Date | CalendarPreviewScaleValue,
- scale: CalendarPreviewScale
+ value: Date | ScaleValue,
+ scale: Scale,
+ timeZone?: string
): string {
const date = value instanceof Date ? value : parseKey(value.date);
- if (scale === 'day') return formatDayLabel(date);
- if (scale === 'month') return formatMonthLabel(date);
+ if (scale === 'day') return formatDayLabel(date, timeZone);
+ if (scale === 'month') return formatMonthLabel(date, timeZone);
- const key = dayKey(date);
+ const key = dayKey(date, timeZone);
const year = yearOf(key);
if (scale === 'year') return String(year);
const month = monthOf(key);
@@ -200,6 +183,11 @@ export function defaultFormatValue(
export function CalendarPreviewRoot({
selection = 'single',
+ scales: scalesProp = 'day',
+ scale: scaleProp,
+ defaultScale,
+ onScaleChange,
+ trailingValue = false,
value: valueProp,
defaultValue = null,
onValueChange,
@@ -214,7 +202,7 @@ export function CalendarPreviewRoot({
maxDate,
isDateUnavailable: isDateUnavailableProp,
defaultDate,
- formatValue = defaultFormatValue,
+ formatValue: formatValueProp = defaultFormatValue,
timeZone,
today: todayProp,
clearable = true,
@@ -228,8 +216,6 @@ export function CalendarPreviewRoot({
}: CalendarPreviewProps) {
const today = useMemo(() => todayProp ?? new Date(), [todayProp]);
- /* The public props are discriminated on `selection`; the implementation is
- shared and works in the widened value. This is the one seam between them. */
const emit = onValueChange as
| ((
value: CalendarPreviewValue,
@@ -246,10 +232,7 @@ export function CalendarPreviewRoot({
const [month, setMonthUnwrapped] = useControlled({
controlled: monthProp,
- /* `valueProp` before `defaultValue`: `defaultValue` is forced to null the
- moment `value` is controlled, so reading it alone opened a controlled
- calendar on today's month with the selection off-screen — against this
- prop's own documented default. */
+ /* `valueProp` first: `defaultValue` is nulled once `value` is controlled. */
default:
defaultMonth ??
monthAnchor(valueProp) ??
@@ -259,15 +242,53 @@ export function CalendarPreviewRoot({
state: 'month'
});
- /* Uncontrolled until the scale switcher lands in PR 5. The state lives here
- now so the parts and `useCalendar()` read it from one place either way. */
- const [scale, setScaleUnwrapped] = useControlled({
- controlled: undefined,
- default: 'day',
+ const scales = useMemo(() => {
+ const list = (Array.isArray(scalesProp) ? scalesProp : [scalesProp]).filter(
+ isScale
+ );
+ return list.length > 0 ? Array.from(new Set(list)) : ['day'];
+ }, [scalesProp]);
+
+ const [scale, setScaleUnwrapped] = useControlled({
+ controlled: scaleProp,
+ /* The value's own scale, or a quarter opens on the day grid unmarked. */
+ default:
+ defaultScale ??
+ (isScaleValue(valueProp)
+ ? valueProp.scale
+ : isScaleValue(defaultValue)
+ ? defaultValue.scale
+ : scales[0]),
name: 'CalendarPreview',
state: 'scale'
});
+ const [scaleDraft, setScaleDraftState] = useState(null);
+
+ /* Escape drops the draft twice in one event; state is a render behind. */
+ const scaleDraftRef = useRef(null);
+
+ const setScaleDraft = useCallback((next: ScaleValue | null) => {
+ scaleDraftRef.current = next;
+ setScaleDraftState(next);
+ }, []);
+
+ const draftOrigin = useRef<{
+ value: ScaleValue | null;
+ month: Date;
+ scale: Scale;
+ } | null>(null);
+
+ const clearScaleDraft = useCallback(() => {
+ setScaleDraft(null);
+ draftOrigin.current = null;
+ }, [setScaleDraft]);
+
+ const carriesScale = Array.isArray(scalesProp) || scalesProp !== 'day';
+
+ /* A typed date can land outside the visible month; a click cannot. */
+ const revealMonthRef = useRef<((date: Date) => void) | null>(null);
+
const setMonth = useCallback(
(next: Date) => {
setMonthUnwrapped(next);
@@ -276,10 +297,8 @@ export function CalendarPreviewRoot({
[setMonthUnwrapped, onMonthChange]
);
- /* The inertness guard lives here rather than in the grid's click handler:
- `useCalendar().setValue` and `reset()` reach this same function, and a
- guard further out would leave both of them able to write to a calendar
- the consumer asked to be read-only. */
+ const emitted = useRef(value);
+
const setValue = useCallback(
(
next: CalendarPreviewValue,
@@ -287,16 +306,40 @@ export function CalendarPreviewRoot({
occasion: Date
) => {
if (readOnly || disabled) return;
+ emitted.current = next;
setValueUnwrapped(next);
emit?.(next, {
reason,
- period: periodOf(occasion, scale, timeZone),
+ period: periodOf(
+ dayKey(occasion, timeZone),
+ isScaleValue(next) ? next.scale : scale
+ ),
toDate: () => occasion
});
},
[setValueUnwrapped, emit, scale, timeZone, readOnly, disabled]
);
+ const commitDay = useCallback(
+ (date: Date, reason: CalendarPreviewChangeReason) => {
+ const key = dayKey(date, timeZone);
+ clearScaleDraft();
+ if (!carriesScale) {
+ setValue(date, reason, date);
+ return;
+ }
+ setValue(
+ { date: key, scale: 'day' },
+ scaleChanged(value, 'day') ? 'scale' : reason,
+ date
+ );
+ /* The view follows what was committed, or it has no cell to mark. */
+ settleScaleRef.current?.('day');
+ revealMonthRef.current?.(date);
+ },
+ [carriesScale, timeZone, value, setValue, clearScaleDraft]
+ );
+
const [open, setOpenUnwrapped] = useControlled({
controlled: openProp,
default: defaultOpen,
@@ -304,27 +347,52 @@ export function CalendarPreviewRoot({
state: 'open'
});
- /* Escape, a press on the trigger, and completing a range all leave focus on
- the trigger, so the focus event that follows would immediately undo the
- close. Recording the reason lets `.Trigger` swallow exactly that one focus
- — the rule floating-ui's own `useFocus` applies, plus `closePress`, which
- is ours because auto-closing on completion is. */
const focusOpenBlocked = useRef(false);
+ const triggerRef = useRef(null);
+
+ /* The restore trails the close by the exit transition, so nothing timed is safe. */
+ useEffect(() => {
+ const release = () => {
+ focusOpenBlocked.current = false;
+ };
+ document.addEventListener('pointerdown', release, true);
+ document.addEventListener('keydown', release, true);
+ return () => {
+ document.removeEventListener('pointerdown', release, true);
+ document.removeEventListener('keydown', release, true);
+ };
+ }, []);
+
+ const armFocusGuard = useCallback((leaving: boolean) => {
+ /* Read on pointerdown, before focus has moved, so only the reason says it is leaving. */
+ focusOpenBlocked.current =
+ leaving || !triggerRef.current?.contains(document.activeElement);
+ }, []);
+
+ /* `dropDraft` and `settleScale` close over state declared further down. */
+ const dropDraftRef = useRef<(() => void) | null>(null);
+ const settleScaleRef = useRef<((scale: Scale) => void) | null>(null);
+
+ const dismissedByOutsidePress = useRef(false);
const setOpen = useCallback(
(next: boolean, details: CalendarPreviewOpenChangeDetails) => {
- if (
- !next &&
- (details.reason === REASONS.escapeKey ||
- details.reason === REASONS.triggerPress ||
- details.reason === REASONS.closePress)
- ) {
- focusOpenBlocked.current = true;
+ if (!next) {
+ const outside = details.reason === REASONS.outsidePress;
+ dismissedByOutsidePress.current = outside;
+ armFocusGuard(outside);
+ dropDraftRef.current?.();
}
setOpenUnwrapped(next);
onOpenChange?.(next, details);
},
- [setOpenUnwrapped, onOpenChange]
+ [setOpenUnwrapped, onOpenChange, armFocusGuard]
+ );
+
+ /* Base UI returns focus to the trigger's first tabbable child — the `.Input`. */
+ const shouldRestoreFinalFocus = useCallback(
+ () => !dismissedByOutsidePress.current,
+ []
);
const shouldIgnoreFocusOpen = useCallback(() => {
@@ -334,8 +402,11 @@ export function CalendarPreviewRoot({
}, []);
const setScale = useCallback(
- (next: CalendarPreviewScale) => setScaleUnwrapped(next),
- [setScaleUnwrapped]
+ (next: Scale) => {
+ setScaleUnwrapped(next);
+ onScaleChange?.(next);
+ },
+ [setScaleUnwrapped, onScaleChange]
);
const [draft, setDraft] = useState(null);
@@ -353,27 +424,85 @@ export function CalendarPreviewRoot({
[]
);
- /*
- * The from/to machine, unchanged from the shipped picker:
- * no from -> set from, advance to the end input
- * from, day earlier -> that day becomes the new from
- * from, day later -> completes, emits, closes
- * from and to -> restart from the new day
- *
- * It lives on the root because completing a range both writes the value and
- * closes the popover, and closing has to go through `setOpen` so a consumer
- * controlling `open` is not fought.
- */
+ const [triggerHasInput, setTriggerHasInputState] = useState(false);
+
+ const setTriggerHasInput = useCallback((next: boolean) => {
+ setTriggerHasInputState(current => (current === next ? current : next));
+ }, []);
+
+ useEffect(() => {
+ if (value === emitted.current) return;
+ emitted.current = value;
+ setDraft(null);
+ setActiveField('start');
+ }, [value]);
+
+ /* Day-keys, so a `minDate` carrying a time of day leaves its own day selectable. */
+ const isDateUnavailable = useCallback(
+ (date: Date) => {
+ const key = dayKey(date, timeZone);
+ if (minDate && key < dayKey(minDate, timeZone)) return true;
+ if (maxDate && key > dayKey(maxDate, timeZone)) return true;
+ return isDateUnavailableProp?.(date) ?? false;
+ },
+ [minDate, maxDate, isDateUnavailableProp, timeZone]
+ );
+
+ const spans = useCallback(
+ (from: Date, to: Date) =>
+ anyDayBetween(
+ dayKey(from, timeZone),
+ dayKey(to, timeZone),
+ isDateUnavailable
+ ),
+ [timeZone, isDateUnavailable]
+ );
+
const selectDay = useCallback(
(date: Date) => {
if (readOnly || disabled) return;
if (selection === 'single') {
- const isSame =
- value instanceof Date &&
- dayKey(value, timeZone) === dayKey(date, timeZone);
- if (isSame && clearable) setValue(null, 'clear', date);
- else setValue(date, 'select', date);
+ const key = dayKey(date, timeZone);
+ const current = isScaleValue(value)
+ ? value.date
+ : value instanceof Date
+ ? dayKey(value, timeZone)
+ : null;
+ if (current === key && clearable) {
+ clearScaleDraft();
+ setValue(null, 'clear', date);
+ return;
+ }
+ commitDay(date, 'select');
+ return;
+ }
+
+ const fixed = fieldReadOnly.start
+ ? (draft?.from ?? (isRange(value) ? value.from : undefined))
+ : undefined;
+ if (fixed) {
+ if (fieldReadOnly.end) return;
+ if (dayKey(date, timeZone) < dayKey(fixed, timeZone)) return;
+ if (spans(fixed, date)) return;
+ setDraft(null);
+ setActiveField('start');
+ setValue({ from: fixed, to: date }, 'select', date);
+ return;
+ }
+
+ const lone = draft && !draft.from ? draft.to : undefined;
+ if (lone) {
+ if (fieldReadOnly.start) return;
+ const ordered = dayKey(date, timeZone) <= dayKey(lone, timeZone);
+ if (!ordered || spans(date, lone)) {
+ setDraft({ from: date });
+ setActiveField('end');
+ return;
+ }
+ setDraft(null);
+ setActiveField('start');
+ setValue({ from: date, to: lone }, 'select', date);
return;
}
@@ -392,13 +521,13 @@ export function CalendarPreviewRoot({
}
if (fieldReadOnly.end) return;
+ if (spans(from, date)) {
+ setDraft({ from: date });
+ return;
+ }
setDraft(null);
setActiveField('start');
setValue({ from, to: date }, 'select', date);
- setOpen(
- false,
- createChangeEventDetails(REASONS.closePress, undefined, undefined)
- );
},
[
selection,
@@ -406,16 +535,131 @@ export function CalendarPreviewRoot({
draft,
fieldReadOnly,
clearable,
+ commitDay,
+ clearScaleDraft,
+ timeZone,
+ readOnly,
+ disabled,
+ spans,
+ setValue
+ ]
+ );
+
+ const scaleValue = useMemo(() => {
+ if (scaleDraft) return scaleDraft;
+ if (value instanceof Date) return { date: dayKey(value, timeZone), scale };
+ if (isScaleValue(value)) return value;
+ return null;
+ }, [scaleDraft, value, scale, timeZone]);
+
+ const switchScale = useCallback(
+ (next: Scale) => {
+ if (scaleDraft === null) {
+ draftOrigin.current = { value: scaleValue, month, scale };
+ }
+ const origin = draftOrigin.current ?? { value: scaleValue, month, scale };
+
+ if (next === origin.scale) {
+ clearScaleDraft();
+ setMonth(origin.month);
+ setScale(next);
+ return;
+ }
+
+ const anchor = origin.value ?? {
+ date: dayKey(origin.month, timeZone),
+ scale: origin.scale
+ };
+ setScaleDraft(convertScale(anchor, next, trailingValue));
+ setMonth(parseKey(convertScale(anchor, next, false).date));
+ setScale(next);
+ },
+ [
+ scaleValue,
+ scaleDraft,
+ month,
+ timeZone,
+ scale,
+ trailingValue,
+ clearScaleDraft,
+ setMonth,
+ setScale,
+ setScaleDraft
+ ]
+ );
+
+ const selectPeriod = useCallback(
+ (date: Date | string, next: Scale) => {
+ if (readOnly || disabled) return;
+ const key = anchorOf(
+ periodOf(
+ typeof date === 'string' ? date : dayKey(date, timeZone),
+ next
+ ),
+ trailingValue
+ );
+ clearScaleDraft();
+ setValue(
+ { date: key, scale: next },
+ scaleChanged(value, next) ? 'scale' : 'select',
+ parseKey(key)
+ );
+ settleScaleRef.current?.(next);
+ revealMonthRef.current?.(parseKey(key));
+ },
+ [
+ trailingValue,
timeZone,
readOnly,
disabled,
+ value,
setValue,
- setOpen
+ clearScaleDraft
]
);
- /* A click means "the next endpoint"; typing into a field means that field,
- so a typed date cannot go through `selectDay`. */
+ /* Through `setScale`: a controlled `scale` moves only when told. */
+ const settleScale = useCallback(
+ (next: Scale) => {
+ clearScaleDraft();
+ if (next !== scale) setScale(next);
+ },
+ [scale, setScale, clearScaleDraft]
+ );
+
+ const dropDraft = useCallback(() => {
+ if (scaleDraftRef.current === null) return;
+ const origin = draftOrigin.current;
+ if (origin) setMonth(origin.month);
+ settleScale(
+ origin?.scale ?? (isScaleValue(value) ? value.scale : scales[0])
+ );
+ }, [value, scales, settleScale, setMonth]);
+
+ dropDraftRef.current = dropDraft;
+ settleScaleRef.current = settleScale;
+ revealMonthRef.current = (date: Date) => {
+ if (
+ dayKey(date, timeZone).slice(0, 7) === dayKey(month, timeZone).slice(0, 7)
+ )
+ return;
+ setMonth(date);
+ };
+
+ const isPeriodAvailable = useCallback(
+ (date: Date | string, next: Scale) =>
+ isAvailable(
+ typeof date === 'string' ? date : dayKey(date, timeZone),
+ next,
+ {
+ trailing: trailingValue,
+ min: minDate && dayKey(minDate, timeZone),
+ max: maxDate && dayKey(maxDate, timeZone)
+ }
+ ),
+ [trailingValue, minDate, maxDate, timeZone]
+ );
+
const setEndpoint = useCallback(
(field: CalendarPreviewField, date: Date) => {
if (readOnly || disabled || fieldReadOnly[field]) return;
@@ -424,49 +668,42 @@ export function CalendarPreviewRoot({
const from = field === 'start' ? date : base?.from;
const to = field === 'end' ? date : base?.to;
- /* An ordered pair completes. Anything else — one edge still missing, or
- a typed day that crossed its partner — restarts from that day. */
if (from && to && dayKey(from, timeZone) <= dayKey(to, timeZone)) {
setDraft(null);
setActiveField('start');
setValue({ from, to }, 'input', date);
return;
}
- setDraft({ from: date });
- setActiveField('end');
+ setDraft(field === 'start' ? { from: date } : { to: date });
+ setActiveField(field === 'start' ? 'end' : 'start');
},
[value, draft, fieldReadOnly, timeZone, readOnly, disabled, setValue]
);
- /* `'reset'`, not `'select'`: restoring the default is not a pick, and a
- consumer that logs or validates on selection needs to tell them apart. */
+ const clearEndpoint = useCallback(
+ (field: CalendarPreviewField) => {
+ if (readOnly || disabled || fieldReadOnly[field]) return;
+ const base = draft ?? (isRange(value) ? value : null);
+ const kept = field === 'start' ? { to: base?.to } : { from: base?.from };
+ setDraft(kept.from || kept.to ? kept : null);
+ setActiveField(field);
+ if (isRange(value)) setValue(null, 'clear', monthAnchor(value) ?? today);
+ },
+ [value, draft, fieldReadOnly, readOnly, disabled, setValue, today]
+ );
+
const reset = useCallback(() => {
if (defaultDate === undefined) return;
- /* A `null` default clears, and reports the day it cleared: `'reset'` would
- claim a day was restored when none was. */
+ settleScale(isScaleValue(defaultDate) ? defaultDate.scale : scales[0]);
if (defaultDate === null) {
if (value == null) return;
setValue(null, 'clear', monthAnchor(value) ?? today);
return;
}
- /* `occasion` is one day, so a range reports the day it starts on. */
setValue(defaultDate, 'reset', monthAnchor(defaultDate) ?? today);
- }, [defaultDate, value, setValue, today]);
+ }, [defaultDate, value, scales, settleScale, setValue, today]);
- /* Day-keys, not instants: a `minDate` carrying a time of day still leaves
- its own day selectable, which the current family gets wrong. */
- const isDateUnavailable = useCallback(
- (date: Date) => {
- const key = dayKey(date, timeZone);
- if (minDate && key < dayKey(minDate, timeZone)) return true;
- if (maxDate && key > dayKey(maxDate, timeZone)) return true;
- return isDateUnavailableProp?.(date) ?? false;
- },
- [minDate, maxDate, isDateUnavailableProp, timeZone]
- );
-
- /* A year the user can never scroll to is a trap, so the span stretches to
- cover the bounds even though bounds never clamp navigation. */
+ /* Stretches to cover the bounds; a year you cannot scroll to is a trap. */
const yearRange = useMemo(() => {
if (yearRangeProp) return yearRangeProp;
const base = today.getFullYear();
@@ -476,13 +713,29 @@ export function CalendarPreviewRoot({
return { from: Math.min(...years), to: Math.max(...years) };
}, [yearRangeProp, today, minDate, maxDate]);
- const context = useMemo>(
+ /* Bound here, or a `formatValue` without `timeZone` renders the neighbouring day. */
+ const formatValue = useCallback(
+ (value: Date | ScaleValue, scale: Scale) =>
+ formatValueProp(value, scale, timeZone),
+ [formatValueProp, timeZone]
+ );
+
+ const context = useMemo(
() => ({
value,
setValue,
+ scales,
+ trailingValue,
+ scaleDraft,
+ switchScale,
+ selectPeriod,
+ dropDraft,
+ isPeriodAvailable,
selection,
selectDay,
+ commitDay,
setEndpoint,
+ clearEndpoint,
draft: draft ?? (isRange(value) ? value : null),
activeField,
setActiveField,
@@ -491,6 +744,10 @@ export function CalendarPreviewRoot({
open,
setOpen,
shouldIgnoreFocusOpen,
+ shouldRestoreFinalFocus,
+ triggerRef,
+ triggerHasInput,
+ setTriggerHasInput,
defaultDate,
reset,
month,
@@ -511,9 +768,18 @@ export function CalendarPreviewRoot({
[
value,
setValue,
+ scales,
+ trailingValue,
+ scaleDraft,
+ switchScale,
+ selectPeriod,
+ dropDraft,
+ isPeriodAvailable,
selection,
selectDay,
+ commitDay,
setEndpoint,
+ clearEndpoint,
draft,
activeField,
fieldReadOnly,
@@ -521,6 +787,9 @@ export function CalendarPreviewRoot({
open,
setOpen,
shouldIgnoreFocusOpen,
+ shouldRestoreFinalFocus,
+ triggerHasInput,
+ setTriggerHasInput,
defaultDate,
reset,
month,
@@ -540,9 +809,6 @@ export function CalendarPreviewRoot({
]
);
- /* A real element, not a bare provider: `.Days` and `.Footer` are in-flow
- siblings, and without a box of their own they inherit whatever the
- surrounding layout does — sitting side by side inside a flex row. */
const element = useRender({
defaultTagName: 'div',
ref,
@@ -560,16 +826,13 @@ export function CalendarPreviewRoot({
)
});
- /* Base UI owns dismissal — outside press, escape and focus-out all come from
- `Popover.Root`, which is why no file here has an outside-click listener. */
+ /* Base UI owns dismissal, which is why no file here listens for outside clicks. */
return (
- }
- >
+
{element}
-
+
);
}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx
new file mode 100644
index 000000000..d8a340191
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx
@@ -0,0 +1,109 @@
+import { mergeProps, useRender } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import { Tabs } from '../tabs';
+import styles from './calendar-preview.module.css';
+import { useCalendarPreviewContext } from './calendar-preview-context';
+import type { Scale } from './lib/scale';
+
+const LABELS: Record = {
+ day: 'Day',
+ month: 'Month',
+ quarter: 'Quarter',
+ halfYear: 'Half-year',
+ year: 'Year'
+};
+
+export type CalendarPreviewScalesProps = useRender.ComponentProps<'div'>;
+
+export function CalendarPreviewScales({
+ className,
+ children,
+ render,
+ ref,
+ ...props
+}: CalendarPreviewScalesProps) {
+ const { scales, scale, switchScale, disabled } = useCalendarPreviewContext(
+ 'CalendarPreview.Scales'
+ );
+
+ const element = useRender({
+ defaultTagName: 'div',
+ ref,
+ render,
+ props: mergeProps<'div'>(
+ {
+ className: cx(styles.scales, className),
+ 'data-slot': 'calendar-preview-scales',
+ children: children ?? (
+ switchScale(next as Scale)}
+ >
+
+ {scales.map(one => (
+
+ {LABELS[one]}
+
+ ))}
+
+
+ )
+ } as useRender.ComponentProps<'div'>,
+ props
+ )
+ });
+
+ return scales.length > 1 ? element : null;
+}
+
+CalendarPreviewScales.displayName = 'CalendarPreview.Scales';
+
+export interface CalendarPreviewScaleProps
+ extends useRender.ComponentProps<'button'> {
+ value: Scale;
+}
+
+export function CalendarPreviewScale({
+ value,
+ className,
+ children,
+ render,
+ ref,
+ ...props
+}: CalendarPreviewScaleProps) {
+ const { scale, switchScale, disabled } = useCalendarPreviewContext(
+ 'CalendarPreview.Scale'
+ );
+
+ return useRender({
+ defaultTagName: 'button',
+ ref,
+ render,
+ props: mergeProps<'button'>(
+ {
+ type: 'button',
+ className,
+ 'data-slot': 'calendar-preview-scale',
+ 'data-scale': value,
+ /* Says it is pressed rather than claiming a `tab` role with no tablist around it. */
+ 'aria-pressed': scale === value,
+ 'data-active': scale === value || undefined,
+ disabled,
+ onClick: () => switchScale(value),
+ children: children ?? LABELS[value]
+ } as useRender.ComponentProps<'button'>,
+ props
+ )
+ });
+}
+
+CalendarPreviewScale.displayName = 'CalendarPreview.Scale';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-separator.tsx b/packages/raystack/components/calendar-preview/calendar-preview-separator.tsx
new file mode 100644
index 000000000..d14a18fe9
--- /dev/null
+++ b/packages/raystack/components/calendar-preview/calendar-preview-separator.tsx
@@ -0,0 +1,28 @@
+import { mergeProps, useRender } from '@base-ui/react';
+import { cx } from 'class-variance-authority';
+import styles from './calendar-preview.module.css';
+
+export type CalendarPreviewSeparatorProps = useRender.ComponentProps<'div'>;
+
+export function CalendarPreviewSeparator({
+ className,
+ render,
+ ref,
+ ...props
+}: CalendarPreviewSeparatorProps) {
+ return useRender({
+ defaultTagName: 'div',
+ ref,
+ render,
+ props: mergeProps<'div'>(
+ {
+ className: cx(styles.separator, className),
+ 'data-slot': 'calendar-preview-separator',
+ role: 'separator'
+ } as useRender.ComponentProps<'div'>,
+ props
+ )
+ });
+}
+
+CalendarPreviewSeparator.displayName = 'CalendarPreview.Separator';
diff --git a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
index 3ffad3661..7ca90169b 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview-trigger.tsx
@@ -1,37 +1,44 @@
import { mergeProps, Popover, useRender } from '@base-ui/react';
import { createChangeEventDetails } from '@base-ui/react/internals/createBaseUIEventDetails';
import { REASONS } from '@base-ui/react/internals/reasons';
+import type { BaseUIEvent } from '@base-ui/react/types';
+import { useMergedRefs } from '@base-ui/utils/useMergedRefs';
import { cx } from 'class-variance-authority';
-import { type ComponentProps, type FocusEvent, useRef } from 'react';
+import {
+ type ComponentProps,
+ createContext,
+ type FocusEvent,
+ type MouseEvent,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState
+} from 'react';
import styles from './calendar-preview.module.css';
import { useCalendarPreviewContext } from './calendar-preview-context';
-import type { CalendarPreviewValue } from './calendar-preview-root';
+import { isRange } from './calendar-preview-root';
+
+/* Per trigger, not per root: a childless trigger beside a `.Body` input is still a button. */
+const TriggerInputContext = createContext<{
+ registerInput: (mounted: boolean) => void;
+} | null>(null);
+
+export function useTriggerInput() {
+ return useContext(TriggerInputContext);
+}
export interface CalendarPreviewTriggerProps
extends useRender.ComponentProps<'div'> {
- /** Shown when there is no value and no children. */
placeholder?: string;
+ /** @defaultValue false */
+ nativeButton?: boolean;
}
-/**
- * Anchors the popover and owns opening it.
- *
- * Base UI has no focus-to-open option, so this is a handler — but it is the
- * only one, and it lives here rather than on `.Input`. Two guards keep it from
- * fighting Base UI, both verified against real browser input:
- *
- * - during a pointer press, `useClick` is already going to open the popover,
- * so opening here too produced open/close/open;
- * - when focus arrives back from the popup, the popover has just been
- * dismissed — reopening on that made Escape impossible to use.
- *
- * Neither guard touches dismissal, which stays entirely Base UI's.
- *
- * Renders a `div`, never a `button`: it wraps an `.Input` in the picker
- * composition, and a control inside a button is not focusable on its own.
- */
export function CalendarPreviewTrigger({
placeholder = 'Select date',
+ nativeButton = false,
className,
children,
render,
@@ -42,31 +49,63 @@ export function CalendarPreviewTrigger({
value,
formatValue,
scale,
+ open,
setOpen,
shouldIgnoreFocusOpen,
+ triggerRef,
+ setTriggerHasInput,
disabled,
readOnly
- } = useCalendarPreviewContext(
- 'CalendarPreview.Trigger'
- );
+ } = useCalendarPreviewContext('CalendarPreview.Trigger');
+
+ const [inputCount, setInputCount] = useState(0);
+ const hasInput = inputCount > 0;
+
+ const registerInput = useCallback((mounted: boolean) => {
+ setInputCount(current => current + (mounted ? 1 : -1));
+ }, []);
+
+ const inputContext = useMemo(() => ({ registerInput }), [registerInput]);
+
+ useEffect(() => {
+ setTriggerHasInput(hasInput);
+ return () => setTriggerHasInput(false);
+ }, [hasInput, setTriggerHasInput]);
- /* Tracks the pointer, not the open state: Base UI owns whether the popover
- is open, and this only says whether a press is mid-flight. */
const pressing = useRef(false);
- /* One cast at the boundary: Base UI types its trigger for the `button` it
- renders by default, and this one is always a `div`. Consumer props stay
- last, inside the merge. */
+ /* A pointer released outside never reaches `onPointerUp`, and a stuck flag swallows focus. */
+ useEffect(() => {
+ const release = () => {
+ pressing.current = false;
+ };
+ window.addEventListener('pointerup', release);
+ window.addEventListener('pointercancel', release);
+ return () => {
+ window.removeEventListener('pointerup', release);
+ window.removeEventListener('pointercancel', release);
+ };
+ }, []);
+
+ const mergedRef = useMergedRefs(triggerRef, ref);
+
+ /* Base UI types its trigger for the `button` it renders by default; this is a `div`. */
const triggerProps = {
- nativeButton: false,
+ nativeButton,
disabled,
render: render ?? ,
- ref,
+ ref: mergedRef,
...mergeProps<'div'>(
{
className: cx(styles.trigger, className),
'data-slot': 'calendar-preview-trigger',
- 'data-scale': scale,
+ /* Base UI adds no `tabIndex` to a rendered `div`, and around a field its role would nest a control in a button. */
+ role: hasInput || nativeButton ? undefined : 'button',
+ tabIndex: hasInput ? -1 : nativeButton ? undefined : 0,
+ /* Only the closing half, or a press could not reopen a field that never lost focus. */
+ onClick: (event: BaseUIEvent>) => {
+ if (hasInput && open) event.preventBaseUIHandler();
+ },
onPointerDown: () => {
pressing.current = true;
},
@@ -74,8 +113,10 @@ export function CalendarPreviewTrigger({
pressing.current = false;
},
onFocus: (event: FocusEvent) => {
- if (disabled || readOnly || pressing.current) return;
- if (shouldIgnoreFocusOpen()) return;
+ if (disabled || readOnly) return;
+ /* Consumed before the press guard, or it stays armed against the next focus. */
+ const returning = shouldIgnoreFocusOpen();
+ if (returning || (!hasInput && pressing.current)) return;
setOpen(
true,
createChangeEventDetails(
@@ -90,16 +131,21 @@ export function CalendarPreviewTrigger({
)
} as ComponentProps;
- /* `formatValue` takes a single day, so a range formats as its two ends. */
const label =
value instanceof Date
? formatValue(value, scale)
- : value
+ : isRange(value)
? `${formatValue(value.from, scale)} – ${formatValue(value.to, scale)}`
- : placeholder;
+ : value
+ ? formatValue(value, value.scale)
+ : placeholder;
return (
- {children ?? label}
+
+
+ {children ?? label}
+
+
);
}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css
index 266fc55ca..48707d576 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview.module.css
+++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css
@@ -1,18 +1,14 @@
-/* Hugs its content and stacks its parts, so `.Days` and `.Footer` sit one
- above the other whatever the surrounding layout does. */
.root {
display: flex;
flex-direction: column;
width: fit-content;
}
-/* The day view hugs its content — no reserved height, so the surface around it
- can size itself instead of being padded out to a fixed number. */
.days {
display: flex;
flex-direction: column;
width: fit-content;
- padding: var(--rs-space-3);
+ padding: var(--rs-space-4);
border-radius: var(--rs-radius-4);
background: var(--rs-color-background-base-primary);
color: var(--rs-color-foreground-base-primary);
@@ -22,24 +18,16 @@
pointer-events: none;
}
-/* Inset by the gap a weekday label leaves inside its 40px cell. Aligning the
- header to the column box instead would sit the caption visibly left of
- "Sun", because the label is centred in the cell rather than flush to it. */
.header {
display: flex;
align-items: center;
gap: var(--rs-space-2);
- min-height: var(--rs-space-9);
+ min-height: var(--rs-space-7);
margin-bottom: var(--rs-space-3);
- padding-inline: var(--rs-space-3);
}
-/* The week-number column is a gutter, not a date column, so the caption starts
- past it — aligned with Sunday rather than with the grid's outer edge. The
- header cannot read `showWeekNumber`, which is a `.Grid` prop, so it asks the
- rendered grid instead. */
.days:has(.week-number-header) .header {
- padding-inline-start: calc(var(--rs-space-10) + var(--rs-space-3));
+ padding-inline-start: var(--rs-space-10);
}
.nav-button {
@@ -52,9 +40,19 @@
cursor: not-allowed;
}
-/* Takes the space left of the buttons, so the caption sits against the start
- edge and the reset and two nav buttons group at the end — the single-month
- header in reference A. Source order already matches, so nothing reorders. */
+.nav-button.reset[aria-disabled="true"] {
+ opacity: 0.5;
+ color: var(--rs-color-foreground-base-tertiary);
+ cursor: not-allowed;
+}
+
+.nav-button.reset[aria-disabled="true"]:hover,
+.nav-button.reset[aria-disabled="true"]:active {
+ background-color: transparent;
+ color: var(--rs-color-foreground-base-tertiary);
+ transform: none;
+}
+
.caption {
flex: 1;
text-align: start;
@@ -67,9 +65,6 @@
-webkit-user-select: none;
}
-/* The caption that opens the scroller is a filled chip, so the affordance
- reads without an adjacent glyph. `flex: none` undoes `.caption`'s stretch —
- the chip hugs its label rather than running to the nav buttons. */
.caption-trigger {
display: inline-flex;
flex: none;
@@ -77,17 +72,18 @@
align-items: center;
justify-content: center;
gap: var(--rs-space-1);
- padding: var(--rs-space-1) var(--rs-space-3);
+ padding: var(--rs-space-2) var(--rs-space-3);
border: none;
border-radius: var(--rs-radius-2);
- background: var(--rs-color-background-neutral-secondary);
+ background: transparent;
color: inherit;
- font: inherit;
+ font-family: inherit;
cursor: pointer;
}
-.caption-trigger:hover:not(:disabled) {
- background: var(--rs-color-background-neutral-secondary-hover);
+.caption-trigger:hover:not(:disabled),
+.caption-trigger:active:not(:disabled) {
+ background: var(--rs-color-background-base-primary-hover);
}
.caption-trigger:focus-visible {
@@ -104,31 +100,49 @@
z-index: 1;
}
-/* Our own scroller, not a Select: two plain columns of buttons in a popup we
- own, so nothing here portals a listbox the surrounding popover has to
- recognise as inside itself. */
.caption-popup {
display: flex;
- gap: var(--rs-space-2);
- padding: var(--rs-space-2);
- border: 1px solid var(--rs-color-border-base-primary);
- border-radius: var(--rs-radius-4);
+ overflow: hidden;
+ border: 0.5px solid var(--rs-color-border-base-primary);
+ border-radius: var(--rs-radius-2);
background: var(--rs-color-background-base-primary);
box-shadow: var(--rs-shadow-lifted);
}
+.caption-popup .caption-divider[data-orientation="vertical"] {
+ width: 0.5px;
+ height: auto;
+ align-self: stretch;
+}
+
+.caption-popup .caption-scroller {
+ flex: none;
+ width: auto;
+}
+
+.caption-scroller [data-slot="scroll-area-viewport"] {
+ max-height: calc(var(--rs-space-10) * 6);
+}
+
.caption-column {
+ box-sizing: border-box;
display: flex;
flex-direction: column;
- gap: var(--rs-space-1);
- overflow-y: auto;
- /* Six rows of the day-cell height; taller lists scroll. */
- max-height: calc(var(--rs-space-10) * 6);
+ gap: var(--rs-space-2);
+ padding: var(--rs-space-2);
+}
+
+.caption-column[data-slot="calendar-preview-caption-months"] {
+ width: var(--rs-space-12);
+}
+
+.caption-column[data-slot="calendar-preview-caption-years"] {
+ width: var(--rs-space-13);
}
.caption-option {
flex: none;
- padding: var(--rs-space-2) var(--rs-space-3);
+ padding: var(--rs-space-3);
border: none;
border-radius: var(--rs-radius-2);
background: transparent;
@@ -150,8 +164,6 @@
outline-offset: var(--rs-focus-ring-offset-inset);
}
-/* Grey, not accent: the scroller marks which month and year are in view, which
- is a different thing from the selected day the grid fills in accent. */
.caption-option[data-active] {
background: var(--rs-color-background-neutral-secondary);
color: var(--rs-color-foreground-base-primary);
@@ -161,23 +173,29 @@
flex: none;
}
-/* Both nav tracks stay reserved whether or not this month draws a button, so
- the caption centres on its grid rather than on the remaining space. The
- track width is the size-3 IconButton the nav renders. */
.month-header {
display: grid;
- grid-template-columns: var(--rs-space-6) 1fr var(--rs-space-6);
+ grid-template-columns: var(--rs-space-8) 1fr var(--rs-space-8);
align-items: center;
gap: var(--rs-space-2);
- min-height: var(--rs-space-9);
+ min-height: var(--rs-space-7);
margin-bottom: var(--rs-space-3);
- padding-inline: var(--rs-space-3);
}
.month-header-prev {
grid-column: 1;
}
+.header .caption:not([data-dropdown]),
+.panel .header .caption:not([data-dropdown]) {
+ padding-inline-start: var(--rs-space-4);
+}
+
+.header .caption[data-dropdown],
+.panel .header .caption[data-dropdown] {
+ margin-inline-start: var(--rs-space-2);
+}
+
.month-header-caption {
grid-column: 2;
text-align: center;
@@ -201,12 +219,8 @@
flex-direction: column;
}
-/* `.Header` owns the visible caption. This one stays in the tree because
- react-day-picker points each grid's accessible name at the month, and a
- removed node would take that name with it. */
.month-caption {
position: absolute;
- /* A hairline box, not a spacing value — the space scale starts at 2px. */
width: 1px;
height: 1px;
margin: -1px;
@@ -225,8 +239,6 @@
position: relative;
}
-/* The user-agent's 2px border-spacing would ring the grid, leaving the header
- two pixels wider than the columns it sits above. */
.weeks table {
border-spacing: 0;
}
@@ -236,10 +248,6 @@
display: flex;
}
-/* Cells size to the border box and drop the user-agent's table-cell padding,
- so a heading and the days under it are the same 40px column. Content-box
- would make the bordered day cell 4px wider than its heading, and the two
- rows would drift a column apart by Saturday. */
.weekday,
.day,
.week-number,
@@ -253,7 +261,7 @@
align-items: center;
justify-content: center;
width: var(--rs-space-10);
- height: var(--rs-space-10);
+ height: var(--rs-space-9);
color: var(--rs-color-foreground-base-secondary);
text-align: center;
font-weight: var(--rs-font-weight-medium);
@@ -319,8 +327,6 @@
visibility: hidden;
}
-/* Sits between the cell and the button, so it has to pass the cell's box
- through: `.day-button` inherits its radius and sizes against it. */
.day-trigger {
display: block;
width: 100%;
@@ -356,8 +362,6 @@
cursor: not-allowed;
}
-/* The same border hover paints, so the two rings match. An outline on the
- button cannot: its radius grows outward and misses the arc by a pixel. */
.day:has(.day-button:focus-visible) {
border-color: var(--rs-color-border-accent-emphasis);
}
@@ -366,7 +370,6 @@
outline: none;
}
-/* Today's dot sits under the number, and rides up when a day carries info. */
.day-button[data-today]::after {
content: "";
position: absolute;
@@ -415,7 +418,7 @@
.skeleton {
position: absolute;
inset: 0;
- /* Solid backing so the grid underneath doesn't ghost through mid-fade. */
+
background: var(--rs-color-background-base-primary);
opacity: 0;
visibility: hidden;
@@ -425,7 +428,6 @@
.skeleton[data-visible] {
opacity: 1;
visibility: visible;
- /* Block clicks on the day grid underneath while loading. */
pointer-events: auto;
}
@@ -433,19 +435,17 @@
display: flex;
flex-direction: column;
gap: var(--rs-space-6);
- padding-top: var(--rs-space-6);
+ padding-top: var(--rs-space-9);
}
@media (prefers-reduced-motion: no-preference) {
.skeleton {
- /* Exiting: fade opacity, then flip visibility after the fade. */
transition:
opacity var(--rs-duration-fast) var(--rs-ease-out),
visibility 0s linear var(--rs-duration-fast);
}
.skeleton[data-visible] {
- /* Entering: visibility flips immediately, opacity fades in. */
transition: opacity var(--rs-duration-fast) var(--rs-ease-out);
}
}
@@ -455,7 +455,6 @@
margin-top: var(--rs-space-2);
}
-/* The trigger is a plain box: it wraps `.Input`, which draws its own field. */
.trigger {
display: inline-flex;
align-items: center;
@@ -465,10 +464,6 @@
cursor: not-allowed;
}
-/* Wider than the trigger and centred on it, per the frames. `.Days` brings its
- own padding, so the surface adds none — and it opts out of the shared
- popover's `max-width: 18rem`, sized for text at 288px against the 296px
- seven 40px columns need, which cropped Saturday flush to the border. */
.content {
padding: 0;
width: max-content;
@@ -479,17 +474,11 @@
width: 100%;
}
-/* The endpoints are pill-rounded on their outer edges and the days between sit
- on one continuous band. The track is drawn on the cell rather than the day
- button so neighbouring cells meet with no seam. */
.range-middle {
background: var(--rs-color-background-neutral-secondary);
border-radius: 0;
}
-/* react-day-picker marks every day of the range `selected`, and the single-day
- rule paints that white for the accent pill. The days on the track are on
- grey, so they keep the ordinary text colour. */
.range-middle .day-button {
background: transparent;
color: var(--rs-color-foreground-base-primary);
@@ -500,8 +489,6 @@
background: var(--rs-color-background-neutral-secondary);
}
-/* A half-open range has one endpoint and no band to join, so it keeps the
- plain selected pill instead of a flat edge. */
.range-start:not(.range-end) {
border-start-start-radius: var(--rs-radius-5);
border-end-start-radius: var(--rs-radius-5);
@@ -528,9 +515,137 @@
background-color: var(--rs-color-foreground-base-emphasis);
}
-/* Two fields side by side, sharing the trigger's width. */
-.range-fields {
+.body {
display: flex;
- align-items: center;
+ flex-direction: column;
gap: var(--rs-space-3);
+ padding: var(--rs-space-3) var(--rs-space-4);
+ width: max-content;
+}
+
+.label {
+ color: var(--rs-color-foreground-base-secondary);
+ font-weight: var(--rs-font-weight-medium);
+ font-size: var(--rs-font-size-mini);
+ line-height: var(--rs-line-height-mini);
+ letter-spacing: var(--rs-letter-spacing-mini);
+}
+
+.body .label {
+ margin-bottom: calc(var(--rs-space-2) - var(--rs-space-3));
+}
+
+.body [data-slot="input-container"] {
+ box-sizing: border-box;
+ height: var(--rs-space-9);
+ border-width: 0.5px;
+ border-color: var(--rs-color-border-base-tertiary);
+}
+
+.separator {
+ height: 1px;
+ background: var(--rs-color-border-base-primary);
+}
+
+.scales {
+ display: flex;
+}
+
+.scales [data-slot="tabs-list"] {
+ width: auto;
+ gap: var(--rs-space-3);
+ padding: 0;
+}
+
+.scales .scale {
+ flex: 1 1 auto;
+ padding-inline: var(--rs-space-2);
+}
+
+.panel {
+ width: calc(var(--rs-space-10) * 7);
+}
+
+.panel .days {
+ width: 100%;
+ padding: 0;
+}
+
+.panel .weeks table {
+ width: 100%;
+}
+
+.panel .weekday,
+.panel .day,
+.panel .week-number,
+.panel .week-number-header {
+ flex: 1;
+}
+
+.panel[data-scale="day"] {
+ display: block;
+}
+
+.periods {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-4);
+ height: calc(var(--rs-space-10) * 8);
+ overflow-y: auto;
+}
+
+.period-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--rs-space-3);
+}
+
+.period-year {
+ color: var(--rs-color-foreground-base-secondary);
+ font-size: var(--rs-font-size-mini);
+ line-height: var(--rs-line-height-mini);
+ letter-spacing: var(--rs-letter-spacing-mini);
+}
+
+.period-cells {
+ display: grid;
+ grid-template-columns: repeat(var(--rs-period-columns), 1fr);
+ gap: var(--rs-space-4);
+}
+
+.period {
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 var(--rs-space-2);
+ border: 0.5px solid var(--rs-color-border-base-primary);
+ border-radius: var(--rs-radius-2);
+ background: transparent;
+ color: var(--rs-color-foreground-base-secondary);
+ font-weight: var(--rs-font-weight-medium);
+ font-size: var(--rs-font-size-mini);
+ line-height: var(--rs-line-height-mini);
+ letter-spacing: var(--rs-letter-spacing-mini);
+ cursor: pointer;
+}
+
+.period:hover:not(:disabled) {
+ background: var(--rs-color-background-base-primary-hover);
+}
+
+.period:focus-visible {
+ outline: var(--rs-focus-ring);
+ outline-offset: var(--rs-focus-ring-offset-inset);
+}
+
+.period[data-selected] {
+ border-color: var(--rs-color-border-base-secondary);
+ background: var(--rs-color-background-neutral-primary);
+ color: var(--rs-color-foreground-base-primary);
+}
+
+.period[data-unavailable] {
+ color: var(--rs-color-foreground-base-tertiary);
+ cursor: not-allowed;
}
diff --git a/packages/raystack/components/calendar-preview/calendar-preview.tsx b/packages/raystack/components/calendar-preview/calendar-preview.tsx
index 6717b31df..ad4652bd4 100644
--- a/packages/raystack/components/calendar-preview/calendar-preview.tsx
+++ b/packages/raystack/components/calendar-preview/calendar-preview.tsx
@@ -1,5 +1,6 @@
'use client';
+import { CalendarPreviewBody } from './calendar-preview-body';
import { CalendarPreviewCaption } from './calendar-preview-caption';
import { CalendarPreviewContent } from './calendar-preview-content';
import { CalendarPreviewDays } from './calendar-preview-days';
@@ -15,14 +16,37 @@ import {
CalendarPreviewPrevMonth
} from './calendar-preview-header';
import { CalendarPreviewInput } from './calendar-preview-input';
+import { CalendarPreviewLabel } from './calendar-preview-label';
+import { CalendarPreviewPanel } from './calendar-preview-panel';
+import {
+ CalendarPreviewHalfYears,
+ CalendarPreviewMonths,
+ CalendarPreviewQuarters,
+ CalendarPreviewYears
+} from './calendar-preview-periods';
import { CalendarPreviewReset } from './calendar-preview-reset';
import { CalendarPreviewRoot } from './calendar-preview-root';
+import {
+ CalendarPreviewScale,
+ CalendarPreviewScales
+} from './calendar-preview-scales';
+import { CalendarPreviewSeparator } from './calendar-preview-separator';
import { CalendarPreviewTrigger } from './calendar-preview-trigger';
export const CalendarPreview = Object.assign(CalendarPreviewRoot, {
Trigger: CalendarPreviewTrigger,
Content: CalendarPreviewContent,
Input: CalendarPreviewInput,
+ Body: CalendarPreviewBody,
+ Label: CalendarPreviewLabel,
+ Scales: CalendarPreviewScales,
+ Scale: CalendarPreviewScale,
+ Separator: CalendarPreviewSeparator,
+ Panel: CalendarPreviewPanel,
+ Months: CalendarPreviewMonths,
+ Quarters: CalendarPreviewQuarters,
+ HalfYears: CalendarPreviewHalfYears,
+ Years: CalendarPreviewYears,
Days: CalendarPreviewDays,
Header: CalendarPreviewHeader,
PrevMonth: CalendarPreviewPrevMonth,
diff --git a/packages/raystack/components/calendar-preview/date-adapter.ts b/packages/raystack/components/calendar-preview/date-adapter.ts
index 6c2962e15..9e5320e27 100644
--- a/packages/raystack/components/calendar-preview/date-adapter.ts
+++ b/packages/raystack/components/calendar-preview/date-adapter.ts
@@ -1,35 +1,23 @@
-/* The only module in `calendar-preview/` that may import a date library.
- Importing one elsewhere re-opens the import-order failure `dayjs.extend()`
- caused, and costs the swappability the RFC keeps for Temporal. */
+/* The only module here that may import a date library, so it stays swappable. */
import { TZDate } from '@date-fns/tz';
import {
+ addDays,
addMonths,
endOfMonth,
- endOfQuarter,
- endOfYear,
format,
isValid,
parse,
- startOfMonth,
- startOfQuarter,
- startOfYear
+ startOfMonth
} from 'date-fns';
-/* Lexicographic order is chronological order, so `lib/` orders days as
- strings — no library call, and no drift by timezone. */
export type DayKey = string;
-/* `uuuu`, not `yyyy`: `yyyy` is year-of-era, so JS year 0 formats as `'0001'`
- * and collides with year 1. `uuuu` is the astronomical year and round-trips. */
+/* `uuuu`, not `yyyy`: year-of-era formats JS year 0 as `'0001'`. */
const DAY_KEY_FORMAT = 'uuuu-MM-dd';
const DAY_KEY_SHAPE = /^\d{4}-\d{2}-\d{2}$/;
-/* Every token in DAY_KEY_FORMAT comes from the input, so no field is ever
- inherited from this reference. */
const PARSE_REFERENCE = new Date(2000, 0, 1);
-/* Passing `timeZone` is what keeps a grid rendered in that zone from keying
- its cells a day off — the current family's tooltip/`dateInfo` bug. */
export function dayKey(date: Date, timeZone?: string): DayKey {
const key = format(zoned(date, timeZone), DAY_KEY_FORMAT);
if (!DAY_KEY_SHAPE.test(key)) {
@@ -38,19 +26,11 @@ export function dayKey(date: Date, timeZone?: string): DayKey {
return key;
}
-/* Not for ordering two days: an epoch carries a time and an offset, so two
- Dates on the same calendar day can order either way. Compare dayKeys. */
-export function epoch(date: Date): number {
- return date.getTime();
-}
-
-/** Whether `value` is a real calendar day. `'2027-02-29'` is not. */
export function isDayKey(value: string): boolean {
return DAY_KEY_SHAPE.test(value) && isValid(parseStrict(value));
}
-/* Throws rather than returning null: callers handling typed input gate on
- isDayKey or build with dayKeyFromParts, so a throw here is a real bug. */
+/* Throws: callers gate on isDayKey first, so reaching here is a real bug. */
export function parseKey(key: DayKey): Date {
if (!DAY_KEY_SHAPE.test(key)) {
throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(key)}`);
@@ -62,8 +42,6 @@ export function parseKey(key: DayKey): Date {
return date;
}
-/* `month` is 1-12. Validates against the real calendar, so 31 April is
- rejected rather than rolled forward the way `new Date` would. */
export function dayKeyFromParts(
year: number,
month: number,
@@ -83,32 +61,14 @@ export function endOfMonthKey(key: DayKey): DayKey {
return dayKey(endOfMonth(parseKey(key)));
}
-export function startOfQuarterKey(key: DayKey): DayKey {
- return dayKey(startOfQuarter(parseKey(key)));
-}
-
-export function endOfQuarterKey(key: DayKey): DayKey {
- return dayKey(endOfQuarter(parseKey(key)));
-}
-
-export function startOfYearKey(key: DayKey): DayKey {
- return dayKey(startOfYear(parseKey(key)));
-}
-
-export function endOfYearKey(key: DayKey): DayKey {
- return dayKey(endOfYear(parseKey(key)));
-}
-
export function yearOf(key: DayKey): number {
return Number(key.slice(0, 4));
}
-/** The calendar month of `key`, 1-12. */
export function monthOf(key: DayKey): number {
return Number(key.slice(5, 7));
}
-/* Accepts both the full and three-letter forms. */
export function monthFromName(name: string): number | null {
for (const pattern of ['MMMM', 'MMM']) {
const date = parse(name, pattern, PARSE_REFERENCE);
@@ -117,41 +77,48 @@ export function monthFromName(name: string): number | null {
return null;
}
-/* Normalising to the first stops repeated navigation drifting: stepping on
- from 31 January would clamp to the 28th and stay there. */
+export function anyDayBetween(
+ from: DayKey,
+ to: DayKey,
+ match: (date: Date) => boolean
+): boolean {
+ let cursor = from;
+ while (cursor <= to) {
+ const date = parseKey(cursor);
+ if (match(date)) return true;
+ cursor = dayKey(addDays(date, 1));
+ }
+ return false;
+}
+
+/* Normalising to the first, or stepping on from 31 January clamps to the 28th. */
export function shiftMonths(date: Date, delta: number): Date {
return addMonths(startOfMonth(date), delta);
}
-/** The first day of a calendar month. `monthIndex` is 0-11, as on `Date`. */
export function monthStart(year: number, monthIndex: number): Date {
return new Date(year, monthIndex, 1);
}
-/* Day-first, matching what `lib/parse.ts` accepts, so a rendered value can be
- typed straight back in. */
export function formatDayLabel(date: Date, timeZone?: string): string {
- return format(zoned(date, timeZone), 'dd/MM/yyyy');
+ return format(zoned(date, timeZone), 'dd MMM yyyy');
}
export function formatMonthLabel(date: Date, timeZone?: string): string {
return format(zoned(date, timeZone), 'MMM yyyy');
}
-/* Identical to formatMonthLabel today, kept separate because they answer
- different questions: what the grid shows, versus what a value means. */
+/* Kept separate: what the grid shows versus what a value means. */
export function formatCaptionLabel(date: Date, timeZone?: string): string {
return format(zoned(date, timeZone), 'MMM yyyy');
}
-/* Three letters, against react-day-picker's two-letter default — the frames
- spell them `Sun Mon Tue`. */
+/* Three letters, against RDP's two-letter default. */
export function formatWeekdayLabel(date: Date, timeZone?: string): string {
return format(zoned(date, timeZone), 'EEE');
}
-/* Same locale as monthFromName parses, so the caption's month column and the
- input parser cannot disagree about a name. */
+/* Same locale as monthFromName, so the column and the parser cannot disagree. */
export function monthShortNames(): string[] {
return MONTH_INDEXES.map(index => format(new Date(2001, index, 1), 'MMM'));
}
diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx
index 4ddc84acf..05a153f0f 100644
--- a/packages/raystack/components/calendar-preview/index.tsx
+++ b/packages/raystack/components/calendar-preview/index.tsx
@@ -1,4 +1,5 @@
export { CalendarPreview } from './calendar-preview';
+export type { CalendarPreviewBodyProps } from './calendar-preview-body';
export type { CalendarPreviewCaptionProps } from './calendar-preview-caption';
export type { CalendarPreviewContentProps } from './calendar-preview-content';
export type {
@@ -25,11 +26,19 @@ export type {
CalendarPreviewInputProps,
CalendarPreviewInputValidity
} from './calendar-preview-input';
+export type { CalendarPreviewLabelProps } from './calendar-preview-label';
+export type { CalendarPreviewPanelProps } from './calendar-preview-panel';
+export type { CalendarPreviewPeriodViewProps } from './calendar-preview-periods';
export type { CalendarPreviewResetProps } from './calendar-preview-reset';
export type { CalendarPreviewProps } from './calendar-preview-root';
+export type {
+ CalendarPreviewScaleProps,
+ CalendarPreviewScalesProps
+} from './calendar-preview-scales';
+export type { CalendarPreviewSeparatorProps } from './calendar-preview-separator';
export type { CalendarPreviewTriggerProps } from './calendar-preview-trigger';
export type {
- CalendarPreviewScale,
- CalendarPreviewScaleValue
+ Scale as CalendarPreviewScale,
+ ScaleValue as CalendarPreviewScaleValue
} from './lib/scale';
export { type UseCalendarReturn, useCalendar } from './use-calendar';
diff --git a/packages/raystack/components/calendar-preview/lib/parse.ts b/packages/raystack/components/calendar-preview/lib/parse.ts
index a8b4e2f91..9a0de8033 100644
--- a/packages/raystack/components/calendar-preview/lib/parse.ts
+++ b/packages/raystack/components/calendar-preview/lib/parse.ts
@@ -1,72 +1,28 @@
-/*
- * Turning a typed string into a `CalendarPreviewScaleValue` — pure functions, no React, no UI.
- *
- * Recognition is deliberately narrow: every accepted shape is pinned by a
- * regular expression before any date maths runs, so a near-miss is rejected
- * rather than coerced. The failure mode this replaces is dayjs'
- * `customParseFormat`, lenient enough to read `20/05/27` as the year 27.
- * Anything unrecognised returns `null` and the caller keeps its previous value.
- */
+/* Pinned by a regex before any date maths — dayjs read `20/05/27` as year 27. */
import { dayKeyFromParts, isDayKey, monthFromName } from '../date-adapter';
-import { anchorOf, type CalendarPreviewScaleValue, periodOf } from './scale';
+import { anchorOf, periodOf, type ScaleValue } from './scale';
export interface ParseScaleInputOptions {
- /**
- * The year a bare `Q4`, `H1` or `May` resolves into. Defaults to now.
- *
- * See {@link parseScaleInput} for the inference rule.
- */
referenceDate?: Date;
- /**
- * Which edge of the parsed period to emit — the root's `trailingValue`.
- *
- * @defaultValue false
- */
+ /** @defaultValue false */
trailing?: boolean;
}
-/* Day and month accept 1-2 digits so `5/5/2027` works; the year is pinned at
- * exactly 4 so a two-digit year is rejected rather than read as year 27. */
+/* The year is pinned at 4 digits, or `20/05/27` reads as year 27. */
const DAY_SLASHED = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/;
+/* The form `formatDayLabel` renders, so a displayed value types back in. */
+const DAY_NAMED = /^(\d{1,2})\s+([A-Za-z]{3,9})\s+(\d{4})$/;
const DAY_ISO = /^\d{4}-\d{2}-\d{2}$/;
const MONTH_NAMED = /^([A-Za-z]{3,9})(?:\s+(\d{4}))?$/;
const QUARTER = /^[Qq]([1-4])(?:\s+(\d{4}))?$/;
const HALF_YEAR = /^[Hh]([12])(?:\s+(\d{4}))?$/;
const YEAR = /^(\d{4})$/;
-/**
- * Read a typed string as a date at whichever scale it names, or `null`.
- *
- * Accepted shapes — surrounding and repeated whitespace is ignored, and month
- * names are case-insensitive:
- *
- * | Input | Scale | Notes |
- * |---|---|---|
- * | `20/05/2027`, `5/5/2027` | `day` | `dd/MM/yyyy`, day first |
- * | `2027-05-20` | `day` | the canonical stored form, so it round-trips |
- * | `May 2027`, `September 2027`, `Sep 2027` | `month` | |
- * | `May` | `month` | year inferred |
- * | `Q4 2026`, `Q4` | `quarter` | |
- * | `H1 2026`, `H1` | `halfYear` | H1 is Jan-Jun, H2 is Jul-Dec |
- * | `2025` | `year` | exactly four digits |
- *
- * **Year inference.** A bare `Q4`, `H1` or `May` resolves inside the calendar
- * year of `referenceDate`, and never rolls forward: `Q1` typed in December
- * 2026 is Q1 **2026**. Rolling forward would make the same string mean
- * different years either side of midnight on 31 December.
- *
- * The returned date is the period's edge under `trailing`, matching what
- * clicking that period in the calendar would commit — so typing `Q4 2026` and
- * clicking Q4 2026 in an end field both yield `2026-12-31`.
- *
- * Rejected, among anything else unrecognised: a two-digit year (`20/05/27`), a
- * month/year pair with no day (`05/2027`), a day that does not exist
- * (`31/04/2027`, `29/02/2027`), and an out-of-range period (`Q5`, `H3`).
- */
+/* Never rolls forward, or a string means different years either side of 31 December. */
export function parseScaleInput(
input: string,
options: ParseScaleInputOptions = {}
-): CalendarPreviewScaleValue | null {
+): ScaleValue | null {
const { referenceDate, trailing = false } = options;
const text = input.trim().replace(/\s+/g, ' ');
if (text === '') return null;
@@ -81,6 +37,18 @@ export function parseScaleInput(
return key === null ? null : { date: key, scale: 'day' };
}
+ const namedDay = DAY_NAMED.exec(text);
+ if (namedDay) {
+ const month = monthFromName(namedDay[2]);
+ if (month === null) return null;
+ const key = dayKeyFromParts(
+ Number(namedDay[3]),
+ month,
+ Number(namedDay[1])
+ );
+ return key === null ? null : { date: key, scale: 'day' };
+ }
+
if (DAY_ISO.test(text)) {
return isDayKey(text) ? { date: text, scale: 'day' } : null;
}
@@ -117,14 +85,13 @@ function yearFrom(matched: string | undefined, reference?: Date): number {
return (reference ?? new Date()).getFullYear();
}
-/* `month` is the period's first month, so the first of it always exists and
- always lands inside the period — the edge maths is then `scale.ts`'s. */
+/* The period's first month, so its first day always lands inside the period. */
function at(
year: number,
month: number,
- scale: CalendarPreviewScaleValue['scale'],
+ scale: ScaleValue['scale'],
trailing: boolean
-): CalendarPreviewScaleValue | null {
+): ScaleValue | null {
const inside = dayKeyFromParts(year, month, 1);
if (inside === null) return null;
return { date: anchorOf(periodOf(inside, scale), trailing), scale };
diff --git a/packages/raystack/components/calendar-preview/lib/scale.ts b/packages/raystack/components/calendar-preview/lib/scale.ts
index 4702a1e64..84098f31b 100644
--- a/packages/raystack/components/calendar-preview/lib/scale.ts
+++ b/packages/raystack/components/calendar-preview/lib/scale.ts
@@ -1,51 +1,25 @@
-/*
- * The scale maths from RFC 005 — pure functions, no React, no UI.
- *
- * Everything here is expressed in `DayKey`s (`'YYYY-MM-DD'`, timeless). Every
- * date-library call goes through `../date-adapter`; this file makes none of
- * its own.
- */
+/* `DayKey`s throughout, so nothing here takes a zone; callers convert at their boundary. */
import {
type DayKey,
- dayKey,
endOfMonthKey,
- endOfQuarterKey,
- endOfYearKey,
isDayKey,
monthOf,
- startOfMonthKey,
- startOfQuarterKey,
- startOfYearKey
+ startOfMonthKey
} from '../date-adapter';
-/** The granularities a value can be selected at. */
-export type CalendarPreviewScale =
- | 'day'
- | 'month'
- | 'quarter'
- | 'halfYear'
- | 'year';
+export type Scale = 'day' | 'month' | 'quarter' | 'halfYear' | 'year';
-/**
- * A committed selection: a concrete day, plus what that day *means*.
- *
- * The scale travels with the value rather than sitting in a prop, so a stored
- * `{ date: '2026-08-31', scale: 'month' }` still reads back as August 2026 with
- * no calendar mounted — see RFC 005, "The value carries its scale".
- */
-export interface CalendarPreviewScaleValue {
+export interface ScaleValue {
date: DayKey;
- scale: CalendarPreviewScale;
+ scale: Scale;
}
-/** The inclusive day span a period covers. */
export interface Period {
start: DayKey;
end: DayKey;
}
-/** Every scale, finest first. */
-export const SCALES: readonly CalendarPreviewScale[] = [
+export const SCALES: readonly Scale[] = [
'day',
'month',
'quarter',
@@ -53,110 +27,71 @@ export const SCALES: readonly CalendarPreviewScale[] = [
'year'
];
-export function isScale(value: string): value is CalendarPreviewScale {
+export function isScale(value: string): value is Scale {
return (SCALES as readonly string[]).includes(value);
}
-/**
- * The period of `scale` that contains `date`.
- *
- * `halfYear` is ours to derive — no date library has it. H1 is January to June,
- * H2 is July to December.
- */
-export function periodOf(
- date: Date | DayKey,
- scale: CalendarPreviewScale,
- timeZone?: string
-): Period {
- const key = toKey(date, timeZone);
- switch (scale) {
- case 'day':
- return { start: key, end: key };
- case 'month':
- return { start: startOfMonthKey(key), end: endOfMonthKey(key) };
- case 'quarter':
- return { start: startOfQuarterKey(key), end: endOfQuarterKey(key) };
- case 'halfYear': {
- /* The four half-year edges exist in every year, leap or not, so the key
- * can be composed from the year segment directly. */
- const year = yearSegment(key);
- return monthOf(key) <= 6
- ? { start: `${year}-01-01`, end: `${year}-06-30` }
- : { start: `${year}-07-01`, end: `${year}-12-31` };
- }
- case 'year':
- return { start: startOfYearKey(key), end: endOfYearKey(key) };
+export function periodOf(date: DayKey, scale: Scale): Period {
+ const key = requireKey(date);
+ if (scale === 'day') return { start: key, end: key };
+ /* A month's last day is the only edge that moves with the calendar. */
+ if (scale === 'month') {
+ return { start: startOfMonthKey(key), end: endOfMonthKey(key) };
}
+ const year = key.slice(0, 4);
+ const [start, end] = FIXED_EDGES[scale](monthOf(key));
+ return { start: `${year}-${start}`, end: `${year}-${end}` };
}
-/**
- * The single day a period stands for: its last day when `trailing`, its first
- * otherwise.
- *
- * `trailing` is the root's `trailingValue`. A start field emits the leading
- * edge, an end field the trailing one — so the same period yields a different
- * date at each end of a start–end pair.
- */
+const QUARTERS: readonly (readonly [string, string])[] = [
+ ['01-01', '03-31'],
+ ['04-01', '06-30'],
+ ['07-01', '09-30'],
+ ['10-01', '12-31']
+];
+
+const FIXED_EDGES: Record<
+ Exclude,
+ (month: number) => readonly [string, string]
+> = {
+ quarter: month => QUARTERS[Math.floor((month - 1) / 3)],
+ halfYear: month => (month <= 6 ? ['01-01', '06-30'] : ['07-01', '12-31']),
+ year: () => ['01-01', '12-31']
+};
+
export function anchorOf(period: Period, trailing: boolean): DayKey {
return trailing ? period.end : period.start;
}
-/**
- * Re-read a value at a different scale: take its date as the anchor, find the
- * period of the target scale containing it, emit that period's edge.
- *
- * Converting outward is lossy and does not undo. `2026-08-15` at `'day'`
- * becomes `2026-01-01` at `'year'` when leading, and back at `'day'` stays
- * `2026-01-01` — the anchor is all that survives.
- */
export function convertScale(
- value: CalendarPreviewScaleValue,
- to: CalendarPreviewScale,
- trailing: boolean,
- timeZone?: string
-): CalendarPreviewScaleValue {
- return {
- date: anchorOf(periodOf(value.date, to, timeZone), trailing),
- scale: to
- };
+ value: ScaleValue,
+ to: Scale,
+ trailing: boolean
+): ScaleValue {
+ return { date: anchorOf(periodOf(value.date, to), trailing), scale: to };
}
-/**
- * Whether the period of `scale` containing `value` can be selected.
- *
- * The test is against **the date the period would produce**, not the period's
- * start — so availability depends on `trailing`, and one period can be
- * selectable in a start field and disabled in an end field. With
- * `min = 2026-07-15` and `trailing`, July 2026 (emits 31 Jul) and Q3 2026
- * (emits 30 Sep) are available while H1 2026 (emits 30 Jun) is not. The two
- * rules coincide whenever `trailing` is false.
- *
- * `min` and `max` are inclusive and limit selection only — navigation is
- * never clamped.
- */
+export interface AvailabilityOptions {
+ trailing?: boolean;
+ min?: DayKey;
+ max?: DayKey;
+}
+
+/* Tests the date the period produces, so `trailing` moves the answer. */
export function isAvailable(
- value: Date | DayKey,
- scale: CalendarPreviewScale,
- trailing: boolean,
- min?: Date | DayKey,
- max?: Date | DayKey,
- timeZone?: string
+ date: DayKey,
+ scale: Scale,
+ { trailing = false, min, max }: AvailabilityOptions = {}
): boolean {
- const produced = anchorOf(periodOf(value, scale, timeZone), trailing);
- if (min !== undefined && produced < toKey(min, timeZone)) return false;
- if (max !== undefined && produced > toKey(max, timeZone)) return false;
+ const produced = anchorOf(periodOf(date, scale), trailing);
+ if (min !== undefined && produced < requireKey(min)) return false;
+ if (max !== undefined && produced > requireKey(max)) return false;
return true;
}
-function toKey(date: Date | DayKey, timeZone?: string): DayKey {
- if (typeof date !== 'string') return dayKey(date, timeZone);
- if (!isDayKey(date)) {
- throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(date)}`);
+function requireKey(key: DayKey): DayKey {
+ if (!isDayKey(key)) {
+ throw new RangeError(`Not a YYYY-MM-DD day: ${JSON.stringify(key)}`);
}
- return date;
-}
-
-/** The `YYYY` of a key, as written — not parsed, so it never loses a leading zero. */
-function yearSegment(key: DayKey): string {
- return key.slice(0, 4);
+ return key;
}
diff --git a/packages/raystack/components/calendar-preview/use-calendar.tsx b/packages/raystack/components/calendar-preview/use-calendar.tsx
index c9b5ea89f..a798131a2 100644
--- a/packages/raystack/components/calendar-preview/use-calendar.tsx
+++ b/packages/raystack/components/calendar-preview/use-calendar.tsx
@@ -1,48 +1,50 @@
'use client';
-import { useCalendarPreviewContext } from './calendar-preview-context';
-import type { CalendarPreviewValue } from './calendar-preview-root';
-import type { CalendarPreviewScale } from './lib/scale';
+import {
+ type CalendarPreviewDraftRange,
+ useCalendarPreviewContext
+} from './calendar-preview-context';
+import {
+ type CalendarPreviewValue,
+ monthAnchor
+} from './calendar-preview-root';
+import type { Scale, ScaleValue } from './lib/scale';
export interface UseCalendarReturn {
- /* Holds a range at `selection='range'`. */
value: CalendarPreviewValue;
- /** Commit a day or a range, or clear with `null`. Emits `onValueChange`. */
setValue: (value: CalendarPreviewValue) => void;
- /* Read-only until the scale switcher lands in phase 5. Exposing a setter
- now would be a public API we cannot take back if the switcher reshapes
- it; adding one later is additive. */
- scale: CalendarPreviewScale;
+ scale: Scale;
+ /** Never emitted. */
+ draft: CalendarPreviewDraftRange | null;
+ /** Never emitted. */
+ scaleDraft: ScaleValue | null;
month: Date;
- /** Bounds never clamp the view. */
setMonth: (month: Date) => void;
isDateUnavailable: (date: Date) => boolean;
}
-/**
- * The enclosing `CalendarPreview`'s state, for building parts the library does
- * not ship. Deliberately narrow — everything returned here is semver-covered.
- */
export function useCalendar(): UseCalendarReturn {
- const { value, setValue, scale, month, setMonth, isDateUnavailable } =
- useCalendarPreviewContext('useCalendar');
+ const {
+ value,
+ setValue,
+ scale,
+ draft,
+ scaleDraft,
+ month,
+ setMonth,
+ isDateUnavailable
+ } = useCalendarPreviewContext('useCalendar');
return {
value,
- /* A null commit is a clear, and the day acted on is the day being
- cleared. Reporting `'select'` with `new Date()` broke the context's
- documented promise that `toDate()` is the day acted on — it handed back
- today, which is a day nobody touched. `occasion` is one day either way,
- so a range reports the day it starts on. */
+ /* A clear reports the day being cleared; `new Date()` would name a day nobody touched. */
setValue: next =>
next === null
- ? setValue(
- null,
- 'clear',
- (value instanceof Date ? value : value?.from) ?? new Date()
- )
- : setValue(next, 'select', next instanceof Date ? next : next.from),
+ ? setValue(null, 'clear', monthAnchor(value) ?? new Date())
+ : setValue(next, 'select', monthAnchor(next) ?? new Date()),
scale,
+ draft,
+ scaleDraft,
month,
setMonth,
isDateUnavailable
diff --git a/packages/raystack/icons/__tests__/bundle.test.ts b/packages/raystack/icons/__tests__/bundle.test.ts
index 317c37247..d450a22a6 100644
--- a/packages/raystack/icons/__tests__/bundle.test.ts
+++ b/packages/raystack/icons/__tests__/bundle.test.ts
@@ -3,18 +3,8 @@ import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
-/**
- * `icons/icons.tsx` holds all 32 keys in one module, and a consumer must still
- * pay only for the keys it imports. This test is what keeps that true.
- *
- * Per-key removal from a single module depends on the `/*#__PURE__*\/`
- * annotation on every `createIcon(…)` call, and on nothing in the module having
- * a side effect. It also fails on any aggregate icon map, whether a merged
- * `{ ...defaultIcons, ...overrides }` in `IconProvider`, or a runtime
- * `ICON_NAMES` array, because either puts all 32 icons in every bundle.
- */
-
-/** vitest runs with the package root as the cwd. */
+/* Per-key tree-shaking needs the PURE annotations, no side effects, and no aggregate map. */
+
const ICONS_DIR = resolve(process.cwd(), 'icons');
const IMPORTED = ['CheckIcon', 'CopyIcon', 'XIcon'] as const;
diff --git a/packages/raystack/icons/icons.tsx b/packages/raystack/icons/icons.tsx
index ca4ff3c05..68470038f 100644
--- a/packages/raystack/icons/icons.tsx
+++ b/packages/raystack/icons/icons.tsx
@@ -1,12 +1,7 @@
'use client';
-// The 32 icons Apsara's own components draw: the one place that pairs a key
-// with a drawing. A key names the job or the glyph, never the library, so
-// changing icon library is an edit to this file and nothing else.
-//
-// Keep the `/*#__PURE__*/` annotation on every call. It is what lets a bundler
-// drop an unused key, and its lucide import, out of this single module;
-// `icons/__tests__/bundle.test.ts` checks that it still does.
+// Keep the `/*#__PURE__*/` annotation on every call: it is what lets a bundler
+// drop an unused key and its lucide import out of this module.
import {
ArrowDown,
@@ -48,7 +43,7 @@ export const ArrowDownIcon = /*#__PURE__*/ createIcon(
ArrowDown
);
export const ArrowUpIcon = /*#__PURE__*/ createIcon('ArrowUpIcon', ArrowUp);
-/** Draws lucide `CalendarDays`, not lucide `Calendar`. The key is ours. */
+/** Draws lucide `CalendarDays`, not lucide `Calendar`. */
export const CalendarIcon = /*#__PURE__*/ createIcon(
'CalendarIcon',
CalendarDays
diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx
index 53ecbfbcd..0b7fe4d51 100644
--- a/packages/raystack/index.tsx
+++ b/packages/raystack/index.tsx
@@ -22,6 +22,7 @@ export {
} from './components/calendar';
export {
CalendarPreview,
+ type CalendarPreviewBodyProps,
type CalendarPreviewCaptionProps,
type CalendarPreviewChangeDetails,
type CalendarPreviewChangeReason,
@@ -37,12 +38,18 @@ export {
type CalendarPreviewInputInvalidReason,
type CalendarPreviewInputProps,
type CalendarPreviewInputValidity,
+ type CalendarPreviewLabelProps,
type CalendarPreviewNavProps,
type CalendarPreviewOpenChangeDetails,
+ type CalendarPreviewPanelProps,
+ type CalendarPreviewPeriodViewProps,
type CalendarPreviewProps,
type CalendarPreviewResetProps,
type CalendarPreviewScale,
+ type CalendarPreviewScaleProps,
+ type CalendarPreviewScalesProps,
type CalendarPreviewScaleValue,
+ type CalendarPreviewSeparatorProps,
type CalendarPreviewTriggerProps,
type CalendarPreviewWeekdayProps,
type UseCalendarReturn,
diff --git a/packages/raystack/vitest.config.mjs b/packages/raystack/vitest.config.mjs
index 6579d1bfa..440627a88 100644
--- a/packages/raystack/vitest.config.mjs
+++ b/packages/raystack/vitest.config.mjs
@@ -6,10 +6,6 @@ export default defineConfig({
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
globals: true,
- /* Pinned so date tests do not depend on the machine's zone. Without it the
- suite fails east of UTC+9, where a local-midnight year 10000 is still
- year 9999 in UTC. CI passes only because its runners are UTC. */
- env: { TZ: 'UTC' },
css: {
modules: {
classNameStrategy: 'stable'