@12-apps/ui 3.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -22,7 +22,7 @@
22
22
  * finished window, so a half-picked or impossible range has somewhere to live
23
23
  * while it is being fixed. Every `onChange` carries the verdict with it.
24
24
  */
25
- import { Box, Paper } from '@mui/material';
25
+ import { Box, Paper, useMediaQuery } from '@mui/material';
26
26
  import type { SxProps, Theme } from '@mui/material';
27
27
  import React from 'react';
28
28
 
@@ -31,12 +31,15 @@ import { Calendar } from '../Calendar';
31
31
  import { toLocalDay } from './DateRangePicker.dates';
32
32
  import { useDateRangePicker } from './DateRangePicker.hooks';
33
33
  import { DEFAULT_QUICK_RANGES } from './DateRangePicker.quick';
34
- import type { DateRangePickerProps } from './DateRangePicker.types';
34
+ import type { DateRangeDraft, DateRangePickerProps, WeekStart } from './DateRangePicker.types';
35
35
  import { DateRangeFields } from './DateRangePickerFields';
36
36
  import { DateRangeQuickList } from './DateRangePickerQuickList';
37
37
 
38
38
  const CALENDAR_CLASS = 'date-range-picker__calendar';
39
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
+
40
43
  const ROOT_SX: SxProps<Theme> = {
41
44
  display: 'inline-flex',
42
45
  flexDirection: 'column',
@@ -56,17 +59,140 @@ const ROOT_SX: SxProps<Theme> = {
56
59
 
57
60
  const BODY_SX: SxProps<Theme> = {
58
61
  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',
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' },
65
73
  gap: 2,
66
74
  maxWidth: '100%',
67
- overflowX: 'auto',
68
75
  };
69
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
+
70
196
  export function DateRangePicker({
71
197
  value,
72
198
  onChange,
@@ -91,54 +217,32 @@ export function DateRangePicker({
91
217
  quickRanges,
92
218
  messages,
93
219
  });
220
+ const months = useMonthCount(numberOfMonths);
94
221
 
95
222
  return (
96
223
  <Paper className={className} data-testid={dataTestId} sx={ROOT_SX}>
97
224
  <Box sx={BODY_SX}>
98
- <Calendar
99
- key={picker.viewNonce}
100
- className={CALENDAR_CLASS}
101
- selectionMode="range"
225
+ <RangeCalendar
226
+ picker={picker}
227
+ value={value}
102
228
  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}
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}
116
244
  />
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
245
  </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
246
  </Paper>
143
247
  );
144
248
  }
@@ -20,10 +20,12 @@ import type { ResolvedQuickRange } from './DateRangePicker.types';
20
20
 
21
21
  const optionSx = (active: boolean, overMax: boolean): SxProps<Theme> => ({
22
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.
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.
26
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' },
27
29
  textAlign: 'left',
28
30
  font: 'inherit',
29
31
  fontSize: '0.875rem',
@@ -31,9 +33,14 @@ const optionSx = (active: boolean, overMax: boolean): SxProps<Theme> => ({
31
33
  lineHeight: 1.4,
32
34
  px: 1.5,
33
35
  py: 0.75,
36
+ whiteSpace: { xs: 'nowrap', md: 'normal' },
34
37
  border: '1px solid',
35
- borderColor: active ? 'primary.main' : 'transparent',
36
- borderRadius: 1,
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 },
37
44
  cursor: overMax ? 'not-allowed' : 'pointer',
38
45
  color: overMax ? 'text.disabled' : active ? 'primary.main' : 'text.primary',
39
46
  backgroundColor: active ? 'action.selected' : 'transparent',
@@ -78,11 +85,25 @@ export function DateRangeQuickList({
78
85
  data-testid={`${dataTestId}-quick-list`}
79
86
  sx={{
80
87
  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.
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.
84
96
  flexDirection: { xs: 'row', md: 'column' },
85
- flexWrap: 'wrap',
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' },
86
107
  alignContent: 'flex-start',
87
108
  gap: 0.5,
88
109
  minWidth: { md: 168 },
@@ -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