Skip to content

feat(date-picker): DateRangePicker + RangeCalendar (pp-1373) - #388

Merged
interacsean merged 5 commits into
mainfrom
claude/date-picker-range-pp-1373
Aug 18, 2026
Merged

feat(date-picker): DateRangePicker + RangeCalendar (pp-1373)#388
interacsean merged 5 commits into
mainfrom
claude/date-picker-range-pp-1373

Conversation

@interacsean

@interacsean interacsean commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes https://github.com/tailor-inc/platform-planning/issues/1373

Adds two new components to @tailor-platform/app-shellDateRangePicker and RangeCalendar — with a structured { start, end } range value (DateRange). Both are built on @internationalized/date + Base UI and follow the same dual-mode model as DateField / DatePicker (#392): standalone composite controls that also compose inside Field.Root for automatic label / description / error / form-validation wiring.

To keep this clean, the Field-integration bridge and the calendar base state are extracted into shared hooks — so existing DateField / DatePicker / Calendar behavior is unchanged.

Demo

range-demo

Public API

import {
  DateRangePicker, type DateRangePickerProps,
  RangeCalendar, type RangeCalendarProps,
  type DateRange, // { start: DateValue; end: DateValue }
} from "@tailor-platform/app-shell";

DateRangePicker

  • Value — a two-ended DateRange object via value / onChange (two internal date engines).
  • Selection (react-aria model) — the first 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; a range typed in reverse is flagged invalid rather than silently swapped.
  • Standalone or composed — works standalone via id / aria-*, or drops into Field.Root (auto-wires Field.Label / Field.Description / Field.Error + Form validation).
    • Inside Field.Root it registers one combined proxy control — empty until both ends are complete, so isRequired blocks a partial range; date-specific problems surface as customError (Field.Error match="customError").
    • startName / endName additionally emit two plain hidden inputs for a classic form POST.
  • ConstraintsminValue / maxValue / isDateUnavailable flag invalid input rather than silently clamping.

RangeCalendar

Inline two-value range calendar that shares the calendar engine with Calendar — locale-aware weekday labels, firstDayOfWeek, min/max, and unavailable dates.

Internal refactors (no behavior change)

  • use-date-field-bridge.ts — extracts the Base UI Field/Form integration (proxy registration, a11y labeling, validity commit) now shared by DateField / DatePicker / DateRangePicker.
  • use-calendar-base-state.ts — extracts the shared calendar state consumed by both use-calendar-state (single) and the new use-range-calendar-state (range).
  • DateFieldRow re-extracted from the date input group for reuse by the range input group (DOM-identical; existing single-field snapshots preserved).

Examples

  • vite date-picker page — range picker + range calendar demos, plus an in-situ Base UI Form submission (onFormSubmit → one combined start/end field) and a React Hook Form reference snippet.
  • nextjs custom-page — a live React Hook Form demo (the common convention): Base UI Form + Field.Root for a11y / error display, Controller + zod for state / validation, with an async server error routed back through Field.Error.
  • Orders page rebuilt as a DataTable backed by an async mock (useOrdersQuery, ~800 ms latency) to exercise the loading state and to demonstrate the existing date between filter's range editor.

Docs & changeset

  • docs/components/date-picker.md: DateRangePicker / RangeCalendar sections + prop tables.
  • Changeset: minor.

Testing

  • New: date-range-picker.test.tsx (standalone aria + Field.Root / Form submit-blocking, customError, valueMissing) and range-calendar.test.tsx. Snapshots added; existing single-field snapshots unchanged.
  • Type-check, lint, and format are clean.

Notes

interacsean and others added 2 commits August 17, 2026 12:19
…Field integration

Adds the range components from the DatePicker proposal (pp#1373), built to the
Field.Root model that #392 established for DateField/DatePicker:

- DateRangePicker: start/end segmented inputs sharing one popover range calendar.
  Standalone via id/aria-*, or composed inside Field.Root (label/description/
  error + form validation) — no component-owned label/description/errorMessage.
  A single combined proxy input is the one registered Field control (empty until
  both ends complete, so isRequired blocks a partial range); startName/endName
  emit two plain hidden inputs for classic form POST. Reversed typed range is
  flagged invalid (customError) rather than swapped; per-end min/max/unavailable
  surface through the same path.
- RangeCalendar: standalone inline range calendar (two-click anchor→commit,
  hover/keyboard preview, endpoint swap, Escape cancel, contiguous-range clamp).
- Shared internals: extracted useDateFieldFieldBridge (+ proxy-input / a11y-
  labeling hooks) into use-date-field-bridge.ts, reused by DateField/DatePicker
  and DateRangePicker; re-extracted DateFieldRow from DateInputGroup so the range
  group and the single field share one keyboard layer. Calendar grid engine split
  into use-calendar-base-state (shared by single + range).
- Adopts main's @base-ui/react 1.6.0 pin (Field internals). The DataTable date
  "between" filter keeps main's single-calendar range editor (#392/#395 era);
  the range components are the standalone contribution.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the static Table on the vite example Orders page with a DataTable over
~22 mock orders (useOrdersQuery, ~800ms latency) exercising loading state,
URL-synced filters/sort/pagination, and a date column whose "between" filter
uses the panel's single-calendar range editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@interacsean
interacsean force-pushed the claude/date-picker-range-pp-1373 branch from 2d987de to 4d5b9fc Compare August 17, 2026 02:20
… (nextjs)

Two complementary in-situ demos for the range picker in forms:

- vite date-picker page: an in-situ Base UI `Form` + `Field.Root name`
  submission, where `onFormSubmit` yields one combined "start/end" field,
  plus a React Hook Form reference snippet contrasting the conventions.
- nextjs custom-page: a realistic React Hook Form demo showing the
  idiomatic composition — Base UI `Form`/`Field.Root`/`Fieldset` own
  accessibility wiring and error display (label/description auto-associate
  with the composite control; no manual aria-*), while RHF (`Controller`
  + zod) owns state and validation. `<Field.Root {...fieldState}>` feeds
  RHF validity into the control's Field bridge (drives the invalid state),
  and a simulated async server error is routed back into the same
  `Field.Error` via `setError`. Cross-field zod rules (order, no past
  start) round it out.

The two layers are designed to be used together: `Form`'s `onSubmit`
hands submission to RHF's `handleSubmit` (vs `onFormSubmit` for the
no-form-library path). The RHF demo notes the one gotcha — don't also
bind the control's own constraints when zod owns validation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@interacsean interacsean changed the title feat(date-picker): DateRangePicker + RangeCalendar + DataTable range filter (pp-1373) feat(date-picker): DateRangePicker + RangeCalendar (pp-1373) Aug 17, 2026
@interacsean

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

API Design Review completed successfully!

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated by API Design Review for issue #388 · 105.3 AIC · ⌖ 8.7 AIC · ⊞ 5.8K
Comment /review to run again

"aria-labelledby": ariaLabelledBy,
}: RangeCalendarProps<T>) {
const { locale: shellLocale } = useResolvedLocale();
const shellTz = useTimeZone();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[2/2 — Low] onChange cast erases generic T — TypeScript cannot verify the emitted type

onChange: onChange as (v: DateRange) => void,

useRangeCalendarState uses the non-generic DateRange (≡ DateRange<DateValue>). The cast silences the mismatch with the public onChange?: (value: DateRange<T>) => void signature. At runtime this is correct because withDatePart preserves each endpoint's original type, but TypeScript is prevented from verifying it.

A consumer with T = CalendarDateTime who inspects the inferred type of the value passed to onChange will see DateRange<CalendarDateTime> promised by the prop, but the cast means tsc cannot catch a regression if withDatePart ever returned a narrower type.

Consider propagating the generic into useRangeCalendarState (or adding an overload) so the call site can drop the cast, or at minimum add a comment explaining why the cast is sound.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the cast, but hardened the contract it stands in for — here's the reasoning.

The cast is load-bearing and mirrors the single Calendar (calendar.tsx:71). The emitted endpoint type genuinely isn't derivable from T: withDatePart decides it per pick, at runtime, via instanceof on the previous value —

if (base instanceof ZonedDateTime)    return base.set(parts); // → ZonedDateTime
if (base instanceof CalendarDateTime) return base.set(parts); // → CalendarDateTime
return date;                                                   // → CalendarDate

so a cold selection with no prior value emits a plain CalendarDate regardless of the declared T. Threading the generic through useRangeCalendarState + withDatePart wouldn't add safety — the return date branch isn't T, so it becomes date as T inside a generic-looking signature: the same cast, just disguised as verified.

Since the invariant is a runtime one, I hardened it at the runtime layer instead (f99bdef): added withDatePart unit tests pinning part-preservation — ZonedDateTime → ZonedDateTime, CalendarDateTime → CalendarDateTime, and null / undefined / CalendarDate → CalendarDate. That's what actually catches the "returned a narrower type" regression you flagged. Also added a comment at the cast documenting the above.

Comment thread packages/core/src/components/date-field/date-range-picker.tsx Outdated
… contract

Review follow-ups (#388):

- DateRangePicker gains `name?: string`, wired to the combined proxy input
  for a single `start/end` native-POST field — restoring parity with
  DateField / DatePicker. A wrapping `Field.Root` name still takes
  precedence; `startName` / `endName` remain the split-field option.
  Covered by tests, docs, and the changeset.

- Document why RangeCalendar's `onChange` cast is sound: the emitted
  endpoint type is a runtime, per-pick decision in `withDatePart` (a cold
  selection with no prior value emits a plain CalendarDate regardless of
  `T`), not something derivable from `T` — so threading the generic through
  would only relocate/disguise the cast. Add `withDatePart` unit tests
  pinning the part-preservation contract, guarding the regression at the
  runtime layer where the invariant actually lives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@interacsean

Copy link
Copy Markdown
Contributor Author

/review

@github-actions

github-actions Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

API Design Review completed successfully!

API Design Review — Round 2 complete. Both previously raised issues have been addressed: (1) the Medium-severity missing name prop on DateRangePickerProps is resolved — name?: string was added and wired through correctly; (2) the Low-severity onChange cast has an explanatory comment and withDatePart unit tests covering the runtime invariant. No new High/Medium issues found. Public API surface (DateRangePicker, DateRangePickerProps, RangeCalendar, RangeCalendarProps, DateRange) is consistent with the existing single-date counterparts (Calendar, DateField). Verdict: Approve — all prior blocking/notable issues resolved, API is consistent and well-documented.

@interacsean
interacsean merged commit 1a768e1 into main Aug 18, 2026
5 checks passed
@interacsean
interacsean deleted the claude/date-picker-range-pp-1373 branch August 18, 2026 05:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants