@micha.bigler/ui-core-micha 2.29.2 → 2.31.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/DESIGN.md +5 -5
- package/WORK_ORDERS.md +6 -0
- package/dev/entries.jsx +167 -1
- package/dev/main.jsx +10 -3
- package/dist/components/charts/BarChart.js +8 -6
- package/dist/components/charts/LineChart.js +23 -6
- package/dist/components/charts/TimeSeriesChart.js +12 -3
- package/dist/components/charts/chartDefaults.js +80 -0
- package/dist/components/charts/chartLabels.js +31 -0
- package/dist/components/charts/palette.js +7 -3
- package/dist/components/charts/yearTickInterval.js +7 -0
- package/dist/index.js +4 -0
- package/dist/theme/createAppTheme.js +168 -0
- package/dist/theme/index.js +4 -0
- package/dist/theme/themeCompleteness.js +320 -0
- package/dist/theme/tokens.js +325 -0
- package/package.json +4 -1
- package/src/components/charts/BarChart.jsx +37 -9
- package/src/components/charts/LineChart.jsx +61 -9
- package/src/components/charts/TimeSeriesChart.jsx +21 -0
- package/src/components/charts/chartDefaults.js +111 -0
- package/src/components/charts/chartLabels.js +36 -0
- package/src/components/charts/palette.js +6 -3
- package/src/components/charts/yearTickInterval.js +7 -0
- package/src/index.js +16 -0
- package/src/theme/createAppTheme.js +212 -0
- package/src/theme/index.js +14 -0
- package/src/theme/themeCompleteness.js +355 -0
- package/src/theme/tokens.js +376 -0
- package/tests/BarChart.test.jsx +4 -1
- package/tests/LineChart.test.jsx +5 -2
- package/tests/chartDefaults.test.js +177 -0
- package/tests/chartLabels.test.js +50 -0
- package/tests/chartsPalette.test.js +40 -0
- package/tests/createAppTheme.test.js +103 -0
- package/tests/themeCompleteness.test.js +63 -0
- package/tests/timeSeriesLegend.test.js +99 -0
- package/work-orders/CHART-6.md +428 -0
- package/work-orders/SHELL-2.md +197 -0
- package/work-orders/THEME-1.md +587 -0
- package/work-orders/assets/SHELL-2-account-navigation.html +1159 -0
- package/work-orders/assets/THEME-1-baseline-reference-sheet.html +1352 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { darken, lighten } from '@mui/material/styles';
|
|
2
|
+
|
|
3
|
+
// MUI's palette augmentation (createPalette -> augmentColor) only runs on the
|
|
4
|
+
// FIRST argument passed to createTheme; every later argument -- which is how
|
|
5
|
+
// createAppTheme layers BASELINE_PALETTE on top -- is deep-merged as a raw
|
|
6
|
+
// object with no augmentation. A status entry missing `main`/`contrastText`
|
|
7
|
+
// therefore silently keeps MUI's stock hue for `color="success"` etc. even
|
|
8
|
+
// though `text`/`fill`/`bg` are set correctly, so every status channel MUI
|
|
9
|
+
// recognises (success/warning/error/info) must carry its own explicit
|
|
10
|
+
// main/light/dark/contrastText, computed the same way augmentColor would.
|
|
11
|
+
const withMainShades = (main, contrastText) => ({
|
|
12
|
+
main,
|
|
13
|
+
light: lighten(main, 0.2),
|
|
14
|
+
dark: darken(main, 0.3),
|
|
15
|
+
contrastText,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const SERIES_COLOURS = [
|
|
19
|
+
'#3D5A99',
|
|
20
|
+
'#3E80B8',
|
|
21
|
+
'#2E8F8A',
|
|
22
|
+
'#7A5FA8',
|
|
23
|
+
'#9C4F86',
|
|
24
|
+
'#8A7355',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const STATUS_KEYS = ['success', 'warning', 'error', 'info', 'stale'];
|
|
28
|
+
|
|
29
|
+
export const TYPOGRAPHY_VARIANTS = [
|
|
30
|
+
'h1',
|
|
31
|
+
'h2',
|
|
32
|
+
'h3',
|
|
33
|
+
'h4',
|
|
34
|
+
'h5',
|
|
35
|
+
'h6',
|
|
36
|
+
'subtitle1',
|
|
37
|
+
'subtitle2',
|
|
38
|
+
'body1',
|
|
39
|
+
'body2',
|
|
40
|
+
'button',
|
|
41
|
+
'caption',
|
|
42
|
+
'overline',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const typeVariant = (fontSize, fontWeight, lineHeight, extra = {}) => ({
|
|
46
|
+
fontSize,
|
|
47
|
+
fontWeight,
|
|
48
|
+
lineHeight,
|
|
49
|
+
letterSpacing: 0,
|
|
50
|
+
...extra,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
export const BASELINE_PALETTE = {
|
|
54
|
+
ink: {
|
|
55
|
+
primary: '#212529',
|
|
56
|
+
secondary: '#5B6670',
|
|
57
|
+
muted: '#6A7178',
|
|
58
|
+
},
|
|
59
|
+
text: {
|
|
60
|
+
primary: '#212529',
|
|
61
|
+
secondary: '#5B6670',
|
|
62
|
+
disabled: '#6A7178',
|
|
63
|
+
},
|
|
64
|
+
background: {
|
|
65
|
+
default: '#FAFAFA',
|
|
66
|
+
paper: '#FFFFFF',
|
|
67
|
+
},
|
|
68
|
+
divider: 'rgba(33,37,41,.10)',
|
|
69
|
+
controlBorder: {
|
|
70
|
+
main: 'rgba(33,37,41,.50)',
|
|
71
|
+
hover: 'rgba(33,37,41,.65)',
|
|
72
|
+
error: '#BF3227',
|
|
73
|
+
},
|
|
74
|
+
success: {
|
|
75
|
+
...withMainShades('#1B8038', '#FFFFFF'),
|
|
76
|
+
text: '#35794A',
|
|
77
|
+
fill: '#1B8038',
|
|
78
|
+
fillText: '#FFFFFF',
|
|
79
|
+
bg: '#E5F4E9',
|
|
80
|
+
},
|
|
81
|
+
warning: {
|
|
82
|
+
...withMainShades('#C08A2C', '#212529'),
|
|
83
|
+
text: '#976100',
|
|
84
|
+
fill: '#C08A2C',
|
|
85
|
+
fillText: '#212529',
|
|
86
|
+
bg: '#FBF0DC',
|
|
87
|
+
},
|
|
88
|
+
error: {
|
|
89
|
+
...withMainShades('#BF3227', '#FFFFFF'),
|
|
90
|
+
text: '#BF3227',
|
|
91
|
+
fill: '#BF3227',
|
|
92
|
+
fillText: '#FFFFFF',
|
|
93
|
+
bg: '#FBEAE8',
|
|
94
|
+
},
|
|
95
|
+
// The canonical sheet introduces no new info hue. These values reuse
|
|
96
|
+
// MUI's own info.dark/main family while adding the shared status shape.
|
|
97
|
+
info: {
|
|
98
|
+
...withMainShades('#01579B', '#FFFFFF'),
|
|
99
|
+
text: '#01579B',
|
|
100
|
+
fill: '#01579B',
|
|
101
|
+
fillText: '#FFFFFF',
|
|
102
|
+
bg: '#E5F6FD',
|
|
103
|
+
},
|
|
104
|
+
stale: {
|
|
105
|
+
text: '#5B6B7D',
|
|
106
|
+
fill: '#5B6B7D',
|
|
107
|
+
fillText: '#FFFFFF',
|
|
108
|
+
bg: '#EAEDF1',
|
|
109
|
+
},
|
|
110
|
+
dataSeries: {
|
|
111
|
+
categorical: SERIES_COLOURS,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export const OVERLAY_SHADOW =
|
|
116
|
+
'0 8px 24px rgba(20,26,31,.16), 0 2px 8px rgba(20,26,31,.08)';
|
|
117
|
+
|
|
118
|
+
export const MOTION = {
|
|
119
|
+
duration: {
|
|
120
|
+
fast: 120,
|
|
121
|
+
base: 180,
|
|
122
|
+
overlayEnter: 220,
|
|
123
|
+
overlayExit: 180,
|
|
124
|
+
chart: 300,
|
|
125
|
+
},
|
|
126
|
+
easing: {
|
|
127
|
+
enter: 'cubic-bezier(0.0, 0, 0.2, 1)',
|
|
128
|
+
exit: 'cubic-bezier(0.4, 0, 1, 1)',
|
|
129
|
+
state: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
130
|
+
},
|
|
131
|
+
reduced: '0.01ms',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const coarseHitArea = {
|
|
135
|
+
'@media (any-pointer: coarse)': {
|
|
136
|
+
minHeight: 44,
|
|
137
|
+
minWidth: 44,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const autofill = {
|
|
142
|
+
'&:-webkit-autofill': {
|
|
143
|
+
WebkitBoxShadow: '0 0 0 100px #FFFFFF inset',
|
|
144
|
+
WebkitTextFillColor: '#212529',
|
|
145
|
+
caretColor: '#212529',
|
|
146
|
+
},
|
|
147
|
+
'&:autofill': {
|
|
148
|
+
boxShadow: '0 0 0 100px #FFFFFF inset',
|
|
149
|
+
WebkitTextFillColor: '#212529',
|
|
150
|
+
caretColor: '#212529',
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export const BASELINE_STATIC = {
|
|
155
|
+
typography: {
|
|
156
|
+
fontFamily: "'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
|
157
|
+
h1: typeVariant('32px', 600, 1.2),
|
|
158
|
+
h2: typeVariant('28px', 600, 1.22),
|
|
159
|
+
h3: typeVariant('24px', 600, 1.25),
|
|
160
|
+
h4: typeVariant('20px', 600, 1.3),
|
|
161
|
+
h5: typeVariant('18px', 600, 1.3),
|
|
162
|
+
h6: typeVariant('16px', 600, 1.35),
|
|
163
|
+
subtitle1: typeVariant('15px', 600, 1.4),
|
|
164
|
+
subtitle2: typeVariant('13px', 500, 1.4),
|
|
165
|
+
body1: typeVariant('14px', 400, 1.55),
|
|
166
|
+
body2: typeVariant('13px', 400, 1.55),
|
|
167
|
+
button: typeVariant('14px', 500, 1.4, { textTransform: 'none' }),
|
|
168
|
+
caption: typeVariant('12px', 400, 1.4),
|
|
169
|
+
overline: typeVariant('11px', 600, 1.4, {
|
|
170
|
+
textTransform: 'uppercase',
|
|
171
|
+
letterSpacing: '0.4px',
|
|
172
|
+
}),
|
|
173
|
+
},
|
|
174
|
+
shape: {
|
|
175
|
+
borderRadius: 3,
|
|
176
|
+
},
|
|
177
|
+
radius: {
|
|
178
|
+
control: 3,
|
|
179
|
+
card: 8,
|
|
180
|
+
},
|
|
181
|
+
spacing: 8,
|
|
182
|
+
breakpoints: {
|
|
183
|
+
values: { xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 },
|
|
184
|
+
},
|
|
185
|
+
density: {
|
|
186
|
+
controlHeight: 40,
|
|
187
|
+
touchTarget: 44,
|
|
188
|
+
tableCellPadding: '10px 16px',
|
|
189
|
+
chip: { height: 32, radius: 16, fontSize: 13 },
|
|
190
|
+
containerGutter: { default: 24, wide: 32 },
|
|
191
|
+
},
|
|
192
|
+
fontLoading: {
|
|
193
|
+
family: 'DM Sans',
|
|
194
|
+
weights: [400, 500, 600],
|
|
195
|
+
source: '@fontsource/dm-sans',
|
|
196
|
+
},
|
|
197
|
+
shadow: {
|
|
198
|
+
rest: 'none',
|
|
199
|
+
overlay: OVERLAY_SHADOW,
|
|
200
|
+
},
|
|
201
|
+
motion: MOTION,
|
|
202
|
+
transitions: {
|
|
203
|
+
duration: {
|
|
204
|
+
fast: MOTION.duration.fast,
|
|
205
|
+
base: MOTION.duration.base,
|
|
206
|
+
overlayEnter: MOTION.duration.overlayEnter,
|
|
207
|
+
overlayExit: MOTION.duration.overlayExit,
|
|
208
|
+
chart: MOTION.duration.chart,
|
|
209
|
+
},
|
|
210
|
+
easing: {
|
|
211
|
+
enter: MOTION.easing.enter,
|
|
212
|
+
exit: MOTION.easing.exit,
|
|
213
|
+
state: MOTION.easing.state,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
// MUI's 25-entry shadows array is intentionally retained intact. Resting
|
|
217
|
+
// surfaces are made shadowless via the Paper/Card defaults below, while
|
|
218
|
+
// named transient surfaces receive shadow.overlay explicitly.
|
|
219
|
+
components: {
|
|
220
|
+
MuiCssBaseline: {
|
|
221
|
+
styleOverrides: {
|
|
222
|
+
'@media (prefers-reduced-motion: reduce)': {
|
|
223
|
+
'*, *::before, *::after': {
|
|
224
|
+
animationDuration: '0.01ms !important',
|
|
225
|
+
animationIterationCount: '1 !important',
|
|
226
|
+
transitionDuration: '0.01ms !important',
|
|
227
|
+
scrollBehavior: 'auto !important',
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
MuiButton: {
|
|
233
|
+
defaultProps: { disableElevation: true },
|
|
234
|
+
styleOverrides: {
|
|
235
|
+
root: {
|
|
236
|
+
minHeight: 40,
|
|
237
|
+
borderRadius: 3,
|
|
238
|
+
textTransform: 'none',
|
|
239
|
+
transitionDuration: '120ms',
|
|
240
|
+
...coarseHitArea,
|
|
241
|
+
},
|
|
242
|
+
contained: { boxShadow: 'none', '&:hover': { boxShadow: 'none' } },
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
MuiTableCell: {
|
|
246
|
+
styleOverrides: { root: { padding: '10px 16px' } },
|
|
247
|
+
},
|
|
248
|
+
MuiIconButton: {
|
|
249
|
+
styleOverrides: { root: { minHeight: 40, minWidth: 40, ...coarseHitArea } },
|
|
250
|
+
},
|
|
251
|
+
MuiDivider: {
|
|
252
|
+
styleOverrides: { root: { borderColor: 'rgba(33,37,41,.10)' } },
|
|
253
|
+
},
|
|
254
|
+
MuiSelect: {
|
|
255
|
+
defaultProps: { variant: 'outlined' },
|
|
256
|
+
styleOverrides: {
|
|
257
|
+
select: {
|
|
258
|
+
minHeight: 40,
|
|
259
|
+
boxSizing: 'border-box',
|
|
260
|
+
'@media (any-pointer: coarse)': { minHeight: 44 },
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
MuiCheckbox: {
|
|
265
|
+
styleOverrides: { root: { minHeight: 40, minWidth: 40, ...coarseHitArea } },
|
|
266
|
+
},
|
|
267
|
+
MuiFormControlLabel: {
|
|
268
|
+
styleOverrides: {
|
|
269
|
+
root: { minHeight: 40, ...coarseHitArea },
|
|
270
|
+
label: { fontSize: '14px', lineHeight: 1.55 },
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
MuiContainer: {
|
|
274
|
+
styleOverrides: {
|
|
275
|
+
root: {
|
|
276
|
+
paddingLeft: 24,
|
|
277
|
+
paddingRight: 24,
|
|
278
|
+
'@media (min-width:1200px)': { paddingLeft: 32, paddingRight: 32 },
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
MuiPaper: {
|
|
283
|
+
defaultProps: { elevation: 0, variant: 'outlined' },
|
|
284
|
+
styleOverrides: {
|
|
285
|
+
root: { borderRadius: 8, boxShadow: 'none' },
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
MuiCard: {
|
|
289
|
+
defaultProps: { elevation: 0, variant: 'outlined' },
|
|
290
|
+
styleOverrides: { root: { borderRadius: 8, boxShadow: 'none' } },
|
|
291
|
+
},
|
|
292
|
+
MuiChip: {
|
|
293
|
+
styleOverrides: {
|
|
294
|
+
root: {
|
|
295
|
+
height: 32,
|
|
296
|
+
borderRadius: 16,
|
|
297
|
+
fontSize: '13px',
|
|
298
|
+
position: 'relative',
|
|
299
|
+
'@media (any-pointer: coarse)': {
|
|
300
|
+
'&::after': {
|
|
301
|
+
content: '""',
|
|
302
|
+
position: 'absolute',
|
|
303
|
+
inset: '-6px',
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
MuiTextField: {
|
|
310
|
+
defaultProps: { variant: 'outlined' },
|
|
311
|
+
styleOverrides: { root: { minHeight: 40, ...coarseHitArea } },
|
|
312
|
+
},
|
|
313
|
+
MuiOutlinedInput: {
|
|
314
|
+
styleOverrides: {
|
|
315
|
+
root: {
|
|
316
|
+
minHeight: 40,
|
|
317
|
+
borderRadius: 3,
|
|
318
|
+
...coarseHitArea,
|
|
319
|
+
},
|
|
320
|
+
input: autofill,
|
|
321
|
+
},
|
|
322
|
+
// `variants` is a sibling of `styleOverrides`, not nested inside it -
|
|
323
|
+
// MUI matches these by `props` and applies `style` independently of the
|
|
324
|
+
// styleOverrides merge, so this is the correct placement (a `variants`
|
|
325
|
+
// array embedded inside styleOverrides.root would just be inert CSS-in-JS).
|
|
326
|
+
variants: [
|
|
327
|
+
{ props: { multiline: false }, style: { height: 40 } },
|
|
328
|
+
],
|
|
329
|
+
},
|
|
330
|
+
MuiFilledInput: {
|
|
331
|
+
styleOverrides: { input: autofill },
|
|
332
|
+
},
|
|
333
|
+
MuiInput: {
|
|
334
|
+
styleOverrides: { input: autofill },
|
|
335
|
+
},
|
|
336
|
+
MuiDialog: {
|
|
337
|
+
defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
|
|
338
|
+
styleOverrides: { paper: { borderRadius: 8, boxShadow: OVERLAY_SHADOW } },
|
|
339
|
+
},
|
|
340
|
+
MuiDrawer: {
|
|
341
|
+
defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
|
|
342
|
+
styleOverrides: { paper: { boxShadow: OVERLAY_SHADOW } },
|
|
343
|
+
},
|
|
344
|
+
MuiMenu: {
|
|
345
|
+
defaultProps: { transitionDuration: { enter: 220, exit: 180 } },
|
|
346
|
+
styleOverrides: { paper: { boxShadow: OVERLAY_SHADOW } },
|
|
347
|
+
},
|
|
348
|
+
MuiTooltip: {
|
|
349
|
+
defaultProps: {
|
|
350
|
+
slotProps: { transition: { timeout: { enter: 220, exit: 180 } } },
|
|
351
|
+
},
|
|
352
|
+
styleOverrides: {
|
|
353
|
+
tooltip: { boxShadow: OVERLAY_SHADOW, transitionDuration: '220ms' },
|
|
354
|
+
},
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
export const BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS = [
|
|
360
|
+
{
|
|
361
|
+
surface: 'palette.background.paper',
|
|
362
|
+
reason: 'The canonical surface is deliberately MUI white.',
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
surface: 'spacing.unit',
|
|
366
|
+
reason: 'The canonical spacing rhythm deliberately retains MUI\'s 8px unit.',
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
surface: 'breakpoints.values',
|
|
370
|
+
reason: 'The canonical breakpoint values deliberately retain MUI\'s defaults.',
|
|
371
|
+
},
|
|
372
|
+
...['subtitle2', 'body1', 'body2', 'button', 'caption'].map((variant) => ({
|
|
373
|
+
surface: `typography.${variant}.fontWeight`,
|
|
374
|
+
reason: `The canonical ${variant} weight deliberately equals MUI's numeric default.`,
|
|
375
|
+
})),
|
|
376
|
+
];
|
package/tests/BarChart.test.jsx
CHANGED
|
@@ -26,7 +26,10 @@ describe('BarChart preset', () => {
|
|
|
26
26
|
|
|
27
27
|
expect(props.xAxis[0].label).toBe('Year');
|
|
28
28
|
expect(props.yAxis[0].label).toBe('Value (kg)');
|
|
29
|
-
expect(props.slotProps).toEqual({
|
|
29
|
+
expect(props.slotProps).toEqual({
|
|
30
|
+
tooltip: { trigger: 'axis' },
|
|
31
|
+
legend: { position: { vertical: 'bottom', horizontal: 'start' } },
|
|
32
|
+
});
|
|
30
33
|
expect(props.hideLegend).toBe(true);
|
|
31
34
|
expect(screen.getByTestId('bar-chart-container').getAttribute('style') || '').not.toContain('px');
|
|
32
35
|
});
|
package/tests/LineChart.test.jsx
CHANGED
|
@@ -5,7 +5,7 @@ import { cleanup, render } from '@testing-library/react';
|
|
|
5
5
|
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
6
|
|
|
7
7
|
const chartSpy = vi.hoisted(() => vi.fn(() => <div data-testid="mui-line-chart" />));
|
|
8
|
-
vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: chartSpy }));
|
|
8
|
+
vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: chartSpy, MarkElement: 'path' }));
|
|
9
9
|
|
|
10
10
|
import { LineChart } from '../src/components/charts/LineChart';
|
|
11
11
|
|
|
@@ -28,6 +28,9 @@ describe('LineChart preset', () => {
|
|
|
28
28
|
expect(props.xAxis[0].label).toBe('Date');
|
|
29
29
|
expect(props.yAxis[0].label).toBe('Rate (%)');
|
|
30
30
|
expect(props.hideLegend).toBe(false);
|
|
31
|
-
expect(props.slotProps).toEqual({
|
|
31
|
+
expect(props.slotProps).toEqual({
|
|
32
|
+
tooltip: { trigger: 'axis' },
|
|
33
|
+
legend: { position: { vertical: 'bottom', horizontal: 'start' } },
|
|
34
|
+
});
|
|
32
35
|
});
|
|
33
36
|
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import i18next from 'i18next';
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { cleanup, render } from '@testing-library/react';
|
|
6
|
+
import { I18nextProvider } from 'react-i18next';
|
|
7
|
+
import { ThemeProvider } from '@mui/material/styles';
|
|
8
|
+
|
|
9
|
+
const barSpy = vi.hoisted(() => vi.fn(() => null));
|
|
10
|
+
const lineSpy = vi.hoisted(() => vi.fn(() => null));
|
|
11
|
+
vi.mock('@mui/x-charts/BarChart', () => ({ BarChart: barSpy }));
|
|
12
|
+
vi.mock('@mui/x-charts/LineChart', () => ({ LineChart: lineSpy, MarkElement: 'path' }));
|
|
13
|
+
|
|
14
|
+
import { BarChart } from '../src/components/charts/BarChart';
|
|
15
|
+
import { LineChart } from '../src/components/charts/LineChart';
|
|
16
|
+
import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
|
|
17
|
+
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
18
|
+
|
|
19
|
+
const theme = createAppTheme({ palette: { primary: { main: '#3D5A99' } } });
|
|
20
|
+
const i18n = i18next.createInstance();
|
|
21
|
+
i18n.init({
|
|
22
|
+
lng: 'en',
|
|
23
|
+
resources: {
|
|
24
|
+
en: {
|
|
25
|
+
translation: {
|
|
26
|
+
'TimeSeriesChart.RANGE_LABEL': 'Range',
|
|
27
|
+
'TimeSeriesChart.RANGE_1_DAY': '1 day',
|
|
28
|
+
'TimeSeriesChart.RANGE_1_WEEK': '1 week',
|
|
29
|
+
'TimeSeriesChart.RANGE_1_MONTH': '1 month',
|
|
30
|
+
'TimeSeriesChart.RANGE_1_YEAR': '1 year',
|
|
31
|
+
'TimeSeriesChart.SERIES_LABEL': 'Series',
|
|
32
|
+
'ChartFrame.EMPTY_DEFAULT': 'No data available.',
|
|
33
|
+
'ChartFrame.LOADING': 'Loading chart.',
|
|
34
|
+
'ChartFrame.ERROR_DEFAULT': 'The chart could not be loaded.',
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
interpolation: { escapeValue: false },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function withProviders(children) {
|
|
42
|
+
return React.createElement(
|
|
43
|
+
ThemeProvider,
|
|
44
|
+
{ theme },
|
|
45
|
+
React.createElement(I18nextProvider, { i18n }, children),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function renderBar(props = {}) {
|
|
50
|
+
return render(withProviders(
|
|
51
|
+
React.createElement(BarChart, {
|
|
52
|
+
xAxisLabel: 'Month',
|
|
53
|
+
yAxisLabel: 'Cases',
|
|
54
|
+
series: [{ label: 'Cases', data: [1, 2] }],
|
|
55
|
+
...props,
|
|
56
|
+
}),
|
|
57
|
+
));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function renderLine(props = {}) {
|
|
61
|
+
return render(withProviders(
|
|
62
|
+
React.createElement(LineChart, {
|
|
63
|
+
xAxisLabel: 'Month',
|
|
64
|
+
yAxisLabel: 'Cases',
|
|
65
|
+
series: [{ label: 'Cases', data: [1, 2] }],
|
|
66
|
+
...props,
|
|
67
|
+
}),
|
|
68
|
+
));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('chart chrome defaults', () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
cleanup();
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('gives minimal BarChart, LineChart, and TimeSeriesChart calls the baseline ticks and horizontal grid, with line marks off', () => {
|
|
78
|
+
renderBar();
|
|
79
|
+
const barProps = barSpy.mock.calls.at(-1)[0];
|
|
80
|
+
expect(barProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
81
|
+
expect(barProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
82
|
+
expect(barProps.grid).toEqual({ horizontal: true });
|
|
83
|
+
|
|
84
|
+
renderLine();
|
|
85
|
+
const lineProps = lineSpy.mock.calls.at(-1)[0];
|
|
86
|
+
expect(lineProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
87
|
+
expect(lineProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
88
|
+
expect(lineProps.grid).toEqual({ horizontal: true });
|
|
89
|
+
expect(lineProps.series[0].showMark).toBe(false);
|
|
90
|
+
|
|
91
|
+
render(withProviders(React.createElement(TimeSeriesChart, {
|
|
92
|
+
title: 'Activity',
|
|
93
|
+
xAxisLabel: 'Day',
|
|
94
|
+
yAxisLabel: 'Cases',
|
|
95
|
+
data: {
|
|
96
|
+
xLabels: ['Mon', 'Tue'],
|
|
97
|
+
series: [{ key: 'cases', label: 'Cases', data: [1, 2] }],
|
|
98
|
+
},
|
|
99
|
+
skipAnimation: true,
|
|
100
|
+
})));
|
|
101
|
+
const timeSeriesProps = barSpy.mock.calls.at(-1)[0];
|
|
102
|
+
expect(timeSeriesProps.xAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
103
|
+
expect(timeSeriesProps.yAxis[0].tickLabelStyle.fontSize).toBe(theme.typography.caption.fontSize);
|
|
104
|
+
expect(timeSeriesProps.grid).toEqual({ horizontal: true });
|
|
105
|
+
expect(timeSeriesProps.skipAnimation).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('keeps caller tick font, grid, showMark, and legendPosition values instead of the defaults', () => {
|
|
109
|
+
const legendPosition = { vertical: 'top', horizontal: 'end' };
|
|
110
|
+
renderLine({
|
|
111
|
+
xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { fontSize: 17 } }],
|
|
112
|
+
grid: { horizontal: false, vertical: true },
|
|
113
|
+
series: [{ label: 'Cases', data: [1, 2], showMark: true }],
|
|
114
|
+
legendPosition,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const props = lineSpy.mock.calls.at(-1)[0];
|
|
118
|
+
expect(props.xAxis[0].tickLabelStyle.fontSize).toBe(17);
|
|
119
|
+
expect(props.grid).toEqual({ horizontal: false, vertical: true });
|
|
120
|
+
expect(props.series[0].showMark).toBe(true);
|
|
121
|
+
expect(props.slotProps.legend.position).toEqual(legendPosition);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Found live in the dev harness (not by this mocked test, which cannot
|
|
125
|
+
// reproduce a real MUI scale computation): a caller-supplied xAxis with
|
|
126
|
+
// `data` but no `scaleType` lost the wrapper's default scaleType entirely,
|
|
127
|
+
// leaving MUI's point/band scale undefined and every rendered path NaN.
|
|
128
|
+
// This asserts the prop-level contract; the harness's "Chart defaults" /
|
|
129
|
+
// "Caller overrides" LineChart specimens are the real-render proof.
|
|
130
|
+
it('keeps the default scaleType on a caller-supplied axis that does not set one', () => {
|
|
131
|
+
renderLine({ xAxis: [{ data: ['Jan', 'Feb'] }] });
|
|
132
|
+
expect(lineSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('point');
|
|
133
|
+
|
|
134
|
+
renderBar({ xAxis: [{ data: ['Jan', 'Feb'] }] });
|
|
135
|
+
expect(barSpy.mock.calls.at(-1)[0].xAxis[0].scaleType).toBe('band');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('adds a font-and-angle-derived bottom margin only for rotated tick labels', () => {
|
|
139
|
+
renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: -45 } }] });
|
|
140
|
+
const rotatedProps = barSpy.mock.calls.at(-1)[0];
|
|
141
|
+
expect(rotatedProps.margin.bottom).toBeGreaterThan(20);
|
|
142
|
+
expect(rotatedProps.xAxis[0].height).toBeGreaterThan(45);
|
|
143
|
+
|
|
144
|
+
cleanup();
|
|
145
|
+
renderBar({ xAxis: [{ data: ['Jan', 'Feb'], tickLabelStyle: { angle: 0 } }] });
|
|
146
|
+
expect(barSpy.mock.calls.at(-1)[0].margin).toBeUndefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('uses the series colour as the fill when a caller opts line markers back in', () => {
|
|
150
|
+
renderLine({ series: [{ label: 'Cases', data: [1, 2], showMark: true }] });
|
|
151
|
+
const props = lineSpy.mock.calls.at(-1)[0];
|
|
152
|
+
const mark = props.slots.mark({
|
|
153
|
+
color: '#3D5A99',
|
|
154
|
+
id: 'cases',
|
|
155
|
+
dataIndex: 0,
|
|
156
|
+
shape: 'circle',
|
|
157
|
+
x: 10,
|
|
158
|
+
y: 10,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(mark.props.style.fill).toBe('#3D5A99');
|
|
162
|
+
expect(mark.props.style.fill).not.toBe(theme.palette.background.paper);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('defaults legendPosition to bottom-start and lets a caller choose another MUI position', () => {
|
|
166
|
+
renderBar({ series: [{ data: [1] }, { data: [2] }] });
|
|
167
|
+
expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual({
|
|
168
|
+
vertical: 'bottom',
|
|
169
|
+
horizontal: 'start',
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
cleanup();
|
|
173
|
+
const requested = { vertical: 'middle', horizontal: 'end' };
|
|
174
|
+
renderBar({ series: [{ data: [1] }, { data: [2] }], legendPosition: requested });
|
|
175
|
+
expect(barSpy.mock.calls.at(-1)[0].slotProps.legend.position).toEqual(requested);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
formatShortDate,
|
|
5
|
+
formatShortMonth,
|
|
6
|
+
formatShortTime,
|
|
7
|
+
formatShortYear,
|
|
8
|
+
} from '../src/components/charts/chartLabels';
|
|
9
|
+
import { yearTickInterval } from '../src/components/charts/yearTickInterval';
|
|
10
|
+
|
|
11
|
+
// 2026-08-05 14:07, a fixed instant so assertions aren't flaky around
|
|
12
|
+
// midnight/year boundaries.
|
|
13
|
+
const FIXED = new Date(2026, 7, 5, 14, 7, 0);
|
|
14
|
+
|
|
15
|
+
describe('chart label helpers', () => {
|
|
16
|
+
it('ports fitness-monitor short-label behaviour, including locale fallback and forced 24-hour time', () => {
|
|
17
|
+
expect(formatShortDate(FIXED, 'de-CH')).toBe('05.08.');
|
|
18
|
+
expect(formatShortDate(FIXED, 'de-CH').length).toBeLessThan(10);
|
|
19
|
+
expect(formatShortTime(FIXED, 'de-CH')).toBe('14:07');
|
|
20
|
+
expect(formatShortTime(FIXED, 'en-US')).toBe('14:07');
|
|
21
|
+
expect(formatShortMonth(FIXED, 'de-CH')).toMatch(/26$/);
|
|
22
|
+
expect(formatShortMonth(FIXED, 'de-CH').length).toBeLessThan(10);
|
|
23
|
+
expect(formatShortYear(FIXED, 'de-CH')).toBe('2026');
|
|
24
|
+
expect(formatShortYear(FIXED, 'en-US')).toBe('2026');
|
|
25
|
+
expect(formatShortDate(FIXED, 'en-US')).toBe('08/05');
|
|
26
|
+
expect(formatShortDate(FIXED)).toBe(formatShortDate(FIXED, 'de-CH'));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns one tick per year and proves dense unfiltered dates produce duplicate year labels', () => {
|
|
30
|
+
const denseDates = [];
|
|
31
|
+
for (let year = 2022; year <= 2025; year += 1) {
|
|
32
|
+
for (let month = 0; month < 12; month += 1) {
|
|
33
|
+
denseDates.push(new Date(year, month, 1));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const unfilteredLabels = denseDates.map((date) => formatShortYear(date, 'de-CH'));
|
|
38
|
+
expect(new Set(unfilteredLabels).size).toBeLessThan(unfilteredLabels.length);
|
|
39
|
+
|
|
40
|
+
const ticks = yearTickInterval(denseDates);
|
|
41
|
+
expect(ticks).toHaveLength(4);
|
|
42
|
+
expect(ticks.every((date) => date.getMonth() === 0 && date.getDate() === 1)).toBe(true);
|
|
43
|
+
expect(ticks.map((date) => formatShortYear(date, 'de-CH'))).toEqual([
|
|
44
|
+
'2022',
|
|
45
|
+
'2023',
|
|
46
|
+
'2024',
|
|
47
|
+
'2025',
|
|
48
|
+
]);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -36,4 +36,44 @@ describe('chart palette and formatters', () => {
|
|
|
36
36
|
expect(formatCompact(12500, 'en-US')).toMatch(/12.5K|13K/);
|
|
37
37
|
expect(createChartFormatters('fr-FR').ratio(1.5)).toContain('1');
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
it('uses the theme categorical ramp when one is present', () => {
|
|
41
|
+
const ramp = ['series-1', 'series-2', 'series-3'];
|
|
42
|
+
const theme = {
|
|
43
|
+
palette: {
|
|
44
|
+
dataSeries: { categorical: ramp },
|
|
45
|
+
mode: 'light',
|
|
46
|
+
primary: { main: 'primary-main', light: 'primary-light' },
|
|
47
|
+
action: { disabled: 'action-disabled' },
|
|
48
|
+
text: { secondary: 'text-secondary' },
|
|
49
|
+
divider: 'divider',
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
expect(getNeutralChartPalette(theme).categorical).toEqual(ramp);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps the existing palette derivation when no ramp is present', () => {
|
|
57
|
+
const theme = {
|
|
58
|
+
palette: {
|
|
59
|
+
mode: 'light',
|
|
60
|
+
primary: { main: 'primary-main', light: 'primary-light' },
|
|
61
|
+
secondary: { main: 'secondary-main' },
|
|
62
|
+
info: { main: 'info-main' },
|
|
63
|
+
success: { main: 'success-main' },
|
|
64
|
+
warning: { main: 'warning-main' },
|
|
65
|
+
action: { disabled: 'action-disabled' },
|
|
66
|
+
text: { secondary: 'text-secondary' },
|
|
67
|
+
divider: 'divider',
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
expect(getNeutralChartPalette(theme).categorical).toEqual([
|
|
72
|
+
'primary-main',
|
|
73
|
+
'secondary-main',
|
|
74
|
+
'info-main',
|
|
75
|
+
'success-main',
|
|
76
|
+
'warning-main',
|
|
77
|
+
]);
|
|
78
|
+
});
|
|
39
79
|
});
|