Skip to content
Merged
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
213 changes: 213 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 @@ -145,6 +145,17 @@ export const resetDemo = {
<CalendarPreview.Days />
</CalendarPreview>`
},
{
name: 'Range',
code: `<CalendarPreview
selection="range"
defaultMonth={new Date(2024, 3, 1)}
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) }}
>
<CalendarPreview.Days />
</CalendarPreview>`
},
{
name: 'Clear the selection',
code: `<CalendarPreview
Expand Down Expand Up @@ -352,6 +363,21 @@ export const pickerDemo = {
</CalendarPreview>
</Field>`
},
{
name: 'Reset',
code: `<CalendarPreview
defaultMonth={new Date(2024, 3, 1)}
defaultDate={new Date(2024, 3, 17)}
defaultValue={new Date(2024, 3, 24)}
>
<CalendarPreview.Trigger>
<CalendarPreview.Input />
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days />
</CalendarPreview.Content>
</CalendarPreview>`
},
{
name: 'Invalid input',
code: `
Expand Down Expand Up @@ -422,3 +448,190 @@ function CalendarPreviewInvalidExample() {
}
]
};

export const rangeDemo = {
type: 'code',
tabs: [
{
name: 'Basic',
code: `<CalendarPreview selection="range" defaultMonth={new Date(2024, 3, 1)}>
<CalendarPreview.Trigger>
<Flex align="center" gap={3}>
<CalendarPreview.Input field="start" />
<CalendarPreview.Input field="end" />
</Flex>
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>`
},
{
name: 'Disabled',
code: `<CalendarPreview selection="range" defaultMonth={new Date(2024, 3, 1)} disabled>
<CalendarPreview.Trigger>
<Flex align="center" gap={3}>
<CalendarPreview.Input field="start" />
<CalendarPreview.Input field="end" />
</Flex>
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>`
},
{
name: 'Disabled dates',
code: `<CalendarPreview
selection="range"
defaultMonth={new Date(2024, 3, 1)}
minDate={new Date(2024, 3, 10)}
isDateUnavailable={date => date.getDay() === 0 || date.getDay() === 6}
>
<CalendarPreview.Trigger>
<Flex align="center" gap={3}>
<CalendarPreview.Input field="start" />
<CalendarPreview.Input field="end" />
</Flex>
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>`
},
{
name: 'Without calendar icon',
code: `<CalendarPreview selection="range" defaultMonth={new Date(2024, 3, 1)}>
<CalendarPreview.Trigger>
<Flex align="center" gap={3}>
<CalendarPreview.Input field="start" trailingIcon={null} />
<CalendarPreview.Input field="end" trailingIcon={null} />
</Flex>
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>`
},
{
name: 'Read-only start',
code: `<CalendarPreview
selection="range"
defaultMonth={new Date(2024, 3, 1)}
defaultValue={{ from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) }}
>
<CalendarPreview.Trigger>
<Flex align="center" gap={3}>
<CalendarPreview.Input field="start" readOnly />
<CalendarPreview.Input field="end" />
</Flex>
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>`
},
{
name: 'Reset',
code: `<CalendarPreview
selection="range"
defaultMonth={new Date(2024, 3, 1)}
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) }}
>
<CalendarPreview.Trigger>
<Flex align="center" gap={3}>
<CalendarPreview.Input field="start" />
<CalendarPreview.Input field="end" />
</Flex>
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days />
</CalendarPreview.Content>
</CalendarPreview>`
},
{
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 (
<Flex direction="column" gap={7} style={{ maxWidth: 320 }}>
<Field
label="Trip dates"
description="Type an end before 10/04/2024 — typing rejects, clicking restarts"
error={defaultError}
>
<CalendarPreview {...range}>
<CalendarPreview.Trigger>
<Flex align="center" gap={3}>
<CalendarPreview.Input
field="start"
onValidityChange={({ message }) => setDefaultError(message)}
/>
<CalendarPreview.Input
field="end"
onValidityChange={({ message }) => setDefaultError(message)}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
</Flex>
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>
</Field>

<Field
label="Trip dates"
description="The same crossing, worded with errorMessages"
error={customError}
>
<CalendarPreview {...range}>
<CalendarPreview.Trigger>
<Flex align="center" gap={3}>
<CalendarPreview.Input
field="start"
errorMessages={{ 'out-of-order': 'Start must not pass the end' }}
onValidityChange={({ message }) => setCustomError(message)}
/>
<CalendarPreview.Input
field="end"
errorMessages={{ 'out-of-order': 'Pick a day on or after the start' }}
onValidityChange={({ message }) => setCustomError(message)}
/>
</Flex>
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>
</Field>
</Flex>
);
}`
},
{
name: 'Custom trigger',
code: `<CalendarPreview
selection="range"
defaultMonth={new Date(2024, 3, 1)}
defaultValue={{ from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) }}
>
<CalendarPreview.Trigger render={<Button variant="outline" />}>
10 Apr – 20 Apr
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>`
}
]
};
63 changes: 61 additions & 2 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,7 @@ import {
gridDemo,
dateInfoDemo,
pickerDemo,
rangeDemo,
} from "./demo.ts";

<Demo data={preview} />
Expand Down Expand Up @@ -212,7 +213,16 @@ 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 only when `defaultDate` is set *and* the current value differs from it, so the button disappears once there is nothing to restore.
`.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
<CalendarPreview
selection="range"
defaultDate={{ from: new Date(2024, 3, 10), to: new Date(2024, 3, 20) }}
>
```

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

Expand Down Expand Up @@ -263,6 +273,51 @@ The popover opens when the input takes focus. Enter, blur and an outside click a

<Demo data={pickerDemo} />

### Range selection

`selection="range"` turns clicks into endpoints. Give each `.Input` a `field`:

```tsx
<CalendarPreview selection="range" value={range} onValueChange={setRange}>
<CalendarPreview.Trigger>
<CalendarPreview.Input field="start" />
<CalendarPreview.Input field="end" />
</CalendarPreview.Trigger>
<CalendarPreview.Content>
<CalendarPreview.Days numberOfMonths={2} />
</CalendarPreview.Content>
</CalendarPreview>
```

**`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
<CalendarPreview.Input
field="end"
errorMessages={{ 'out-of-order': 'Pick a day after the start' }}
onValidityChange={({ message }) => 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.

<Demo data={rangeDemo} />
### Invalid typed dates

Typing is checked on every keystroke, and a date that fails is **never committed** — `onValueChange`
Expand All @@ -288,10 +343,13 @@ it is `undefined` while valid, which is exactly what [Field](/docs/components/fi
</Field>
```

The default is a flat **"Invalid input"** for every reason. It stays deliberately vague because only
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:

Expand All @@ -312,6 +370,7 @@ The reason is also on the payload if you would rather branch on it yourself:
| `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 |

It fires only when validity *changes*, not on every keystroke, so it is safe to drive state with.

Expand Down
14 changes: 9 additions & 5 deletions apps/www/src/content/docs/components/calendar-preview/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,10 @@ 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.
* `.Reset` clears; omitting the prop renders no button at all. Takes a
* range at `selection="range"`.
*/
defaultDate?: Date | null;
defaultDate?: Date | { from: Date; to: Date } | null;

/**
* The zone the grid reads days in. Forwarded to the grid; the component does
Expand Down Expand Up @@ -242,17 +243,20 @@ export interface CalendarPreviewInputProps {
*/
onValidityChange?: (validity: {
valid: boolean;
reason?: 'unparseable' | 'out-of-bounds' | 'unavailable';
reason?: 'unparseable' | 'out-of-bounds' | 'unavailable' | 'out-of-order';
message?: string;
}) => void;

/**
* Replaces the message for one or more reasons; anything left out keeps the
* default.
* @default "Invalid input" for every reason
* @default "Invalid input", except out-of-order, which words itself
*/
errorMessages?: Partial<
Record<'unparseable' | 'out-of-bounds' | 'unavailable', string>
Record<
'unparseable' | 'out-of-bounds' | 'unavailable' | 'out-of-order',
string
>
>;

/** Read and navigable, but not typeable. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1454,7 +1454,9 @@ describe('useCalendar', () => {
useCalendar();
return (
<div>
<span data-testid='value'>{value ? value.getDate() : 'none'}</span>
<span data-testid='value'>
{value instanceof Date ? value.getDate() : 'none'}
</span>
<span data-testid='month'>{month.getMonth()}</span>
<span data-testid='scale'>{scale}</span>
<span data-testid='blocked'>
Expand Down
Loading
Loading