@12-apps/ui 3.1.0 → 3.3.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 +1 -1
- package/src/components/data-display/Chart/Chart.tsx +1 -1
- package/src/components/data-display/Chart/Chart.types.ts +10 -0
- package/src/components/data-display/Chart/ChartComposables.tsx +10 -1
- package/src/components/data-display/Chart/SpecChart.tsx +4 -0
- package/src/components/data-display/DataViews/data-views-display-tabs.tsx +40 -15
- package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +7 -15
- package/src/components/data-display/DataViews/data-views-grid-helpers.tsx +21 -0
- package/src/components/data-display/DataViews/data-views-layout-context.tsx +24 -5
- package/src/components/form/DateRangePicker/DateRangePicker.tsx +154 -50
- package/src/components/form/DateRangePicker/DateRangePickerQuickList.tsx +30 -9
- package/src/components/navigation/Breadcrumbs/BreadcrumbEntry.tsx +7 -6
- package/src/components/navigation/Breadcrumbs/Breadcrumbs.types.ts +12 -1
package/package.json
CHANGED
|
@@ -93,7 +93,7 @@ export const Chart: React.FC<ChartProps> = (props) => {
|
|
|
93
93
|
<ChartContainer
|
|
94
94
|
responsive={props.responsive ?? true}
|
|
95
95
|
width={props.width ?? '100%'}
|
|
96
|
-
height={sizeStyles.height}
|
|
96
|
+
height={props.fillHeight === true ? '100%' : sizeStyles.height}
|
|
97
97
|
data-testid={`${dataTestId}-container`}
|
|
98
98
|
>
|
|
99
99
|
{renderChartByType(props, theme)}
|
|
@@ -83,6 +83,16 @@ export interface ChartProps {
|
|
|
83
83
|
maxCategoryTicks?: number;
|
|
84
84
|
/** Category tick labels longer than this are truncated with an ellipsis. Default 12. */
|
|
85
85
|
tickLabelMaxChars?: number;
|
|
86
|
+
/**
|
|
87
|
+
* Take the height of the box this chart is placed in, rather than the size
|
|
88
|
+
* preset's fixed one.
|
|
89
|
+
*
|
|
90
|
+
* For a host that has already sized the chart's container — a dashboard cell
|
|
91
|
+
* with a chosen height, say. The preset is a sensible default precisely
|
|
92
|
+
* because most callers have NOT sized anything, so this is opt-in: without
|
|
93
|
+
* it a chart in a box with no definite height would collapse to nothing.
|
|
94
|
+
*/
|
|
95
|
+
fillHeight?: boolean;
|
|
86
96
|
/** Bar corner radius in px (top corners only; stacked segments stay square). Default 3. */
|
|
87
97
|
barRadius?: number;
|
|
88
98
|
/**
|
|
@@ -15,7 +15,16 @@ export const ChartTooltip = Tooltip;
|
|
|
15
15
|
export const ChartLegend = Legend;
|
|
16
16
|
|
|
17
17
|
export interface ChartContainerProps {
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A number of pixels, or a PERCENTAGE of the box this container is given.
|
|
20
|
+
*
|
|
21
|
+
* The difference is not cosmetic: Recharts sizes the chart from the number
|
|
22
|
+
* when it is one and only MEASURES its own box when it is a percentage
|
|
23
|
+
* (`calculateChartDimensions`). So a container stretched by its parent's
|
|
24
|
+
* layout goes on drawing a 300px chart inside a 600px box — visibly, as
|
|
25
|
+
* blank space under the plot — until the height is handed over as a percent.
|
|
26
|
+
*/
|
|
27
|
+
height: number | `${number}%`;
|
|
19
28
|
width?: number | string;
|
|
20
29
|
responsive?: boolean;
|
|
21
30
|
children: React.ReactElement;
|
|
@@ -12,6 +12,8 @@ export interface SpecChartProps {
|
|
|
12
12
|
spec: ChartSpec;
|
|
13
13
|
data: ChartDataPoint[];
|
|
14
14
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
|
15
|
+
/** Take the height of the box this chart is placed in — see `ChartProps`. */
|
|
16
|
+
fillHeight?: boolean;
|
|
15
17
|
loading?: boolean;
|
|
16
18
|
className?: string;
|
|
17
19
|
onClick?: (point: ChartDataPoint) => void;
|
|
@@ -27,6 +29,7 @@ export const SpecChart: React.FC<SpecChartProps> = ({
|
|
|
27
29
|
spec,
|
|
28
30
|
data,
|
|
29
31
|
size,
|
|
32
|
+
fillHeight,
|
|
30
33
|
loading,
|
|
31
34
|
className,
|
|
32
35
|
onClick,
|
|
@@ -45,6 +48,7 @@ export const SpecChart: React.FC<SpecChartProps> = ({
|
|
|
45
48
|
<Chart
|
|
46
49
|
{...chartProps}
|
|
47
50
|
size={size}
|
|
51
|
+
fillHeight={fillHeight}
|
|
48
52
|
loading={loading}
|
|
49
53
|
className={className}
|
|
50
54
|
onClick={onClick}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
|
|
4
4
|
import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
|
|
5
|
+
import useMediaQuery from "@mui/material/useMediaQuery";
|
|
6
|
+
import type { Theme } from "@mui/material/styles";
|
|
5
7
|
|
|
6
8
|
import type { SortFieldDefinition } from "../../layout/ContentToolbar";
|
|
7
9
|
import { Box } from "../../../mui/Box";
|
|
@@ -286,9 +288,44 @@ const LAYOUT_TILE_LABELS: Record<DataViewsLayout, string> = {
|
|
|
286
288
|
* `data-views-glyphs.tsx` for why. A layout this table cannot render is simply
|
|
287
289
|
* not offered, and the panel says why rather than leaving a gap.
|
|
288
290
|
*/
|
|
291
|
+
/**
|
|
292
|
+
* The three density tiles — or, on a phone, a line saying why there are none:
|
|
293
|
+
* the card grid is `1fr` there, so all three would draw one card per row. Says
|
|
294
|
+
* so rather than vanishing, for the same reason as the board note below it.
|
|
295
|
+
*/
|
|
296
|
+
function DensityTiles({ testIdPrefix }: { testIdPrefix: string }): React.JSX.Element {
|
|
297
|
+
const { layout, density, setDensity } = useDataViewsLayout();
|
|
298
|
+
// Matches the card grid's own `xs: "1fr"` track, so the control and the thing
|
|
299
|
+
// it controls agree on where density stops meaning anything.
|
|
300
|
+
const isOneColumn = useMediaQuery((t: Theme) => t.breakpoints.down("sm"));
|
|
301
|
+
if (layout === "cards" && isOneColumn) {
|
|
302
|
+
return (
|
|
303
|
+
<Text variant="caption" as="p">
|
|
304
|
+
<Box component="span" sx={{ px: 0.5, pt: 0.5, display: "block", color: "text.disabled" }}>
|
|
305
|
+
Um card por linha nesta largura — a densidade volta em telas maiores.
|
|
306
|
+
</Box>
|
|
307
|
+
</Text>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return (
|
|
311
|
+
<Box sx={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: 0.75, mt: 0.5 }}>
|
|
312
|
+
{densityTiles(layout).map((tile) => (
|
|
313
|
+
<Tile
|
|
314
|
+
key={tile.value}
|
|
315
|
+
active={density === tile.value}
|
|
316
|
+
label={tile.label}
|
|
317
|
+
onClick={() => setDensity(tile.value)}
|
|
318
|
+
testId={`${testIdPrefix}-density-${tile.value}`}
|
|
319
|
+
>
|
|
320
|
+
{densityGlyph(layout, tile, density === tile.value)}
|
|
321
|
+
</Tile>
|
|
322
|
+
))}
|
|
323
|
+
</Box>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
289
327
|
export function DisplayTab({ testIdPrefix }: { testIdPrefix: string }): React.JSX.Element {
|
|
290
|
-
const { layout, setLayout, canUseCards, canUseList, canUseBoard
|
|
291
|
-
useDataViewsLayout();
|
|
328
|
+
const { layout, setLayout, canUseCards, canUseList, canUseBoard } = useDataViewsLayout();
|
|
292
329
|
const available: DataViewsLayout[] = [
|
|
293
330
|
"table",
|
|
294
331
|
...(canUseList ? (["list"] as const) : []),
|
|
@@ -336,19 +373,7 @@ export function DisplayTab({ testIdPrefix }: { testIdPrefix: string }): React.JS
|
|
|
336
373
|
{DENSITY_HEADINGS[layout]}
|
|
337
374
|
</Box>
|
|
338
375
|
</Text>
|
|
339
|
-
<
|
|
340
|
-
{densityTiles(layout).map((tile) => (
|
|
341
|
-
<Tile
|
|
342
|
-
key={tile.value}
|
|
343
|
-
active={density === tile.value}
|
|
344
|
-
label={tile.label}
|
|
345
|
-
onClick={() => setDensity(tile.value)}
|
|
346
|
-
testId={`${testIdPrefix}-density-${tile.value}`}
|
|
347
|
-
>
|
|
348
|
-
{densityGlyph(layout, tile, density === tile.value)}
|
|
349
|
-
</Tile>
|
|
350
|
-
))}
|
|
351
|
-
</Box>
|
|
376
|
+
<DensityTiles testIdPrefix={testIdPrefix} />
|
|
352
377
|
{!canUseBoard && (
|
|
353
378
|
<Text variant="caption" as="p">
|
|
354
379
|
<Box component="span" sx={{ px: 0.5, pt: 1.25, display: "block", color: "text.disabled" }}>
|
|
@@ -6,14 +6,14 @@ import { DataGrid, type GridColumn, type GridSort } from "../DataGrid";
|
|
|
6
6
|
import { Box } from "../../../mui/Box";
|
|
7
7
|
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
cardTargetWidthFor,
|
|
10
10
|
cardScaleForZoom,
|
|
11
11
|
DENSITY_BOARD_SCALE,
|
|
12
|
-
DENSITY_CARD_COLUMNS,
|
|
13
12
|
DENSITY_ROW_PADDING,
|
|
14
13
|
useDataViewsLayout,
|
|
15
14
|
type DataViewsDensity,
|
|
16
15
|
} from "./data-views-layout-context";
|
|
16
|
+
import { cardGridTracks } from "./data-views-grid-helpers";
|
|
17
17
|
import { DataViewsBoard, type BoardConfig } from "./DataViewsBoard";
|
|
18
18
|
import { SelectAllStrip } from "./data-views-select-all-strip";
|
|
19
19
|
import { ListCardGroup, type ListGroupConfig } from "./list-card-rails";
|
|
@@ -90,9 +90,8 @@ interface CardBodyProps<T extends Record<string, unknown>> {
|
|
|
90
90
|
getRowId: (row: T) => string | number;
|
|
91
91
|
selectedIds: Set<string | number>;
|
|
92
92
|
onToggleId: (id: string | number) => void;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
maxColumns: number;
|
|
93
|
+
/** The width each card asks for; `auto-fill` turns it into a column count. */
|
|
94
|
+
targetCardWidth: number;
|
|
96
95
|
/** Content scale (padding + type) handed to each card, from the zoom slider. */
|
|
97
96
|
cardScale: number;
|
|
98
97
|
dataTestId?: string;
|
|
@@ -112,8 +111,7 @@ function CardBody<T extends Record<string, unknown>>({
|
|
|
112
111
|
getRowId,
|
|
113
112
|
selectedIds,
|
|
114
113
|
onToggleId,
|
|
115
|
-
|
|
116
|
-
maxColumns,
|
|
114
|
+
targetCardWidth,
|
|
117
115
|
cardScale,
|
|
118
116
|
dataTestId,
|
|
119
117
|
emptyState,
|
|
@@ -129,12 +127,7 @@ function CardBody<T extends Record<string, unknown>>({
|
|
|
129
127
|
// Fixed inter-card gap — deliberately NOT scaled by the zoom slider, so
|
|
130
128
|
// only the cards grow while the space between them stays constant.
|
|
131
129
|
gap: 1.5,
|
|
132
|
-
|
|
133
|
-
// ALLOWED, so "Poucos" reads as bigger cards rather than more of them.
|
|
134
|
-
gridTemplateColumns: {
|
|
135
|
-
xs: "1fr",
|
|
136
|
-
sm: `repeat(auto-fill, minmax(max(${minCardWidth}px, calc((100% - ${(maxColumns - 1) * 12}px) / ${maxColumns})), 1fr))`,
|
|
137
|
-
},
|
|
130
|
+
...cardGridTracks(targetCardWidth),
|
|
138
131
|
}}
|
|
139
132
|
data-testid={dataTestId ? `${dataTestId}-cards` : "data-views-cards"}
|
|
140
133
|
>
|
|
@@ -353,8 +346,7 @@ function headerlessBody<T extends Record<string, unknown>>(
|
|
|
353
346
|
getRowId={getRowId}
|
|
354
347
|
selectedIds={c.selectedIds}
|
|
355
348
|
onToggleId={c.toggleId}
|
|
356
|
-
|
|
357
|
-
maxColumns={DENSITY_CARD_COLUMNS[density]}
|
|
349
|
+
targetCardWidth={cardTargetWidthFor(zoom, density)}
|
|
358
350
|
cardScale={cardScaleForZoom(zoom)}
|
|
359
351
|
dataTestId={dataTestId}
|
|
360
352
|
emptyState={emptyState}
|
|
@@ -246,3 +246,24 @@ export function togglePillValues(current: string[] | undefined, value: string, c
|
|
|
246
246
|
const list = current ?? [];
|
|
247
247
|
return checked ? [...list, value] : list.filter((v) => v !== value);
|
|
248
248
|
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The card grid's `grid-template-columns`, as an sx fragment.
|
|
252
|
+
*
|
|
253
|
+
* One rule at every width: each card asks for `targetWidth` and `auto-fill`
|
|
254
|
+
* decides how many fit. Density moves the target, so it changes the count at
|
|
255
|
+
* every size — which the previous column-count cap could not do, because its
|
|
256
|
+
* track floor bottomed out at a constant shared by all three densities.
|
|
257
|
+
*
|
|
258
|
+
* Below `sm` this is a single column, and no target changes that. That is why
|
|
259
|
+
* the density control hides itself there rather than offering three settings
|
|
260
|
+
* that all draw the same thing.
|
|
261
|
+
*/
|
|
262
|
+
export function cardGridTracks(targetWidth: number) {
|
|
263
|
+
return {
|
|
264
|
+
gridTemplateColumns: {
|
|
265
|
+
xs: "1fr",
|
|
266
|
+
sm: `repeat(auto-fill, minmax(${targetWidth}px, 1fr))`,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -81,13 +81,32 @@ export const DENSITY_ROW_PADDING: Record<DataViewsDensity, number> = {
|
|
|
81
81
|
comfortable: 1.5,
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The card WIDTH each density aims for, as a multiple of the base card width.
|
|
86
|
+
*
|
|
87
|
+
* Density used to cap the column COUNT, with a track floor of
|
|
88
|
+
* `max(baseWidth, 100% / count)`. That base is a constant, so wherever it won
|
|
89
|
+
* the arithmetic every density produced an identical track — "Muitos" and
|
|
90
|
+
* "Medio" both drew 3 cards of 279px at md — and where the percentage won
|
|
91
|
+
* instead, on a wide screen, the count was pinned and each card grew to a share
|
|
92
|
+
* of the viewport: ~430px of card holding one chip and two lines of text.
|
|
93
|
+
*
|
|
94
|
+
* One width per density fixes both, because the number `auto-fill` divides by
|
|
95
|
+
* now differs per density at every size. The spread is chosen so the three land
|
|
96
|
+
* on DIFFERENT column counts across the range rather than by eye — 4/3/2 at md,
|
|
97
|
+
* 6/4/3 at lg, 7/5/4 at xl, 13/9/8 at 2560.
|
|
98
|
+
*/
|
|
99
|
+
export const DENSITY_CARD_SCALE: Record<DataViewsDensity, number> = {
|
|
100
|
+
compact: 0.84,
|
|
101
|
+
cozy: 1.15,
|
|
102
|
+
comfortable: 1.4,
|
|
89
103
|
};
|
|
90
104
|
|
|
105
|
+
/** The width a card aims for: the base width, scaled by zoom, then by density. */
|
|
106
|
+
export function cardTargetWidthFor(zoom: number, density: DataViewsDensity): number {
|
|
107
|
+
return Math.round(cardMinWidthForZoom(zoom) * DENSITY_CARD_SCALE[density]);
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
/**
|
|
92
111
|
* The BOARD's column-width multiplier per density.
|
|
93
112
|
*
|
|
@@ -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
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
selectionMode="range"
|
|
225
|
+
<RangeCalendar
|
|
226
|
+
picker={picker}
|
|
227
|
+
value={value}
|
|
102
228
|
locale={locale}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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;
|
|
24
|
-
//
|
|
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
|
-
|
|
36
|
-
|
|
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;
|
|
82
|
-
// no width for both.
|
|
83
|
-
//
|
|
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
|