@react-magma/charts 15.0.0-rc.1 → 15.0.0-rc.2

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.2",
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
 
@@ -895,4 +901,131 @@ describe('CarbonChart', () => {
895
901
  ).toBeInTheDocument();
896
902
  });
897
903
  });
904
+
905
+ describe('Legend accessibility wrap', () => {
906
+ const testId = 'legend-a11y';
907
+
908
+ it('wraps the Carbon legend in a fieldset with a legend caption derived from the chart title', () => {
909
+ const { getByTestId } = render(
910
+ <CarbonChart
911
+ testId={testId}
912
+ dataSet={dataSet}
913
+ options={chartOptions}
914
+ type={CarbonChartType.bar}
915
+ />
916
+ );
917
+ const wrapper = getByTestId(testId);
918
+
919
+ const fieldset = wrapper.querySelector('.cds--cc--legend-fieldset');
920
+ expect(fieldset).not.toBeNull();
921
+ expect(fieldset.tagName).toBe('FIELDSET');
922
+ expect(fieldset.querySelector('.cds--cc--legend')).not.toBeNull();
923
+
924
+ const caption = fieldset.querySelector('legend');
925
+ expect(caption).not.toBeNull();
926
+ expect(caption.textContent).toBe(
927
+ `${chartOptions.title}. Checking these checkboxes will update the chart.`
928
+ );
929
+ });
930
+
931
+ it('uses ariaLabel for the legend caption when provided', () => {
932
+ const ariaLabel = 'Quarterly sales chart';
933
+ const { getByTestId } = render(
934
+ <CarbonChart
935
+ testId={testId}
936
+ ariaLabel={ariaLabel}
937
+ dataSet={dataSet}
938
+ options={chartOptions}
939
+ type={CarbonChartType.bar}
940
+ />
941
+ );
942
+ const wrapper = getByTestId(testId);
943
+
944
+ const caption = wrapper.querySelector(
945
+ '.cds--cc--legend-fieldset > legend'
946
+ );
947
+ expect(caption).not.toBeNull();
948
+ expect(caption.textContent).toBe(
949
+ `${ariaLabel}. Checking these checkboxes will update the chart.`
950
+ );
951
+ });
952
+
953
+ it('does not duplicate the fieldset when the mutation observer fires again with no DOM changes', () => {
954
+ const { getByTestId } = render(
955
+ <CarbonChart
956
+ testId={testId}
957
+ dataSet={dataSet}
958
+ options={chartOptions}
959
+ type={CarbonChartType.bar}
960
+ />
961
+ );
962
+ const wrapper = getByTestId(testId);
963
+
964
+ const before = wrapper.querySelectorAll('.cds--cc--legend-fieldset');
965
+ expect(before).toHaveLength(1);
966
+
967
+ act(() => {
968
+ mutationObserverCallback?.();
969
+ mutationObserverCallback?.();
970
+ });
971
+
972
+ const after = wrapper.querySelectorAll('.cds--cc--legend-fieldset');
973
+ expect(after).toHaveLength(1);
974
+ expect(after[0]).toBe(before[0]);
975
+ });
976
+
977
+ it('replaces Carbon\'s generic "Data groups" aria-label with semantic list roles on the legend and its items', () => {
978
+ const { getByTestId } = render(
979
+ <CarbonChart
980
+ testId={testId}
981
+ dataSet={dataSet}
982
+ options={chartOptions}
983
+ type={CarbonChartType.bar}
984
+ />
985
+ );
986
+ const wrapper = getByTestId(testId);
987
+
988
+ const legend = wrapper.querySelector('.cds--cc--legend');
989
+ expect(legend).not.toBeNull();
990
+ expect(legend.getAttribute('aria-label')).toBeNull();
991
+ expect(legend.getAttribute('role')).toBe('list');
992
+
993
+ const items = legend.querySelectorAll('.legend-item');
994
+ expect(items.length).toBeGreaterThan(0);
995
+ items.forEach(item => {
996
+ expect(item.getAttribute('role')).toBe('listitem');
997
+ });
998
+ });
999
+
1000
+ it('uses translated legend instructions from I18nContext', () => {
1001
+ const translatedInstructions =
1002
+ 'Marquer ces cases mettra à jour le graphique.';
1003
+ const i18nValue = {
1004
+ ...defaultI18n,
1005
+ charts: {
1006
+ ...defaultI18n.charts,
1007
+ toolbar: { legendInstructions: translatedInstructions },
1008
+ },
1009
+ };
1010
+
1011
+ const { getByTestId } = render(
1012
+ <I18nContext.Provider value={i18nValue}>
1013
+ <CarbonChart
1014
+ testId={testId}
1015
+ dataSet={dataSet}
1016
+ options={chartOptions}
1017
+ type={CarbonChartType.bar}
1018
+ />
1019
+ </I18nContext.Provider>
1020
+ );
1021
+ const wrapper = getByTestId(testId);
1022
+
1023
+ const caption = wrapper.querySelector(
1024
+ '.cds--cc--legend-fieldset > legend'
1025
+ );
1026
+ expect(caption.textContent).toBe(
1027
+ `${chartOptions.title}. ${translatedInstructions}`
1028
+ );
1029
+ });
1030
+ });
898
1031
  });
@@ -38,6 +38,7 @@ import {
38
38
  } from 'react-magma-icons';
39
39
 
40
40
  import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
41
+ // @ts-ignore
41
42
  import './carbon-charts.css';
42
43
  import {
43
44
  ChartFullscreenButton,
@@ -161,6 +162,23 @@ const CarbonChartWrapper = styled.div<{
161
162
  groupsLength: number;
162
163
  theme: ThemeInterface;
163
164
  }>`
165
+ .cds--cc--legend-fieldset {
166
+ border: 0;
167
+ margin: 0;
168
+ min-inline-size: 0;
169
+ padding: 0;
170
+ }
171
+
172
+ .cds--cc--legend-fieldset > legend {
173
+ clip: rect(1px, 1px, 1px, 1px);
174
+ height: 1px;
175
+ overflow: hidden;
176
+ position: absolute;
177
+ top: auto;
178
+ white-space: nowrap;
179
+ width: 1px;
180
+ }
181
+
164
182
  &:fullscreen,
165
183
  &:-webkit-full-screen {
166
184
  background: ${props =>
@@ -1134,6 +1152,55 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1134
1152
  const chartTitle: string =
1135
1153
  (options as any).title || toolbarI18n.defaultTitle;
1136
1154
 
1155
+ const legendLabel = `${ariaLabel || chartTitle}. ${toolbarI18n.legendInstructions}`;
1156
+
1157
+ React.useEffect(() => {
1158
+ const container = internalRef.current;
1159
+
1160
+ if (!container) return;
1161
+
1162
+ const applyListSemantics = (legend: Element) => {
1163
+ legend.removeAttribute('aria-label');
1164
+ legend.setAttribute('role', 'list');
1165
+ legend
1166
+ .querySelectorAll('.legend-item')
1167
+ .forEach(item => item.setAttribute('role', 'listitem'));
1168
+ };
1169
+
1170
+ const wrapLegend = () => {
1171
+ const legend = container.querySelector('.cds--cc--legend');
1172
+
1173
+ if (!legend) return;
1174
+
1175
+ applyListSemantics(legend);
1176
+
1177
+ const parent = legend.parentElement;
1178
+
1179
+ if (parent?.tagName === 'FIELDSET') {
1180
+ const existingCaption = parent.querySelector('legend');
1181
+
1182
+ if (existingCaption) existingCaption.textContent = legendLabel;
1183
+
1184
+ return;
1185
+ }
1186
+
1187
+ const fieldset = document.createElement('fieldset');
1188
+ const caption = document.createElement('legend');
1189
+
1190
+ fieldset.className = 'cds--cc--legend-fieldset';
1191
+ caption.textContent = legendLabel;
1192
+ legend.before(fieldset);
1193
+ fieldset.append(caption, legend);
1194
+ };
1195
+
1196
+ wrapLegend();
1197
+ const observer = new MutationObserver(wrapLegend);
1198
+
1199
+ observer.observe(container, { childList: true, subtree: true });
1200
+
1201
+ return () => observer.disconnect();
1202
+ }, [legendLabel]);
1203
+
1137
1204
  const handleModalDownloadCsv = React.useCallback(() => {
1138
1205
  downloadCsv(dataSet as Array<Record<string, unknown>>, chartTitle);
1139
1206
  }, [dataSet, chartTitle]);
@@ -1203,6 +1270,26 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1203
1270
 
1204
1271
  const ChartType = allCharts[type] as any;
1205
1272
 
1273
+ React.useEffect(() => {
1274
+ const timer = setTimeout(() => {
1275
+ if (internalRef.current) {
1276
+ internalRef.current
1277
+ .querySelectorAll<SVGGElement>('g[aria-label]')
1278
+ .forEach(g => {
1279
+ const role = g.getAttribute('role');
1280
+
1281
+ if (!role) {
1282
+ g.setAttribute('role', 'img');
1283
+ } else if (role === 'graphics-object group') {
1284
+ g.setAttribute('role', 'graphics-object');
1285
+ }
1286
+ });
1287
+ }
1288
+ }, 0);
1289
+
1290
+ return () => clearTimeout(timer);
1291
+ }, [type, dataSet]);
1292
+
1206
1293
  const groupsLength = Object.keys(buildColors()).length;
1207
1294
 
1208
1295
  const showTable = chartToolbar?.showAsTable !== false;
@@ -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,