feat(date-picker): DateRangePicker + RangeCalendar (pp-1373) - #388
Conversation
67c5899 to
ce883c5
Compare
ce883c5 to
a20f5c0
Compare
…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>
2d987de to
4d5b9fc
Compare
… (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>
|
/review |
|
✅ API Design Review completed successfully! |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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; // → CalendarDateso 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.
… 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>
|
/review |
|
✅ API Design Review completed successfully! API Design Review — Round 2 complete. Both previously raised issues have been addressed: (1) the Medium-severity missing |
Summary
Closes https://github.com/tailor-inc/platform-planning/issues/1373
Adds two new components to
@tailor-platform/app-shell—DateRangePickerandRangeCalendar— with a structured{ start, end }range value (DateRange). Both are built on@internationalized/date+ Base UI and follow the same dual-mode model asDateField/DatePicker(#392): standalone composite controls that also compose insideField.Rootfor 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/Calendarbehavior is unchanged.Demo
Public API
DateRangePickerDateRangeobject viavalue/onChange(two internal date engines).id/aria-*, or drops intoField.Root(auto-wiresField.Label/Field.Description/Field.Error+ Form validation).Field.Rootit registers one combined proxy control — empty until both ends are complete, soisRequiredblocks a partial range; date-specific problems surface ascustomError(Field.Error match="customError").startName/endNameadditionally emit two plain hidden inputs for a classic form POST.minValue/maxValue/isDateUnavailableflag invalid input rather than silently clamping.RangeCalendarInline 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 byDateField/DatePicker/DateRangePicker.use-calendar-base-state.ts— extracts the shared calendar state consumed by bothuse-calendar-state(single) and the newuse-range-calendar-state(range).DateFieldRowre-extracted from the date input group for reuse by the range input group (DOM-identical; existing single-field snapshots preserved).Examples
date-pickerpage — range picker + range calendar demos, plus an in-situ Base UIFormsubmission (onFormSubmit→ one combinedstart/endfield) and a React Hook Form reference snippet.custom-page— a live React Hook Form demo (the common convention): Base UIForm+Field.Rootfor a11y / error display,Controller+ zod for state / validation, with an async server error routed back throughField.Error.DataTablebacked 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/RangeCalendarsections + prop tables.Testing
date-range-picker.test.tsx(standalone aria +Field.Root/ Form submit-blocking,customError,valueMissing) andrange-calendar.test.tsx. Snapshots added; existing single-field snapshots unchanged.Notes
@base-ui/reactis pinned to exact1.6.0(the bridge couples to Base UI internal subpaths).