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 df204e56e..aa6c4e024 100644 --- a/apps/www/src/content/docs/components/calendar-preview/demo.ts +++ b/apps/www/src/content/docs/components/calendar-preview/demo.ts @@ -409,3 +409,92 @@ export const rangeDemo = { } ] }; + +export const scaleDemo = { + type: 'code', + tabs: [ + { + name: 'Inline', + code: ` + + ` + }, + { + name: 'Day scale', + code: ` + + ` + }, + { + name: 'In a popover', + code: ` + + + + + ` + }, + { + name: 'Periods only', + code: ` + + ` + }, + { + name: 'One view alone', + code: ` + + ` + }, + { + name: 'Bounded', + code: ` + + ` + } + ] +}; + +export const scalePairDemo = { + type: 'code', + code: ` + + + + + + + + + + + + + + + + ` +}; 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 c14e8a371..4504d7ac6 100644 --- a/apps/www/src/content/docs/components/calendar-preview/index.mdx +++ b/apps/www/src/content/docs/components/calendar-preview/index.mdx @@ -13,6 +13,8 @@ import { dateInfoDemo, pickerDemo, rangeDemo, + scaleDemo, + scalePairDemo, } from "./demo.ts"; @@ -102,6 +104,26 @@ The portaled popover surface. Takes `Popover.Content` props — `side`, `align`, +### CalendarPreview.Body + +The popup body: label, input, scale switcher and the view for the active scale. Renders all four when given no children. Takes `render`, `className` and `ref`. + +### CalendarPreview.Scales / CalendarPreview.Scale + +The scale switcher, built on Apsara `Tabs`. **Renders nothing when only one scale is offered**, so a plain day calendar never grows a one-tab row. `.Scale` is only needed to relabel or reorder. + +### CalendarPreview.Panel + +The view container. Mounts all five views; each gates on the active scale itself, so `.Quarters` can be mounted alone with no day grid in the tree. + +### CalendarPreview.Months / .Quarters / .HalfYears / .Years + +Year-grouped period lists at 3, 4, 2 and 1 columns. Each is one continuous 320px scroll area with the year numbers as headings inside it, opening on the active year. + +### CalendarPreview.Label / CalendarPreview.Separator + +The field label above the input, and the rule between the switcher and the view. + ### CalendarPreview.Footer The row below the calendar. A bare string is wrapped in `Text`; anything else renders as given. @@ -151,6 +173,16 @@ 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 | +| `calendar-preview-period` | One period cell | | `calendar-preview-footer` | The footer row | | `calendar-preview-footer-text` | The `Text` wrapping a string footer | @@ -257,6 +289,60 @@ Instead of a `lock` prop, mark one endpoint's `.Input` as `readOnly` — the gri +### Scale-aware selection + +Pass `scales` to select at granularities coarser than a day. A single value hides the switcher; anything more shows it. + +```tsx + + + + + + +``` + + + +#### The value carries its scale + +A `Date` cannot say whether it means "August 2026" or "1 August 2026", so beyond day scale the value is a `ScaleValue`: + +```ts +interface ScaleValue { date: 'YYYY-MM-DD'; scale: Scale } +``` + +| `scales` | `value` | +|---|---| +| omitted, or `'day'` | `Date` — unchanged | +| any other scale, or any array | `ScaleValue` | + +`date` is stored as `YYYY-MM-DD` because lexicographic order is chronological order, which is what lets bounds compare without parsing. **It is never what you see** — every trigger, input and annotation renders through `formatValue`, which is `DD MMM YYYY` at day scale and the period's own shorthand above it. `onValueChange`'s details carry `toDate()` if you want a `Date`. + +#### Switching scale drafts, it does not emit + +Moving between scales moves the view and sets a draft. Nothing is emitted until a cell is clicked or Enter is pressed; Escape drops the draft and restores the input from `value`. + +#### trailingValue picks the edge + +A period has two edges, and which one a field means depends on the field. `trailingValue` emits the period's **last** day rather than its first — "July 2026" becomes `2026-07-31` instead of `2026-07-01`. It changes the value, not the formatting, and it is month-end correct: February 2028 trailing is `2028-02-29`. + +That also decides availability, which tests **the date a period would produce**. Bounded at 15 July 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 | + +Every one of those periods starts before the bound. Only the produced date separates them. + +#### A start/end pair is two roots + +Not `selection="range"`. Each end has its own `scales` and `trailingValue`, and they can hold different scales — "1 Aug 2026 → Q3 2026" is not expressible as one range value. The consumer owns the pair and any `from <= to` check. + + + ## Accessibility - Arrow keys move between days; the focused cell carries `data-draft` until it is committed diff --git a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx index 57d2942d4..08b528279 100644 --- a/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/calendar-preview.test.tsx @@ -914,19 +914,29 @@ describe('CalendarPreview public surface', () => { it('exports exactly the parts this phase builds', () => { expect(partNames.sort()).toEqual( [ + 'Body', 'Caption', 'Content', 'Day', 'Days', 'Footer', 'Grid', + 'HalfYears', 'Header', + 'Input', + 'Label', + 'Months', 'NextMonth', + 'Panel', 'PrevMonth', - 'Input', + 'Quarters', 'Reset', + 'Scale', + 'Scales', + 'Separator', 'Trigger', - 'Weekday' + 'Weekday', + 'Years' ].sort() ); }); @@ -945,8 +955,10 @@ describe('CalendarPreview public surface', () => { }); describe('defaultFormatValue', () => { - it('formats a day as DD/MM/YYYY', () => { - expect(defaultFormatValue(new Date(2027, 4, 20), 'day')).toBe('20/05/2027'); + it('formats a day as DD MMM YYYY', () => { + expect(defaultFormatValue(new Date(2027, 4, 20), 'day')).toBe( + '20 May 2027' + ); }); it('formats the coarser scales by their own shorthand', () => { diff --git a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts index f1b0d7483..a5c06a031 100644 --- a/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/date-adapter.test.ts @@ -199,9 +199,9 @@ describe('monthStart', () => { }); describe('label formatters', () => { - it('formats a day as DD/MM/YYYY', () => { - expect(formatDayLabel(new Date(2027, 4, 20))).toBe('20/05/2027'); - expect(formatDayLabel(new Date(2027, 0, 5))).toBe('05/01/2027'); + it('formats a day as DD MMM YYYY', () => { + expect(formatDayLabel(new Date(2027, 4, 20))).toBe('20 May 2027'); + expect(formatDayLabel(new Date(2027, 0, 5))).toBe('05 Jan 2027'); }); it('formats a month in short form', () => { @@ -215,7 +215,7 @@ describe('label formatters', () => { it('reads the labels in an explicit zone', () => { const instant = new Date(Date.UTC(2026, 7, 31, 20, 0)); - expect(formatDayLabel(instant, 'Asia/Tokyo')).toBe('01/09/2026'); + expect(formatDayLabel(instant, 'Asia/Tokyo')).toBe('01 Sep 2026'); expect(formatMonthLabel(instant, 'Asia/Tokyo')).toBe('Sep 2026'); expect(formatCaptionLabel(instant, 'UTC')).toBe('Aug 2026'); }); diff --git a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts index ee69349c0..93e286354 100644 --- a/packages/raystack/components/calendar-preview/__tests__/parse.test.ts +++ b/packages/raystack/components/calendar-preview/__tests__/parse.test.ts @@ -24,6 +24,22 @@ describe('parseScaleInput — day', () => { }); }); + /* `formatDayLabel` renders this form, and a field shows it. Selecting all + and retyping it verbatim has to come back as the same day. */ + it.each([ + ['15 Aug 2026', '2026-08-15'], + ['15 August 2026', '2026-08-15'], + ['5 Jan 2027', '2027-01-05'], + ['01 Sep 2026', '2026-09-01'] + ])('round-trips the rendered day form %s', (input, expected) => { + expect(parseScaleInput(input)?.date).toBe(expected); + expect(parseScaleInput(input)?.scale).toBe('day'); + }); + + it('rejects a day-named form with an impossible day', () => { + expect(parseScaleInput('31 Feb 2026')).toBeNull(); + }); + it('accepts 29 February in a leap year', () => { expect(parseScaleInput('29/02/2028', IN_2026)).toEqual({ date: '2028-02-29', diff --git a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx index 77a182a54..04579b5f4 100644 --- a/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/picker.test.tsx @@ -135,7 +135,7 @@ describe('CalendarPreview.Input commit', () => { it('emits nothing while typing', () => { const onValueChange = vi.fn(); const { input } = renderPicker({ onValueChange }); - for (const text of ['2', '20', '20/', '20/0', '20/05', '20/05/2027']) { + for (const text of ['2', '20', '20/', '20/0', '20/05', '20 May 2027']) { fireEvent.change(input, { target: { value: text } }); } expect(onValueChange).not.toHaveBeenCalled(); @@ -151,7 +151,7 @@ describe('CalendarPreview.Input commit', () => { }); it.each([ - ['20/05/2027', new Date(2027, 4, 20)], + ['20 May 2027', new Date(2027, 4, 20)], ['5/5/2027', new Date(2027, 4, 5)], ['2027-05-20', new Date(2027, 4, 20)] ])('accepts %s at day scale', (text, expected) => { @@ -283,7 +283,7 @@ describe('CalendarPreview.Trigger content', () => { ); expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent( - '20/08/2026' + '20 Aug 2026' ); }); diff --git a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx index 745f145ff..54ac2ee46 100644 --- a/packages/raystack/components/calendar-preview/__tests__/range.test.tsx +++ b/packages/raystack/components/calendar-preview/__tests__/range.test.tsx @@ -147,8 +147,8 @@ describe('CalendarPreview range inputs', () => { fireEvent.click(day(document.body, '10')); fireEvent.click(day(document.body, '20')); const [start, end] = inputs(container); - expect(start.value).toBe('10/08/2026'); - expect(end.value).toBe('20/08/2026'); + expect(start.value).toBe('10 Aug 2026'); + expect(end.value).toBe('20 Aug 2026'); }); /* `lock` is gone: a read-only endpoint is one read-only `.Input`. */ diff --git a/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx new file mode 100644 index 000000000..b96ff3350 --- /dev/null +++ b/packages/raystack/components/calendar-preview/__tests__/scale-selection.test.tsx @@ -0,0 +1,390 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { getAllSlots, getSlot } from '~/test-utils/data-slots'; +import { CalendarPreview } from '../calendar-preview'; +import type { Scale } from '../lib/scale'; + +const TODAY = new Date(2026, 7, 15); +const ALL: Scale[] = ['day', 'month', 'quarter', 'halfYear', 'year']; + +function renderBody(props = {}) { + return render( + + + + ); +} + +/* The list runs across every year in `yearRange`, so a label alone is + ambiguous — "Aug" exists once per year. */ +const period = (container: HTMLElement, label: string, year = 2026) => { + const group = getAllSlots(container, 'calendar-preview-period-group').find( + node => + getSlot(node, 'calendar-preview-period-year')?.textContent === + String(year) + ); + if (!group) throw new Error(`no year group ${year}`); + const match = getAllSlots(group, 'calendar-preview-period').find( + cell => cell.textContent === label + ); + if (!match) throw new Error(`no period cell ${label} in ${year}`); + return match; +}; + +const switchTo = (container: HTMLElement, scale: Scale) => { + const chip = getAllSlots(container, 'calendar-preview-scale').find( + node => node.getAttribute('data-scale') === scale + ); + fireEvent.click(chip as HTMLElement); +}; + +describe('CalendarPreview scale switching', () => { + it('emits nothing on a scale switch — it only drafts', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange }); + switchTo(container, 'quarter'); + expect(onValueChange).not.toHaveBeenCalled(); + switchTo(container, 'year'); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('emits once a period is picked', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange }); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q3')); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-07-01', + scale: 'quarter' + }); + }); + + it('reports the scale it moved to', () => { + const onScaleChange = vi.fn(); + const { container } = renderBody({ onScaleChange }); + switchTo(container, 'month'); + expect(onScaleChange).toHaveBeenCalledWith('month'); + }); +}); + +describe('CalendarPreview trailingValue', () => { + /* The value itself changes, not the formatting — a start field emits the + period's first day and an end field its last. */ + it.each([ + ['month', 'Aug', '2026-08-01', '2026-08-31'], + ['quarter', 'Q3', '2026-07-01', '2026-09-30'], + ['halfYear', 'H2', '2026-07-01', '2026-12-31'], + ['year', '2026', '2026-01-01', '2026-12-31'] + ] as const)('flips the emitted edge for %s', (scale, label, lead, trail) => { + for (const [trailing, expected] of [ + [false, lead], + [true, trail] + ] as const) { + const onValueChange = vi.fn(); + const { container, unmount } = renderBody({ + onValueChange, + trailingValue: trailing + }); + switchTo(container, scale); + fireEvent.click(period(container, label)); + expect(onValueChange.mock.calls[0][0]).toEqual({ date: expected, scale }); + unmount(); + } + }); + + it('is month-end correct in a leap February', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ + onValueChange, + trailingValue: true, + today: new Date(2028, 1, 10), + yearRange: { from: 2028, to: 2028 } + }); + switchTo(container, 'month'); + fireEvent.click(period(container, 'Feb', 2028)); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2028-02-29', + scale: 'month' + }); + }); +}); + +/* The RFC's table: an end field bounded at 15 July 2026 disables H1 2026, + which would emit 30 June, while allowing July and Q3, which emit later. The + same periods are all available to a start field. */ +describe('CalendarPreview availability differs by field', () => { + const bounded = { minDate: new Date(2026, 6, 15), today: TODAY }; + + /* The same period, opposite answers: Q3 2026 starts 1 July — before the + bound — but ends 30 September, after it. Only the produced date separates + them, which is the whole reason availability takes `trailing`. */ + it.each([ + ['quarter', 'Q3'], + ['month', 'Jul'] + ] as const)('disables %s for a start field and allows it for an end field', (scale, label) => { + const start = renderBody({ ...bounded, trailingValue: false }); + switchTo(start.container, scale); + expect(period(start.container, label)).toBeDisabled(); + start.unmount(); + + const end = renderBody({ ...bounded, trailingValue: true }); + switchTo(end.container, scale); + expect(period(end.container, label)).not.toBeDisabled(); + }); + + it('disables H1 2026 for an end field, which would emit 30 June', () => { + const { container } = renderBody({ ...bounded, trailingValue: true }); + switchTo(container, 'halfYear'); + expect(period(container, 'H1')).toBeDisabled(); + expect(period(container, 'H2')).not.toBeDisabled(); + }); + + it('allows July and Q3 for an end field, because they emit after the bound', () => { + const { container } = renderBody({ ...bounded, trailingValue: true }); + switchTo(container, 'month'); + expect(period(container, 'Jul')).not.toBeDisabled(); + switchTo(container, 'quarter'); + expect(period(container, 'Q3')).not.toBeDisabled(); + }); + + it('shows out-of-bounds periods rather than hiding them', () => { + const { container } = renderBody({ + maxDate: new Date(2026, 7, 31), + today: TODAY + }); + switchTo(container, 'month'); + expect(period(container, 'Dec')).toBeInTheDocument(); + expect(period(container, 'Dec')).toBeDisabled(); + }); +}); + +describe('CalendarPreview.Scales', () => { + it('renders nothing when only one scale is offered', () => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-scales')).toBeNull(); + }); + + it('renders one chip per offered scale', () => { + const { container } = renderBody(); + expect(getAllSlots(container, 'calendar-preview-scale')).toHaveLength(5); + }); +}); + +describe('CalendarPreview period views mount alone', () => { + it.each([ + ['quarter', CalendarPreview.Quarters, 'calendar-preview-quarters'], + ['month', CalendarPreview.Months, 'calendar-preview-months'], + ['halfYear', CalendarPreview.HalfYears, 'calendar-preview-half-years'], + ['year', CalendarPreview.Years, 'calendar-preview-years'] + ] as const)('%s renders with no other view in the tree', (scale, View, slot) => { + const { container } = render( + + + + ); + expect(getSlot(container, slot)).toBeInTheDocument(); + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + }); + + it('gates on the active scale, so the others stay unmounted', () => { + const { container } = renderBody({ defaultScale: 'quarter' }); + expect(getSlot(container, 'calendar-preview-quarters')).toBeInTheDocument(); + expect(getSlot(container, 'calendar-preview-months')).toBeNull(); + expect(getSlot(container, 'calendar-preview-grid')).toBeNull(); + }); +}); + +/* DataView cells and FilterChip labels render the annotation with no calendar + anywhere in the tree. */ +describe('CalendarPreview.Trigger annotation', () => { + it.each([ + ['day', '2026-07-02', '02 Jul 2026'], + ['month', '2026-06-01', 'Jun 2026'], + ['quarter', '2026-07-01', 'Q3 2026'], + ['halfYear', '2026-01-01', 'H1 2026'], + ['year', '2025-01-01', '2025'] + ] as const)('formats %s with no popover open', (scale, date, expected) => { + const { container } = render( + + + + ); + expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent( + expected + ); + expect(getSlot(document.body, 'calendar-preview-content')).toBeNull(); + }); + + it('shows the empty state when there is no value', () => { + render( + + + + ); + expect(screen.getByText('Add start date')).toBeInTheDocument(); + }); +}); + +describe('CalendarPreview.Input at scale', () => { + const input = (container: HTMLElement) => + getSlot(container, 'calendar-preview-input') as HTMLInputElement; + + it('advertises the formats it accepts', () => { + const { container } = renderBody(); + expect(input(container)).toHaveAttribute( + 'placeholder', + 'Try: 15 Aug 2026, May 2027, Q4' + ); + }); + + it('moves the scale to match what was typed', () => { + const onValueChange = vi.fn(); + const onScaleChange = vi.fn(); + const { container } = renderBody({ onValueChange, onScaleChange }); + fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2026-10-01', + scale: 'quarter' + }); + }); + + it('refuses a scale this root does not offer', () => { + const { container } = render( + + + + ); + fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); + expect(getSlot(container, 'calendar-preview-input')).toHaveAttribute( + 'aria-invalid' + ); + }); + + it('drops the draft on Escape and falls back to the value', () => { + const { container } = renderBody({ + value: { date: '2026-08-20', scale: 'day' } + }); + expect(input(container).value).toBe('20 Aug 2026'); + + switchTo(container, 'quarter'); + expect(input(container).value).toBe('Q3 2026'); + + fireEvent.keyDown( + getSlot(container, 'calendar-preview-body') as HTMLElement, + { + key: 'Escape' + } + ); + expect(input(container).value).toBe('20 Aug 2026'); + }); +}); + +describe('CalendarPreview change details at scale', () => { + const input = (container: HTMLElement) => + getSlot(container, 'calendar-preview-input') as HTMLInputElement; + + it('hands back the produced date through toDate()', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange, trailingValue: true }); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q3')); + const details = onValueChange.mock.calls[0][1]; + expect(typeof details.toDate).toBe('function'); + expect(details.toDate()).toEqual(new Date(2026, 8, 30)); + }); + + it('reports the period of the scale that was committed, not the view', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange }); + switchTo(container, 'month'); + fireEvent.click(period(container, 'Aug')); + expect(onValueChange.mock.calls[0][1].period).toEqual({ + start: '2026-08-01', + end: '2026-08-31' + }); + }); + + /* Typing commits a scale the view has not moved to yet. */ + it('reports the typed scale period, not the scale still on screen', () => { + const onValueChange = vi.fn(); + const { container } = renderBody({ onValueChange }); + fireEvent.change(input(container), { target: { value: 'Q4 2026' } }); + fireEvent.keyDown(input(container), { key: 'Enter' }); + expect(onValueChange.mock.calls[0][1].period).toEqual({ + start: '2026-10-01', + end: '2026-12-31' + }); + }); +}); + +describe('CalendarPreview scale anchors on the visible month', () => { + /* The day grid is showing 2030; switching scale must land there, not on + whatever year today happens to be. */ + it('drafts from the view month rather than today', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q1', 2030)); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2030-01-01', + scale: 'quarter' + }); + }); + + it('opens the period list on the view month year', () => { + const { container } = render( + + + + ); + switchTo(container, 'month'); + /* Both years exist in the list; the point is which one is anchored. */ + expect(period(container, 'Jan', 2030)).toBeInTheDocument(); + expect(period(container, 'Jan', 2026)).toBeInTheDocument(); + }); + + it('still follows the value when there is one', () => { + const onValueChange = vi.fn(); + const { container } = render( + + + + ); + switchTo(container, 'quarter'); + fireEvent.click(period(container, 'Q2', 2027)); + expect(onValueChange.mock.calls[0][0]).toEqual({ + date: '2027-04-01', + scale: 'quarter' + }); + }); +}); diff --git a/packages/raystack/components/calendar-preview/calendar-preview-body.tsx b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx new file mode 100644 index 000000000..be1145b00 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-body.tsx @@ -0,0 +1,56 @@ +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 { CalendarPreviewInput } from './calendar-preview-input'; +import { CalendarPreviewLabel } from './calendar-preview-label'; +import { CalendarPreviewPanel } from './calendar-preview-panel'; +import { CalendarPreviewScales } from './calendar-preview-scales'; +import { CalendarPreviewSeparator } from './calendar-preview-separator'; + +export type CalendarPreviewBodyProps = useRender.ComponentProps<'div'>; + +/** + * The popup body: label, input, scale switcher and the view for the active + * scale. The input sits above the switcher, which is where the frames put it. + */ +export function CalendarPreviewBody({ + className, + children, + render, + ref, + ...props +}: CalendarPreviewBodyProps) { + const { scale, dropDraft } = useCalendarPreviewContext( + 'CalendarPreview.Body' + ); + + return useRender({ + defaultTagName: 'div', + ref, + render, + props: mergeProps<'div'>( + { + className: cx(styles.body, className), + 'data-slot': 'calendar-preview-body', + 'data-scale': scale, + /* Escape drops the draft on its way to Base UI, which closes on it. */ + onKeyDown: (event: React.KeyboardEvent) => { + if (event.key === 'Escape') dropDraft(); + }, + children: children ?? ( + <> + + + + + + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); +} + +CalendarPreviewBody.displayName = 'CalendarPreview.Body'; diff --git a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx index e41d26e95..4f54a89ab 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-context.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-context.tsx @@ -89,6 +89,24 @@ export interface CalendarPreviewContextValue { readOnly: boolean; formatValue: (value: Date | ScaleValue, scale: Scale) => string; + /** Every scale the switcher offers. One entry hides `.Scales`. */ + scales: readonly Scale[]; + /** Whether a period emits its last day rather than its first. */ + trailingValue: boolean; + /** + * The pending value after a scale switch or a keystroke. Never emitted — a + * cell click or Enter commits it, Escape drops it. + */ + scaleDraft: ScaleValue | null; + /** Moves the view and sets the draft. Emits nothing. */ + switchScale: (scale: Scale) => void; + /** Commits a period at `scale`, honouring `trailingValue`. */ + selectPeriod: (date: Date | string, scale: Scale) => void; + /** Drops the draft; the input falls back to `value`. */ + dropDraft: () => void; + /** Whether the period containing `date` can be selected at `scale`. */ + isPeriodAvailable: (date: Date | string, scale: Scale) => boolean; + selection: 'single' | 'range'; /** * Commits a clicked day. Single scale commits it directly; range runs the diff --git a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx index 8d8664ccf..0fd0bbae3 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-days.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-days.tsx @@ -66,6 +66,10 @@ export function CalendarPreviewDays({ ) }); + /* A sibling of the period views, gating the same way, so `.Panel` can mount + all five and only the active one renders. */ + if (scale !== 'day') return null; + return ( {element} diff --git a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx index cc5c40eb6..df8dd1e1c 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-input.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-input.tsx @@ -7,6 +7,7 @@ import type { CalendarPreviewField } from './calendar-preview-context'; import { useCalendarPreviewContext } from './calendar-preview-context'; import { dayKey, parseKey } from './date-adapter'; import { parseScaleInput } from './lib/parse'; +import type { Scale } from './lib/scale'; export type CalendarPreviewInputValidity = { valid: boolean; @@ -58,6 +59,10 @@ export function CalendarPreviewInput({ today, disabled, readOnly, + scales, + scaleDraft, + selectPeriod, + isPeriodAvailable, selection, selectDay, draft, @@ -91,15 +96,23 @@ export function CalendarPreviewInput({ onValidityChange?.(next); }; - const resolve = (text: string): CalendarPreviewInputValidity | Date => { + /* Only the scales this root offers: typing "Q4" into a day-only field is not + a quarter, it is a typo. */ + const resolve = ( + text: string + ): CalendarPreviewInputValidity | { date: Date; scale: Scale } => { 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') { + 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)) || @@ -108,7 +121,7 @@ export function CalendarPreviewInput({ return { valid: false, reason: 'out-of-bounds' }; } if (isDateUnavailable(date)) return { valid: false, reason: 'unavailable' }; - return date; + return { date, scale: 'day' }; }; const commit = () => { @@ -121,11 +134,13 @@ export function CalendarPreviewInput({ return; } const resolved = resolve(trimmed); - if (!(resolved instanceof Date)) return; + if ('valid' in resolved) return; /* A typed endpoint goes through the same machine a clicked one does, so the two cannot disagree about what completes a range. */ - if (isRange) selectDay(resolved); - else setValue(resolved, 'input', resolved); + if (isRange) selectDay(resolved.date); + else if (resolved.scale !== 'day') + selectPeriod(resolved.date, resolved.scale); + else setValue(resolved.date, 'input', resolved.date); setText(null); report(VALID); }; @@ -134,15 +149,19 @@ export function CalendarPreviewInput({ const endpoint = isRange ? ((field === 'start' ? draft?.from : draft?.to) ?? null) - : (value as Date | null); + : (scaleDraft ?? (value as Date | null)); const committedText = endpoint ? formatValue(endpoint, scale) : ''; + /* A multi-scale field has to advertise what it accepts; a day-only one does + not, and the old placeholder still reads correctly there. */ const resolvedPlaceholder = placeholder ?? - (isRange - ? field === 'start' - ? 'Select start date' - : 'Select end date' - : 'Select date'); + (scales.length > 1 + ? 'Try: 15 Aug 2026, May 2027, Q4' + : isRange + ? field === 'start' + ? 'Select start date' + : 'Select end date' + : 'Select date'); return ( { 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..f0182e893 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-label.tsx @@ -0,0 +1,33 @@ +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'; + +export type CalendarPreviewLabelProps = useRender.ComponentProps<'span'>; + +export function CalendarPreviewLabel({ + className, + children, + render, + ref, + ...props +}: CalendarPreviewLabelProps) { + const { scale } = useCalendarPreviewContext('CalendarPreview.Label'); + + return useRender({ + defaultTagName: 'span', + ref, + render, + props: mergeProps<'span'>( + { + className: cx(styles.label, className), + 'data-slot': 'calendar-preview-label', + 'data-scale': scale, + children: children ?? 'Date' + } as useRender.ComponentProps<'span'>, + props + ) + }); +} + +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..73d9c4238 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-panel.tsx @@ -0,0 +1,53 @@ +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'>; + +/** + * The view container. Mounts all five views when childless; each one gates on + * the active scale itself, so a consumer can mount `.Quarters` alone with no + * day grid in the tree. + */ +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..632158782 --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-periods.tsx @@ -0,0 +1,248 @@ +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 { dayKey, monthShortNames, monthStart, yearOf } from './date-adapter'; +import { anchorOf, periodOf, type Scale } from './lib/scale'; + +export type CalendarPreviewPeriodViewProps = useRender.ComponentProps<'div'>; + +interface Cell { + key: string; + label: string; + /** The day this cell stands for, before `trailingValue` is applied. */ + date: Date; +} + +const MONTHS = monthShortNames(); + +function cellsFor(scale: Scale, year: number): Cell[] { + if (scale === 'month') { + return MONTHS.map((label, index) => ({ + key: `${year}-${index}`, + label, + date: monthStart(year, index) + })); + } + if (scale === 'quarter') { + return [0, 1, 2, 3].map(q => ({ + key: `${year}-q${q}`, + label: `Q${q + 1}`, + date: monthStart(year, q * 3) + })); + } + if (scale === 'halfYear') { + return [0, 1].map(h => ({ + key: `${year}-h${h}`, + label: `H${h + 1}`, + date: monthStart(year, h * 6) + })); + } + return [{ key: `${year}`, label: String(year), date: monthStart(year, 0) }]; +} + +/** + * One scale's period list. + * + * Every year is a heading inside a single scrolling column rather than a page + * of its own, so the whole list scrolls past the bounds — periods outside them + * render disabled rather than being cut off. + */ +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]); + + /* Compared as day-keys so a re-rendered Date never counts as a change. The + draft wins: it is what the user is looking at after a scale switch. */ + const activeYear = yearOf( + scaleDraft?.date ?? + (value && !(value instanceof Date) && 'date' in value + ? (value as { date: string }).date + : dayKey(month, timeZone)) + ); + + const selectedKey = + scaleDraft?.date ?? + (value && !(value instanceof Date) && 'date' in value + ? (value as { date: string }).date + : null); + + /* + * A twenty-year list otherwise opens on its first year, twenty scrolls from + * the one the user means. + * + * Runs when the view becomes active, not on mount: every view is mounted at + * once and hooks still run while one returns null, so a mount effect fires + * with an empty ref and never fires again when the view appears. + * + * Scrolls the container rather than calling `scrollIntoView`, which walks + * every scrollable ancestor and would move the popover with it. + */ + const activeRef = useRef(null); + const isActive = scale === viewScale; + useEffect(() => { + if (!isActive) return; + const group = activeRef.current; + const list = group?.parentElement; + /* The ref lags a render behind `activeYear`, so scrolling to a group that + is no longer the active one would land on the previous year. */ + 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, + 'data-scale': viewScale, + children: children ?? ( + <> + {years.map(year => ( +
+
+ {year} +
+
+ {cellsFor(viewScale, year).map(cell => { + const produced = anchorOf( + periodOf(cell.date, viewScale), + trailingValue + ); + const unavailable = !isPeriodAvailable( + cell.date, + viewScale + ); + return ( + + ); + })} +
+
+ ))} + + ) + } as useRender.ComponentProps<'div'>, + props + ) + }); + + /* Sibling views all mount; each gates on the active scale, so `.Quarters` + can stand alone with no day grid in the tree. */ + 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-root.tsx b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx index bfd572f4d..46dd942b0 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview-root.tsx +++ b/packages/raystack/components/calendar-preview/calendar-preview-root.tsx @@ -25,7 +25,16 @@ import { parseKey, yearOf } from './date-adapter'; -import { periodOf, type Scale, type ScaleValue } from './lib/scale'; +import { + anchorOf, + convertScale, + isAvailable, + isScale, + periodOf, + SCALES, + type Scale, + type ScaleValue +} from './lib/scale'; const DEFAULT_YEAR_SPAN = 10; @@ -36,18 +45,25 @@ function isRange(value: unknown): value is CalendarPreviewDateRange { /* The day the view should open on, whichever selection shape the value is. */ function monthAnchor(value: CalendarPreviewValue): 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. */ -type CalendarPreviewValue = Date | CalendarPreviewDateRange | null; +type CalendarPreviewValue = Date | CalendarPreviewDateRange | ScaleValue | null; + +function isScaleValue(value: CalendarPreviewValue): 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'; + /** @defaultValue 'day' */ + scales?: 'day'; /** The selected day (controlled). */ value?: Date | null; /** The initially selected day (uncontrolled). */ @@ -60,6 +76,7 @@ interface CalendarPreviewSingleProps { interface CalendarPreviewRangeProps { selection: 'range'; + scales?: 'day'; /** The selected range (controlled). Both edges, or nothing. */ value?: CalendarPreviewDateRange | null; /** The initial range (uncontrolled). */ @@ -74,14 +91,48 @@ interface CalendarPreviewRangeProps { ) => void; } +/* + * Open Item 1 in the RFC: expressing "day-only keeps `Date`" so that + * `['day','month']` still narrows. TypeScript cannot test an array's contents, + * so the discriminator is the SHAPE of `scales` rather than its members — + * omitted or the literal `'day'` keeps `Date`; any other scale, or any array, + * moves to `ScaleValue`. The wart is that `scales={['day']}` takes the + * scale-aware arm where `scales='day'` does not. + */ +interface CalendarPreviewScaleAwareProps { + /* Ranges across scales are not a thing this ships — a start/end pair is two + independent roots, each with its own `scales` and `trailingValue`. */ + selection?: 'single'; + scales: Exclude | Scale[]; + /** The selected period. `date` is timeless `'YYYY-MM-DD'`. */ + value?: ScaleValue | null; + defaultValue?: ScaleValue | null; + onValueChange?: ( + value: ScaleValue | null, + details: CalendarPreviewChangeDetails + ) => void; +} + export type CalendarPreviewProps = ( | CalendarPreviewSingleProps | CalendarPreviewRangeProps + | CalendarPreviewScaleAwareProps ) & CalendarPreviewSharedProps; interface CalendarPreviewSharedProps extends Omit, 'defaultValue' | 'onChange'> { + /** The scale the picker opens on. @defaultValue the first of `scales` */ + defaultScale?: Scale; + scale?: Scale; + 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. + * @defaultValue false + */ + trailingValue?: boolean; /** Whether the popover is open (controlled). Ignored by an inline calendar. */ open?: boolean; /** @defaultValue false */ @@ -123,7 +174,7 @@ interface CalendarPreviewSharedProps /** * Renders a value for display. - * @defaultValue `DD/MM/YYYY` at day scale + * @defaultValue `DD MMM YYYY` at day scale */ formatValue?: (value: Date | ScaleValue, scale: Scale) => string; /** Forwarded to the grid. No conversion is done here. */ @@ -169,6 +220,11 @@ export function defaultFormatValue( export function CalendarPreviewRoot({ selection = 'single', + scales: scalesProp = 'day', + scale: scaleProp, + defaultScale, + onScaleChange, + trailingValue = false, value: valueProp, defaultValue = null, onValueChange, @@ -222,13 +278,22 @@ export function CalendarPreviewRoot({ /* 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 scales = useMemo(() => { + const list = (Array.isArray(scalesProp) ? scalesProp : [scalesProp]).filter( + isScale + ); + return list.length > 0 ? SCALES.filter(s => list.includes(s)) : ['day']; + }, [scalesProp]); + const [scale, setScaleUnwrapped] = useControlled({ - controlled: undefined, - default: 'day', + controlled: scaleProp, + default: defaultScale ?? scales[0], name: 'CalendarPreview', state: 'scale' }); + const [scaleDraft, setScaleDraft] = useState(null); + const setMonth = useCallback( (next: Date) => { setMonthUnwrapped(next); @@ -246,7 +311,9 @@ export function CalendarPreviewRoot({ setValueUnwrapped(next); emit?.(next, { reason, - period: periodOf(occasion, scale), + /* The scale that was committed, not the one on screen: typing + "Q4 2026" commits a quarter while the view is still on days. */ + period: periodOf(occasion, isScaleValue(next) ? next.scale : scale), toDate: () => occasion }); }, @@ -290,8 +357,11 @@ export function CalendarPreviewRoot({ }, []); const setScale = useCallback( - (next: Scale) => setScaleUnwrapped(next), - [setScaleUnwrapped] + (next: Scale) => { + setScaleUnwrapped(next); + onScaleChange?.(next); + }, + [setScaleUnwrapped, onScaleChange] ); const [draft, setDraft] = useState(null); @@ -370,6 +440,60 @@ export function CalendarPreviewRoot({ ] ); + /* The value as a ScaleValue, whichever shape the consumer holds. */ + 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]); + + /* A scale switch moves the view and drafts; it never emits. The draft is + what the user is looking at, so the input and the views read it. */ + const switchScale = useCallback( + (next: Scale) => { + /* Falls back to the month on screen, not to today: a consumer opening on + 2030, or a user who navigated there, must not be thrown back to this + year by switching scale. `month` already resolves to today when + nothing else set it. */ + const anchor = scaleValue ?? { + date: dayKey(month, timeZone), + scale + }; + setScaleDraft(convertScale(anchor, next, trailingValue)); + setMonth(parseKey(convertScale(anchor, next, false).date)); + setScale(next); + }, + [scaleValue, month, timeZone, scale, trailingValue, setMonth, setScale] + ); + + const selectPeriod = useCallback( + (date: Date | string, next: Scale) => { + if (readOnly || disabled) return; + const key = anchorOf(periodOf(date, next), trailingValue); + setScaleDraft(null); + setValue({ date: key, scale: next } as never, 'select', parseKey(key)); + setOpen( + false, + createChangeEventDetails(REASONS.closePress, undefined, undefined) + ); + }, + [trailingValue, readOnly, disabled, setValue, setOpen] + ); + + /* Restoring the input means restoring the scale too: a day value rendered at + the drafted quarter scale would still read "Q3 2026". */ + const dropDraft = useCallback(() => { + setScaleDraft(null); + setScaleUnwrapped(isScaleValue(value) ? value.scale : scales[0]); + }, [value, scales, setScaleUnwrapped]); + + const isPeriodAvailable = useCallback( + (date: Date | string, next: Scale) => + isAvailable(date, next, trailingValue, minDate, maxDate), + [trailingValue, minDate, maxDate] + ); + const reset = useCallback(() => { if (!defaultDate) return; setValue(defaultDate, 'select', defaultDate); @@ -402,6 +526,13 @@ export function CalendarPreviewRoot({ () => ({ value, setValue, + scales, + trailingValue, + scaleDraft, + switchScale, + selectPeriod, + dropDraft, + isPeriodAvailable, selection, selectDay, draft: draft ?? (isRange(value) ? value : null), @@ -432,6 +563,13 @@ export function CalendarPreviewRoot({ [ value, setValue, + scales, + trailingValue, + scaleDraft, + switchScale, + selectPeriod, + dropDraft, + isPeriodAvailable, selection, selectDay, draft, 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..b864a7e7b --- /dev/null +++ b/packages/raystack/components/calendar-preview/calendar-preview-scales.tsx @@ -0,0 +1,110 @@ +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'>; + +/** + * The scale switcher. Renders nothing when only one scale is offered, which is + * what keeps a plain day calendar from growing a one-tab row. + */ +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; +} + +/** One scale. Only needed to relabel or reorder what `.Scales` renders. */ +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: cx(styles.scale, className), + 'data-slot': 'calendar-preview-scale', + 'data-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.module.css b/packages/raystack/components/calendar-preview/calendar-preview.module.css index 502159f54..bdaa1ac7d 100644 --- a/packages/raystack/components/calendar-preview/calendar-preview.module.css +++ b/packages/raystack/components/calendar-preview/calendar-preview.module.css @@ -519,3 +519,90 @@ align-items: center; gap: var(--rs-space-3); } + +.body { + display: flex; + flex-direction: column; + gap: var(--rs-space-3); + padding: var(--rs-space-3); + width: max-content; +} + +.label { + color: var(--rs-color-foreground-base-secondary); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.separator { + height: 1px; + background: var(--rs-color-border-base-primary); +} + +.scales { + display: flex; +} + +/* The day view hugs; every period list is a fixed box that scrolls as one, so + the year headings scroll with their cells rather than pinning. */ +.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-2); +} + +.period-year { + color: var(--rs-color-foreground-base-secondary); + font-size: var(--rs-font-size-micro); + line-height: var(--rs-line-height-micro); + letter-spacing: var(--rs-letter-spacing-micro); +} + +.period-cells { + display: grid; + grid-template-columns: repeat(var(--rs-period-columns), 1fr); + gap: var(--rs-space-2); +} + +.period { + padding: var(--rs-space-2) var(--rs-space-3); + border: 1px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + background: transparent; + color: var(--rs-color-foreground-base-primary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + 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] { + background: var(--rs-color-background-neutral-secondary); +} + +.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 e7970bec2..46ccb01ab 100644 --- a/packages/raystack/components/calendar-preview/date-adapter.ts +++ b/packages/raystack/components/calendar-preview/date-adapter.ts @@ -131,10 +131,11 @@ 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. */ +/* `lib/parse.ts` accepts this form back, so a rendered value can be typed + straight in. Day-first and month-named, matching the frames and the shipped + picker's `dateFormat`. */ export function formatDayLabel(date: Date, timeZone?: string): string { - return format(zoned(date, timeZone), 'dd/MM/yyyy'); + return format(zoned(date, timeZone), 'dd MMM yyyy'); } /** `'May 2027'` — the default label for a value at month scale. */ diff --git a/packages/raystack/components/calendar-preview/index.tsx b/packages/raystack/components/calendar-preview/index.tsx index 9b83afa74..00b0c3004 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 { @@ -24,8 +25,16 @@ 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 { Scale, ScaleValue } 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 cd4cfb70b..15c468188 100644 --- a/packages/raystack/components/calendar-preview/lib/parse.ts +++ b/packages/raystack/components/calendar-preview/lib/parse.ts @@ -35,6 +35,8 @@ export interface ParseScaleInputOptions { /* 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. */ 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}))?$/; @@ -50,6 +52,7 @@ const YEAR = /^(\d{4})$/; * | Input | Scale | Notes | * |---|---|---| * | `20/05/2027`, `5/5/2027` | `day` | `dd/MM/yyyy`, day first | + * | `15 Aug 2026`, `15 August 2026` | `day` | what `formatDayLabel` renders | * | `2027-05-20` | `day` | the canonical stored form, so it round-trips | * | `May 2027`, `September 2027`, `Sep 2027` | `month` | | * | `May` | `month` | year inferred | @@ -91,6 +94,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; } diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index aff716c65..dc84b453b 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,