Component contract
Renders a date-range picker. Stores values as ISO date strings, not Date objects, to avoid timezone bugs. Calendar UI is for selection only — the source of truth is the underlying ISO strings.
start: string | null— ISO date "YYYY-MM-DD" (no time component).end: string | null— ISO date "YYYY-MM-DD".onChange: ({ start, end }) => void— fires only when both are valid OR both are null.timezone: string— IANA tz, e.g. "America/New_York". Required. Drives day boundaries.min?: string— earliest selectable date (ISO).max?: string— latest selectable date (ISO).presets?: PresetDef[]— quick-pick buttons (e.g., "Last 7 days", "This month", "Year to date").locale?: string— defaults to navigator.language; affects week-start and date format.label?: string— accessible label for the trigger.
Interaction surface
-
Trigger button shows current range.
When start and end are set: "Mar 1 – Mar 14, 2026". When only one is set: "Mar 1, 2026 – pick end". When neither: the placeholder ("Select date range").
-
Click trigger opens the calendar.
Two-month calendar by default; one-month on narrow viewports. First click sets start; second click sets end. If the second click is BEFORE the first, the picker prompts "Did you mean to select end before start?" with a confirm-or-swap option — never silently swaps.
-
Manual text input.
Two text fields above the calendar accept manual date entry in locale-friendly format (e.g., "3/1/2026" in en-US, "01/03/2026" in en-GB). Parses with locale awareness; invalid input shows inline error and doesn't update state.
-
Presets fire onChange immediately.
Preset clicks compute start and end from "now" in the configured timezone. Picker stays open after preset click so user can fine-tune.
-
Apply / Cancel.
Apply commits the current selection via onChange and closes. Cancel discards changes and closes. Clicking outside or pressing Esc behaves as Cancel.
-
Keyboard navigation.
In calendar: arrow keys move day focus; PgUp/PgDn changes month; Home/End moves to start/end of week. Enter selects the focused date. Tab moves through trigger → text fields → calendar grid → presets → Apply/Cancel.
Failure modes
Picker uses local Date arithmetic, drifts across DST
Trigger: implementation calls new Date(start) and setDate for nav; on DST transition days, the calendar shows a duplicated or skipped day.
All date math uses Intl + the configured tz. The harness tests: tz="America/New_York", navigate calendar across the spring-forward boundary, assert each day Mar 1..Mar 31 appears exactly once.
End-before-start silently swapped
Trigger: user clicks Mar 14 then Mar 1. Picker silently swaps to start=Mar 1, end=Mar 14.
The picker prompts the user. Silent swap loses information about user intent and confuses screen readers. Harness: click Mar 14 then Mar 1, assert prompt visible offering "Set Mar 1 as start" or "Cancel".
Manual text input parsed in wrong locale
Trigger: locale=en-GB, user types "03/01/2026" (intending 3 Jan 2026), picker parses as 1 Mar 2026 (US format).
Use Intl.DateTimeFormat with the configured locale to parse. Show the parsed date inline as a confirmation ("3 January 2026") so the user can verify. Harness: locale=en-GB, type "03/01/2026", assert parsed value = "2026-01-03".
min/max not enforced in calendar
Trigger: max="2026-04-30" but the user can navigate to May and click May 5; onChange fires.
Days outside [min, max] are visually dimmed AND have aria-disabled=true AND don't fire on click. Months entirely outside the range cannot be navigated to. Harness: max=2026-04-30, click May 5, onChange NOT called.
onChange fires with partial state
Trigger: user clicks start; component fires onChange({start, end: null}); parent re-fetches with one date.
onChange fires only with both dates set OR both null (clear). Mid-selection state is internal. Harness: click start, assert onChange NOT called yet; click end, onChange called once with both.
Calendar grid loses focus when changing months
Trigger: user navigates with PgDn, focus jumps to the body or back to the trigger.
After month nav, focus moves to the same calendar day in the new month (or last day of month if shorter). Tab still works to escape the grid. Harness: focus Mar 15, PgDn, assert focus on Apr 15.
Preset "Last 7 days" excludes today
Trigger: implementation uses [today-7, today-1] vs the user's mental model of [today-6, today].
Document the convention explicitly. Each preset's tooltip shows the exact dates it would select before confirming ("Mar 8–14, 2026"). Harness: today=Mar 14, click "Last 7 days" preset, assert tooltip showed "Mar 8–14, 2026" or that the chosen range matches the documented convention.
Esc on trigger closes the entire page modal
Trigger: picker is inside a modal; Esc on the picker bubbles up and closes the modal.
Esc handling is scoped to the picker's open state. When picker is open, Esc closes picker. When picker is closed, Esc bubbles. Harness: open picker inside ui-modal, Esc closes picker only, modal still open.
Accessibility
- Trigger is a
<button>with descriptive aria-label including current range. - Picker popover has
role="dialog"with aria-label="Date range picker". - Calendar uses
role="grid"with each day asrole="gridcell"; selected days havearia-selected="true"; in-range days havearia-selected="true"AND a visual range fill. - Disabled days have
aria-disabled="true". - Manual text inputs have visible labels and aria-describedby pointing at format hints.
- Color contrast: day numbers ≥ 4.5:1, range fill against day text ≥ 4.5:1, selected day ≥ 4.5:1.
- Live region announces "Selected start: Mar 1, 2026" and "Selected range: Mar 1–14, 2026".
- axe-clean at severity ≥ serious.
Stable test attributes
| data-test | Where | Purpose |
|---|---|---|
ui-date-range-picker | Outer wrapper | Component identity |
ui-date-range-trigger | Trigger button | Opens picker |
ui-date-range-popover | Open picker dialog | Visible only when open |
ui-date-range-input-start | Manual start input | Text field |
ui-date-range-input-end | Manual end input | Text field |
ui-date-range-calendar | Calendar grid | role=grid |
ui-date-range-day | Each day cell | data-date="YYYY-MM-DD" attr |
ui-date-range-prev-month | Prev month nav | Disabled when at min boundary |
ui-date-range-next-month | Next month nav | Disabled when at max boundary |
ui-date-range-preset | Preset buttons | data-preset-id attr |
ui-date-range-apply | Apply button | Commits selection |
ui-date-range-cancel | Cancel button | Discards selection |
ui-date-range-swap-prompt | End-before-start prompt | Visible only when out-of-order detected |
Agent test plan
Standalone probes against /admin-test/ui-date-range-picker-fixture with variants: empty, partial, full, with-min-max, with-presets, locale=en-US, locale=en-GB, tz=America/New_York during DST window, tz=Pacific/Auckland.
Probe list
- trigger-shows-empty: start=null, end=null → trigger text matches placeholder
- trigger-shows-range: start, end set → trigger formats as "Mar 1 – Mar 14, 2026" (locale-aware)
- click-trigger-opens-picker: ui-date-range-popover visible
- click-day-sets-start: click Mar 1, internal state start=2026-03-01, end=null, onChange NOT yet called
- click-second-day-sets-end: click Mar 14, onChange called with start=2026-03-01, end=2026-03-14
- end-before-start-prompts: click Mar 14 then Mar 1, ui-date-range-swap-prompt visible
- end-before-start-no-silent-swap: assert onChange NOT called until user resolves prompt
- min-max-disables-days: max=2026-04-30, May days have aria-disabled=true
- click-disabled-day-noop: click May 5 (max=Apr 30), onChange NOT called
- nav-disabled-at-boundary: max=2026-04-30, navigate to April, ui-date-range-next-month disabled
- preset-fires-onchange: click "Last 7 days" preset, onChange called with computed dates
- preset-tooltip-shows-dates: hover preset, tooltip text matches the dates it would set
- locale-en-US-format: locale=en-US, type "03/01/2026" → parsed as 2026-03-01
- locale-en-GB-format: locale=en-GB, type "03/01/2026" → parsed as 2026-01-03
- invalid-text-shows-error: type "garbage", inline error visible, state unchanged
- dst-spring-forward-no-skip: tz=America/New_York, navigate Mar 2026, every day Mar 1..31 appears once
- keyboard-nav-arrows: focus Mar 15, ArrowRight, focus on Mar 16
- keyboard-nav-pgdn: focus Mar 15, PgDn, focus on Apr 15
- keyboard-enter-selects: focus Mar 15, Enter, day selected
- esc-closes-picker-only: open picker inside modal, Esc closes picker, modal still open
- apply-commits-and-closes: select range, click Apply, onChange called, picker closed
- cancel-discards: select range, click Cancel, onChange NOT called, picker closed
- aria-selected-on-range: selected start..end, all days in range have aria-selected=true
- color-contrast-day-text: ≥ 4.5:1
- color-contrast-range-fill: ≥ 4.5:1
- axe-clean-serious: no serious violations