@12-apps/ui 3.0.0 → 3.1.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,146 @@
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 } 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 { DateRangePickerProps } from './DateRangePicker.types';
35
+ import { DateRangeFields } from './DateRangePickerFields';
36
+ import { DateRangeQuickList } from './DateRangePickerQuickList';
37
+
38
+ const CALENDAR_CLASS = 'date-range-picker__calendar';
39
+
40
+ const ROOT_SX: SxProps<Theme> = {
41
+ display: 'inline-flex',
42
+ flexDirection: 'column',
43
+ gap: 2,
44
+ p: 2,
45
+ maxWidth: '100%',
46
+ // `Calendar` renders its own `Paper` with 24px of padding. Nested in this one
47
+ // that reads as a box inside a box, so the inner frame is flattened here
48
+ // through the className hook the component exposes for exactly this.
49
+ [`& .${CALENDAR_CLASS}`]: {
50
+ p: 0,
51
+ boxShadow: 'none',
52
+ backgroundColor: 'transparent',
53
+ backgroundImage: 'none',
54
+ },
55
+ };
56
+
57
+ const BODY_SX: SxProps<Theme> = {
58
+ display: 'flex',
59
+ // The quick column sits BESIDE the calendar and drops UNDER it when there is
60
+ // no room for both. At 390px a side column would squeeze the day grid to
61
+ // roughly half its width and make the numbers unreadable; stacking keeps the
62
+ // grid whole and costs a scroll. Checked at 390px.
63
+ flexDirection: { xs: 'column', md: 'row' },
64
+ alignItems: 'flex-start',
65
+ gap: 2,
66
+ maxWidth: '100%',
67
+ overflowX: 'auto',
68
+ };
69
+
70
+ export function DateRangePicker({
71
+ value,
72
+ onChange,
73
+ timeZone,
74
+ now,
75
+ maxRangeDays,
76
+ weekStartsOn = 0,
77
+ quickRanges = DEFAULT_QUICK_RANGES,
78
+ locale = 'en-US',
79
+ numberOfMonths,
80
+ messages,
81
+ dataTestId = 'date-range-picker',
82
+ className,
83
+ }: DateRangePickerProps): React.JSX.Element {
84
+ const picker = useDateRangePicker({
85
+ value,
86
+ onChange,
87
+ timeZone,
88
+ now,
89
+ maxRangeDays,
90
+ weekStartsOn,
91
+ quickRanges,
92
+ messages,
93
+ });
94
+
95
+ return (
96
+ <Paper className={className} data-testid={dataTestId} sx={ROOT_SX}>
97
+ <Box sx={BODY_SX}>
98
+ <Calendar
99
+ key={picker.viewNonce}
100
+ className={CALENDAR_CLASS}
101
+ selectionMode="range"
102
+ locale={locale}
103
+ // One prop drives the grid's first column AND the day "this week"
104
+ // starts on, so a highlighted week can never begin mid-row.
105
+ firstDayOfWeek={weekStartsOn}
106
+ numberOfMonths={numberOfMonths}
107
+ range={{ start: toLocalDay(value.from), end: toLocalDay(value.to) }}
108
+ // BOTH callbacks, and the intermediate one does the work: the other
109
+ // fires only on the click that CLOSES a range, so a picker wired to
110
+ // it alone would not repaint after the first click.
111
+ onIntermediateRangeChange={picker.pickOnCalendar}
112
+ onRangeChange={picker.pickOnCalendar}
113
+ // The same ceiling the quick list is judged against, so the calendar
114
+ // cannot hand back a window the control has just called too long.
115
+ maxRangeLength={maxRangeDays}
116
+ />
117
+ {picker.options.length > 0 && (
118
+ <DateRangeQuickList
119
+ options={picker.options}
120
+ activeId={picker.activeId}
121
+ onPick={picker.pickQuick}
122
+ reasonFor={(option) =>
123
+ picker.copy.overMax({
124
+ maxRangeDays: maxRangeDays ?? option.days,
125
+ days: option.days,
126
+ })
127
+ }
128
+ label={picker.copy.quickRanges}
129
+ dataTestId={dataTestId}
130
+ />
131
+ )}
132
+ </Box>
133
+ <DateRangeFields
134
+ value={value}
135
+ status={picker.status}
136
+ messages={picker.copy}
137
+ maxRangeDays={maxRangeDays}
138
+ onEdit={picker.editBound}
139
+ statusId={picker.statusId}
140
+ dataTestId={dataTestId}
141
+ />
142
+ </Paper>
143
+ );
144
+ }
145
+
146
+ 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
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The two typed ends, and the one line that says what the picker currently has.
3
+ *
4
+ * WHEN A TYPED DATE COMMITS: the moment it is a WHOLE, REAL day — eight digits
5
+ * that name a date that exists. Not on every keystroke (`2026` passes through
6
+ * `0002`, `0020` and `0202`, each a legal year, and committing those makes the
7
+ * calendar jump to the year 20 while you are still typing) and not only on blur
8
+ * (a reader who types a date and looks at the calendar to check it would see
9
+ * the old month until they clicked somewhere else). Half-typed text stays in
10
+ * the field and changes nothing; leaving the field discards it and shows what
11
+ * the range actually is. Enter needs no special case — by the time it can be
12
+ * pressed the date has already committed.
13
+ *
14
+ * The field itself is `DayBoundInput`, the masked `dd/mm/aaaa` control the data
15
+ * views already use. Same package, imported rather than re-implemented: a
16
+ * second masked date field is a second set of rules for what backspace does.
17
+ */
18
+ import { Box, Typography } from '@mui/material';
19
+ import React from 'react';
20
+
21
+ import { DayBoundInput } from '../../data-display/DataViews/data-views-day-input';
22
+
23
+ import { statusMessage } from './DateRangePicker.range';
24
+ import type {
25
+ DateRangePickerMessages,
26
+ DateRangeDraft,
27
+ DateRangeStatus,
28
+ DayString,
29
+ } from './DateRangePicker.types';
30
+
31
+ export interface DateRangeFieldsProps {
32
+ value: DateRangeDraft;
33
+ status: DateRangeStatus;
34
+ messages: DateRangePickerMessages;
35
+ maxRangeDays: number | undefined;
36
+ onEdit: (which: 'from' | 'to', bound: DayString | null) => void;
37
+ /** Id of the status line, so both fields can point at it. */
38
+ statusId: string;
39
+ dataTestId: string;
40
+ }
41
+
42
+ export function DateRangeFields({
43
+ value,
44
+ status,
45
+ messages,
46
+ maxRangeDays,
47
+ onEdit,
48
+ statusId,
49
+ dataTestId,
50
+ }: DateRangeFieldsProps): React.JSX.Element {
51
+ // "Incomplete" is not an error — it is a range that is not finished yet, and
52
+ // painting it red would make the first click on a two-click control look like
53
+ // a mistake. The pair being impossible is what gets marked.
54
+ const rejected = !status.ok && status.problem !== 'incomplete';
55
+
56
+ return (
57
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
58
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 2 }}>
59
+ <DayBoundInput
60
+ label={messages.from}
61
+ value={value.from ?? undefined}
62
+ onChange={(bound) => onEdit('from', bound ?? null)}
63
+ testId={`${dataTestId}-from`}
64
+ error={rejected}
65
+ describedBy={statusId}
66
+ />
67
+ <DayBoundInput
68
+ label={messages.to}
69
+ value={value.to ?? undefined}
70
+ onChange={(bound) => onEdit('to', bound ?? null)}
71
+ testId={`${dataTestId}-to`}
72
+ error={rejected}
73
+ describedBy={statusId}
74
+ />
75
+ </Box>
76
+ <Typography
77
+ id={statusId}
78
+ // A live region, so the reason a range was refused is spoken when it
79
+ // appears rather than only being found by someone who goes looking.
80
+ role="status"
81
+ aria-live="polite"
82
+ variant="body2"
83
+ color={rejected ? 'error.main' : 'text.secondary'}
84
+ data-testid={`${dataTestId}-status`}
85
+ >
86
+ {statusMessage(status, messages, maxRangeDays)}
87
+ </Typography>
88
+ </Box>
89
+ );
90
+ }