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
14 changes: 14 additions & 0 deletions .changeset/olive-pandas-cheer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@tailor-platform/app-shell": minor
---

Add `DateRangePicker` and `RangeCalendar` components with a `{ start, end }` range value (exported as `DateRange`). Selection follows the react-aria model: the first calendar pick anchors the range and keeps the popover open, the highlight live-extends to the hovered/focused day, and the second pick completes it — picking backwards swaps the endpoints, while a range typed in reverse is flagged invalid instead.

```tsx
import { DateRangePicker, type DateRange } from "@tailor-platform/app-shell";

const [range, setRange] = useState<DateRange | null>(null);
<DateRangePicker label="Billing period" value={range} onChange={setRange} />;
```

DataTable date filters now use a single `DateRangePicker` for the `between` operator (one shared range calendar instead of two separate date pickers), and a reversed `between` range can no longer be applied.
46 changes: 44 additions & 2 deletions docs/components/date-picker.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ description: Accessible date input components (@internationalized/date + Base UI

# DatePicker

Three related components for date input — a segmented field, a field with a calendar popover, and a standalone calendar grid. Built on [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/) (the value layer) and Base UI (`Popover`), with the segmented input and calendar grid implemented to the [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/) date-picker/grid patterns. They integrate automatically with AppShell's locale and timezone context.
Five related components for date input — a segmented field, a field with a calendar popover, a standalone calendar grid, and their range counterparts (`DateRangePicker`, `RangeCalendar`). Built on [`@internationalized/date`](https://react-spectrum.adobe.com/internationalized/date/) (the value layer) and Base UI (`Popover`), with the segmented input and calendar grid implemented to the [ARIA Authoring Practices](https://www.w3.org/WAI/ARIA/apg/patterns/) date-picker/grid patterns. They integrate automatically with AppShell's locale and timezone context.

> **Implementation note.** This is the `@internationalized/date` + Base UI variant. The public API and accessibility contract are identical to the react-aria variant; only the internals differ.

Expand All @@ -15,12 +15,15 @@ Three related components for date input — a segmented field, a field with a ca
import {
DateField,
DatePicker,
DateRangePicker,
Calendar,
RangeCalendar,
// Date value helpers (re-exported from @internationalized/date)
parseDate,
getLocalTimeZone,
type CalendarDate,
type DateValue,
type DateRange,
} from "@tailor-platform/app-shell";
```

Expand Down Expand Up @@ -86,6 +89,27 @@ A standalone calendar grid for custom date-selection UIs (e.g. reporting filters
<Calendar aria-label="Select date" onChange={(date) => console.log(date)} />
```

## DateRangePicker

Start and end segmented inputs sharing one calendar popover, with a `{ start, end }` value — a **separate component** from `DatePicker`, not a mode. Selection follows the react-aria DateRangePicker model: the first calendar pick anchors the range and keeps the popover open, the highlight follows the pointer (or arrow keys), and the second pick completes the range and closes the popover. Picking a day _before_ the anchor swaps the endpoints, so a picked range is always ordered; a range **typed** in reverse is flagged invalid (with a built-in message) rather than silently swapped.

```tsx
const [range, setRange] = useState<DateRange | null>(null);
<DateRangePicker label="Billing period" value={range} onChange={setRange} />;
```

`onChange` fires with a complete `{ start, end }` range, or `null` when the range is cleared or an end is removed — never with a half-typed intermediate. For form submission, `startName` / `endName` emit two hidden inputs.

The `DataTable` date filters use this component for the `between` operator.

## RangeCalendar

The standalone inline range calendar (the grid inside `DateRangePicker`), for custom layouts.

```tsx
<RangeCalendar aria-label="Stay dates" onChange={(range) => console.log(range)} />
```

## Localization

Locale and timezone come from AppShell automatically. Override per field with `locale` / `timeZone`:
Expand All @@ -100,7 +124,8 @@ Segment order, first-day-of-week, and month/weekday names all follow the resolve

- **Segments:** `↑`/`↓` increment/decrement, digits type-to-fill (auto-advance), `←`/`→` move between segments, `Backspace` clears, `/` commits the current segment and advances (so a single `1` means January, not the start of `1x`).
- **Whole-date shortcuts** (QuickBooks Online-style, case-insensitive): `t` today · `m`/`h` start/end of the entered month (current month when empty) · `y`/`r` start/end of the year · `w`/`k` start/end of the week (locale-aware) · `-` previous day · `=`/`+` next day (both step across month **and** year boundaries; `+` needs no Shift). A 1–2 digit year expands to the 2000s on blur (`26` → `2026`). These work **from a focused date segment** (they set the field value, clamped to `minValue`/`maxValue`) **and while the calendar popover is open** (they move the highlighted day like the arrow keys — press `Enter` to confirm; `minValue`/`maxValue` clamp and unavailable days can't be confirmed).
- **Calendar grid:** arrows move by day/week, `Home`/`End` to week start/end, `PageUp`/`PageDown` by month, `Shift`+`PageUp`/`PageDown` by year, `Enter`/`Space` selects. `Alt`+`↓` opens the calendar from the field (`DatePicker`).
- **Calendar grid:** arrows move by day/week, `Home`/`End` to week start/end, `PageUp`/`PageDown` by month, `Shift`+`PageUp`/`PageDown` by year, `Enter`/`Space` selects. `Alt`+`↓` opens the calendar from the field (`DatePicker` / `DateRangePicker`).
- **Range selection** (`RangeCalendar` / `DateRangePicker` popover): `Enter`/`Space` anchors the range and moves focus one day forward; plain arrows then extend the highlight (no `Shift` chord needed); a second `Enter`/`Space` commits. `Escape` cancels an in-progress selection (inside the popover, the same keypress also dismisses it — react-aria behaviour). In the range field, `←`/`→` run through the start segments into the end segments as one sequence.

## Accessibility

Expand Down Expand Up @@ -164,6 +189,23 @@ The standalone calendar grid. It has no segmented input, so its surface is liste
| `aria-label` / `aria-labelledby` | `string` | Accessible name for the grid |
| `className` | `string` | Root element class |

### DateRangePickerProps

The `DateFieldProps` surface (minus `name`), plus the `DatePickerProps` calendar props, with the range-specific differences:

| Prop | Type | Description |
| ------------------------ | -------------------------------- | ------------------------------------------------------------------------------- |
| `value` / `defaultValue` | `DateRange \| null` | Controlled / uncontrolled `{ start, end }` range |
| `onChange` | `(v: DateRange \| null) => void` | Fires with a complete range, or `null` when cleared/incomplete |
| `startName` / `endName` | `string` | Emit hidden `<input>`s with the ISO start/end values for form submission |
| `isDateUnavailable` | `(date: DateValue) => boolean` | Also constrains selection: an in-progress range can't cross an unavailable date |

A typed range with `end` before `start` sets the invalid state and shows a built-in localized error (unless `errorMessage` overrides it).

### RangeCalendarProps

Same surface as `CalendarProps`, with `value` / `defaultValue`: `DateRange | null` and `onChange`: `(v: DateRange) => void` (fired once per selection, when the second date completes the range).

### Proposed / not yet implemented

Accepted by the prop types (for parity with the react-aria variant) but **not acted on** in this variant yet:
Expand Down
Loading
Loading