From 460649304f38c7fb529685a2d78d282ee45db04f Mon Sep 17 00:00:00 2001 From: interacsean Date: Mon, 6 Jul 2026 16:15:38 +1000 Subject: [PATCH 1/2] feat(date-picker): DateRangePicker + RangeCalendar with range selection in DataTable between filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two deferred-but-designed-for range components from the DatePicker proposal (pp#1373), mirroring react-aria's DateRangePicker interaction spec: - useCalendarGridState: selection-agnostic grid/focus/keyboard engine extracted from useCalendarState (behaviour preserved), with a selection adapter so the single and range hooks share everything but their selection model. - useRangeCalendarState + RangeCalendar: two-click anchor→commit selection, live hover/keyboard preview (focus follows the highlight), backwards-pick endpoint swap, Escape cancel, contiguous-range clamping around unavailable dates, and the pre-wired data-selection-start/end cell styling plus a new data-in-range band painted on the gridcell. - DateRangePicker: start/end segmented inputs (cross-field arrow navigation, per-field blur normalization, QuickBooks shortcuts) in one labelled group with a single trigger and shared popover that closes only when the second pick completes the range; a range typed in reverse flags invalid with a localized message instead of silently swapping. - DataTable date filters: the between operator now renders one DateRangePicker instead of two stacked DatePickers. The committed {min,max} value shape is unchanged (GraphQL/URL serialization untouched); reversed ranges can no longer be applied; switching a date filter to between seeds both bounds; malformed URL date values no longer crash the editor; an emptied range disables Apply (remove stays on the chip's ✕). Co-Authored-By: Claude Fable 5 --- .changeset/olive-pandas-cheer.md | 14 + docs/components/date-picker.md | 46 ++- .../vite-app/src/pages/date-picker/page.tsx | 52 +++ ...mponents__calendar__calendar.test.tsx.snap | 2 +- ...ts__calendar__range-calendar.test.tsx.snap | 3 + ...ents__date-field__date-field.test.tsx.snap | 6 +- ...ate-field__date-range-picker.test.tsx.snap | 3 + .../src/components/calendar/calendar-view.tsx | 60 ++- .../src/components/calendar/calendar.test.tsx | 28 ++ packages/core/src/components/calendar/i18n.ts | 9 +- .../core/src/components/calendar/index.ts | 2 + .../calendar/range-calendar.test.tsx | 331 +++++++++++++++ .../components/calendar/range-calendar.tsx | 99 +++++ .../calendar/use-calendar-base-state.ts | 381 ++++++++++++++++++ .../components/calendar/use-calendar-state.ts | 322 ++------------- .../calendar/use-range-calendar-state.ts | 178 ++++++++ .../components/data-table/toolbar.test.tsx | 189 +++++++-- .../src/components/data-table/toolbar.tsx | 144 +++++-- .../src/components/date-field/date-field.tsx | 4 +- .../date-field/date-input-group.tsx | 237 +++++++---- .../date-field/date-range-input-group.tsx | 144 +++++++ .../date-field/date-range-picker.test.tsx | 258 ++++++++++++ .../date-field/date-range-picker.tsx | 296 ++++++++++++++ .../core/src/components/date-field/i18n.ts | 14 + .../core/src/components/date-field/index.ts | 1 + packages/core/src/index.ts | 10 +- 26 files changed, 2370 insertions(+), 463 deletions(-) create mode 100644 .changeset/olive-pandas-cheer.md create mode 100644 packages/core/__snapshots__/src__components__calendar__range-calendar.test.tsx.snap create mode 100644 packages/core/__snapshots__/src__components__date-field__date-range-picker.test.tsx.snap create mode 100644 packages/core/src/components/calendar/range-calendar.test.tsx create mode 100644 packages/core/src/components/calendar/range-calendar.tsx create mode 100644 packages/core/src/components/calendar/use-calendar-base-state.ts create mode 100644 packages/core/src/components/calendar/use-range-calendar-state.ts create mode 100644 packages/core/src/components/date-field/date-range-input-group.tsx create mode 100644 packages/core/src/components/date-field/date-range-picker.test.tsx create mode 100644 packages/core/src/components/date-field/date-range-picker.tsx diff --git a/.changeset/olive-pandas-cheer.md b/.changeset/olive-pandas-cheer.md new file mode 100644 index 00000000..cdf3d11b --- /dev/null +++ b/.changeset/olive-pandas-cheer.md @@ -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(null); +; +``` + +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. diff --git a/docs/components/date-picker.md b/docs/components/date-picker.md index a91d84f0..b718a9db 100644 --- a/docs/components/date-picker.md +++ b/docs/components/date-picker.md @@ -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. @@ -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"; ``` @@ -86,6 +89,27 @@ A standalone calendar grid for custom date-selection UIs (e.g. reporting filters 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(null); +; +``` + +`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 + console.log(range)} /> +``` + ## Localization Locale and timezone come from AppShell automatically. Override per field with `locale` / `timeZone`: @@ -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 @@ -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 ``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: diff --git a/examples/vite-app/src/pages/date-picker/page.tsx b/examples/vite-app/src/pages/date-picker/page.tsx index b421d4e5..afd61a53 100644 --- a/examples/vite-app/src/pages/date-picker/page.tsx +++ b/examples/vite-app/src/pages/date-picker/page.tsx @@ -3,13 +3,16 @@ import { Layout, DateField, DatePicker, + DateRangePicker, Calendar, + RangeCalendar, Form, Button, useTimeZone, parseDate, type CalendarDate, type DateValue, + type DateRange, type AppShellPageProps, } from "@tailor-platform/app-shell"; import { CalendarDays } from "lucide-react"; @@ -20,6 +23,8 @@ const DatePickerPage = () => { const [pickerValue, setPickerValue] = useState(null); const [calendarValue, setCalendarValue] = useState(null); const [weekendValue, setWeekendValue] = useState(null); + const [rangeValue, setRangeValue] = useState(null); + const [inlineRange, setInlineRange] = useState(null); // Form-validation demo state. const [deliveryDate, setDeliveryDate] = useState(null); @@ -132,6 +137,38 @@ const DatePickerPage = () => { )} + {/* ── DateRangePicker ────────────────────────────────────── */} +
+

DateRangePicker

+

+ One shared calendar: the first pick anchors the range (the popover stays open and the + highlight follows the pointer/arrows), the second pick completes it. Picking backwards + swaps the endpoints; a range typed in reverse is flagged invalid instead. +

+
+ + + { + const day = d.toDate(tz.value).getDay(); + return day === 0 || day === 6; + }} + /> +
+ {rangeValue && ( +

+ Selected: {rangeValue.start.toString()} →{" "} + {rangeValue.end.toString()} +

+ )} +
+ {/* ── In a form (submit validation) ───────────────────────── */}

In a form (submit validation)

@@ -236,6 +273,21 @@ const DatePickerPage = () => { onChange={(v) => setWeekendValue(v as CalendarDate)} /> + +
+

RangeCalendar

+ + {inlineRange && ( +

+ Selected: {inlineRange.start.toString()} →{" "} + {inlineRange.end.toString()} +

+ )} +
diff --git a/packages/core/__snapshots__/src__components__calendar__calendar.test.tsx.snap b/packages/core/__snapshots__/src__components__calendar__calendar.test.tsx.snap index 99bd521b..992ad9a8 100644 --- a/packages/core/__snapshots__/src__components__calendar__calendar.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__calendar__calendar.test.tsx.snap @@ -1,3 +1,3 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`snapshots > Calendar — pre-selected 1`] = `"
June 2025
"`; +exports[`snapshots > Calendar — pre-selected 1`] = `"
June 2025
"`; diff --git a/packages/core/__snapshots__/src__components__calendar__range-calendar.test.tsx.snap b/packages/core/__snapshots__/src__components__calendar__range-calendar.test.tsx.snap new file mode 100644 index 00000000..717ac1ba --- /dev/null +++ b/packages/core/__snapshots__/src__components__calendar__range-calendar.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`snapshots > RangeCalendar — pre-selected range 1`] = `"
June 2025
"`; diff --git a/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap b/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap index 856ed2c5..66ee5627 100644 --- a/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__date-field__date-field.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`snapshots > DateField 1`] = `"
Invoice date
mm
dd
yyyy
"`; +exports[`snapshots > DateField 1`] = `"
Invoice date
mm
dd
yyyy
"`; -exports[`snapshots > DateField — invalid with error 1`] = `"
Date
mm
dd
yyyy

Pick a date

"`; +exports[`snapshots > DateField — invalid with error 1`] = `"
Date
mm
dd
yyyy

Pick a date

"`; -exports[`snapshots > DatePicker — closed 1`] = `"
Ship date
mm
dd
yyyy
"`; +exports[`snapshots > DatePicker — closed 1`] = `"
Ship date
mm
dd
yyyy
"`; diff --git a/packages/core/__snapshots__/src__components__date-field__date-range-picker.test.tsx.snap b/packages/core/__snapshots__/src__components__date-field__date-range-picker.test.tsx.snap new file mode 100644 index 00000000..50aef5a7 --- /dev/null +++ b/packages/core/__snapshots__/src__components__date-field__date-range-picker.test.tsx.snap @@ -0,0 +1,3 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`snapshots > DateRangePicker — closed 1`] = `"
Billing period
mm
dd
yyyy
mm
dd
yyyy
"`; diff --git a/packages/core/src/components/calendar/calendar-view.tsx b/packages/core/src/components/calendar/calendar-view.tsx index b7800c72..3f7d3df3 100644 --- a/packages/core/src/components/calendar/calendar-view.tsx +++ b/packages/core/src/components/calendar/calendar-view.tsx @@ -3,12 +3,13 @@ import { cva } from "class-variance-authority"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { useCalendarT } from "./i18n"; -import type { CalendarDay, useCalendarState } from "./use-calendar-state"; +import type { CalendarDay, CalendarViewState } from "./use-calendar-base-state"; /** * Calendar-grid presentation — the APG date-grid markup, driven by our own - * `useCalendarState` engine. Shared by the standalone `Calendar` and the - * `DatePicker` popover. Not exported from the package. + * calendar state engines (`useCalendarState` / `useRangeCalendarState`). + * Shared by the standalone `Calendar`/`RangeCalendar` and the + * `DatePicker`/`DateRangePicker` popovers. Not exported from the package. * * Styling mirrors the rest of the library (`astw:` tokens, dark mode). */ @@ -36,8 +37,10 @@ const calendarCellVariants = cva( "astw:data-[outside-month]:pointer-events-none astw:data-[outside-month]:opacity-40", "astw:data-[unavailable]:pointer-events-none astw:data-[unavailable]:text-muted-foreground astw:data-[unavailable]:line-through", "astw:data-[disabled]:pointer-events-none astw:data-[disabled]:opacity-50", - // Range states — wired now so a future DateRangePicker is purely additive + // Range states: endpoints keep the selected pill; the days in between + // read from the band painted on the underneath. "astw:data-[selection-start]:rounded-l-md astw:data-[selection-end]:rounded-r-md", + "astw:data-[in-range]:text-accent-foreground astw:data-[in-range]:data-[selected]:text-primary-foreground", "astw:data-[today]:font-semibold astw:data-[today]:underline astw:data-[today]:underline-offset-2", ], }, @@ -46,6 +49,14 @@ const calendarCellVariants = cva( }, ); +// The continuous range band, painted on the so it runs edge-to-edge +// between the rounded endpoint pills (the buttons stay `size-9 rounded-md`). +const cellTdClasses = cn( + "astw:p-0", + "astw:data-[in-range]:bg-accent", + "astw:data-[selection-start]:rounded-l-md astw:data-[selection-end]:rounded-r-md", +); + // Keyboard focus ring — the same `ring` treatment used by Button / inputs. const navButtonClasses = cn( "astw:flex astw:size-7 astw:items-center astw:justify-center astw:rounded-sm astw:outline-none", @@ -55,10 +66,8 @@ const navButtonClasses = cn( "astw:disabled:pointer-events-none astw:disabled:opacity-50", ); -type CalendarState = ReturnType; - interface CalendarViewProps { - state: CalendarState; + state: CalendarViewState; ariaLabel?: string; ariaLabelledBy?: string; className?: string; @@ -80,6 +89,7 @@ export function CalendarView({ }: CalendarViewProps) { const t = useCalendarT(); const headingId = React.useId(); + const rangePromptId = React.useId(); const cellRefs = React.useRef>(new Map()); const prevBtnRef = React.useRef(null); const nextBtnRef = React.useRef(null); @@ -109,7 +119,14 @@ export function CalendarView({ // Contain Tab within the prev → next → grid loop while in the popover. const handleContainerKeyDown = (e: React.KeyboardEvent) => { if (!inPopover || e.key !== "Tab") return; - const gridCell = cellRefs.current.get(state.focusedDate.toString()) ?? null; + // The grid stop is whichever cell actually holds DOM focus — it can lag + // the roving focusedDate (e.g. a click that didn't focus the button, as + // Safari's do) and Tab must still cycle rather than escape the dialog. + const active = document.activeElement as HTMLButtonElement | null; + const activeIsCell = active != null && [...cellRefs.current.values()].includes(active); + const gridCell = activeIsCell + ? active + : (cellRefs.current.get(state.focusedDate.toString()) ?? null); const stops = [ state.prevDisabled ? null : prevBtnRef.current, state.nextDisabled ? null : nextBtnRef.current, @@ -163,12 +180,20 @@ export function CalendarView({ + {/* Announced from the focused cell in range mode: tells the user whether + the next confirm starts or finishes the range selection. */} + {state.isRange && ( + + )} {/* APG calendar-grid pattern: role="grid" upgrades the table's cell/row semantics so arrow-key navigation is announced correctly. */} @@ -193,8 +218,10 @@ export function CalendarView({ key={day.date.toString()} day={day} label={state.cellLabel(day.date)} + describedById={state.isRange && day.isFocused ? rangePromptId : undefined} onSelect={() => state.selectDate(day.date)} onKeyDown={(e) => state.onCellKeyDown(e, day.date)} + onHover={state.onCellHover} registerRef={(el) => cellRefs.current.set(day.date.toString(), el)} onFocus={() => { state.isFocusedRef.current = true; @@ -212,8 +239,10 @@ export function CalendarView({ interface CalendarCellProps { day: CalendarDay; label: string; + describedById?: string; onSelect: () => void; onKeyDown: (e: React.KeyboardEvent) => void; + onHover?: (date: CalendarDay["date"]) => void; registerRef: (el: HTMLButtonElement | null) => void; onFocus: () => void; } @@ -221,8 +250,10 @@ interface CalendarCellProps { function CalendarCell({ day, label, + describedById, onSelect, onKeyDown, + onHover, registerRef, onFocus, }: CalendarCellProps) { @@ -235,20 +266,31 @@ function CalendarCell({ const focusable = !day.isOutsideMonth; // `
` inside `role="grid"` is implicitly a gridcell — no explicit role needed. return ( - + - - - ))} - - + + + + + + + + + );