@micha.bigler/ui-core-micha 2.30.0 → 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.
@@ -0,0 +1,111 @@
1
+ export const DEFAULT_LEGEND_POSITION = {
2
+ vertical: 'bottom',
3
+ horizontal: 'start',
4
+ };
5
+
6
+ export function withAxisDefaults(axes, label, defaults, tickFontSize) {
7
+ const values = axes?.length ? axes : [defaults];
8
+ return values.map((axis) => ({
9
+ // `defaults.scaleType` (and any other scale-defining default) must
10
+ // survive even when the caller passes their OWN xAxis/yAxis array --
11
+ // confirmed live in the dev harness: a caller-supplied axis with `data`
12
+ // but no `scaleType` previously lost 'point'/'band' entirely (only
13
+ // applied when the whole array was absent), leaving MUI's scale
14
+ // undefined and every point position NaN (`<path> attribute d:
15
+ // Expected number, "MNaN,..."` in the real rendered SVG -- a mocked
16
+ // prop-assertion test cannot catch this, it only reproduces on an
17
+ // actual MUI scale computation).
18
+ ...defaults,
19
+ ...axis,
20
+ tickLabelStyle: {
21
+ fontSize: tickFontSize,
22
+ ...axis.tickLabelStyle,
23
+ },
24
+ label: axis.label || label,
25
+ }));
26
+ }
27
+
28
+ function cssLengthToPixels(value) {
29
+ if (typeof value === 'number') return value;
30
+ if (typeof value !== 'string') return 0;
31
+
32
+ const parsed = Number.parseFloat(value);
33
+ if (!Number.isFinite(parsed)) return 0;
34
+ if (value.endsWith('rem') || value.endsWith('em')) return parsed * 16;
35
+ return parsed;
36
+ }
37
+
38
+ const MUI_LABELLED_X_AXIS_HEIGHT = 45;
39
+ const MUI_CHART_MARGIN_BOTTOM = 20;
40
+ const AVERAGE_GLYPH_WIDTH_EM = 0.6;
41
+
42
+ function formattedTickText(axis, value) {
43
+ if (!axis.valueFormatter) return String(value);
44
+ try {
45
+ return String(axis.valueFormatter(value, { location: 'tick' }));
46
+ } catch {
47
+ // Some formatters require MUI's complete scale context. Falling back to
48
+ // the raw value is conservative: it reserves at least as much space.
49
+ return String(value);
50
+ }
51
+ }
52
+
53
+ function rotatedTickMetrics(axis, defaultLineHeight) {
54
+ const angle = Number(axis.tickLabelStyle?.angle) || 0;
55
+ if (angle === 0) return null;
56
+
57
+ const fontSize = cssLengthToPixels(axis.tickLabelStyle?.fontSize);
58
+ if (fontSize === 0) return null;
59
+
60
+ const lineHeight = Number(axis.tickLabelStyle?.lineHeight) || defaultLineHeight;
61
+ const longestLabel = (axis.data || []).reduce((longest, value) => {
62
+ const text = formattedTickText(axis, value);
63
+ return text.length > longest.length ? text : longest;
64
+ }, '');
65
+ const textWidth = Math.max(fontSize, longestLabel.length * fontSize * AVERAGE_GLYPH_WIDTH_EM);
66
+ const radians = (angle * Math.PI) / 180;
67
+ const projectedHeight = Math.abs(Math.cos(radians)) * fontSize * lineHeight
68
+ + Math.abs(Math.sin(radians)) * textWidth;
69
+ const extraHeight = Math.max(1, Math.ceil(projectedHeight - fontSize));
70
+
71
+ return { extraHeight };
72
+ }
73
+
74
+ /**
75
+ * Rotated labels project both their line box and text width vertically.
76
+ * X-Charts uses xAxis.height when deciding whether a label fits, while margin
77
+ * only separates the axis from the SVG edge, so both surfaces must grow.
78
+ * Explicit caller height and bottom-margin values always remain untouched.
79
+ */
80
+ export function spaceForRotatedTicks(xAxis, margin, defaultLineHeight = 1) {
81
+ let largestExtraHeight = 0;
82
+ const spacedXAxis = xAxis.map((axis) => {
83
+ const metrics = rotatedTickMetrics(axis, defaultLineHeight);
84
+ if (!metrics) return axis;
85
+
86
+ largestExtraHeight = Math.max(largestExtraHeight, metrics.extraHeight);
87
+ if (axis.height != null) return axis;
88
+ return { ...axis, height: MUI_LABELLED_X_AXIS_HEIGHT + metrics.extraHeight };
89
+ });
90
+
91
+ const callerSetBottom = typeof margin === 'number' || margin?.bottom != null;
92
+ const spacedMargin = largestExtraHeight > 0 && !callerSetBottom
93
+ ? { ...margin, bottom: MUI_CHART_MARGIN_BOTTOM + largestExtraHeight }
94
+ : margin;
95
+
96
+ return { xAxis: spacedXAxis, margin: spacedMargin };
97
+ }
98
+
99
+ export function withChartSlotDefaults(slotProps, legendPosition) {
100
+ return {
101
+ ...slotProps,
102
+ tooltip: {
103
+ trigger: 'axis',
104
+ ...slotProps?.tooltip,
105
+ },
106
+ legend: {
107
+ position: legendPosition,
108
+ ...slotProps?.legend,
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,36 @@
1
+ // Short, granularity-aware x-axis tick labels.
2
+ //
3
+ // Not a style choice -- MUI X-Charts has a known, unfixed bug (mui-x#18768,
4
+ // duplicate of #18399): when the measured space for a tick label gets
5
+ // tight, its ellipsize() logic doesn't truncate the text, it collapses it
6
+ // to an empty string (the <tspan> stays in the DOM with no content). It
7
+ // only reproduces with long label text ("08/05, 10:00 AM"-style, ~16
8
+ // characters) combined with this app's actual font metrics (DM Sans) --
9
+ // short labels like "05.08." or "10:00" leave enough margin that the bug
10
+ // never triggers. Same fix already proven in jg-ferien's ActivitySection.
11
+
12
+ // `locale` defaults to "de-CH" only as a fallback for callers that don't
13
+ // pass the app's active i18n language (found in review: an earlier version
14
+ // hardcoded "de-CH" always, showing German month names to en/fr users).
15
+
16
+ export function formatShortTime(date, locale = 'de-CH') {
17
+ // hour12 forced false regardless of locale -- an English 12-hour label
18
+ // ("10:00 AM") is exactly the longer style that triggered the original
19
+ // collapse bug; staying short is the point, not locale fidelity here.
20
+ return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false });
21
+ }
22
+
23
+ export function formatShortDate(date, locale = 'de-CH') {
24
+ return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' });
25
+ }
26
+
27
+ export function formatShortMonth(date, locale = 'de-CH') {
28
+ return date.toLocaleDateString(locale, { month: 'short', year: '2-digit' });
29
+ }
30
+
31
+ // "All" spans years -- day/month labels repeat (e.g. every "01.04." shows up
32
+ // once per year) and read as meaningless noise; a bare year number is the
33
+ // only granularity dense multi-year history can show cleanly.
34
+ export function formatShortYear(date, locale = 'de-CH') {
35
+ return date.toLocaleDateString(locale, { year: 'numeric' });
36
+ }
@@ -0,0 +1,7 @@
1
+ /** Return one January tick for every calendar year represented in dates. */
2
+ export function yearTickInterval(dates) {
3
+ const years = new Set(dates.map((date) => date.getFullYear()));
4
+ return [...years]
5
+ .sort((first, second) => first - second)
6
+ .map((year) => new Date(year, 0, 1));
7
+ }
package/src/index.js CHANGED
@@ -55,6 +55,13 @@ export { BarChart } from './components/charts/BarChart';
55
55
  export { LineChart } from './components/charts/LineChart';
56
56
  export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
57
57
  export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
58
+ export {
59
+ formatShortTime,
60
+ formatShortDate,
61
+ formatShortMonth,
62
+ formatShortYear,
63
+ } from './components/charts/chartLabels';
64
+ export { yearTickInterval } from './components/charts/yearTickInterval';
58
65
  export {
59
66
  formatPercentage,
60
67
  formatCompact,
@@ -5,3 +5,10 @@ export {
5
5
  reportThemeAdoption,
6
6
  THEME_COMPLETENESS_SURFACES,
7
7
  } from './themeCompleteness';
8
+ export {
9
+ formatShortTime,
10
+ formatShortDate,
11
+ formatShortMonth,
12
+ formatShortYear,
13
+ } from '../components/charts/chartLabels';
14
+ export { yearTickInterval } from '../components/charts/yearTickInterval';
@@ -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({ tooltip: { trigger: 'axis' } });
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
  });
@@ -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({ tooltip: { trigger: 'axis' } });
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
+ });
@@ -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
+ });