@12-apps/ui 3.0.0 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -311,6 +311,14 @@ Components for user input and form interactions.
311
311
  **Location**: `src/components/form/Calendar`
312
312
  **Docs**: `src/components/form/Calendar/Calendar.md`
313
313
 
314
+ ### DateRangePicker
315
+ **Purpose**: Pick a range of days three ways over one value — calendar, quick ranges, typed dates
316
+ **Use Cases**: Report periods, analytics filters, booking windows, any from/to a reader edits
317
+ **Features**: Range `Calendar` reused (not forked), quick-range column (today, yesterday, this week, last 7/30/365 days, this month, this quarter, this year) with `aria-pressed`, masked `dd/mm/aaaa` fields, `timeZone`-aware "today", a `maxRangeDays` cap that disables over-cap options WITH the reason instead of clamping, overridable copy, stacks on narrow screens
318
+ **Types**: `DateRangeDraft`, `DayWindow`, `DateRangeStatus`, `QuickRange`
319
+ **Location**: `src/components/form/DateRangePicker`
320
+ **Docs**: `src/components/form/DateRangePicker/DateRangePicker.md`
321
+
314
322
  ### Checkbox
315
323
  **Purpose**: Toggle selection checkbox
316
324
  **Use Cases**: Multi-select, boolean options, agreements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "3.0.0",
3
+ "version": "3.2.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -84,6 +84,7 @@
84
84
  "./form/CodeEditor": "./src/components/form/CodeEditor/index.ts",
85
85
  "./form/Command": "./src/components/form/Command/index.ts",
86
86
  "./form/CreatableSelect": "./src/components/form/CreatableSelect/index.ts",
87
+ "./form/DateRangePicker": "./src/components/form/DateRangePicker/index.ts",
87
88
  "./form/Form": "./src/components/form/Form/index.ts",
88
89
  "./form/HeaderButton": "./src/components/form/HeaderButton/index.ts",
89
90
  "./form/total-form": "./src/components/form/total-form/index.ts",
@@ -70,6 +70,36 @@ export function maskBrDate(raw: string): string {
70
70
  return `${digits.slice(0, 2)}/${digits.slice(2, 4)}/${digits.slice(4)}`;
71
71
  }
72
72
 
73
+ /**
74
+ * What has to land on the `<input>` itself rather than on the `TextField`.
75
+ *
76
+ * Anything `TextField` does not recognise is spread onto its root `FormControl`
77
+ * div. `inputMode` went up there once: the attribute sat on a non-editable
78
+ * wrapper, meant nothing, and the phone went on offering the letter keyboard
79
+ * for a field that only takes digits (the `dd/mm/aaaa` hint under a QWERTY
80
+ * layout). Only the `<input>` this reaches decides which keypad opens — and the
81
+ * same is true of `aria-describedby`, which on the wrapper points at a
82
+ * description no screen reader will read out for the field.
83
+ *
84
+ * `numeric` and not `tel`: both raise a keypad, but the phone one is a dialler
85
+ * with `+ * #` on it — keys that cannot occur in a date. A paste carrying
86
+ * separators still lands, because the MASK is what filters the text; the keypad
87
+ * only narrows what a thumb can reach for.
88
+ *
89
+ * Autofill off for the same reason: the browser's saved-value strip covers the
90
+ * keypad to suggest names and addresses, none of which are a date, and none of
91
+ * which the mask would keep.
92
+ */
93
+ function dayInputProps(testId: string, describedBy: string | undefined) {
94
+ return {
95
+ 'data-testid': testId,
96
+ maxLength: 10,
97
+ inputMode: 'numeric' as const,
98
+ autoComplete: 'off',
99
+ ...(describedBy ? { 'aria-describedby': describedBy } : {}),
100
+ };
101
+ }
102
+
73
103
  /**
74
104
  * One end of a day window. The BOUND stays `AAAA-MM-DD` — the form the backend's
75
105
  * inclusive day comparison already speaks, and the form the chip label reads —
@@ -80,12 +110,23 @@ export function DayBoundInput({
80
110
  value,
81
111
  onChange,
82
112
  testId,
113
+ error = false,
114
+ describedBy,
83
115
  }: {
84
116
  label: string;
85
117
  /** The applied bound, `AAAA-MM-DD`, or undefined when this end is open. */
86
118
  value: string | undefined;
87
119
  onChange: (bound: string | undefined) => void;
88
120
  testId: string;
121
+ /**
122
+ * Paint the field as rejected. It only ever writes a real day by itself, so
123
+ * this is for a caller judging the PAIR: a window whose end precedes its
124
+ * start is two valid days and one impossible period, and the field it was
125
+ * typed into is the only place to say which half is being complained about.
126
+ */
127
+ error?: boolean;
128
+ /** Id of the element carrying that complaint, so it is read WITH the field. */
129
+ describedBy?: string;
89
130
  }): React.JSX.Element {
90
131
  const applied = value ?? '';
91
132
  const [text, setText] = useState(() => isoToBr(applied));
@@ -104,6 +145,7 @@ export function DayBoundInput({
104
145
  size="small"
105
146
  type="text"
106
147
  label={label}
148
+ error={error}
107
149
  // The mask IS the placeholder, so the expected order is on screen before
108
150
  // the first keystroke rather than discovered by getting it wrong.
109
151
  placeholder="dd/mm/aaaa"
@@ -124,28 +166,7 @@ export function DayBoundInput({
124
166
  onBlur={() => setText(isoToBr(applied))}
125
167
  // The placeholder occupies the space an un-shrunk label would render into.
126
168
  InputLabelProps={{ shrink: true }}
127
- // `inputMode` HAS to be here rather than on the TextField. Anything
128
- // TextField does not recognise is spread onto its root `FormControl` div,
129
- // and `inputMode` is not in that list — so passing it up there put the
130
- // attribute on a non-editable wrapper, where it means nothing, and the
131
- // phone went on offering the letter keyboard for a field that only takes
132
- // digits (the `dd/mm/aaaa` hint sitting under a QWERTY layout). Only the
133
- // `<input>` this reaches decides which keypad opens.
134
- //
135
- // `numeric` and not `tel`: both raise a keypad, but the phone one is a
136
- // dialler with `+ * #` on it — keys that cannot occur in a date. A paste
137
- // carrying separators still lands, because the MASK is what filters the
138
- // text; the keypad only narrows what a thumb can reach for.
139
- //
140
- // Autofill off for the same reason: the browser's saved-value strip
141
- // covers the keypad to suggest names and addresses, none of which are a
142
- // date, and none of which the mask would keep.
143
- inputProps={{
144
- 'data-testid': testId,
145
- maxLength: 10,
146
- inputMode: 'numeric',
147
- autoComplete: 'off',
148
- }}
169
+ inputProps={dayInputProps(testId, describedBy)}
149
170
  sx={{ width: 165 }}
150
171
  />
151
172
  );
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Civil-day arithmetic for the range picker.
3
+ *
4
+ * Two representations meet here and the boundary between them is the whole
5
+ * point of the file:
6
+ *
7
+ * - `AAAA-MM-DD` — what the picker's value, the quick ranges and any caller
8
+ * speak. A day with no instant attached, so it cannot drift.
9
+ * - a LOCAL `Date` at NOON — what `Calendar` deals in. Noon (not midnight) is
10
+ * `Calendar.dates.normalizeDate`'s own rule: a daylight-saving jump at
11
+ * midnight can move a date across a day boundary, and midday never can.
12
+ *
13
+ * The arithmetic itself is `Calendar.dates`' — `addDays` and `startOfWeek` are
14
+ * imported rather than re-written, so a week here starts on the same day the
15
+ * grid draws it.
16
+ *
17
+ * ONE thing needs a time zone: which day is today. Everything after that is
18
+ * calendar arithmetic that has no clock in it.
19
+ */
20
+ import { addDays, startOfWeek } from '../Calendar/Calendar.dates';
21
+
22
+ import type { DayString, WeekStart } from './DateRangePicker.types';
23
+
24
+ const NOON = 12;
25
+ const MS_PER_DAY = 86_400_000;
26
+ const MONTHS_PER_QUARTER = 3;
27
+ const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
28
+
29
+ /** `[year, month 1-12, day]`, or null when the text is not a day at all. */
30
+ function dayParts(day: DayString): [number, number, number] | null {
31
+ const parts = DAY_PATTERN.exec(day);
32
+ if (!parts) return null;
33
+ return [Number(parts[1]), Number(parts[2]), Number(parts[3])];
34
+ }
35
+
36
+ const pad = (value: number): string => String(value).padStart(2, '0');
37
+
38
+ /** `AAAA-MM-DD` from calendar parts. Month is 1-12. */
39
+ function formatDay(year: number, month: number, day: number): DayString {
40
+ return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`;
41
+ }
42
+
43
+ /** A LOCAL `Date` → the day it falls on, read off the same local getters. */
44
+ export function toDayString(date: Date): DayString {
45
+ return formatDay(date.getFullYear(), date.getMonth() + 1, date.getDate());
46
+ }
47
+
48
+ /**
49
+ * `AAAA-MM-DD` → a LOCAL `Date` at noon, or null when the text is not a real
50
+ * day.
51
+ *
52
+ * Deliberately not `new Date(day)`: the spec reads a bare `AAAA-MM-DD` as UTC
53
+ * midnight, so west of Greenwich it lands on the day BEFORE and every date in
54
+ * the picker is off by one. The round-trip check is what rejects `2026-02-31`,
55
+ * which `Date` would otherwise roll silently into March.
56
+ */
57
+ export function toLocalDay(day: DayString | null | undefined): Date | null {
58
+ if (!day) return null;
59
+ const parts = dayParts(day);
60
+ if (!parts) return null;
61
+ const date = new Date(parts[0], parts[1] - 1, parts[2], NOON);
62
+ return toDayString(date) === day ? date : null;
63
+ }
64
+
65
+ const civilFormatters = new Map<string, Intl.DateTimeFormat>();
66
+
67
+ function civilFormatter(timeZone: string): Intl.DateTimeFormat {
68
+ const cached = civilFormatters.get(timeZone);
69
+ if (cached) return cached;
70
+ const created = new Intl.DateTimeFormat('en-US', {
71
+ timeZone,
72
+ year: 'numeric',
73
+ month: '2-digit',
74
+ day: '2-digit',
75
+ });
76
+ civilFormatters.set(timeZone, created);
77
+ return created;
78
+ }
79
+
80
+ /**
81
+ * The civil day `now` falls on, on `timeZone`'s clock (the host's when absent).
82
+ *
83
+ * Read through `formatToParts` rather than by adding an offset to an epoch: the
84
+ * zone database already knows about every transition, and reconstructing one by
85
+ * hand is how a picker ends up a day out for exactly one week of the year.
86
+ */
87
+ export function todayIn(timeZone: string | undefined, now: Date): DayString {
88
+ if (!timeZone) return toDayString(now);
89
+ const parts = civilFormatter(timeZone).formatToParts(now);
90
+ const read = (type: Intl.DateTimeFormatPartTypes): string =>
91
+ parts.find((part) => part.type === type)?.value ?? '';
92
+ return `${read('year')}-${read('month')}-${read('day')}`;
93
+ }
94
+
95
+ /** The same day `days` later (or earlier). */
96
+ export function shiftDay(day: DayString, days: number): DayString {
97
+ const date = toLocalDay(day);
98
+ return date ? toDayString(addDays(date, days)) : day;
99
+ }
100
+
101
+ /** The 1st of `day`'s month. */
102
+ export function startOfMonthDay(day: DayString): DayString {
103
+ const parts = dayParts(day);
104
+ return parts ? formatDay(parts[0], parts[1], 1) : day;
105
+ }
106
+
107
+ /**
108
+ * The 1st of `day`'s CALENDAR quarter — Jan/Apr/Jul/Oct. Fiscal quarters are a
109
+ * different thing and belong in a caller's own quick range, not in a default.
110
+ */
111
+ export function startOfQuarterDay(day: DayString): DayString {
112
+ const parts = dayParts(day);
113
+ if (!parts) return day;
114
+ const month = parts[1] - ((parts[1] - 1) % MONTHS_PER_QUARTER);
115
+ return formatDay(parts[0], month, 1);
116
+ }
117
+
118
+ /** The 1st of January of `day`'s year. */
119
+ export function startOfYearDay(day: DayString): DayString {
120
+ const parts = dayParts(day);
121
+ return parts ? formatDay(parts[0], 1, 1) : day;
122
+ }
123
+
124
+ /** The first day of `day`'s week, on the same week start the grid uses. */
125
+ export function startOfWeekDay(day: DayString, weekStartsOn: WeekStart): DayString {
126
+ const date = toLocalDay(day);
127
+ return date ? toDayString(startOfWeek(date, weekStartsOn)) : day;
128
+ }
129
+
130
+ /**
131
+ * How many days a window covers, both ends INCLUDED — so a single day is 1.
132
+ *
133
+ * Measured on UTC epochs rather than on the local dates: the difference between
134
+ * two local noons can be 23 or 25 hours across a transition, and dividing that
135
+ * by a fixed day length is how a 30-day window starts reporting 29.96.
136
+ */
137
+ export function dayCount(from: DayString, to: DayString): number {
138
+ const start = dayParts(from);
139
+ const end = dayParts(to);
140
+ if (!start || !end) return 0;
141
+ const startMs = Date.UTC(start[0], start[1] - 1, start[2]);
142
+ const endMs = Date.UTC(end[0], end[1] - 1, end[2]);
143
+ return Math.round((endMs - startMs) / MS_PER_DAY) + 1;
144
+ }
145
+
146
+ /** `AAAA-MM-DD` sorts lexicographically, so this needs no parsing at all. */
147
+ export function isAfterDay(a: DayString, b: DayString): boolean {
148
+ return a > b;
149
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Everything the picker derives and every way its range can move, kept out of
3
+ * the component so that file is a layout and this one is the behaviour — the
4
+ * same split `Calendar.hooks.ts` uses next door.
5
+ */
6
+ import { useId, useMemo, useState } from 'react';
7
+
8
+ import { toDayString, todayIn } from './DateRangePicker.dates';
9
+ import {
10
+ activeQuickRangeId,
11
+ defaultMessages,
12
+ resolveDayRange,
13
+ resolveQuickRanges,
14
+ } from './DateRangePicker.range';
15
+ import type {
16
+ DateRangeChangeMeta,
17
+ DateRangeDraft,
18
+ DateRangePickerState,
19
+ UseDateRangePickerArgs,
20
+ } from './DateRangePicker.types';
21
+
22
+ export function useDateRangePicker({
23
+ value,
24
+ onChange,
25
+ timeZone,
26
+ now,
27
+ maxRangeDays,
28
+ weekStartsOn,
29
+ quickRanges,
30
+ messages,
31
+ }: UseDateRangePickerArgs): DateRangePickerState {
32
+ const statusId = useId();
33
+ // `Calendar` reads which month to open on from the range it is handed, and
34
+ // only when it MOUNTS. Bumping this key remounts it, which is how the other
35
+ // two views move it. It changes for a quick pick and for a typed date and
36
+ // NEVER for a click inside the grid — a calendar that jumped under the
37
+ // pointer picking in it would be unusable.
38
+ const [viewNonce, setViewNonce] = useState(0);
39
+
40
+ const copy = useMemo(() => ({ ...defaultMessages(), ...messages }), [messages]);
41
+ const today = useMemo(() => todayIn(timeZone, now ?? new Date()), [timeZone, now]);
42
+ const options = useMemo(
43
+ () => resolveQuickRanges(quickRanges, { today, weekStartsOn }, maxRangeDays),
44
+ [quickRanges, today, weekStartsOn, maxRangeDays],
45
+ );
46
+ const status = useMemo(() => resolveDayRange(value, maxRangeDays), [value, maxRangeDays]);
47
+
48
+ const emit = (
49
+ next: DateRangeDraft,
50
+ source: DateRangeChangeMeta['source'],
51
+ quickRangeId?: string,
52
+ ): void => {
53
+ onChange(next, {
54
+ source,
55
+ ...(quickRangeId ? { quickRangeId } : {}),
56
+ // Judged here, once. A caller left to re-derive "is this usable" is a
57
+ // second opinion able to disagree with the message on screen.
58
+ status: resolveDayRange(next, maxRangeDays),
59
+ });
60
+ };
61
+
62
+ return {
63
+ copy,
64
+ options,
65
+ activeId: activeQuickRangeId(options, value),
66
+ status,
67
+ statusId,
68
+ viewNonce,
69
+ pickQuick: (option) => {
70
+ setViewNonce((nonce) => nonce + 1);
71
+ emit({ from: option.window.from, to: option.window.to }, 'quick', option.id);
72
+ },
73
+ editBound: (which, bound) => {
74
+ setViewNonce((nonce) => nonce + 1);
75
+ emit({ ...value, [which]: bound }, which);
76
+ },
77
+ pickOnCalendar: (range) => {
78
+ emit(
79
+ {
80
+ from: range.start ? toDayString(range.start) : null,
81
+ to: range.end ? toDayString(range.end) : null,
82
+ },
83
+ 'calendar',
84
+ );
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,228 @@
1
+ # DateRangePicker
2
+
3
+ Three views of ONE day range — a range calendar, a quick-pick column, and two
4
+ typed `dd/mm/aaaa` fields. Change any and the other two follow.
5
+
6
+ ## Purpose
7
+
8
+ A range calendar alone makes "this quarter" cost two clicks, a month of paging
9
+ and a mental note of which day a quarter starts on. A list of presets alone
10
+ cannot express "the 3rd to the 19th". Typed fields alone make you spell out a
11
+ window you can already see. Each of the three is the fastest route to a
12
+ *different* range, so this control offers all three over one value instead of
13
+ picking a favourite.
14
+
15
+ It is a design-system control and knows nothing about the data behind the
16
+ range. The quick list, the maximum length and the clock "today" is read on are
17
+ all props — a consumer with its own periods passes them rather than teaching
18
+ this component about its domain.
19
+
20
+ ## Not a fork of Calendar
21
+
22
+ The calendar half **is** [`Calendar`](../Calendar/Calendar.md) in
23
+ `selectionMode="range"`. The hover preview, the keyboard grid, backwards
24
+ selection and the length check all come from there. This component composes it;
25
+ it does not reimplement it.
26
+
27
+ The typed fields are `DayBoundInput`, the masked `dd/mm/aaaa` field the data
28
+ views already use — imported from inside the package rather than exported,
29
+ because a second masked date field would mean a second set of rules for what
30
+ backspace does.
31
+
32
+ ## Public API
33
+
34
+ ```ts
35
+ interface DateRangePickerProps {
36
+ value: DateRangeDraft; // { from: string | null; to: string | null }
37
+ onChange: (next: DateRangeDraft, meta: DateRangeChangeMeta) => void;
38
+ timeZone?: string; // IANA zone (or offset) TODAY is read on
39
+ now?: Date; // freeze the clock (stories, tests)
40
+ maxRangeDays?: number; // longest usable range, inclusive days
41
+ weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6; // default 0 (Sunday)
42
+ quickRanges?: QuickRange[]; // default: the nine below; [] hides the column
43
+ locale?: string; // month names / weekday initials
44
+ numberOfMonths?: number; // default 2
45
+ messages?: Partial<DateRangePickerMessages>;
46
+ dataTestId?: string; // default 'date-range-picker'
47
+ className?: string;
48
+ }
49
+ ```
50
+
51
+ ### The value is a DRAFT, not a window
52
+
53
+ `value` is `{ from, to }` with either end possibly `null`, and it accepts a pair
54
+ that is reversed or too long. A control whose value could only hold a legal
55
+ range has nowhere to put a reader who typed the end date first, and has to
56
+ either discard the keystrokes or lie about what is on screen.
57
+
58
+ Every `onChange` carries the verdict with it:
59
+
60
+ ```ts
61
+ interface DateRangeChangeMeta {
62
+ source: 'quick' | 'calendar' | 'from' | 'to';
63
+ quickRangeId?: string; // set only when source is 'quick'
64
+ status: DateRangeStatus; // { ok: true, window, days } | { ok: false, problem, days }
65
+ }
66
+ ```
67
+
68
+ `resolveDayRange(draft, maxRangeDays)` is exported so a caller's confirm button
69
+ reads the SAME verdict rather than re-deriving "is this usable" from the two
70
+ strings and drifting from the message on screen.
71
+
72
+ `quickRangeId` is what lets a caller express a pick as its own thing. The
73
+ reports surface uses it to apply its real `today` preset when "Hoje" is chosen,
74
+ so the period afterwards reads `Hoje` and not `Personalizado…`.
75
+
76
+ ## Quick ranges
77
+
78
+ Nine built-ins, all derivable from today and the calendar alone:
79
+
80
+ | id | window |
81
+ |---|---|
82
+ | `today` | today |
83
+ | `yesterday` | the day before today |
84
+ | `this-week` | the `weekStartsOn` day of this week → today |
85
+ | `last-7-days` | today and the 6 days before it |
86
+ | `this-month` | the 1st of this month → today |
87
+ | `last-30-days` | today and the 29 days before it |
88
+ | `this-quarter` | the 1st of this CALENDAR quarter (Jan/Apr/Jul/Oct) → today |
89
+ | `this-year` | 1 January → today |
90
+ | `last-365-days` | today and the 364 days before it |
91
+
92
+ "Last N days" **includes** today: on the 10th, "last 7 days" is the 4th through
93
+ the 10th. Quarters are calendar quarters; a fiscal quarter is a caller's own
94
+ `quickRanges` entry, not a variant of this one.
95
+
96
+ `createQuickRanges({ today: 'Hoje', … })` relabels the built-ins without
97
+ re-deriving their arithmetic — which is how two lists that claim the same thing
98
+ start disagreeing. `quickRanges` replaces the list wholesale; `[]` removes the
99
+ column.
100
+
101
+ ## Time zone
102
+
103
+ Civil days are zone-free; **which day is today is not**. At 21:30 on 31 July in
104
+ São Paulo it is already 1 August in UTC, so "this month" resolved on the host's
105
+ clock can name a period the reader's store has not reached. `timeZone` is read
106
+ for exactly one thing — today — and every quick range hangs off it.
107
+
108
+ Absent, the host's own zone is used. That is right for a picker over the
109
+ reader's own data and wrong for one reporting on somewhere else.
110
+
111
+ ## Maximum length
112
+
113
+ `maxRangeDays` is an inclusive day count and is enforced in three places at
114
+ once:
115
+
116
+ - a quick range longer than it is `aria-disabled`, keeps its place in the tab
117
+ order, and renders the reason under its label;
118
+ - the calendar is given the same ceiling, so it cannot close a range over it;
119
+ - a typed pair over it is reported as `problem: 'over-max'` with the reason in
120
+ the status line.
121
+
122
+ **Nothing is ever clamped.** Handing back eleven months for a control labelled
123
+ "This year" is worse than refusing: the reader gets a number for a period they
124
+ did not ask for and nothing on screen says so.
125
+
126
+ ## When a typed date commits
127
+
128
+ The moment it is a **whole, real day** — eight digits naming a date that exists.
129
+
130
+ - Not on every keystroke: `2026` passes through `0002`, `0020` and `0202`, each
131
+ a legal year, and committing those sends the calendar to the year 20 while the
132
+ reader is still typing.
133
+ - Not only on blur: someone who types a date and looks at the calendar to check
134
+ it would see the old month until they clicked somewhere else.
135
+ - Half-typed text stays in the field and changes nothing; leaving the field
136
+ discards it and shows what the range actually is.
137
+ - Enter needs no special case — by the time it can be pressed, the date has
138
+ already committed.
139
+
140
+ ## Which month the calendar shows
141
+
142
+ `Calendar` reads its opening month from the range it is handed, and only when it
143
+ mounts. The picker remounts it when a **quick pick** or a **typed date** moves
144
+ the range, and never when a day inside the grid is clicked — a calendar that
145
+ jumped under the pointer picking in it would be unusable.
146
+
147
+ So the grid follows the range's **start**. Picking "This year" in August moves
148
+ it to January, because that is where the range now begins.
149
+
150
+ ## Layout
151
+
152
+ Calendar first, quick column beside it (`md` and up), fields underneath. Below
153
+ `md` the quick entries wrap and stack under the calendar rather than taking a
154
+ column out of the day grid: at 390px a side column squeezes the grid to about
155
+ half its width and the numbers stop being readable.
156
+
157
+ There is no `size` prop. The day grid has a fixed metric that a size scale would
158
+ not change, and a prop that only altered the padding of the quick entries would
159
+ be a scale in name only.
160
+
161
+ ## Accessibility
162
+
163
+ - The quick entries are native `<button>`s with `aria-pressed`; the active one
164
+ is the entry the draft currently equals. (A listbox with `aria-selected` would
165
+ model this too — the rule is to pick one and use it for every entry.)
166
+ - Over-cap entries are `aria-disabled`, **not** `disabled`: a disabled button is
167
+ skipped by the tab order, so the reason under it would be unreachable by
168
+ exactly the reader most likely to need it. They also carry
169
+ `aria-describedby` pointing at that reason.
170
+ - The status line is `role="status" aria-live="polite"`, so a refusal is spoken
171
+ when it appears rather than found by someone who goes looking.
172
+ - Both fields point at that line through `aria-describedby`, and are marked
173
+ `error` when the PAIR is impossible — never merely incomplete, which would
174
+ make the first click of a two-click control look like a mistake.
175
+ - Keyboard: the quick column is buttons; the grid is `Calendar`'s own roving
176
+ focus (arrows, `Home`/`End`, `PageUp`/`PageDown`, Enter/Space).
177
+
178
+ ## Copy
179
+
180
+ Every string is in `messages` and defaults to English. The summary prints days
181
+ in the same `dd/mm/aaaa` the fields are masked to rather than in the host
182
+ locale's order: one control cannot show the same date two ways, and an `en-US`
183
+ summary reading `08/05` over a field reading `05/08` leaves the reader to work
184
+ out which is lying. Replace `messages.summary` to choose another order.
185
+
186
+ ## Test IDs
187
+
188
+ All derived from `dataTestId` (default `date-range-picker`):
189
+
190
+ | id | element |
191
+ |---|---|
192
+ | `{root}` | the container |
193
+ | `{root}-quick-list` | the quick group |
194
+ | `{root}-quick-{quickRangeId}` | one quick entry |
195
+ | `{root}-from`, `{root}-to` | the two `<input>`s |
196
+ | `{root}-status` | the summary / refusal line |
197
+
198
+ The calendar keeps its own ids (`calendar-container`, `calendar-header`,
199
+ `calendar-date-{day}`). **`calendar-date-{day}` is the day of the month only**,
200
+ so with two months on screen every day appears twice; a test that selects days
201
+ by that id should render `numberOfMonths={1}`.
202
+
203
+ ## Usage
204
+
205
+ ```tsx
206
+ const [range, setRange] = useState<DateRangeDraft>({ from: null, to: null });
207
+ const [status, setStatus] = useState<DateRangeStatus>(resolveDayRange(range));
208
+
209
+ <DateRangePicker
210
+ value={range}
211
+ onChange={(next, meta) => {
212
+ setRange(next);
213
+ setStatus(meta.status);
214
+ }}
215
+ timeZone="America/Sao_Paulo"
216
+ maxRangeDays={366}
217
+ locale="pt-BR"
218
+ numberOfMonths={1}
219
+ />
220
+ <Button disabled={!status.ok} onClick={() => status.ok && apply(status.window)}>
221
+ Aplicar
222
+ </Button>
223
+ ```
224
+
225
+ ## Related
226
+
227
+ - [`Calendar`](../Calendar/Calendar.md) — the range grid this composes
228
+ - `DataViews` — where the masked `dd/mm/aaaa` field comes from