@react-magma/charts 15.0.0-rc.1 → 15.0.0-rc.3
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/dist/charts.js +106 -6
- package/dist/charts.js.map +1 -1
- package/dist/charts.modern.module.js +107 -7
- package/dist/charts.modern.module.js.map +1 -1
- package/dist/charts.umd.js +270 -13
- package/dist/charts.umd.js.map +1 -1
- package/dist/components/ChartTable/chartToolbarI18n.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/CarbonChart/CarbonChart.test.js +142 -4
- package/src/components/CarbonChart/CarbonChart.tsx +142 -0
- package/src/components/ChartTable/chartToolbarI18n.ts +4 -0
package/package.json
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
|
|
3
3
|
import { act, render, screen, fireEvent } from '@testing-library/react';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ThemeContext,
|
|
6
|
+
magma,
|
|
7
|
+
DropdownMenuItem,
|
|
8
|
+
I18nContext,
|
|
9
|
+
defaultI18n,
|
|
10
|
+
} from 'react-magma-dom';
|
|
5
11
|
|
|
6
12
|
import { CarbonChart, CarbonChartType } from '.';
|
|
7
13
|
|
|
@@ -11,10 +17,14 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
|
|
11
17
|
disconnect: jest.fn(),
|
|
12
18
|
}));
|
|
13
19
|
|
|
14
|
-
// Capture MutationObserver callbacks so we can trigger them manually
|
|
15
|
-
|
|
20
|
+
// Capture MutationObserver callbacks so we can trigger them manually.
|
|
21
|
+
// Multiple observers may be created per component; we collect all of them and
|
|
22
|
+
// call each one so that no observer is silently skipped.
|
|
23
|
+
let mutationObserverCallbacks = [];
|
|
24
|
+
const mutationObserverCallback = mutations =>
|
|
25
|
+
mutationObserverCallbacks.forEach(cb => cb(mutations));
|
|
16
26
|
global.MutationObserver = jest.fn().mockImplementation(callback => {
|
|
17
|
-
|
|
27
|
+
mutationObserverCallbacks.push(callback);
|
|
18
28
|
return {
|
|
19
29
|
observe: jest.fn(),
|
|
20
30
|
disconnect: jest.fn(),
|
|
@@ -597,6 +607,7 @@ describe('CarbonChart', () => {
|
|
|
597
607
|
let otherButton;
|
|
598
608
|
|
|
599
609
|
beforeEach(() => {
|
|
610
|
+
mutationObserverCallbacks = [];
|
|
600
611
|
jest.useFakeTimers();
|
|
601
612
|
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
|
|
602
613
|
cb(0);
|
|
@@ -895,4 +906,131 @@ describe('CarbonChart', () => {
|
|
|
895
906
|
).toBeInTheDocument();
|
|
896
907
|
});
|
|
897
908
|
});
|
|
909
|
+
|
|
910
|
+
describe('Legend accessibility wrap', () => {
|
|
911
|
+
const testId = 'legend-a11y';
|
|
912
|
+
|
|
913
|
+
it('wraps the Carbon legend in a fieldset with a legend caption derived from the chart title', () => {
|
|
914
|
+
const { getByTestId } = render(
|
|
915
|
+
<CarbonChart
|
|
916
|
+
testId={testId}
|
|
917
|
+
dataSet={dataSet}
|
|
918
|
+
options={chartOptions}
|
|
919
|
+
type={CarbonChartType.bar}
|
|
920
|
+
/>
|
|
921
|
+
);
|
|
922
|
+
const wrapper = getByTestId(testId);
|
|
923
|
+
|
|
924
|
+
const fieldset = wrapper.querySelector('.cds--cc--legend-fieldset');
|
|
925
|
+
expect(fieldset).not.toBeNull();
|
|
926
|
+
expect(fieldset.tagName).toBe('FIELDSET');
|
|
927
|
+
expect(fieldset.querySelector('.cds--cc--legend')).not.toBeNull();
|
|
928
|
+
|
|
929
|
+
const caption = fieldset.querySelector('legend');
|
|
930
|
+
expect(caption).not.toBeNull();
|
|
931
|
+
expect(caption.textContent).toBe(
|
|
932
|
+
`${chartOptions.title}. Checking these checkboxes will update the chart.`
|
|
933
|
+
);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('uses ariaLabel for the legend caption when provided', () => {
|
|
937
|
+
const ariaLabel = 'Quarterly sales chart';
|
|
938
|
+
const { getByTestId } = render(
|
|
939
|
+
<CarbonChart
|
|
940
|
+
testId={testId}
|
|
941
|
+
ariaLabel={ariaLabel}
|
|
942
|
+
dataSet={dataSet}
|
|
943
|
+
options={chartOptions}
|
|
944
|
+
type={CarbonChartType.bar}
|
|
945
|
+
/>
|
|
946
|
+
);
|
|
947
|
+
const wrapper = getByTestId(testId);
|
|
948
|
+
|
|
949
|
+
const caption = wrapper.querySelector(
|
|
950
|
+
'.cds--cc--legend-fieldset > legend'
|
|
951
|
+
);
|
|
952
|
+
expect(caption).not.toBeNull();
|
|
953
|
+
expect(caption.textContent).toBe(
|
|
954
|
+
`${ariaLabel}. Checking these checkboxes will update the chart.`
|
|
955
|
+
);
|
|
956
|
+
});
|
|
957
|
+
|
|
958
|
+
it('does not duplicate the fieldset when the mutation observer fires again with no DOM changes', () => {
|
|
959
|
+
const { getByTestId } = render(
|
|
960
|
+
<CarbonChart
|
|
961
|
+
testId={testId}
|
|
962
|
+
dataSet={dataSet}
|
|
963
|
+
options={chartOptions}
|
|
964
|
+
type={CarbonChartType.bar}
|
|
965
|
+
/>
|
|
966
|
+
);
|
|
967
|
+
const wrapper = getByTestId(testId);
|
|
968
|
+
|
|
969
|
+
const before = wrapper.querySelectorAll('.cds--cc--legend-fieldset');
|
|
970
|
+
expect(before).toHaveLength(1);
|
|
971
|
+
|
|
972
|
+
act(() => {
|
|
973
|
+
mutationObserverCallback?.();
|
|
974
|
+
mutationObserverCallback?.();
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
const after = wrapper.querySelectorAll('.cds--cc--legend-fieldset');
|
|
978
|
+
expect(after).toHaveLength(1);
|
|
979
|
+
expect(after[0]).toBe(before[0]);
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
it('replaces Carbon\'s generic "Data groups" aria-label with semantic list roles on the legend and its items', () => {
|
|
983
|
+
const { getByTestId } = render(
|
|
984
|
+
<CarbonChart
|
|
985
|
+
testId={testId}
|
|
986
|
+
dataSet={dataSet}
|
|
987
|
+
options={chartOptions}
|
|
988
|
+
type={CarbonChartType.bar}
|
|
989
|
+
/>
|
|
990
|
+
);
|
|
991
|
+
const wrapper = getByTestId(testId);
|
|
992
|
+
|
|
993
|
+
const legend = wrapper.querySelector('.cds--cc--legend');
|
|
994
|
+
expect(legend).not.toBeNull();
|
|
995
|
+
expect(legend.getAttribute('aria-label')).toBeNull();
|
|
996
|
+
expect(legend.getAttribute('role')).toBe('list');
|
|
997
|
+
|
|
998
|
+
const items = legend.querySelectorAll('.legend-item');
|
|
999
|
+
expect(items.length).toBeGreaterThan(0);
|
|
1000
|
+
items.forEach(item => {
|
|
1001
|
+
expect(item.getAttribute('role')).toBe('listitem');
|
|
1002
|
+
});
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it('uses translated legend instructions from I18nContext', () => {
|
|
1006
|
+
const translatedInstructions =
|
|
1007
|
+
'Marquer ces cases mettra à jour le graphique.';
|
|
1008
|
+
const i18nValue = {
|
|
1009
|
+
...defaultI18n,
|
|
1010
|
+
charts: {
|
|
1011
|
+
...defaultI18n.charts,
|
|
1012
|
+
toolbar: { legendInstructions: translatedInstructions },
|
|
1013
|
+
},
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
const { getByTestId } = render(
|
|
1017
|
+
<I18nContext.Provider value={i18nValue}>
|
|
1018
|
+
<CarbonChart
|
|
1019
|
+
testId={testId}
|
|
1020
|
+
dataSet={dataSet}
|
|
1021
|
+
options={chartOptions}
|
|
1022
|
+
type={CarbonChartType.bar}
|
|
1023
|
+
/>
|
|
1024
|
+
</I18nContext.Provider>
|
|
1025
|
+
);
|
|
1026
|
+
const wrapper = getByTestId(testId);
|
|
1027
|
+
|
|
1028
|
+
const caption = wrapper.querySelector(
|
|
1029
|
+
'.cds--cc--legend-fieldset > legend'
|
|
1030
|
+
);
|
|
1031
|
+
expect(caption.textContent).toBe(
|
|
1032
|
+
`${chartOptions.title}. ${translatedInstructions}`
|
|
1033
|
+
);
|
|
1034
|
+
});
|
|
1035
|
+
});
|
|
898
1036
|
});
|
|
@@ -24,11 +24,13 @@ import {
|
|
|
24
24
|
import styled from '@emotion/styled';
|
|
25
25
|
import { transparentize } from 'polished';
|
|
26
26
|
import {
|
|
27
|
+
Announce,
|
|
27
28
|
DropdownDivider,
|
|
28
29
|
DropdownMenuItem,
|
|
29
30
|
ThemeInterface,
|
|
30
31
|
ThemeContext,
|
|
31
32
|
useIsInverse,
|
|
33
|
+
VisuallyHidden,
|
|
32
34
|
} from 'react-magma-dom';
|
|
33
35
|
import {
|
|
34
36
|
FullscreenExitIcon,
|
|
@@ -38,6 +40,7 @@ import {
|
|
|
38
40
|
} from 'react-magma-icons';
|
|
39
41
|
|
|
40
42
|
import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
|
|
43
|
+
// @ts-ignore
|
|
41
44
|
import './carbon-charts.css';
|
|
42
45
|
import {
|
|
43
46
|
ChartFullscreenButton,
|
|
@@ -161,6 +164,23 @@ const CarbonChartWrapper = styled.div<{
|
|
|
161
164
|
groupsLength: number;
|
|
162
165
|
theme: ThemeInterface;
|
|
163
166
|
}>`
|
|
167
|
+
.cds--cc--legend-fieldset {
|
|
168
|
+
border: 0;
|
|
169
|
+
margin: 0;
|
|
170
|
+
min-inline-size: 0;
|
|
171
|
+
padding: 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.cds--cc--legend-fieldset > legend {
|
|
175
|
+
clip: rect(1px, 1px, 1px, 1px);
|
|
176
|
+
height: 1px;
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
position: absolute;
|
|
179
|
+
top: auto;
|
|
180
|
+
white-space: nowrap;
|
|
181
|
+
width: 1px;
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
&:fullscreen,
|
|
165
185
|
&:-webkit-full-screen {
|
|
166
186
|
background: ${props =>
|
|
@@ -1057,7 +1077,9 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
1057
1077
|
|
|
1058
1078
|
const [isTableOpen, setIsTableOpen] = React.useState(false);
|
|
1059
1079
|
const [isFullscreen, setIsFullscreen] = React.useState(false);
|
|
1080
|
+
const [legendAnnouncement, setLegendAnnouncement] = React.useState('');
|
|
1060
1081
|
const lastTableTriggerRef = React.useRef<HTMLButtonElement | null>(null);
|
|
1082
|
+
const legendAnnouncedRef = React.useRef(false);
|
|
1061
1083
|
|
|
1062
1084
|
const mergedRef = React.useCallback(
|
|
1063
1085
|
(node: HTMLDivElement | null) => {
|
|
@@ -1134,6 +1156,57 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
1134
1156
|
const chartTitle: string =
|
|
1135
1157
|
(options as any).title || toolbarI18n.defaultTitle;
|
|
1136
1158
|
|
|
1159
|
+
const legendLabel = `${ariaLabel || chartTitle}. ${toolbarI18n.legendInstructions}`;
|
|
1160
|
+
|
|
1161
|
+
React.useEffect(() => {
|
|
1162
|
+
const container = internalRef.current;
|
|
1163
|
+
|
|
1164
|
+
if (!container) return;
|
|
1165
|
+
|
|
1166
|
+
const applyListSemantics = (legend: Element) => {
|
|
1167
|
+
legend.removeAttribute('aria-label');
|
|
1168
|
+
legend.setAttribute('role', 'list');
|
|
1169
|
+
legend
|
|
1170
|
+
.querySelectorAll('.legend-item')
|
|
1171
|
+
.forEach(item => item.setAttribute('role', 'listitem'));
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
const wrapLegend = () => {
|
|
1175
|
+
const legend = container.querySelector('.cds--cc--legend');
|
|
1176
|
+
|
|
1177
|
+
if (!legend) return;
|
|
1178
|
+
|
|
1179
|
+
applyListSemantics(legend);
|
|
1180
|
+
|
|
1181
|
+
const parent = legend.parentElement;
|
|
1182
|
+
|
|
1183
|
+
if (parent?.tagName === 'FIELDSET') {
|
|
1184
|
+
const existingCaption = parent.querySelector('legend');
|
|
1185
|
+
|
|
1186
|
+
if (existingCaption && existingCaption.textContent !== legendLabel) {
|
|
1187
|
+
existingCaption.textContent = legendLabel;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
const fieldset = document.createElement('fieldset');
|
|
1194
|
+
const caption = document.createElement('legend');
|
|
1195
|
+
|
|
1196
|
+
fieldset.className = 'cds--cc--legend-fieldset';
|
|
1197
|
+
caption.textContent = legendLabel;
|
|
1198
|
+
legend.before(fieldset);
|
|
1199
|
+
fieldset.append(caption, legend);
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
wrapLegend();
|
|
1203
|
+
const observer = new MutationObserver(wrapLegend);
|
|
1204
|
+
|
|
1205
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
1206
|
+
|
|
1207
|
+
return () => observer.disconnect();
|
|
1208
|
+
}, [legendLabel]);
|
|
1209
|
+
|
|
1137
1210
|
const handleModalDownloadCsv = React.useCallback(() => {
|
|
1138
1211
|
downloadCsv(dataSet as Array<Record<string, unknown>>, chartTitle);
|
|
1139
1212
|
}, [dataSet, chartTitle]);
|
|
@@ -1203,12 +1276,81 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
|
|
|
1203
1276
|
|
|
1204
1277
|
const ChartType = allCharts[type] as any;
|
|
1205
1278
|
|
|
1279
|
+
React.useEffect(() => {
|
|
1280
|
+
const timer = setTimeout(() => {
|
|
1281
|
+
if (internalRef.current) {
|
|
1282
|
+
internalRef.current
|
|
1283
|
+
.querySelectorAll<SVGGElement>('g[aria-label]')
|
|
1284
|
+
.forEach(g => {
|
|
1285
|
+
const role = g.getAttribute('role');
|
|
1286
|
+
|
|
1287
|
+
if (!role) {
|
|
1288
|
+
g.setAttribute('role', 'img');
|
|
1289
|
+
} else if (role === 'graphics-object group') {
|
|
1290
|
+
g.setAttribute('role', 'graphics-object');
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
}
|
|
1294
|
+
}, 0);
|
|
1295
|
+
|
|
1296
|
+
return () => clearTimeout(timer);
|
|
1297
|
+
}, [type, dataSet]);
|
|
1298
|
+
|
|
1299
|
+
React.useEffect(() => {
|
|
1300
|
+
const wrapper = internalRef.current;
|
|
1301
|
+
|
|
1302
|
+
if (!wrapper) return;
|
|
1303
|
+
|
|
1304
|
+
const observer = new MutationObserver(() => {
|
|
1305
|
+
const allItems = wrapper.querySelectorAll<HTMLElement>(
|
|
1306
|
+
'.legend-item:not(.additional)'
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
if (allItems.length <= 1) return;
|
|
1310
|
+
|
|
1311
|
+
const activeLabels: string[] = [];
|
|
1312
|
+
|
|
1313
|
+
allItems.forEach(el => {
|
|
1314
|
+
const checkbox = el.querySelector<HTMLElement>('.checkbox');
|
|
1315
|
+
const label = el.querySelector('p');
|
|
1316
|
+
|
|
1317
|
+
if (checkbox?.getAttribute('aria-checked') === 'true' && label) {
|
|
1318
|
+
activeLabels.push(label.textContent?.trim() || '');
|
|
1319
|
+
}
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
if (activeLabels.length === 1) {
|
|
1323
|
+
if (!legendAnnouncedRef.current) {
|
|
1324
|
+
legendAnnouncedRef.current = true;
|
|
1325
|
+
setLegendAnnouncement(`Only ${activeLabels[0]} is selected`);
|
|
1326
|
+
}
|
|
1327
|
+
} else if (activeLabels.length === allItems.length) {
|
|
1328
|
+
legendAnnouncedRef.current = false;
|
|
1329
|
+
setLegendAnnouncement('All items selected');
|
|
1330
|
+
} else {
|
|
1331
|
+
legendAnnouncedRef.current = false;
|
|
1332
|
+
setLegendAnnouncement('');
|
|
1333
|
+
}
|
|
1334
|
+
});
|
|
1335
|
+
|
|
1336
|
+
observer.observe(wrapper, {
|
|
1337
|
+
subtree: true,
|
|
1338
|
+
attributes: true,
|
|
1339
|
+
attributeFilter: ['aria-checked'],
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
return () => observer.disconnect();
|
|
1343
|
+
}, [type, dataSet]);
|
|
1344
|
+
|
|
1206
1345
|
const groupsLength = Object.keys(buildColors()).length;
|
|
1207
1346
|
|
|
1208
1347
|
const showTable = chartToolbar?.showAsTable !== false;
|
|
1209
1348
|
|
|
1210
1349
|
return (
|
|
1211
1350
|
<FullscreenRoot ref={mergedRef} isInverse={isInverse} theme={theme}>
|
|
1351
|
+
<VisuallyHidden>
|
|
1352
|
+
<Announce>{legendAnnouncement}</Announce>
|
|
1353
|
+
</VisuallyHidden>
|
|
1212
1354
|
<CarbonChartWrapper
|
|
1213
1355
|
data-testid={testId}
|
|
1214
1356
|
isInverse={isInverse}
|
|
@@ -8,6 +8,7 @@ export interface ChartToolbarI18n {
|
|
|
8
8
|
downloadAsJpg: string;
|
|
9
9
|
downloadAsPng: string;
|
|
10
10
|
exitFullScreen: string;
|
|
11
|
+
legendInstructions: string;
|
|
11
12
|
makeFullScreen: string;
|
|
12
13
|
moreOptionsAriaLabel: string;
|
|
13
14
|
showAsTableTooltip: string;
|
|
@@ -20,6 +21,7 @@ const defaults: ChartToolbarI18n = {
|
|
|
20
21
|
downloadAsJpg: 'Download as JPG',
|
|
21
22
|
downloadAsPng: 'Download as PNG',
|
|
22
23
|
exitFullScreen: 'Exit full screen',
|
|
24
|
+
legendInstructions: 'Checking these checkboxes will update the chart.',
|
|
23
25
|
makeFullScreen: 'Make full screen',
|
|
24
26
|
moreOptionsAriaLabel: 'More options',
|
|
25
27
|
showAsTableTooltip: 'Show as table',
|
|
@@ -44,6 +46,8 @@ export function useChartToolbarI18n(): ChartToolbarI18n {
|
|
|
44
46
|
downloadAsJpg: toolbar.downloadAsJpg ?? defaults.downloadAsJpg,
|
|
45
47
|
downloadAsPng: toolbar.downloadAsPng ?? defaults.downloadAsPng,
|
|
46
48
|
exitFullScreen: toolbar.exitFullScreen ?? defaults.exitFullScreen,
|
|
49
|
+
legendInstructions:
|
|
50
|
+
toolbar.legendInstructions ?? defaults.legendInstructions,
|
|
47
51
|
makeFullScreen: toolbar.makeFullScreen ?? defaults.makeFullScreen,
|
|
48
52
|
moreOptionsAriaLabel:
|
|
49
53
|
toolbar.moreOptionsAriaLabel ?? defaults.moreOptionsAriaLabel,
|