@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.
- package/components-catalog.md +8 -0
- package/package.json +2 -1
- package/src/components/data-display/DataViews/data-views-day-input.tsx +43 -22
- 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 +250 -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 +144 -0
- package/src/components/form/DateRangePicker/index.ts +25 -0
- package/src/components/form/index.ts +1 -0
- package/src/components/navigation/Breadcrumbs/BreadcrumbEntry.tsx +7 -6
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.types.ts +12 -1
|
@@ -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,144 @@
|
|
|
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; pills in the narrow row. A fixed full
|
|
24
|
+
// width in both would make the narrow layout nine stacked lines.
|
|
25
|
+
width: { xs: 'auto', md: '100%' },
|
|
26
|
+
// A pill must not be squeezed by its neighbours — the row SCROLLS rather
|
|
27
|
+
// than compressing "Este trimestre" into two characters and an ellipsis.
|
|
28
|
+
flex: { xs: '0 0 auto', md: '0 1 auto' },
|
|
29
|
+
textAlign: 'left',
|
|
30
|
+
font: 'inherit',
|
|
31
|
+
fontSize: '0.875rem',
|
|
32
|
+
fontWeight: active ? 600 : 400,
|
|
33
|
+
lineHeight: 1.4,
|
|
34
|
+
px: 1.5,
|
|
35
|
+
py: 0.75,
|
|
36
|
+
whiteSpace: { xs: 'nowrap', md: 'normal' },
|
|
37
|
+
border: '1px solid',
|
|
38
|
+
// Narrow, an entry has to look tappable on its own, with no column and no
|
|
39
|
+
// rule to group it — so the pill carries a visible edge. Beside the calendar
|
|
40
|
+
// the group already reads as a list and a border on every row would be nine
|
|
41
|
+
// boxes where the selected one should be the only marked thing.
|
|
42
|
+
borderColor: active ? 'primary.main' : { xs: 'divider', md: 'transparent' },
|
|
43
|
+
borderRadius: { xs: 999, md: 1 },
|
|
44
|
+
cursor: overMax ? 'not-allowed' : 'pointer',
|
|
45
|
+
color: overMax ? 'text.disabled' : active ? 'primary.main' : 'text.primary',
|
|
46
|
+
backgroundColor: active ? 'action.selected' : 'transparent',
|
|
47
|
+
'&:hover': { backgroundColor: overMax ? 'transparent' : 'action.hover' },
|
|
48
|
+
'&:focus-visible': { outline: '2px solid', outlineColor: 'primary.main', outlineOffset: 1 },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const REASON_SX: SxProps<Theme> = {
|
|
52
|
+
display: 'block',
|
|
53
|
+
fontSize: '0.6875rem',
|
|
54
|
+
lineHeight: 1.3,
|
|
55
|
+
mt: 0.25,
|
|
56
|
+
color: 'text.disabled',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export interface DateRangeQuickListProps {
|
|
60
|
+
options: ResolvedQuickRange[];
|
|
61
|
+
/** The entry the current draft equals, if any. */
|
|
62
|
+
activeId: string | undefined;
|
|
63
|
+
onPick: (option: ResolvedQuickRange) => void;
|
|
64
|
+
/** Why an entry is refused, rendered under it. */
|
|
65
|
+
reasonFor: (option: ResolvedQuickRange) => string;
|
|
66
|
+
/** Accessible name of the group. */
|
|
67
|
+
label: string;
|
|
68
|
+
dataTestId: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function DateRangeQuickList({
|
|
72
|
+
options,
|
|
73
|
+
activeId,
|
|
74
|
+
onPick,
|
|
75
|
+
reasonFor,
|
|
76
|
+
label,
|
|
77
|
+
dataTestId,
|
|
78
|
+
}: DateRangeQuickListProps): React.JSX.Element {
|
|
79
|
+
const reasonPrefix = useId();
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Box
|
|
83
|
+
role="group"
|
|
84
|
+
aria-label={label}
|
|
85
|
+
data-testid={`${dataTestId}-quick-list`}
|
|
86
|
+
sx={{
|
|
87
|
+
display: 'flex',
|
|
88
|
+
// A column beside the calendar; ONE scrolling row of pills above it
|
|
89
|
+
// once there is no width for both.
|
|
90
|
+
//
|
|
91
|
+
// Scrolling rather than wrapping: nine wrapped pills take three or four
|
|
92
|
+
// lines at 390px, which pushes the calendar most of a screen down and
|
|
93
|
+
// makes the quick ranges — the fastest way to answer — the thing you
|
|
94
|
+
// have to scroll past. One line keeps the calendar in view and costs a
|
|
95
|
+
// horizontal swipe, which is the gesture a chip row already invites.
|
|
96
|
+
flexDirection: { xs: 'row', md: 'column' },
|
|
97
|
+
flexWrap: { xs: 'nowrap', md: 'wrap' },
|
|
98
|
+
overflowX: { xs: 'auto', md: 'visible' },
|
|
99
|
+
// Room for the focus ring, which an `overflow` container would clip.
|
|
100
|
+
px: { xs: 0.25, md: 0 },
|
|
101
|
+
py: { xs: 0.25, md: 0 },
|
|
102
|
+
// The scrollbar is chrome on a phone and noise on a trackpad; the pills
|
|
103
|
+
// themselves are the affordance, and the last one sits half-cut at the
|
|
104
|
+
// edge to say the row continues.
|
|
105
|
+
scrollbarWidth: 'none',
|
|
106
|
+
'&::-webkit-scrollbar': { display: 'none' },
|
|
107
|
+
alignContent: 'flex-start',
|
|
108
|
+
gap: 0.5,
|
|
109
|
+
minWidth: { md: 168 },
|
|
110
|
+
maxWidth: { md: 200 },
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{options.map((option) => {
|
|
114
|
+
const active = option.id === activeId;
|
|
115
|
+
const reasonId = `${reasonPrefix}-${option.id}`;
|
|
116
|
+
return (
|
|
117
|
+
<Box
|
|
118
|
+
component="button"
|
|
119
|
+
type="button"
|
|
120
|
+
key={option.id}
|
|
121
|
+
aria-pressed={active}
|
|
122
|
+
// `aria-disabled`, never the `disabled` attribute: a disabled button
|
|
123
|
+
// is skipped by the tab order, so the reason under it is unreachable
|
|
124
|
+
// by exactly the reader most likely to need it.
|
|
125
|
+
aria-disabled={option.overMax || undefined}
|
|
126
|
+
aria-describedby={option.overMax ? reasonId : undefined}
|
|
127
|
+
data-testid={`${dataTestId}-quick-${option.id}`}
|
|
128
|
+
onClick={() => {
|
|
129
|
+
if (!option.overMax) onPick(option);
|
|
130
|
+
}}
|
|
131
|
+
sx={optionSx(active, option.overMax)}
|
|
132
|
+
>
|
|
133
|
+
{option.label}
|
|
134
|
+
{option.overMax && (
|
|
135
|
+
<Box component="span" id={reasonId} sx={REASON_SX}>
|
|
136
|
+
{reasonFor(option)}
|
|
137
|
+
</Box>
|
|
138
|
+
)}
|
|
139
|
+
</Box>
|
|
140
|
+
);
|
|
141
|
+
})}
|
|
142
|
+
</Box>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
@@ -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';
|
|
@@ -191,11 +191,12 @@ const EllipsisCrumb: React.FC<{ label: string; dataTestId?: string }> = ({
|
|
|
191
191
|
</Tooltip>
|
|
192
192
|
);
|
|
193
193
|
|
|
194
|
-
// The same conditional test id was written at each crumb variant.
|
|
194
|
+
// The same conditional test id was written at each crumb variant. An item that
|
|
195
|
+
// names its OWN id wins: see `BreadcrumbItem.dataTestId`.
|
|
195
196
|
const makeTestId =
|
|
196
197
|
(dataTestId?: string) =>
|
|
197
|
-
(suffix: string): string =>
|
|
198
|
-
dataTestId ? `${dataTestId}-${suffix}` : `breadcrumbs-${suffix}
|
|
198
|
+
(suffix: string, own?: string): string =>
|
|
199
|
+
own ?? (dataTestId ? `${dataTestId}-${suffix}` : `breadcrumbs-${suffix}`);
|
|
199
200
|
|
|
200
201
|
const ClickableCrumb: React.FC<{
|
|
201
202
|
item: BreadcrumbItem;
|
|
@@ -204,7 +205,7 @@ const ClickableCrumb: React.FC<{
|
|
|
204
205
|
size: string;
|
|
205
206
|
variant: string;
|
|
206
207
|
isActive: boolean;
|
|
207
|
-
testId: (suffix: string) => string;
|
|
208
|
+
testId: (suffix: string, own?: string) => string;
|
|
208
209
|
}> = ({ item, index, icon, size, variant, isActive, testId }) => (
|
|
209
210
|
<Tooltip title={item.tooltip || item.label} arrow disableHoverListener={!item.tooltip}>
|
|
210
211
|
<BreadcrumbLink
|
|
@@ -215,7 +216,7 @@ const ClickableCrumb: React.FC<{
|
|
|
215
216
|
active={isActive}
|
|
216
217
|
underline="none"
|
|
217
218
|
aria-label={item.ariaLabel || item.label}
|
|
218
|
-
data-testid={testId(`link-${index}
|
|
219
|
+
data-testid={testId(`link-${index}`, item.dataTestId)}
|
|
219
220
|
>
|
|
220
221
|
{icon}
|
|
221
222
|
<span>{item.label}</span>
|
|
@@ -283,7 +284,7 @@ export const BreadcrumbEntry: React.FC<{
|
|
|
283
284
|
size={size}
|
|
284
285
|
visualStyle={variant}
|
|
285
286
|
aria-current="page"
|
|
286
|
-
data-testid={testId(`item-${index}
|
|
287
|
+
data-testid={testId(`item-${index}`, item.dataTestId)}
|
|
287
288
|
>
|
|
288
289
|
{icon}
|
|
289
290
|
<span>{item.label}</span>
|
|
@@ -37,7 +37,18 @@ export interface BreadcrumbItem {
|
|
|
37
37
|
* Custom aria-label for accessibility
|
|
38
38
|
*/
|
|
39
39
|
ariaLabel?: string;
|
|
40
|
-
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Test id for THIS crumb, replacing the trail's positional
|
|
43
|
+
* `<dataTestId>-link-<index>` / `<dataTestId>-item-<index>`.
|
|
44
|
+
*
|
|
45
|
+
* A trail that takes over from an older affordance has to keep that
|
|
46
|
+
* affordance's id, and the positional one cannot: a crumb's index moves the
|
|
47
|
+
* moment the trail grows a step — which is exactly what happens when a
|
|
48
|
+
* screen gains an "editing" level.
|
|
49
|
+
*/
|
|
50
|
+
dataTestId?: string;
|
|
51
|
+
|
|
41
52
|
/**
|
|
42
53
|
* Internal flag for ellipsis items (used in collapsed behavior)
|
|
43
54
|
* @internal
|