Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions apps/www/src/content/docs/components/calendar-preview/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,92 @@ export const rangeDemo = {
}
]
};

export const scaleDemo = {
type: 'code',
tabs: [
{
name: 'Inline',
code: `<CalendarPreview
scales={['day', 'month', 'quarter', 'halfYear', 'year']}
defaultMonth={new Date(2026, 7, 1)}
defaultScale="quarter"
>
<CalendarPreview.Body />
</CalendarPreview>`
},
{
name: 'Day scale',
code: `<CalendarPreview
scales={['day', 'month', 'quarter', 'halfYear', 'year']}
defaultMonth={new Date(2026, 7, 1)}
>
<CalendarPreview.Body />
</CalendarPreview>`
},
{
name: 'In a popover',
code: `<CalendarPreview
scales={['day', 'month', 'quarter', 'halfYear', 'year']}
defaultMonth={new Date(2026, 7, 1)}
>
<CalendarPreview.Trigger placeholder="Add start date" />
<CalendarPreview.Content>
<CalendarPreview.Body />
</CalendarPreview.Content>
</CalendarPreview>`
},
{
name: 'Periods only',
code: `<CalendarPreview scales={['month', 'quarter', 'year']} defaultScale="month">
<CalendarPreview.Body />
</CalendarPreview>`
},
{
name: 'One view alone',
code: `<CalendarPreview scales="quarter" defaultMonth={new Date(2026, 7, 1)}>
<CalendarPreview.Quarters />
</CalendarPreview>`
},
{
name: 'Bounded',
code: `<CalendarPreview
scales={['month', 'quarter', 'halfYear']}
defaultScale="quarter"
trailingValue
minDate={new Date(2026, 6, 15)}
>
<CalendarPreview.Body />
</CalendarPreview>`
}
]
};

export const scalePairDemo = {
type: 'code',
code: `<Flex align="center" gap={3}>
<CalendarPreview
scales={['day', 'month', 'quarter', 'halfYear', 'year']}
defaultValue={{ date: '2026-08-01', scale: 'day' }}
>
<CalendarPreview.Trigger placeholder="Add start date" />
<CalendarPreview.Content>
<CalendarPreview.Body />
</CalendarPreview.Content>
</CalendarPreview>

<Text size="small" variant="secondary">→</Text>

<CalendarPreview
scales={['day', 'month', 'quarter', 'halfYear', 'year']}
trailingValue
minDate={new Date(2026, 7, 1)}
defaultValue={{ date: '2026-09-30', scale: 'quarter' }}
>
<CalendarPreview.Trigger placeholder="Add end date" />
<CalendarPreview.Content>
<CalendarPreview.Body />
</CalendarPreview.Content>
</CalendarPreview>
</Flex>`
};
86 changes: 86 additions & 0 deletions apps/www/src/content/docs/components/calendar-preview/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
dateInfoDemo,
pickerDemo,
rangeDemo,
scaleDemo,
scalePairDemo,
} from "./demo.ts";

<Demo data={preview} />
Expand Down Expand Up @@ -102,6 +104,26 @@ The portaled popover surface. Takes `Popover.Content` props — `side`, `align`,

<auto-type-table path="./props.ts" name="CalendarPreviewInputProps" />

### 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.
Expand Down Expand Up @@ -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 |

Expand Down Expand Up @@ -257,6 +289,60 @@ Instead of a `lock` prop, mark one endpoint's `.Input` as `readOnly` — the gri

<Demo data={rangeDemo} />

### Scale-aware selection

Pass `scales` to select at granularities coarser than a day. A single value hides the switcher; anything more shows it.

```tsx
<CalendarPreview scales={['day', 'month', 'quarter', 'halfYear', 'year']}>
<CalendarPreview.Trigger placeholder="Add start date" />
<CalendarPreview.Content>
<CalendarPreview.Body />
</CalendarPreview.Content>
</CalendarPreview>
```

<Demo data={scaleDemo} />

#### 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.

<Demo data={scalePairDemo} />

## Accessibility

- Arrow keys move between days; the focused cell carries `data-draft` until it is committed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
);
});
Expand All @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) => {
Expand Down Expand Up @@ -283,7 +283,7 @@ describe('CalendarPreview.Trigger content', () => {
</CalendarPreview>
);
expect(getSlot(container, 'calendar-preview-trigger')).toHaveTextContent(
'20/08/2026'
'20 Aug 2026'
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down
Loading
Loading