@12-apps/ui 2.1.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.
- package/components-catalog.md +8 -0
- package/package.json +2 -1
- package/src/components/data-display/Alert/Alert.types.ts +1 -1
- package/src/components/data-display/Avatar/Avatar.types.ts +1 -1
- package/src/components/data-display/Avatar/Avatar.view.tsx +5 -1
- package/src/components/data-display/Badge/Badge.styles.ts +11 -2
- package/src/components/data-display/Badge/Badge.types.ts +1 -1
- package/src/components/data-display/Carousel/Carousel.types.ts +4 -4
- package/src/components/data-display/Chart/Chart.types.ts +1 -1
- package/src/components/data-display/Chart/ChartSpec.types.ts +1 -1
- package/src/components/data-display/Chip/Chip.tsx +1 -1
- package/src/components/data-display/Chip/Chip.types.ts +6 -3
- package/src/components/data-display/DataViews/data-views-day-input.tsx +43 -22
- package/src/components/data-display/DateTimeDisplay/DateTimeDisplay.types.ts +1 -1
- package/src/components/data-display/InteractiveTooltip/InteractiveTooltip.types.ts +1 -1
- package/src/components/data-display/LoadingState/LoadingState.helpers.ts +4 -0
- package/src/components/data-display/LoadingState/LoadingState.types.ts +1 -1
- package/src/components/data-display/Progress/Progress.styles.ts +3 -0
- package/src/components/data-display/Progress/Progress.types.ts +2 -2
- package/src/components/data-display/Sheet/Sheet.types.ts +1 -1
- package/src/components/data-display/Stepper/Stepper.tsx +18 -6
- package/src/components/data-display/Stepper/Stepper.types.ts +2 -2
- package/src/components/data-display/Tooltip/Tooltip.types.ts +1 -1
- package/src/components/form/Button/Button.styles.ts +1 -0
- package/src/components/form/Command/Command.types.ts +1 -1
- package/src/components/form/CreatableSelect/CreatableSelect.tsx +5 -2
- package/src/components/form/CreatableSelect/CreatableSelect.types.ts +1 -1
- package/src/components/form/DateRangePicker/DateRangePicker.dates.ts +149 -0
- package/src/components/form/DateRangePicker/DateRangePicker.hooks.ts +87 -0
- package/src/components/form/DateRangePicker/DateRangePicker.md +228 -0
- package/src/components/form/DateRangePicker/DateRangePicker.quick.ts +97 -0
- package/src/components/form/DateRangePicker/DateRangePicker.range.ts +105 -0
- package/src/components/form/DateRangePicker/DateRangePicker.tsx +146 -0
- package/src/components/form/DateRangePicker/DateRangePicker.types.ts +193 -0
- package/src/components/form/DateRangePicker/DateRangePickerFields.tsx +90 -0
- package/src/components/form/DateRangePicker/DateRangePickerQuickList.tsx +123 -0
- package/src/components/form/DateRangePicker/index.ts +25 -0
- package/src/components/form/Input/Input.styles.ts +4 -0
- package/src/components/form/Input/Input.types.ts +1 -1
- package/src/components/form/InputOTP/InputOTP.styles.ts +1 -0
- package/src/components/form/InputOTP/InputOTP.types.ts +1 -1
- package/src/components/form/RadioGroup/RadioGroup.styles.ts +1 -0
- package/src/components/form/RadioGroup/RadioGroup.types.ts +1 -1
- package/src/components/form/Select/Select.types.ts +1 -1
- package/src/components/form/Slider/Slider.styles.ts +1 -0
- package/src/components/form/Slider/Slider.types.ts +1 -1
- package/src/components/form/Switch/Switch.styles.ts +1 -0
- package/src/components/form/Switch/Switch.types.ts +1 -1
- package/src/components/form/Textarea/Textarea.styles.ts +1 -0
- package/src/components/form/Textarea/Textarea.types.ts +1 -1
- package/src/components/form/Toggle/Toggle.styles.ts +1 -0
- package/src/components/form/Toggle/Toggle.types.ts +1 -1
- package/src/components/form/ToggleGroup/ToggleGroup.tsx +1 -0
- package/src/components/form/ToggleGroup/ToggleGroup.types.ts +1 -1
- package/src/components/form/index.ts +1 -0
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.styles.tsx +6 -3
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.types.ts +1 -1
- package/src/components/navigation/ContextMenu/ContextMenu.types.ts +1 -1
- package/src/components/navigation/DropdownMenu/DropdownMenu.types.ts +1 -1
- package/src/components/navigation/NavigationMenu/NavigationMenu.types.ts +1 -1
- package/src/components/navigation/Pagination/Pagination.types.ts +1 -1
- package/src/components/navigation/Tabs/Tabs.types.ts +1 -1
- package/src/components/typography/Blockquote/Blockquote.tsx +1 -0
- package/src/components/typography/Blockquote/Blockquote.types.ts +1 -1
- package/src/components/typography/Code/Code.types.ts +1 -1
- package/src/components/typography/Heading/Heading.styles.ts +23 -7
- package/src/components/typography/Heading/Heading.types.ts +1 -1
- package/src/components/typography/Paragraph/Paragraph.tsx +1 -0
- package/src/components/typography/Paragraph/Paragraph.types.ts +1 -1
- package/src/components/typography/Text/Text.tsx +11 -5
- package/src/components/typography/Text/Text.types.ts +1 -1
- package/src/components/utility/AnimatedIcon/AnimatedIcon.styles.ts +1 -0
- package/src/components/utility/AnimatedIcon/AnimatedIcon.types.ts +1 -1
- package/src/components/utility/WorkflowStep/WorkflowStep.types.ts +1 -1
- package/src/tokens/scales.ts +58 -7
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The quick-pick column beside the calendar.
|
|
3
|
+
*
|
|
4
|
+
* These are BUTTONS, not links or list rows: each one sets the range and the
|
|
5
|
+
* pressed one stays visibly pressed, which is what `aria-pressed` says out
|
|
6
|
+
* loud. (A listbox with `aria-selected` would model it too — the rule is to
|
|
7
|
+
* pick one and use it for every entry, so a reader's screen reader describes
|
|
8
|
+
* the whole column the same way.)
|
|
9
|
+
*
|
|
10
|
+
* An over-cap entry is `aria-disabled` and keeps its place in the tab order,
|
|
11
|
+
* with the reason rendered under the label. Removing it, or quietly shortening
|
|
12
|
+
* the window it hands back, both answer "why can't I have this year?" with
|
|
13
|
+
* silence.
|
|
14
|
+
*/
|
|
15
|
+
import { Box } from '@mui/material';
|
|
16
|
+
import type { SxProps, Theme } from '@mui/material';
|
|
17
|
+
import React, { useId } from 'react';
|
|
18
|
+
|
|
19
|
+
import type { ResolvedQuickRange } from './DateRangePicker.types';
|
|
20
|
+
|
|
21
|
+
const optionSx = (active: boolean, overMax: boolean): SxProps<Theme> => ({
|
|
22
|
+
display: 'block',
|
|
23
|
+
// Full-width rows in the side column; chips that flow onto as many lines as
|
|
24
|
+
// they need once the column has become a row under the calendar. A fixed
|
|
25
|
+
// full width in both would make the narrow layout nine stacked lines.
|
|
26
|
+
width: { xs: 'auto', md: '100%' },
|
|
27
|
+
textAlign: 'left',
|
|
28
|
+
font: 'inherit',
|
|
29
|
+
fontSize: '0.875rem',
|
|
30
|
+
fontWeight: active ? 600 : 400,
|
|
31
|
+
lineHeight: 1.4,
|
|
32
|
+
px: 1.5,
|
|
33
|
+
py: 0.75,
|
|
34
|
+
border: '1px solid',
|
|
35
|
+
borderColor: active ? 'primary.main' : 'transparent',
|
|
36
|
+
borderRadius: 1,
|
|
37
|
+
cursor: overMax ? 'not-allowed' : 'pointer',
|
|
38
|
+
color: overMax ? 'text.disabled' : active ? 'primary.main' : 'text.primary',
|
|
39
|
+
backgroundColor: active ? 'action.selected' : 'transparent',
|
|
40
|
+
'&:hover': { backgroundColor: overMax ? 'transparent' : 'action.hover' },
|
|
41
|
+
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: 1 },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const REASON_SX: SxProps<Theme> = {
|
|
45
|
+
display: 'block',
|
|
46
|
+
fontSize: '0.6875rem',
|
|
47
|
+
lineHeight: 1.3,
|
|
48
|
+
mt: 0.25,
|
|
49
|
+
color: 'text.disabled',
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface DateRangeQuickListProps {
|
|
53
|
+
options: ResolvedQuickRange[];
|
|
54
|
+
/** The entry the current draft equals, if any. */
|
|
55
|
+
activeId: string | undefined;
|
|
56
|
+
onPick: (option: ResolvedQuickRange) => void;
|
|
57
|
+
/** Why an entry is refused, rendered under it. */
|
|
58
|
+
reasonFor: (option: ResolvedQuickRange) => string;
|
|
59
|
+
/** Accessible name of the group. */
|
|
60
|
+
label: string;
|
|
61
|
+
dataTestId: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function DateRangeQuickList({
|
|
65
|
+
options,
|
|
66
|
+
activeId,
|
|
67
|
+
onPick,
|
|
68
|
+
reasonFor,
|
|
69
|
+
label,
|
|
70
|
+
dataTestId,
|
|
71
|
+
}: DateRangeQuickListProps): React.JSX.Element {
|
|
72
|
+
const reasonPrefix = useId();
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Box
|
|
76
|
+
role="group"
|
|
77
|
+
aria-label={label}
|
|
78
|
+
data-testid={`${dataTestId}-quick-list`}
|
|
79
|
+
sx={{
|
|
80
|
+
display: 'flex',
|
|
81
|
+
// A column beside the calendar; a wrapping row above it once there is
|
|
82
|
+
// no width for both. Checked at 390px: the calendar keeps its full
|
|
83
|
+
// 280px grid and the entries flow onto as many lines as they need.
|
|
84
|
+
flexDirection: { xs: 'row', md: 'column' },
|
|
85
|
+
flexWrap: 'wrap',
|
|
86
|
+
alignContent: 'flex-start',
|
|
87
|
+
gap: 0.5,
|
|
88
|
+
minWidth: { md: 168 },
|
|
89
|
+
maxWidth: { md: 200 },
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{options.map((option) => {
|
|
93
|
+
const active = option.id === activeId;
|
|
94
|
+
const reasonId = `${reasonPrefix}-${option.id}`;
|
|
95
|
+
return (
|
|
96
|
+
<Box
|
|
97
|
+
component="button"
|
|
98
|
+
type="button"
|
|
99
|
+
key={option.id}
|
|
100
|
+
aria-pressed={active}
|
|
101
|
+
// `aria-disabled`, never the `disabled` attribute: a disabled button
|
|
102
|
+
// is skipped by the tab order, so the reason under it is unreachable
|
|
103
|
+
// by exactly the reader most likely to need it.
|
|
104
|
+
aria-disabled={option.overMax || undefined}
|
|
105
|
+
aria-describedby={option.overMax ? reasonId : undefined}
|
|
106
|
+
data-testid={`${dataTestId}-quick-${option.id}`}
|
|
107
|
+
onClick={() => {
|
|
108
|
+
if (!option.overMax) onPick(option);
|
|
109
|
+
}}
|
|
110
|
+
sx={optionSx(active, option.overMax)}
|
|
111
|
+
>
|
|
112
|
+
{option.label}
|
|
113
|
+
{option.overMax && (
|
|
114
|
+
<Box component="span" id={reasonId} sx={REASON_SX}>
|
|
115
|
+
{reasonFor(option)}
|
|
116
|
+
</Box>
|
|
117
|
+
)}
|
|
118
|
+
</Box>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</Box>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { DateRangePicker, DateRangePicker as default } from './DateRangePicker';
|
|
2
|
+
export {
|
|
3
|
+
createQuickRanges,
|
|
4
|
+
DEFAULT_QUICK_RANGE_LABELS,
|
|
5
|
+
DEFAULT_QUICK_RANGES,
|
|
6
|
+
QUICK_RANGE_IDS,
|
|
7
|
+
type QuickRangeId,
|
|
8
|
+
} from './DateRangePicker.quick';
|
|
9
|
+
// The verdict the picker shows, exported so a caller's confirm button reads the
|
|
10
|
+
// SAME answer instead of re-deriving it from the two strings.
|
|
11
|
+
export { resolveDayRange } from './DateRangePicker.range';
|
|
12
|
+
export type {
|
|
13
|
+
DateRangeChangeMeta,
|
|
14
|
+
DateRangeDraft,
|
|
15
|
+
DateRangePickerMessages,
|
|
16
|
+
DateRangePickerProps,
|
|
17
|
+
DateRangeProblem,
|
|
18
|
+
DateRangeStatus,
|
|
19
|
+
DayString,
|
|
20
|
+
DayWindow,
|
|
21
|
+
QuickRange,
|
|
22
|
+
QuickRangeContext,
|
|
23
|
+
ResolvedQuickRange,
|
|
24
|
+
WeekStart,
|
|
25
|
+
} from './DateRangePicker.types';
|
|
@@ -34,10 +34,14 @@ export const muiVariantFor = (variant: InputVariant): 'outlined' | 'filled' | 's
|
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
// MUI gives a text field two heights, so the five house stops are drawn as two
|
|
38
|
+
// plus padding: `xs` tightens below small, `lg`/`xl` open up above medium.
|
|
37
39
|
export const SIZE_MAP = {
|
|
40
|
+
xs: { size: 'small' as const, sx: { '& .MuiInputBase-input': { padding: '6px 10px' } } },
|
|
38
41
|
sm: { size: 'small' as const },
|
|
39
42
|
md: { size: 'medium' as const },
|
|
40
43
|
lg: { size: 'medium' as const, sx: { '& .MuiInputBase-input': { padding: '16px 14px' } } },
|
|
44
|
+
xl: { size: 'medium' as const, sx: { '& .MuiInputBase-input': { padding: '20px 16px' } } },
|
|
41
45
|
};
|
|
42
46
|
|
|
43
47
|
export const glowStyles = (theme: Theme): CSSObject => ({
|
|
@@ -4,7 +4,7 @@ import type { InputHTMLAttributes, ReactNode } from 'react';
|
|
|
4
4
|
import type React from 'react';
|
|
5
5
|
|
|
6
6
|
export type InputVariant = 'outlined' | 'filled' | 'glass' | 'underline' | 'gradient';
|
|
7
|
-
export type InputSize =
|
|
7
|
+
export type InputSize = SizeValue;
|
|
8
8
|
|
|
9
9
|
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'color'> {
|
|
10
10
|
variant?: InputVariant;
|
|
@@ -38,7 +38,7 @@ export interface SelectProps extends Omit<MuiSelectProps, 'variant' | 'size'> {
|
|
|
38
38
|
* Size of the select component
|
|
39
39
|
* @default 'medium'
|
|
40
40
|
*/
|
|
41
|
-
size?:
|
|
41
|
+
size?: SizeValue;
|
|
42
42
|
/**
|
|
43
43
|
* Placeholder text when no option is selected
|
|
44
44
|
*/
|