@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,103 @@
|
|
|
1
|
+
import { createTheme } from '@mui/material/styles';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { assertThemeComplete, createAppTheme } from '../src/index';
|
|
5
|
+
import { BASELINE_STATIC } from '../src/theme/tokens';
|
|
6
|
+
|
|
7
|
+
describe('createAppTheme', () => {
|
|
8
|
+
it('builds a complete baseline from only an app primary colour', () => {
|
|
9
|
+
const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
|
|
10
|
+
|
|
11
|
+
expect(assertThemeComplete(theme).findings).toEqual([]);
|
|
12
|
+
expect(theme.typography.body1.fontSize).toBe('14px');
|
|
13
|
+
expect(theme.shape.borderRadius).toBe(3);
|
|
14
|
+
expect(theme.components.MuiPaper.defaultProps).toMatchObject({ elevation: 0, variant: 'outlined' });
|
|
15
|
+
expect(theme.palette.dataSeries.categorical).toHaveLength(6);
|
|
16
|
+
expect(theme.palette.primary.light).not.toBe(createTheme().palette.primary.light);
|
|
17
|
+
// MUI's createTheme(options, ...args) only augments the FIRST argument's
|
|
18
|
+
// palette; every later arg is deep-merged raw, so a status entry missing
|
|
19
|
+
// `main`/`contrastText` silently keeps MUI's stock hue for color="success"
|
|
20
|
+
// etc. -- main/contrastText must equal the canonical fill/fillText tones.
|
|
21
|
+
for (const status of ['success', 'warning', 'error', 'info']) {
|
|
22
|
+
expect(theme.palette[status].main).toBe(theme.palette[status].fill);
|
|
23
|
+
expect(theme.palette[status].contrastText).toBe(theme.palette[status].fillText);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('throws when palette.primary is missing', () => {
|
|
28
|
+
expect(() => createAppTheme()).toThrow(/palette\.primary.*required/i);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('throws on a function in an app styleOverrides slot', () => {
|
|
32
|
+
expect(() => createAppTheme({
|
|
33
|
+
palette: { primary: { main: '#0F62FE' } },
|
|
34
|
+
components: { MuiButton: { styleOverrides: { root: () => ({ color: 'red' }) } } },
|
|
35
|
+
})).toThrow(/components\.MuiButton\.styleOverrides\.root.*object.*variants/i);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('computes component values from the final app status palette', () => {
|
|
39
|
+
const theme = createAppTheme({
|
|
40
|
+
palette: {
|
|
41
|
+
primary: { main: '#0F62FE' },
|
|
42
|
+
success: { main: '#14532D', text: '#14532D', bg: '#DCFCE7' },
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(theme.components.MuiAlert.styleOverrides.standardSuccess).toEqual({
|
|
47
|
+
color: '#14532D',
|
|
48
|
+
backgroundColor: '#DCFCE7',
|
|
49
|
+
'& .MuiAlert-icon': { color: '#14532D' },
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('deep-merges app object overrides without dropping baseline properties', () => {
|
|
54
|
+
const theme = createAppTheme({
|
|
55
|
+
palette: { primary: { main: '#0F62FE' } },
|
|
56
|
+
typography: { fontFamily: "'App Sans', sans-serif" },
|
|
57
|
+
components: { MuiButton: { styleOverrides: { root: { fontSize: '15px' } } } },
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(theme.components.MuiButton.styleOverrides.root).toMatchObject({
|
|
61
|
+
minHeight: 40,
|
|
62
|
+
borderRadius: 3,
|
|
63
|
+
fontSize: '15px',
|
|
64
|
+
});
|
|
65
|
+
expect(theme.typography.body1.fontFamily).toBe("'App Sans', sans-serif");
|
|
66
|
+
expect(theme.components.MuiOutlinedInput.styleOverrides.root.height).toBeUndefined();
|
|
67
|
+
// variants is a sibling of styleOverrides (MUI matches it independently by
|
|
68
|
+
// props), not nested inside it -- see src/theme/tokens.js for why.
|
|
69
|
+
expect(theme.components.MuiOutlinedInput.variants).toContainEqual({
|
|
70
|
+
props: { multiline: false },
|
|
71
|
+
style: { height: 40 },
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('returns independent themes for runtime palettes in one process', () => {
|
|
76
|
+
const blue = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
|
|
77
|
+
const purple = createAppTheme({ palette: { primary: { main: '#432CA1' } } });
|
|
78
|
+
|
|
79
|
+
expect(blue).not.toBe(purple);
|
|
80
|
+
expect(blue.palette).not.toBe(purple.palette);
|
|
81
|
+
expect(blue.palette.dataSeries.categorical).not.toBe(purple.palette.dataSeries.categorical);
|
|
82
|
+
expect(blue.palette.primary.main).toBe('#0F62FE');
|
|
83
|
+
expect(purple.palette.primary.main).toBe('#432CA1');
|
|
84
|
+
expect(blue.palette.controlBorder.focus).not.toBe(purple.palette.controlBorder.focus);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('contains no function anywhere in the baseline components tree', () => {
|
|
88
|
+
const functionPaths = [];
|
|
89
|
+
const visit = (value, path) => {
|
|
90
|
+
if (typeof value === 'function') functionPaths.push(path);
|
|
91
|
+
else if (value && typeof value === 'object') {
|
|
92
|
+
Object.entries(value).forEach(([key, child]) => visit(child, `${path}.${key}`));
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
visit(BASELINE_STATIC.components, 'baseline.components');
|
|
97
|
+
visit(
|
|
98
|
+
createAppTheme({ palette: { primary: { main: '#0F62FE' } } }).components,
|
|
99
|
+
'theme.components',
|
|
100
|
+
);
|
|
101
|
+
expect(functionPaths).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { createTheme } from '@mui/material/styles';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
assertThemeComplete,
|
|
6
|
+
calculateContrastRatio,
|
|
7
|
+
createAppTheme,
|
|
8
|
+
} from '../src/index';
|
|
9
|
+
|
|
10
|
+
describe('theme completeness', () => {
|
|
11
|
+
it('finds untouched MUI surfaces and accepts createAppTheme', () => {
|
|
12
|
+
const bare = assertThemeComplete(createTheme());
|
|
13
|
+
const baseline = assertThemeComplete(
|
|
14
|
+
createAppTheme({ palette: { primary: { main: '#0F62FE' } } }),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(bare.findings.length).toBeGreaterThan(0);
|
|
18
|
+
expect(bare.findings.map(({ surface }) => surface)).toContain('shape.borderRadius');
|
|
19
|
+
expect(assertThemeComplete(createTheme({ components: { MuiButton: {} } })).findings
|
|
20
|
+
.map(({ surface }) => surface))
|
|
21
|
+
.toContain('components.MuiButton.styleOverrides.root.minHeight');
|
|
22
|
+
expect(baseline.findings).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('suppresses exactly one surface when an exemption includes a reason', () => {
|
|
26
|
+
const theme = createTheme();
|
|
27
|
+
const before = assertThemeComplete(theme).findings;
|
|
28
|
+
const after = assertThemeComplete(theme, {
|
|
29
|
+
exemptions: [{
|
|
30
|
+
surface: 'palette.background.paper',
|
|
31
|
+
reason: 'This application deliberately uses white as its surface.',
|
|
32
|
+
}],
|
|
33
|
+
}).findings;
|
|
34
|
+
|
|
35
|
+
expect(after).toHaveLength(before.length - 1);
|
|
36
|
+
expect(after.map(({ surface }) => surface)).not.toContain('palette.background.paper');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('reports an exemption without a reason as its own finding', () => {
|
|
40
|
+
const result = assertThemeComplete(createTheme(), {
|
|
41
|
+
exemptions: [{ surface: 'shape.borderRadius' }],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
expect(result.findings.map(({ surface }) => surface)).toContain('exemption.shape.borderRadius');
|
|
45
|
+
expect(result.findings.map(({ surface }) => surface)).toContain('shape.borderRadius');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('meets status-on-tint and control-boundary contrast thresholds', () => {
|
|
49
|
+
const theme = createAppTheme({ palette: { primary: { main: '#8AB4F8' } } });
|
|
50
|
+
|
|
51
|
+
for (const status of ['success', 'warning', 'error', 'info', 'stale']) {
|
|
52
|
+
expect(calculateContrastRatio(theme.palette[status].text, theme.palette[status].bg)).toBeGreaterThanOrEqual(4.5);
|
|
53
|
+
}
|
|
54
|
+
for (const state of ['main', 'hover', 'focus', 'error']) {
|
|
55
|
+
expect(calculateContrastRatio(theme.palette.controlBorder[state], '#FFFFFF')).toBeGreaterThanOrEqual(3);
|
|
56
|
+
expect(calculateContrastRatio(theme.palette.controlBorder[state], theme.palette.background.default)).toBeGreaterThanOrEqual(3);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
theme.palette.success.text = 'not-a-colour';
|
|
60
|
+
expect(assertThemeComplete(theme).findings.map(({ surface }) => surface))
|
|
61
|
+
.toContain('contrast.success.text-on-bg');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,99 @@
|
|
|
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, screen } from '@testing-library/react';
|
|
6
|
+
import { I18nextProvider } from 'react-i18next';
|
|
7
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
8
|
+
|
|
9
|
+
const chartSpy = vi.hoisted(() => vi.fn(() => null));
|
|
10
|
+
vi.mock('@mui/x-charts/BarChart', () => ({ BarChart: chartSpy }));
|
|
11
|
+
|
|
12
|
+
import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
|
|
13
|
+
|
|
14
|
+
chartSpy.mockImplementation((props) => React.createElement(
|
|
15
|
+
'div',
|
|
16
|
+
{ 'data-testid': 'mui-bar-chart' },
|
|
17
|
+
!props.hideLegend
|
|
18
|
+
? React.createElement('div', { 'data-testid': 'mui-legend' }, 'MUI legend')
|
|
19
|
+
: null,
|
|
20
|
+
));
|
|
21
|
+
|
|
22
|
+
const i18n = i18next.createInstance();
|
|
23
|
+
i18n.init({
|
|
24
|
+
lng: 'en',
|
|
25
|
+
resources: {
|
|
26
|
+
en: {
|
|
27
|
+
translation: {
|
|
28
|
+
'TimeSeriesChart.RANGE_LABEL': 'Range',
|
|
29
|
+
'TimeSeriesChart.RANGE_1_DAY': '1 day',
|
|
30
|
+
'TimeSeriesChart.RANGE_1_WEEK': '1 week',
|
|
31
|
+
'TimeSeriesChart.RANGE_1_MONTH': '1 month',
|
|
32
|
+
'TimeSeriesChart.RANGE_1_YEAR': '1 year',
|
|
33
|
+
'TimeSeriesChart.SERIES_LABEL': 'Series',
|
|
34
|
+
'ChartFrame.EMPTY_DEFAULT': 'No data available.',
|
|
35
|
+
'ChartFrame.LOADING': 'Loading chart.',
|
|
36
|
+
'ChartFrame.ERROR_DEFAULT': 'The chart could not be loaded.',
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
interpolation: { escapeValue: false },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const MULTI_SERIES = {
|
|
44
|
+
xLabels: ['Mon', 'Tue'],
|
|
45
|
+
series: [
|
|
46
|
+
{ key: 'users', label: 'Users', data: [1, 2] },
|
|
47
|
+
{ key: 'hours', label: 'Presence hours', data: [3, 4] },
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function renderChart(data = MULTI_SERIES) {
|
|
52
|
+
return render(React.createElement(
|
|
53
|
+
ThemeProvider,
|
|
54
|
+
{ theme: createTheme() },
|
|
55
|
+
React.createElement(
|
|
56
|
+
I18nextProvider,
|
|
57
|
+
{ i18n },
|
|
58
|
+
React.createElement(TimeSeriesChart, {
|
|
59
|
+
title: 'Activity',
|
|
60
|
+
xAxisLabel: 'Day',
|
|
61
|
+
yAxisLabel: 'Count',
|
|
62
|
+
data,
|
|
63
|
+
}),
|
|
64
|
+
),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe('TimeSeriesChart toolbar legend', () => {
|
|
70
|
+
afterEach(() => {
|
|
71
|
+
cleanup();
|
|
72
|
+
vi.clearAllMocks();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('removes the MUI legend when the toolbar names and toggles multiple series', () => {
|
|
76
|
+
renderChart();
|
|
77
|
+
|
|
78
|
+
expect(chartSpy.mock.calls.at(-1)[0].hideLegend).toBe(true);
|
|
79
|
+
expect(screen.queryByTestId('mui-legend')).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('keeps an accessible name on every toolbar series toggle', () => {
|
|
83
|
+
renderChart();
|
|
84
|
+
|
|
85
|
+
expect(screen.getByRole('checkbox', { name: 'Users' })).toBeTruthy();
|
|
86
|
+
expect(screen.getByRole('checkbox', { name: 'Presence hours' })).toBeTruthy();
|
|
87
|
+
expect(screen.getAllByTestId('series-colour-dot')).toHaveLength(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('still hides the redundant MUI legend for a single series', () => {
|
|
91
|
+
renderChart({
|
|
92
|
+
xLabels: ['Mon', 'Tue'],
|
|
93
|
+
series: [{ key: 'users', label: 'Users', data: [1, 2] }],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(chartSpy.mock.calls.at(-1)[0].hideLegend).toBe(true);
|
|
97
|
+
expect(screen.queryByTestId('mui-legend')).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|