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

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.
@@ -56,6 +56,11 @@ export interface ChartToolbarConfig {
56
56
  * @default 2
57
57
  */
58
58
  tableHeaderLevel?: 1 | 2 | 3 | 4 | 5 | 6;
59
+ /**
60
+ * Heading level for the chart title.
61
+ * @default 2
62
+ */
63
+ titleLevel?: 1 | 2 | 3 | 4 | 5 | 6;
59
64
  }
60
65
  export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
61
66
  dataSet: Array<object>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-magma/charts",
3
- "version": "15.0.0-rc.2",
3
+ "version": "15.0.0-rc.4",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,10 +17,14 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({
17
17
  disconnect: jest.fn(),
18
18
  }));
19
19
 
20
- // Capture MutationObserver callbacks so we can trigger them manually
21
- 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));
22
26
  global.MutationObserver = jest.fn().mockImplementation(callback => {
23
- mutationObserverCallback = callback;
27
+ mutationObserverCallbacks.push(callback);
24
28
  return {
25
29
  observe: jest.fn(),
26
30
  disconnect: jest.fn(),
@@ -603,6 +607,7 @@ describe('CarbonChart', () => {
603
607
  let otherButton;
604
608
 
605
609
  beforeEach(() => {
610
+ mutationObserverCallbacks = [];
606
611
  jest.useFakeTimers();
607
612
  jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
608
613
  cb(0);
@@ -848,6 +853,27 @@ describe('CarbonChart', () => {
848
853
  ).not.toBeInTheDocument();
849
854
  });
850
855
 
856
+ it('should render the chart title as h2 by default', () => {
857
+ render(<CarbonChart {...toolbarProps} />);
858
+
859
+ expect(
860
+ screen.getByRole('heading', { level: 2, name: chartOptions.title })
861
+ ).toBeInTheDocument();
862
+ });
863
+
864
+ it('should render the chart title at the level set by titleLevel', () => {
865
+ render(
866
+ <CarbonChart {...toolbarProps} chartToolbar={{ titleLevel: 3 }} />
867
+ );
868
+
869
+ expect(
870
+ screen.getByRole('heading', { level: 3, name: chartOptions.title })
871
+ ).toBeInTheDocument();
872
+ expect(
873
+ screen.queryByRole('heading', { level: 2, name: chartOptions.title })
874
+ ).not.toBeInTheDocument();
875
+ });
876
+
851
877
  it('should always render the more options dropdown with built-in download items', () => {
852
878
  render(<CarbonChart {...toolbarProps} />);
853
879
 
@@ -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,
@@ -103,6 +105,11 @@ export interface ChartToolbarConfig {
103
105
  * @default 2
104
106
  */
105
107
  tableHeaderLevel?: 1 | 2 | 3 | 4 | 5 | 6;
108
+ /**
109
+ * Heading level for the chart title.
110
+ * @default 2
111
+ */
112
+ titleLevel?: 1 | 2 | 3 | 4 | 5 | 6;
106
113
  }
107
114
 
108
115
  export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
@@ -1022,7 +1029,11 @@ function CarbonChartToolbar({
1022
1029
  isInverse={isInverse}
1023
1030
  theme={theme}
1024
1031
  >
1025
- <ChartTitle isInverse={isInverse} theme={theme}>
1032
+ <ChartTitle
1033
+ as={`h${config.titleLevel ?? 2}` as keyof JSX.IntrinsicElements}
1034
+ isInverse={isInverse}
1035
+ theme={theme}
1036
+ >
1026
1037
  {resolvedTitle}
1027
1038
  </ChartTitle>
1028
1039
  <ToolbarActions theme={theme}>
@@ -1075,7 +1086,9 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1075
1086
 
1076
1087
  const [isTableOpen, setIsTableOpen] = React.useState(false);
1077
1088
  const [isFullscreen, setIsFullscreen] = React.useState(false);
1089
+ const [legendAnnouncement, setLegendAnnouncement] = React.useState('');
1078
1090
  const lastTableTriggerRef = React.useRef<HTMLButtonElement | null>(null);
1091
+ const legendAnnouncedRef = React.useRef(false);
1079
1092
 
1080
1093
  const mergedRef = React.useCallback(
1081
1094
  (node: HTMLDivElement | null) => {
@@ -1179,7 +1192,9 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1179
1192
  if (parent?.tagName === 'FIELDSET') {
1180
1193
  const existingCaption = parent.querySelector('legend');
1181
1194
 
1182
- if (existingCaption) existingCaption.textContent = legendLabel;
1195
+ if (existingCaption && existingCaption.textContent !== legendLabel) {
1196
+ existingCaption.textContent = legendLabel;
1197
+ }
1183
1198
 
1184
1199
  return;
1185
1200
  }
@@ -1290,12 +1305,61 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1290
1305
  return () => clearTimeout(timer);
1291
1306
  }, [type, dataSet]);
1292
1307
 
1308
+ React.useEffect(() => {
1309
+ const wrapper = internalRef.current;
1310
+
1311
+ if (!wrapper) return;
1312
+
1313
+ const observer = new MutationObserver(() => {
1314
+ const allItems = wrapper.querySelectorAll<HTMLElement>(
1315
+ '.legend-item:not(.additional)'
1316
+ );
1317
+
1318
+ if (allItems.length <= 1) return;
1319
+
1320
+ const activeLabels: string[] = [];
1321
+
1322
+ allItems.forEach(el => {
1323
+ const checkbox = el.querySelector<HTMLElement>('.checkbox');
1324
+ const label = el.querySelector('p');
1325
+
1326
+ if (checkbox?.getAttribute('aria-checked') === 'true' && label) {
1327
+ activeLabels.push(label.textContent?.trim() || '');
1328
+ }
1329
+ });
1330
+
1331
+ if (activeLabels.length === 1) {
1332
+ if (!legendAnnouncedRef.current) {
1333
+ legendAnnouncedRef.current = true;
1334
+ setLegendAnnouncement(`Only ${activeLabels[0]} is selected`);
1335
+ }
1336
+ } else if (activeLabels.length === allItems.length) {
1337
+ legendAnnouncedRef.current = false;
1338
+ setLegendAnnouncement('All items selected');
1339
+ } else {
1340
+ legendAnnouncedRef.current = false;
1341
+ setLegendAnnouncement('');
1342
+ }
1343
+ });
1344
+
1345
+ observer.observe(wrapper, {
1346
+ subtree: true,
1347
+ attributes: true,
1348
+ attributeFilter: ['aria-checked'],
1349
+ });
1350
+
1351
+ return () => observer.disconnect();
1352
+ }, [type, dataSet]);
1353
+
1293
1354
  const groupsLength = Object.keys(buildColors()).length;
1294
1355
 
1295
1356
  const showTable = chartToolbar?.showAsTable !== false;
1296
1357
 
1297
1358
  return (
1298
1359
  <FullscreenRoot ref={mergedRef} isInverse={isInverse} theme={theme}>
1360
+ <VisuallyHidden>
1361
+ <Announce>{legendAnnouncement}</Announce>
1362
+ </VisuallyHidden>
1299
1363
  <CarbonChartWrapper
1300
1364
  data-testid={testId}
1301
1365
  isInverse={isInverse}
@@ -18,12 +18,19 @@ export default {
18
18
  control: { type: 'select' },
19
19
  options: Object.values(CarbonChartType),
20
20
  },
21
+ titleLevel: {
22
+ control: { type: 'select' },
23
+ options: [1, 2, 3, 4, 5, 6],
24
+ description: 'Heading level for the chart title (h1–h6).',
25
+ },
21
26
  },
22
27
  } as Meta;
23
28
 
24
- const Template: StoryFn<CarbonChartProps> = args => (
29
+ const Template: StoryFn<
30
+ CarbonChartProps & { titleLevel?: 1 | 2 | 3 | 4 | 5 | 6 }
31
+ > = ({ titleLevel, ...args }) => (
25
32
  <Card isInverse={args.isInverse} style={{ padding: '12px' }}>
26
- <CarbonChart {...args} />
33
+ <CarbonChart {...args} chartToolbar={{ titleLevel }} />
27
34
  </Card>
28
35
  );
29
36