@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.
@@ -4,6 +4,7 @@ export interface ChartToolbarI18n {
4
4
  downloadAsJpg: string;
5
5
  downloadAsPng: string;
6
6
  exitFullScreen: string;
7
+ legendInstructions: string;
7
8
  makeFullScreen: string;
8
9
  moreOptionsAriaLabel: string;
9
10
  showAsTableTooltip: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-magma/charts",
3
- "version": "15.0.0-rc.1",
3
+ "version": "15.0.0-rc.3",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 { ThemeContext, magma, DropdownMenuItem } from 'react-magma-dom';
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
- let mutationObserverCallback;
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
- mutationObserverCallback = callback;
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,