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 ` - ))} -
+ +
+ {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 }) => ( +
+ {viewScale !== 'year' && ( +
+ {year} +
+ )} +
+ {cells.map(({ produced, unavailable, ...cell }) => ( + + ))} +
+
+ ))} + + ) + } 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'