@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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The quick-pick column's default list, and how to relabel it.
3
+ *
4
+ * These are DOMAIN-FREE on purpose: every one of them is derivable from "today"
5
+ * and the calendar, so the design system can ship them without knowing what the
6
+ * range is for. A product's own periods (a fiscal quarter, "since the store
7
+ * opened", the last closed month) are a `quickRanges` array the caller passes —
8
+ * the prop exists so that list never has to be smuggled in here.
9
+ *
10
+ * "Last N days" INCLUDES today, which is the reading every dashboard in the
11
+ * wild uses: on the 10th, "last 7 days" is the 4th through the 10th, not the
12
+ * 3rd through the 9th.
13
+ */
14
+ import {
15
+ shiftDay,
16
+ startOfMonthDay,
17
+ startOfQuarterDay,
18
+ startOfWeekDay,
19
+ startOfYearDay,
20
+ } from './DateRangePicker.dates';
21
+ import type { QuickRange, QuickRangeContext } from './DateRangePicker.types';
22
+
23
+ /**
24
+ * The ids the built-in list uses. Stable API: a caller keys off these to map a
25
+ * quick pick onto something of its own (a stored preset, an event name), and
26
+ * renaming one is a breaking change in a way that renaming a label is not.
27
+ */
28
+ export const QUICK_RANGE_IDS = [
29
+ 'today',
30
+ 'yesterday',
31
+ 'this-week',
32
+ 'last-7-days',
33
+ 'this-month',
34
+ 'last-30-days',
35
+ 'this-quarter',
36
+ 'this-year',
37
+ 'last-365-days',
38
+ ] as const;
39
+
40
+ export type QuickRangeId = (typeof QUICK_RANGE_IDS)[number];
41
+
42
+ /** English defaults. Every one is replaceable through {@link createQuickRanges}. */
43
+ export const DEFAULT_QUICK_RANGE_LABELS: Record<QuickRangeId, string> = {
44
+ today: 'Today',
45
+ yesterday: 'Yesterday',
46
+ 'this-week': 'This week',
47
+ 'last-7-days': 'Last 7 days',
48
+ 'this-month': 'This month',
49
+ 'last-30-days': 'Last 30 days',
50
+ 'this-quarter': 'This quarter',
51
+ 'this-year': 'This year',
52
+ 'last-365-days': 'Last 365 days',
53
+ };
54
+
55
+ /** A window that ends today and reaches back `days` days, today included. */
56
+ const trailing =
57
+ (days: number) =>
58
+ ({ today }: QuickRangeContext) => ({ from: shiftDay(today, 1 - days), to: today });
59
+
60
+ /** A window from the start of some period through today. */
61
+ const sinceStartOf =
62
+ (start: (context: QuickRangeContext) => string) => (context: QuickRangeContext) => ({
63
+ from: start(context),
64
+ to: context.today,
65
+ });
66
+
67
+ const RESOLVERS: Record<QuickRangeId, QuickRange['resolve']> = {
68
+ today: trailing(1),
69
+ yesterday: ({ today }) => ({ from: shiftDay(today, -1), to: shiftDay(today, -1) }),
70
+ 'this-week': sinceStartOf(({ today, weekStartsOn }) => startOfWeekDay(today, weekStartsOn)),
71
+ 'last-7-days': trailing(7),
72
+ 'this-month': sinceStartOf(({ today }) => startOfMonthDay(today)),
73
+ 'last-30-days': trailing(30),
74
+ 'this-quarter': sinceStartOf(({ today }) => startOfQuarterDay(today)),
75
+ 'this-year': sinceStartOf(({ today }) => startOfYearDay(today)),
76
+ 'last-365-days': trailing(365),
77
+ };
78
+
79
+ /**
80
+ * The built-in quick ranges, optionally relabelled.
81
+ *
82
+ * Relabelling rather than rebuilding is the point: a Portuguese host wants
83
+ * "Este mês" over the same month-to-date arithmetic, and re-deriving the dates
84
+ * to get the word is how two lists that claim the same thing start disagreeing.
85
+ */
86
+ export function createQuickRanges(
87
+ labels: Partial<Record<QuickRangeId, string>> = {},
88
+ ): QuickRange[] {
89
+ return QUICK_RANGE_IDS.map((id) => ({
90
+ id,
91
+ label: labels[id] ?? DEFAULT_QUICK_RANGE_LABELS[id],
92
+ resolve: RESOLVERS[id],
93
+ }));
94
+ }
95
+
96
+ /** The list a picker uses when the caller says nothing. */
97
+ export const DEFAULT_QUICK_RANGES: QuickRange[] = createQuickRanges();
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Judging a draft, and saying so in words.
3
+ *
4
+ * The picker never repairs what it is given. A reversed pair is not silently
5
+ * swapped and an over-long one is not clipped to the cap: both are reported,
6
+ * because a control that returns a different range from the one on screen is
7
+ * indistinguishable from one that ignored you. The caller reads
8
+ * {@link DateRangeStatus} and keeps its own confirm button shut.
9
+ */
10
+ import { isoToBr } from '../../data-display/DataViews/data-views-day-input';
11
+
12
+ import { dayCount, isAfterDay } from './DateRangePicker.dates';
13
+ import type {
14
+ DateRangePickerMessages,
15
+ DateRangeDraft,
16
+ DateRangeStatus,
17
+ QuickRange,
18
+ QuickRangeContext,
19
+ ResolvedQuickRange,
20
+ } from './DateRangePicker.types';
21
+
22
+ /**
23
+ * Whether a draft is a window this picker will hand over.
24
+ *
25
+ * Exported because the caller needs the same verdict the picker shows: a dialog
26
+ * disabling its "Apply" button must not re-derive "is this legal" from the two
27
+ * strings, or the two answers drift and the button ends up enabled over a
28
+ * message saying the range is impossible.
29
+ */
30
+ export function resolveDayRange(
31
+ draft: DateRangeDraft,
32
+ maxRangeDays?: number,
33
+ ): DateRangeStatus {
34
+ const { from, to } = draft;
35
+ if (!from || !to) return { ok: false, problem: 'incomplete', days: 0 };
36
+ if (isAfterDay(from, to)) return { ok: false, problem: 'reversed', days: 0 };
37
+
38
+ const days = dayCount(from, to);
39
+ if (maxRangeDays !== undefined && days > maxRangeDays) {
40
+ return { ok: false, problem: 'over-max', days };
41
+ }
42
+ return { ok: true, window: { from, to }, days };
43
+ }
44
+
45
+ /**
46
+ * The English defaults.
47
+ *
48
+ * The summary prints the days in the SAME `dd/mm/aaaa` the two fields are
49
+ * masked to, rather than in the host locale's order. One control cannot show
50
+ * the same date two ways: an `en-US` host formatting the summary as `08/05`
51
+ * over a field reading `05/08` leaves the reader to work out which of the two
52
+ * is lying. A host that wants its own order replaces `messages.summary`, which
53
+ * moves both halves of the decision to the same place.
54
+ */
55
+ export function defaultMessages(): DateRangePickerMessages {
56
+ return {
57
+ from: 'Start date',
58
+ to: 'End date',
59
+ quickRanges: 'Quick ranges',
60
+ incomplete: 'Choose both dates.',
61
+ reversed: 'The end date must be on or after the start date.',
62
+ overMax: ({ maxRangeDays }) => `Choose a range of at most ${maxRangeDays} days.`,
63
+ summary: ({ from, to }) => `${isoToBr(from)} – ${isoToBr(to)}`,
64
+ };
65
+ }
66
+
67
+ /** The line under the fields: the chosen window, or why there isn't one. */
68
+ export function statusMessage(
69
+ status: DateRangeStatus,
70
+ messages: DateRangePickerMessages,
71
+ maxRangeDays: number | undefined,
72
+ ): string {
73
+ if (status.ok) return messages.summary(status.window);
74
+ if (status.problem === 'incomplete') return messages.incomplete;
75
+ if (status.problem === 'reversed') return messages.reversed;
76
+ return messages.overMax({ maxRangeDays: maxRangeDays ?? status.days, days: status.days });
77
+ }
78
+
79
+ /** Every quick range with its window, its length, and whether it is over cap. */
80
+ export function resolveQuickRanges(
81
+ quickRanges: QuickRange[],
82
+ context: QuickRangeContext,
83
+ maxRangeDays: number | undefined,
84
+ ): ResolvedQuickRange[] {
85
+ return quickRanges.map((quickRange) => {
86
+ const window = quickRange.resolve(context);
87
+ const days = dayCount(window.from, window.to);
88
+ return {
89
+ ...quickRange,
90
+ window,
91
+ days,
92
+ overMax: maxRangeDays !== undefined && days > maxRangeDays,
93
+ };
94
+ });
95
+ }
96
+
97
+ /** The quick range the draft currently IS, if it is exactly one of them. */
98
+ export function activeQuickRangeId(
99
+ options: ResolvedQuickRange[],
100
+ draft: DateRangeDraft,
101
+ ): string | undefined {
102
+ return options.find(
103
+ (option) => option.window.from === draft.from && option.window.to === draft.to,
104
+ )?.id;
105
+ }
@@ -0,0 +1,250 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Three views of ONE range: a calendar, a quick-pick column, and two typed
5
+ * dates. Change any and the other two follow.
6
+ *
7
+ * WHY IT EXISTS: a range calendar alone makes "this quarter" cost two clicks,
8
+ * a month of paging, and a mental note of which day a quarter starts on; a list
9
+ * of presets alone cannot express "the 3rd to the 19th"; and typed fields alone
10
+ * make you spell out a window you can see. Each of the three is the fastest
11
+ * route to a different range, so the control offers all three over one value
12
+ * rather than picking a favourite.
13
+ *
14
+ * WHAT IT DOES NOT KNOW: anything about the data behind the range. No preset
15
+ * list of a product's own, no ceiling of its own, no notion of whose clock
16
+ * "today" runs on — `quickRanges`, `maxRangeDays` and `timeZone` are props, and
17
+ * a consumer that needs its own periods passes them rather than teaching this
18
+ * file about its domain. The calendar is `Calendar` in `selectionMode="range"`,
19
+ * not a second implementation of one.
20
+ *
21
+ * The value is a DRAFT (`{ from, to }`, either end possibly null) rather than a
22
+ * finished window, so a half-picked or impossible range has somewhere to live
23
+ * while it is being fixed. Every `onChange` carries the verdict with it.
24
+ */
25
+ import { Box, Paper, useMediaQuery } from '@mui/material';
26
+ import type { SxProps, Theme } from '@mui/material';
27
+ import React from 'react';
28
+
29
+ import { Calendar } from '../Calendar';
30
+
31
+ import { toLocalDay } from './DateRangePicker.dates';
32
+ import { useDateRangePicker } from './DateRangePicker.hooks';
33
+ import { DEFAULT_QUICK_RANGES } from './DateRangePicker.quick';
34
+ import type { DateRangeDraft, DateRangePickerProps, WeekStart } from './DateRangePicker.types';
35
+ import { DateRangeFields } from './DateRangePickerFields';
36
+ import { DateRangeQuickList } from './DateRangePickerQuickList';
37
+
38
+ const CALENDAR_CLASS = 'date-range-picker__calendar';
39
+
40
+ /** Below this the picker is a phone layout. MUI's own `md`, written out. */
41
+ const NARROW_QUERY = '(max-width:899.95px)';
42
+
43
+ const ROOT_SX: SxProps<Theme> = {
44
+ display: 'inline-flex',
45
+ flexDirection: 'column',
46
+ gap: 2,
47
+ p: 2,
48
+ maxWidth: '100%',
49
+ // `Calendar` renders its own `Paper` with 24px of padding. Nested in this one
50
+ // that reads as a box inside a box, so the inner frame is flattened here
51
+ // through the className hook the component exposes for exactly this.
52
+ [`& .${CALENDAR_CLASS}`]: {
53
+ p: 0,
54
+ boxShadow: 'none',
55
+ backgroundColor: 'transparent',
56
+ backgroundImage: 'none',
57
+ },
58
+ };
59
+
60
+ const BODY_SX: SxProps<Theme> = {
61
+ display: 'flex',
62
+ // The quick column sits BESIDE the calendar on a wide screen. Narrow, it
63
+ // moves ABOVE it — `column-reverse`, so the DOM order stays calendar-then-
64
+ // list (the calendar is the primary control and should be reached first by a
65
+ // screen reader and by Tab) while the eye meets the quick ranges first, which
66
+ // is the order the phone mock asks for and the order a thumb wants: nine
67
+ // one-tap answers before a grid of thirty.
68
+ //
69
+ // A side column at 390px would squeeze the day grid to roughly half its width
70
+ // and make the numbers unreadable. Checked at 390px.
71
+ flexDirection: { xs: 'column-reverse', md: 'row' },
72
+ alignItems: { xs: 'stretch', md: 'flex-start' },
73
+ gap: 2,
74
+ maxWidth: '100%',
75
+ };
76
+
77
+ /**
78
+ * The rule between the calendar and the quick ranges.
79
+ *
80
+ * Two lists of dates side by side with only whitespace between them read as one
81
+ * ragged thing; the rule says which numbers belong to which control. It turns
82
+ * with the layout — a vertical rule when the list is a column beside the
83
+ * calendar, a horizontal one when it has moved above it — because a divider
84
+ * that keeps its axis while the flex direction flips ends up separating nothing.
85
+ */
86
+ const QUICK_DIVIDER_SX: SxProps<Theme> = {
87
+ borderColor: 'divider',
88
+ borderStyle: 'solid',
89
+ borderWidth: 0,
90
+ borderLeftWidth: { xs: 0, md: '1px' },
91
+ borderBottomWidth: { xs: '1px', md: 0 },
92
+ pl: { xs: 0, md: 2 },
93
+ pb: { xs: 2, md: 0 },
94
+ alignSelf: 'stretch',
95
+ };
96
+
97
+ /** And the rule above the typed pair, which is a third way in, not a caption. */
98
+ const FIELDS_DIVIDER_SX: SxProps<Theme> = {
99
+ borderTop: '1px solid',
100
+ borderColor: 'divider',
101
+ pt: 2,
102
+ };
103
+
104
+ type Picker = ReturnType<typeof useDateRangePicker>;
105
+
106
+ /**
107
+ * How many months to draw.
108
+ *
109
+ * A month grid has a floor: seven columns that must stay legible, which is
110
+ * about 280px. Two of them need 580, so on a phone the consumer's
111
+ * `numberOfMonths` is not a preference that can be honoured — it is a request
112
+ * for a layout that does not fit. Capped here rather than left to the consumer,
113
+ * because the consumer chooses for its own reason (the reports dialog wants two
114
+ * because a period often crosses a month boundary) and has no way to know the
115
+ * picker is currently 390px wide.
116
+ *
117
+ * A media query rather than an `sx` breakpoint: this decides what to RENDER,
118
+ * and CSS cannot un-render a second month.
119
+ *
120
+ * The query is a LITERAL, not `theme.breakpoints.down('md')`. The callback form
121
+ * reads the theme from context and throws on a null one, so a consumer that
122
+ * mounts this outside a `ThemeProvider` would crash — which the unit tests here
123
+ * do, and which is a fair thing for a consumer to do.
124
+ */
125
+ function useMonthCount(requested: number | undefined): number | undefined {
126
+ const narrow = useMediaQuery(NARROW_QUERY);
127
+ return narrow ? 1 : requested;
128
+ }
129
+
130
+ /** The grid, in range mode — `Calendar`, never a second implementation of one. */
131
+ function RangeCalendar({
132
+ picker,
133
+ value,
134
+ locale,
135
+ weekStartsOn,
136
+ months,
137
+ maxRangeDays,
138
+ }: {
139
+ picker: Picker;
140
+ value: DateRangeDraft;
141
+ locale: string;
142
+ weekStartsOn: WeekStart;
143
+ months: number | undefined;
144
+ maxRangeDays: number | undefined;
145
+ }): React.JSX.Element {
146
+ return (
147
+ <Calendar
148
+ key={picker.viewNonce}
149
+ className={CALENDAR_CLASS}
150
+ selectionMode="range"
151
+ locale={locale}
152
+ // One prop drives the grid's first column AND the day "this week" starts
153
+ // on, so a highlighted week can never begin mid-row.
154
+ firstDayOfWeek={weekStartsOn}
155
+ numberOfMonths={months}
156
+ range={{ start: toLocalDay(value.from), end: toLocalDay(value.to) }}
157
+ // BOTH callbacks, and the intermediate one does the work: the other fires
158
+ // only on the click that CLOSES a range, so a picker wired to it alone
159
+ // would not repaint after the first click.
160
+ onIntermediateRangeChange={picker.pickOnCalendar}
161
+ onRangeChange={picker.pickOnCalendar}
162
+ // The same ceiling the quick list is judged against, so the calendar
163
+ // cannot hand back a window the control has just called too long.
164
+ maxRangeLength={maxRangeDays}
165
+ />
166
+ );
167
+ }
168
+
169
+ /** The quick ranges, inside the rule that separates them from the grid. */
170
+ function QuickColumn({
171
+ picker,
172
+ maxRangeDays,
173
+ dataTestId,
174
+ }: {
175
+ picker: Picker;
176
+ maxRangeDays: number | undefined;
177
+ dataTestId: string;
178
+ }): React.JSX.Element | null {
179
+ if (picker.options.length === 0) return null;
180
+ return (
181
+ <Box sx={QUICK_DIVIDER_SX}>
182
+ <DateRangeQuickList
183
+ options={picker.options}
184
+ activeId={picker.activeId}
185
+ onPick={picker.pickQuick}
186
+ reasonFor={(option) =>
187
+ picker.copy.overMax({ maxRangeDays: maxRangeDays ?? option.days, days: option.days })
188
+ }
189
+ label={picker.copy.quickRanges}
190
+ dataTestId={dataTestId}
191
+ />
192
+ </Box>
193
+ );
194
+ }
195
+
196
+ export function DateRangePicker({
197
+ value,
198
+ onChange,
199
+ timeZone,
200
+ now,
201
+ maxRangeDays,
202
+ weekStartsOn = 0,
203
+ quickRanges = DEFAULT_QUICK_RANGES,
204
+ locale = 'en-US',
205
+ numberOfMonths,
206
+ messages,
207
+ dataTestId = 'date-range-picker',
208
+ className,
209
+ }: DateRangePickerProps): React.JSX.Element {
210
+ const picker = useDateRangePicker({
211
+ value,
212
+ onChange,
213
+ timeZone,
214
+ now,
215
+ maxRangeDays,
216
+ weekStartsOn,
217
+ quickRanges,
218
+ messages,
219
+ });
220
+ const months = useMonthCount(numberOfMonths);
221
+
222
+ return (
223
+ <Paper className={className} data-testid={dataTestId} sx={ROOT_SX}>
224
+ <Box sx={BODY_SX}>
225
+ <RangeCalendar
226
+ picker={picker}
227
+ value={value}
228
+ locale={locale}
229
+ weekStartsOn={weekStartsOn}
230
+ months={months}
231
+ maxRangeDays={maxRangeDays}
232
+ />
233
+ <QuickColumn picker={picker} maxRangeDays={maxRangeDays} dataTestId={dataTestId} />
234
+ </Box>
235
+ <Box sx={FIELDS_DIVIDER_SX}>
236
+ <DateRangeFields
237
+ value={value}
238
+ status={picker.status}
239
+ messages={picker.copy}
240
+ maxRangeDays={maxRangeDays}
241
+ onEdit={picker.editBound}
242
+ statusId={picker.statusId}
243
+ dataTestId={dataTestId}
244
+ />
245
+ </Box>
246
+ </Paper>
247
+ );
248
+ }
249
+
250
+ export default DateRangePicker;
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The vocabulary of a two-ended day range. Everything here is CIVIL — a day on
3
+ * a stated clock, never an instant — because that is what a range picker picks:
4
+ * "1 August" is the same day in every zone, and only the question "which day is
5
+ * TODAY" needs a zone to answer.
6
+ */
7
+ import type { DateRange } from '../Calendar';
8
+
9
+ /** One inclusive calendar day as `AAAA-MM-DD`. Sorts lexicographically. */
10
+ export type DayString = string;
11
+
12
+ /** A complete range: two INCLUSIVE calendar days, `from` on or before `to`. */
13
+ export interface DayWindow {
14
+ from: DayString;
15
+ to: DayString;
16
+ }
17
+
18
+ /**
19
+ * A range being edited, which may be half-chosen, reversed or too long.
20
+ *
21
+ * This — not {@link DayWindow} — is the picker's `value`: a control whose value
22
+ * could only hold a legal range would have nowhere to put a reader who typed
23
+ * the end date first, and would have to either discard the keystrokes or lie
24
+ * about what is on screen. {@link resolveDayRange} says whether a draft is a
25
+ * usable window.
26
+ */
27
+ export interface DateRangeDraft {
28
+ from: DayString | null;
29
+ to: DayString | null;
30
+ }
31
+
32
+ /** Why a draft is not a usable window. */
33
+ export type DateRangeProblem =
34
+ /** One end is missing. */
35
+ | 'incomplete'
36
+ /** `from` is after `to`. */
37
+ | 'reversed'
38
+ /** Longer than `maxRangeDays`. */
39
+ | 'over-max';
40
+
41
+ /** A draft judged against the picker's constraints. `days` is 0 when unknown. */
42
+ export type DateRangeStatus =
43
+ | { ok: true; window: DayWindow; days: number }
44
+ | { ok: false; problem: DateRangeProblem; days: number };
45
+
46
+ /** 0 = Sunday … 6 = Saturday, matching `Date.prototype.getDay`. */
47
+ export type WeekStart = 0 | 1 | 2 | 3 | 4 | 5 | 6;
48
+
49
+ /** What a quick range resolves against. No `Date`, no zone — the day is done. */
50
+ export interface QuickRangeContext {
51
+ /** Today, already read on the picker's stated clock. */
52
+ today: DayString;
53
+ /** The same week start the calendar grid renders with. */
54
+ weekStartsOn: WeekStart;
55
+ }
56
+
57
+ /**
58
+ * One entry in the quick-pick column.
59
+ *
60
+ * `resolve` is a function rather than a stored window so the list survives
61
+ * midnight and can be defined once at module scope; `id` is stable and is what
62
+ * a caller matches on when a particular quick range means something to it
63
+ * (a saved preset, an analytics event) — never the label, which is copy.
64
+ */
65
+ export interface QuickRange {
66
+ id: string;
67
+ label: string;
68
+ resolve: (context: QuickRangeContext) => DayWindow;
69
+ }
70
+
71
+ /** A quick range with everything the list needs to draw it. */
72
+ export interface ResolvedQuickRange extends QuickRange {
73
+ window: DayWindow;
74
+ /** Inclusive day count of {@link window}. */
75
+ days: number;
76
+ /** Longer than the cap: offered, announced, and refused rather than clamped. */
77
+ overMax: boolean;
78
+ }
79
+
80
+ /**
81
+ * Every string the picker renders, so a host in another language replaces copy
82
+ * instead of forking the component. Defaults are English.
83
+ */
84
+ export interface DateRangePickerMessages {
85
+ /** Label of the `from` field. */
86
+ from: string;
87
+ /** Label of the `to` field. */
88
+ to: string;
89
+ /** Accessible name of the quick-pick group. */
90
+ quickRanges: string;
91
+ /** Shown while only one end is chosen. */
92
+ incomplete: string;
93
+ /** Shown when the start is after the end. */
94
+ reversed: string;
95
+ /** Shown when the range — typed or quick-picked — exceeds the cap. */
96
+ overMax: (info: { maxRangeDays: number; days: number }) => string;
97
+ /** The chosen window, in words. */
98
+ summary: (window: DayWindow) => string;
99
+ }
100
+
101
+ /** Which of the three views moved the range, and what it moved it to. */
102
+ export interface DateRangeChangeMeta {
103
+ source: 'quick' | 'calendar' | 'from' | 'to';
104
+ /** Set only when `source` is `quick`: the {@link QuickRange.id} clicked. */
105
+ quickRangeId?: string;
106
+ /** The new draft, already judged — so a caller needs no second validator. */
107
+ status: DateRangeStatus;
108
+ }
109
+
110
+ export interface DateRangePickerProps {
111
+ /** The range being edited. Controlled: the picker keeps no copy of it. */
112
+ value: DateRangeDraft;
113
+ onChange: (next: DateRangeDraft, meta: DateRangeChangeMeta) => void;
114
+ /**
115
+ * IANA zone (or offset, e.g. `-03:00`) the quick ranges read TODAY on.
116
+ *
117
+ * Civil days are zone-free, but "today", "this month" and "this quarter" are
118
+ * not: at 21:30 on 31 July in São Paulo it is already 1 August in UTC, so a
119
+ * quarter resolved on the host's clock can name a period the reader's store
120
+ * has not reached. Defaults to the host's own zone, which is right for a
121
+ * picker whose data belongs to whoever is looking at it and wrong for one
122
+ * that reports on somewhere else — pass the zone in that case.
123
+ */
124
+ timeZone?: string;
125
+ /**
126
+ * The instant TODAY is read from. Defaults to the real clock; a story or a
127
+ * test freezes it here rather than mocking `Date`.
128
+ */
129
+ now?: Date;
130
+ /**
131
+ * Longest range the picker will call usable, in inclusive days.
132
+ *
133
+ * A quick range longer than this is rendered `aria-disabled` with the reason,
134
+ * never silently shortened: returning eleven months for a control labelled
135
+ * "This year" is worse than refusing.
136
+ */
137
+ maxRangeDays?: number;
138
+ /**
139
+ * First column of the calendar grid AND the day "this week" starts on — one
140
+ * prop, because a grid that starts on Sunday beside a "this week" that starts
141
+ * on Monday shows a highlighted block that begins mid-row.
142
+ *
143
+ * Defaults to 0 (Sunday), which is what a `pt-BR` calendar renders
144
+ * (`D S T Q Q S S`) and what Brazilian merchants read. Do not "fix" it to
145
+ * Monday for the ISO week without changing the locale that goes with it.
146
+ */
147
+ weekStartsOn?: WeekStart;
148
+ /** Replaces the built-in list wholesale. Empty array: no quick column. */
149
+ quickRanges?: QuickRange[];
150
+ /** BCP-47 tag for month names, weekday initials and the summary sentence. */
151
+ locale?: string;
152
+ /** Months side by side. Defaults to 2, the range calendar's own default. */
153
+ numberOfMonths?: number;
154
+ /** Copy overrides, merged over the English defaults. */
155
+ messages?: Partial<DateRangePickerMessages>;
156
+ /** Root test id; every part derives from it (`-from`, `-quick-today`, …). */
157
+ dataTestId?: string;
158
+ className?: string;
159
+ }
160
+
161
+ /**
162
+ * What `useDateRangePicker` reads — the picker's own props, minus the ones that
163
+ * only decide how it LOOKS (locale, month count, test id, class name).
164
+ */
165
+ export interface UseDateRangePickerArgs {
166
+ value: DateRangeDraft;
167
+ onChange: DateRangePickerProps['onChange'];
168
+ timeZone: string | undefined;
169
+ now: Date | undefined;
170
+ maxRangeDays: number | undefined;
171
+ weekStartsOn: WeekStart;
172
+ quickRanges: QuickRange[];
173
+ messages: Partial<DateRangePickerMessages> | undefined;
174
+ }
175
+
176
+ /** Everything the component renders from, and every way the range can move. */
177
+ export interface DateRangePickerState {
178
+ /** Copy with the caller's overrides already merged over the defaults. */
179
+ copy: DateRangePickerMessages;
180
+ /** Every quick range resolved against today, with its length and verdict. */
181
+ options: ResolvedQuickRange[];
182
+ /** The quick range the draft currently IS, if it is exactly one of them. */
183
+ activeId: string | undefined;
184
+ /** The current draft, judged. */
185
+ status: DateRangeStatus;
186
+ /** Id of the status line; the two fields point at it. */
187
+ statusId: string;
188
+ /** Changes only when the calendar should FOLLOW the range. */
189
+ viewNonce: number;
190
+ pickQuick: (option: ResolvedQuickRange) => void;
191
+ editBound: (which: 'from' | 'to', bound: DayString | null) => void;
192
+ pickOnCalendar: (range: DateRange) => void;
193
+ }