@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,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';
@@ -7,6 +7,7 @@ export * from './CepField';
7
7
  export * from './Checkbox';
8
8
  export * from './CodeEditor';
9
9
  export * from './Command';
10
+ export * from './DateRangePicker';
10
11
  export * from './Form';
11
12
  export * from './Input';
12
13
  export * from './InputOTP';
@@ -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