@react-magma/charts 13.1.0 → 13.1.1

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.
Files changed (33) hide show
  1. package/dist/charts.js +222 -114
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.modern.module.js +222 -114
  4. package/dist/charts.modern.module.js.map +1 -1
  5. package/dist/charts.umd.js +604 -306
  6. package/dist/charts.umd.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/components/CarbonChart/CarbonChart.test.js +164 -0
  9. package/src/components/CarbonChart/CarbonChart.tsx +252 -119
  10. package/src/components/CarbonChart/CarbonChartArea.stories.tsx +1 -1
  11. package/src/components/CarbonChart/CarbonChartAreaStacked.stories.tsx +1 -1
  12. package/src/components/CarbonChart/CarbonChartBar.stories.tsx +1 -1
  13. package/src/components/CarbonChart/CarbonChartBarFloating.stories.tsx +1 -1
  14. package/src/components/CarbonChart/CarbonChartBarGrouped.stories.tsx +1 -1
  15. package/src/components/CarbonChart/CarbonChartBarStacked.stories.tsx +1 -1
  16. package/src/components/CarbonChart/CarbonChartBoxplot.stories.tsx +1 -1
  17. package/src/components/CarbonChart/CarbonChartBubble.stories.tsx +1 -1
  18. package/src/components/CarbonChart/CarbonChartBullet.stories.tsx +1 -1
  19. package/src/components/CarbonChart/CarbonChartCombo.stories.tsx +1 -1
  20. package/src/components/CarbonChart/CarbonChartDonut.stories.tsx +3 -1
  21. package/src/components/CarbonChart/CarbonChartGauge.stories.tsx +1 -1
  22. package/src/components/CarbonChart/CarbonChartHistogram.stories.tsx +1 -1
  23. package/src/components/CarbonChart/CarbonChartLine.stories.tsx +1 -1
  24. package/src/components/CarbonChart/CarbonChartLollipop.stories.tsx +1 -1
  25. package/src/components/CarbonChart/CarbonChartMeter.stories.tsx +1 -1
  26. package/src/components/CarbonChart/CarbonChartPie.stories.tsx +1 -1
  27. package/src/components/CarbonChart/CarbonChartRadar.stories.tsx +1 -1
  28. package/src/components/CarbonChart/CarbonChartScatter.stories.tsx +1 -1
  29. package/src/components/CarbonChart/CarbonChartSparkline.stories.tsx +1 -1
  30. package/src/components/CarbonChart/CarbonChartStep.stories.tsx +1 -1
  31. package/src/components/ChartTable/ChartMoreOptionsButton.tsx +1 -0
  32. package/dist/components/ChartTable/ChartTable.test.d.ts +0 -1
  33. /package/src/components/ChartTable/{ChartTable.test.tsx → ChartTable.test.js} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-magma/charts",
3
- "version": "13.1.0",
3
+ "version": "13.1.1",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,7 +47,7 @@
47
47
  "identity-obj-proxy": "^3.0.0",
48
48
  "react": "^17.0.2",
49
49
  "react-dom": "^17.0.2",
50
- "react-magma-dom": "^4.13.0",
50
+ "react-magma-dom": "^4.14.0",
51
51
  "react-magma-icons": "^3.2.5",
52
52
  "rollup": "^4.52.4",
53
53
  "rollup-plugin-postcss": "^4.0.2"
@@ -57,7 +57,7 @@
57
57
  "@emotion/styled": "^11.13.0",
58
58
  "react": "^17.0.2",
59
59
  "react-dom": "^17.0.2",
60
- "react-magma-dom": "^4.13.0-next.2",
60
+ "react-magma-dom": "^4.14.0",
61
61
  "react-magma-icons": "^3.2.5"
62
62
  },
63
63
  "engines": {
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
2
 
3
3
  import { act, render, screen, fireEvent } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
4
5
  import { ThemeContext, magma, DropdownMenuItem } from 'react-magma-dom';
5
6
 
6
7
  import { CarbonChart, CarbonChartType } from '.';
@@ -588,6 +589,32 @@ describe('CarbonChart', () => {
588
589
  }
589
590
  );
590
591
  });
592
+
593
+ it('should move focus back after closing the More options dropdown and chart toolbar', () => {
594
+ const testId = 'modal-footer-border-test';
595
+ const { getByTestId, getByText } = render(
596
+ <ThemeContext.Provider value={magma}>
597
+ <CarbonChart
598
+ testId={testId}
599
+ dataSet={dataSet}
600
+ options={chartOptions}
601
+ type={CarbonChartType.bar}
602
+ isInverse={false}
603
+ chartToolbar={{}}
604
+ />
605
+ </ThemeContext.Provider>
606
+ );
607
+
608
+ const moreOptionsButton = getByTestId('chart-more-options-button');
609
+ userEvent.click(moreOptionsButton);
610
+
611
+ expect(getByText('Download as CSV')).toBeVisible();
612
+ expect(getByText('Download as PNG')).toBeVisible();
613
+ expect(getByText('Download as JPG')).toBeVisible();
614
+
615
+ userEvent.keyboard('{esc}');
616
+ expect(moreOptionsButton).toHaveFocus();
617
+ });
591
618
  });
592
619
 
593
620
  describe('Modal Focus Management', () => {
@@ -895,4 +922,141 @@ describe('CarbonChart', () => {
895
922
  ).toBeInTheDocument();
896
923
  });
897
924
  });
925
+
926
+ describe('dot keyboard accessibility', () => {
927
+ let rafCallbacks;
928
+
929
+ beforeEach(() => {
930
+ rafCallbacks = [];
931
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
932
+ rafCallbacks.push(cb);
933
+ return rafCallbacks.length - 1;
934
+ });
935
+ });
936
+
937
+ afterEach(() => {
938
+ window.requestAnimationFrame.mockRestore();
939
+ rafCallbacks = [];
940
+ });
941
+
942
+ function renderChart() {
943
+ const { getByTestId } = render(
944
+ <CarbonChart
945
+ testId="dot-tab-test"
946
+ dataSet={dataSet}
947
+ options={chartOptions}
948
+ type={CarbonChartType.scatter}
949
+ />
950
+ );
951
+ return getByTestId('dot-tab-test');
952
+ }
953
+
954
+ function addDot(wrapper) {
955
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
956
+ const dot = document.createElementNS(
957
+ 'http://www.w3.org/2000/svg',
958
+ 'circle'
959
+ );
960
+ dot.classList.add('dot');
961
+ svg.appendChild(dot);
962
+ wrapper.appendChild(svg);
963
+ return dot;
964
+ }
965
+
966
+ describe('tabbing', () => {
967
+ it('should stamp tabindex="0" on circle.dot elements so they are reachable by Tab', () => {
968
+ const wrapper = renderChart();
969
+ const dot = addDot(wrapper);
970
+
971
+ // The last captured RAF is the dots-stamping callback (no ariaLabel provided)
972
+ act(() => rafCallbacks[rafCallbacks.length - 1](0));
973
+
974
+ expect(dot).toHaveAttribute('tabindex', '0');
975
+ });
976
+
977
+ it('should not overwrite an existing tabindex on circle.dot', () => {
978
+ const wrapper = renderChart();
979
+ const dot = addDot(wrapper);
980
+ dot.setAttribute('tabindex', '-1');
981
+
982
+ act(() => rafCallbacks[rafCallbacks.length - 1](0));
983
+
984
+ expect(dot).toHaveAttribute('tabindex', '-1');
985
+ });
986
+ });
987
+
988
+ describe('focus on dot content', () => {
989
+ it('should set opacity to 1 when a dot receives focus', () => {
990
+ const wrapper = renderChart();
991
+ const dot = addDot(wrapper);
992
+
993
+ fireEvent.focusIn(dot);
994
+
995
+ expect(dot.style.opacity).toBe('1');
996
+ });
997
+
998
+ it('should dispatch mouseover on dot focusin to reveal tooltip data', () => {
999
+ const wrapper = renderChart();
1000
+ const dot = addDot(wrapper);
1001
+
1002
+ const mouseoverSpy = jest.fn();
1003
+ dot.addEventListener('mouseover', mouseoverSpy);
1004
+
1005
+ fireEvent.focusIn(dot);
1006
+
1007
+ expect(mouseoverSpy).toHaveBeenCalledTimes(1);
1008
+ });
1009
+
1010
+ it('should dispatch mousemove on dot focusin', () => {
1011
+ const wrapper = renderChart();
1012
+ const dot = addDot(wrapper);
1013
+
1014
+ const mousemoveSpy = jest.fn();
1015
+ dot.addEventListener('mousemove', mousemoveSpy);
1016
+
1017
+ fireEvent.focusIn(dot);
1018
+
1019
+ expect(mousemoveSpy).toHaveBeenCalledTimes(1);
1020
+ });
1021
+
1022
+ it('should not change opacity for non-dot circle elements on focusin', () => {
1023
+ const wrapper = renderChart();
1024
+ const nonDot = document.createElementNS(
1025
+ 'http://www.w3.org/2000/svg',
1026
+ 'circle'
1027
+ );
1028
+ wrapper.appendChild(nonDot);
1029
+
1030
+ fireEvent.focusIn(nonDot);
1031
+
1032
+ expect(nonDot.style.opacity).toBe('');
1033
+ });
1034
+ });
1035
+
1036
+ describe('data visibility', () => {
1037
+ it('should reset dot opacity when dot loses focus', () => {
1038
+ const wrapper = renderChart();
1039
+ const dot = addDot(wrapper);
1040
+
1041
+ fireEvent.focusIn(dot);
1042
+ expect(dot.style.opacity).toBe('1');
1043
+
1044
+ fireEvent.focusOut(dot);
1045
+ expect(dot.style.opacity).toBe('');
1046
+ });
1047
+
1048
+ it('should dispatch mouseout on dot focusout to hide tooltip', () => {
1049
+ const wrapper = renderChart();
1050
+ const dot = addDot(wrapper);
1051
+
1052
+ const mouseoutSpy = jest.fn();
1053
+ dot.addEventListener('mouseout', mouseoutSpy);
1054
+
1055
+ fireEvent.focusIn(dot);
1056
+ fireEvent.focusOut(dot);
1057
+
1058
+ expect(mouseoutSpy).toHaveBeenCalledTimes(1);
1059
+ });
1060
+ });
1061
+ });
898
1062
  });
@@ -490,6 +490,9 @@ const CarbonChartWrapper = styled.div<{
490
490
  : props.theme.colors.focus} !important;
491
491
  outline-offset: 0;
492
492
  }
493
+ circle.dot:focus {
494
+ outline: none !important;
495
+ }
493
496
  .cds--overflow-menu-options__btn:focus,
494
497
  .cds--overflow-menu:focus,
495
498
  .cds--overflow-menu__trigger:focus,
@@ -594,6 +597,17 @@ const CarbonChartWrapper = styled.div<{
594
597
  overflow: visible;
595
598
  }
596
599
 
600
+ circle.dot:focus {
601
+ outline: none;
602
+ stroke: ${props =>
603
+ props.isInverse
604
+ ? props.theme.colors.focusInverse
605
+ : props.theme.colors.focus} !important;
606
+ stroke-width: 6px !important;
607
+ stroke-opacity: 1 !important;
608
+ paint-order: stroke fill;
609
+ }
610
+
597
611
  .cds--cc--chart-wrapper text {
598
612
  font-size: ${props => props.theme.typeScale.size02.fontSize};
599
613
  }
@@ -637,6 +651,11 @@ interface ColorsObject {
637
651
  [key: string]: string;
638
652
  }
639
653
 
654
+ interface ExtendedChartOptions extends ChartOptions {
655
+ title?: string;
656
+ colors?: string[];
657
+ }
658
+
640
659
  const ToolbarWrapper = styled.div<{
641
660
  isFullscreen?: boolean;
642
661
  isInverse?: boolean;
@@ -838,86 +857,124 @@ function downloadImage(
838
857
 
839
858
  const svgRect = svg.getBoundingClientRect();
840
859
  const legendItems = readLegendItems(wrapper);
841
- const scale = 2;
842
860
 
843
- // Measure legend height
844
- const tempCanvas = document.createElement('canvas');
845
- const tempCtx = tempCanvas.getContext('2d');
846
- const fontSize = 13 * scale;
847
- const swatchSize = 12 * scale;
848
- const gap = 8 * scale;
849
- const itemGap = 16 * scale;
850
- const paddingX = 16 * scale;
851
- const canvasWidth = svgRect.width * scale;
852
-
853
- let legendHeight = 0;
854
- if (legendItems.length > 0 && tempCtx) {
855
- tempCtx.font = `${fontSize}px sans-serif`;
856
- let x = paddingX;
857
- let rows = 1;
858
- for (const item of legendItems) {
859
- const textWidth = tempCtx.measureText(item.label).width;
860
- const itemWidth = swatchSize + gap + textWidth + itemGap;
861
- if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
862
- x = paddingX;
863
- rows++;
861
+ const doWork = () => {
862
+ const scale = 2;
863
+
864
+ // Measure legend height
865
+ const tempCanvas = document.createElement('canvas');
866
+ const tempCtx = tempCanvas.getContext('2d');
867
+ const fontSize = 13 * scale;
868
+ const swatchSize = 12 * scale;
869
+ const gap = 8 * scale;
870
+ const itemGap = 16 * scale;
871
+ const paddingX = 16 * scale;
872
+ const canvasWidth = svgRect.width * scale;
873
+
874
+ let legendHeight = 0;
875
+ if (legendItems.length > 0 && tempCtx) {
876
+ tempCtx.font = `${fontSize}px sans-serif`;
877
+ let x = paddingX;
878
+ let rows = 1;
879
+ for (const item of legendItems) {
880
+ const textWidth = tempCtx.measureText(item.label).width;
881
+ const itemWidth = swatchSize + gap + textWidth + itemGap;
882
+ if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
883
+ x = paddingX;
884
+ rows++;
885
+ }
886
+ x += itemWidth;
864
887
  }
865
- x += itemWidth;
888
+ legendHeight = rows * (fontSize + gap) + gap * 2;
866
889
  }
867
- legendHeight = rows * (fontSize + gap) + gap * 2;
868
- }
869
890
 
870
- const width = svgRect.width * scale;
871
- const height = svgRect.height * scale + legendHeight;
891
+ const width = svgRect.width * scale;
892
+ const height = svgRect.height * scale + legendHeight;
872
893
 
873
- const clone = svg.cloneNode(true) as SVGSVGElement;
874
- clone.setAttribute('width', String(svgRect.width));
875
- clone.setAttribute('height', String(svgRect.height));
876
- clone.setAttribute('viewBox', `0 0 ${svgRect.width} ${svgRect.height}`);
877
- clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
894
+ const clone = svg.cloneNode(true) as SVGSVGElement;
895
+ clone.setAttribute('width', String(svgRect.width));
896
+ clone.setAttribute('height', String(svgRect.height));
897
+ clone.setAttribute('viewBox', `0 0 ${svgRect.width} ${svgRect.height}`);
898
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
878
899
 
879
- inlineStyles(svg, clone);
900
+ inlineStyles(svg, clone);
880
901
 
881
- const serializer = new XMLSerializer();
882
- const svgString = serializer.serializeToString(clone);
883
- const svgBlob = new Blob([svgString], {
884
- type: 'image/svg+xml;charset=utf-8',
885
- });
886
- const url = URL.createObjectURL(svgBlob);
887
-
888
- const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
889
- const ext = format === 'jpg' ? 'jpg' : 'png';
890
-
891
- const img = new Image();
892
- img.onload = () => {
893
- const canvas = document.createElement('canvas');
894
- canvas.width = width;
895
- canvas.height = height;
896
- const ctx = canvas.getContext('2d');
897
- if (!ctx) return;
898
-
899
- ctx.fillStyle = '#ffffff';
900
- ctx.fillRect(0, 0, canvas.width, canvas.height);
901
- ctx.drawImage(img, 0, 0, svgRect.width * scale, svgRect.height * scale);
902
- URL.revokeObjectURL(url);
903
-
904
- if (legendItems.length > 0) {
905
- drawLegend(ctx, legendItems, svgRect.height * scale + gap, width, scale);
906
- }
902
+ const serializer = new XMLSerializer();
903
+ const svgString = serializer.serializeToString(clone);
904
+ const svgBlob = new Blob([svgString], {
905
+ type: 'image/svg+xml;charset=utf-8',
906
+ });
907
+ const url = URL.createObjectURL(svgBlob);
908
+
909
+ const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
910
+ const ext = format === 'jpg' ? 'jpg' : 'png';
911
+
912
+ const img = new Image();
913
+ img.onload = () => {
914
+ const canvas = document.createElement('canvas');
915
+ canvas.width = width;
916
+ canvas.height = height;
917
+ const ctx = canvas.getContext('2d');
918
+ if (!ctx) return;
919
+
920
+ ctx.fillStyle = '#ffffff';
921
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
922
+ ctx.drawImage(img, 0, 0, svgRect.width * scale, svgRect.height * scale);
923
+ URL.revokeObjectURL(url);
924
+
925
+ if (legendItems.length > 0) {
926
+ drawLegend(
927
+ ctx,
928
+ legendItems,
929
+ svgRect.height * scale + gap,
930
+ width,
931
+ scale
932
+ );
933
+ }
907
934
 
908
- canvas.toBlob(blob => {
909
- if (!blob) return;
910
- const imgUrl = URL.createObjectURL(blob);
911
- const a = document.createElement('a');
912
- a.href = imgUrl;
913
- a.download = `${title || 'chart'}.${ext}`;
914
- a.click();
915
- URL.revokeObjectURL(imgUrl);
916
- }, mimeType);
935
+ canvas.toBlob(blob => {
936
+ if (!blob) return;
937
+ const imgUrl = URL.createObjectURL(blob);
938
+ const a = document.createElement('a');
939
+ a.href = imgUrl;
940
+ a.download = `${title || 'chart'}.${ext}`;
941
+ a.click();
942
+ URL.revokeObjectURL(imgUrl);
943
+ }, mimeType);
944
+ };
945
+ img.onerror = () => URL.revokeObjectURL(url);
946
+ img.src = url;
917
947
  };
918
- img.src = url;
948
+
949
+ // Defer to idle time with a 2-second deadline so it always runs.
950
+ if (typeof requestIdleCallback === 'function') {
951
+ requestIdleCallback(doWork, { timeout: 2000 });
952
+ } else {
953
+ setTimeout(doWork, 0);
954
+ }
919
955
  }
920
956
 
957
+ const ALL_CHARTS: Record<CarbonChartType, React.ComponentType<any>> = {
958
+ area: AreaChart,
959
+ areaStacked: StackedAreaChart,
960
+ bar: SimpleBarChart,
961
+ barGrouped: GroupedBarChart,
962
+ barStacked: StackedBarChart,
963
+ donut: DonutChart,
964
+ line: LineChart,
965
+ lollipop: LollipopChart,
966
+ pie: PieChart,
967
+ radar: RadarChart,
968
+ boxplot: BoxplotChart,
969
+ bubble: BubbleChart,
970
+ bullet: BulletChart,
971
+ gauge: GaugeChart,
972
+ histogram: HistogramChart,
973
+ meter: MeterChart,
974
+ scatter: ScatterChart,
975
+ combo: ComboChart,
976
+ };
977
+
921
978
  interface InternalToolbarProps {
922
979
  config: ChartToolbarConfig;
923
980
  dataSet: Array<Record<string, unknown>>;
@@ -1078,8 +1135,15 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1078
1135
  }
1079
1136
  };
1080
1137
  document.addEventListener('fullscreenchange', onFullscreenChange);
1081
- return () =>
1138
+ return () => {
1082
1139
  document.removeEventListener('fullscreenchange', onFullscreenChange);
1140
+ // Restore height if the component unmounts while in fullscreen.
1141
+ const chartHolder =
1142
+ internalRef.current?.querySelector<HTMLElement>('.cds--chart-holder');
1143
+ if (chartHolder && document.fullscreenElement) {
1144
+ chartHolder.style.height = savedHeightRef.current;
1145
+ }
1146
+ };
1083
1147
  }, [fullscreenEnabled]);
1084
1148
 
1085
1149
  const openTableModal = React.useCallback(
@@ -1092,9 +1156,9 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1092
1156
 
1093
1157
  const closeTableModal = React.useCallback(() => {
1094
1158
  setIsTableOpen(false);
1095
- setTimeout(() => {
1159
+ requestAnimationFrame(() => {
1096
1160
  lastTableTriggerRef.current?.focus();
1097
- }, 0);
1161
+ });
1098
1162
  }, []);
1099
1163
 
1100
1164
  const toggleFullscreen = React.useCallback(() => {
@@ -1110,64 +1174,47 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1110
1174
  }, []);
1111
1175
 
1112
1176
  const chartTitle: string =
1113
- (options as any).title || toolbarI18n.defaultTitle;
1177
+ (options as ExtendedChartOptions).title || toolbarI18n.defaultTitle;
1114
1178
 
1115
1179
  const handleModalDownloadCsv = React.useCallback(() => {
1116
1180
  downloadCsv(dataSet as Array<Record<string, unknown>>, chartTitle);
1117
1181
  }, [dataSet, chartTitle]);
1118
1182
 
1119
1183
  useCarbonModalFocusManagement(internalRef);
1120
- const allCharts = {
1121
- area: AreaChart,
1122
- areaStacked: StackedAreaChart,
1123
- bar: SimpleBarChart,
1124
- barGrouped: GroupedBarChart,
1125
- barStacked: StackedBarChart,
1126
- donut: DonutChart,
1127
- line: LineChart,
1128
- lollipop: LollipopChart,
1129
- pie: PieChart,
1130
- radar: RadarChart,
1131
- boxplot: BoxplotChart,
1132
- bubble: BubbleChart,
1133
- bullet: BulletChart,
1134
- gauge: GaugeChart,
1135
- histogram: HistogramChart,
1136
- meter: MeterChart,
1137
- scatter: ScatterChart,
1138
- combo: ComboChart,
1139
- };
1140
1184
 
1141
- function buildColors() {
1185
+ const colorScale = React.useMemo(() => {
1142
1186
  const scaleColorsObj: ColorsObject = {};
1143
-
1144
- const allGroups = dataSet.map(item => {
1145
- return 'group' in item ? item['group'] : null;
1146
- });
1147
- const uniqueGroups = allGroups.filter(
1148
- (g, index) => allGroups.indexOf(g) === index
1149
- );
1150
- const customColors = ((options as any).colors as string[]) || [];
1187
+ const customColors = (options as ExtendedChartOptions).colors || [];
1151
1188
  const allColors = [...customColors, ...theme.chartColors];
1152
1189
  const allInverseColors = [...customColors, ...theme.chartColorsInverse];
1190
+ const allGroups = dataSet.map(item =>
1191
+ 'group' in item ? (item as Record<string, unknown>)['group'] : null
1192
+ );
1193
+ const uniqueGroups = Array.from(new Set(allGroups));
1153
1194
 
1154
- uniqueGroups.forEach((group, i) => {
1155
- if (uniqueGroups.length <= allColors.length) {
1156
- return (scaleColorsObj[group || ('null' as any)] = isInverse
1195
+ if (uniqueGroups.length <= allColors.length) {
1196
+ for (let i = 0; i < uniqueGroups.length; i++) {
1197
+ const group = uniqueGroups[i];
1198
+ scaleColorsObj[String(group ?? 'null')] = isInverse
1157
1199
  ? allInverseColors[i]
1158
- : allColors[i]);
1200
+ : allColors[i];
1159
1201
  }
1160
- return {};
1161
- });
1202
+ }
1162
1203
 
1163
1204
  return scaleColorsObj;
1164
- }
1205
+ }, [
1206
+ dataSet,
1207
+ options,
1208
+ theme.chartColors,
1209
+ theme.chartColorsInverse,
1210
+ isInverse,
1211
+ ]);
1165
1212
 
1166
1213
  const newOptions = {
1167
1214
  ...options,
1168
1215
  theme: isInverse ? ChartTheme.G100 : ChartTheme.WHITE,
1169
1216
  color: {
1170
- scale: buildColors(),
1217
+ scale: colorScale,
1171
1218
  },
1172
1219
  tooltip: {
1173
1220
  ...(options?.tooltip || {}),
@@ -1178,21 +1225,107 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
1178
1225
  ...(chartToolbar ? { toolbar: { enabled: false } } : {}),
1179
1226
  };
1180
1227
 
1181
- const ChartType = allCharts[type] as any;
1228
+ const ChartType = ALL_CHARTS[type];
1182
1229
 
1183
- // Adding aria-label to main SVG container
1184
1230
  React.useEffect(() => {
1185
- if (ariaLabel) {
1186
- document.querySelectorAll('.graph-frame ').forEach(div => {
1187
- div.setAttribute('aria-label', ariaLabel);
1188
- });
1189
- }
1190
- });
1231
+ if (!ariaLabel) return;
1232
+ const rafId = requestAnimationFrame(() => {
1233
+ const svgEl = internalRef.current?.querySelector('.graph-frame');
1234
+ if (!svgEl) return;
1235
+ svgEl.setAttribute('aria-label', ariaLabel);
1236
+ });
1237
+ return () => cancelAnimationFrame(rafId);
1238
+ }, [ariaLabel]);
1239
+
1240
+ // Make Carbon Charts data points keyboard-focusable.
1241
+ React.useEffect(() => {
1242
+ const wrapper = internalRef.current;
1243
+ if (!wrapper) return;
1244
+
1245
+ const isDot = (el: EventTarget | null): el is SVGCircleElement =>
1246
+ el instanceof Element &&
1247
+ el.nodeName.toLowerCase() === 'circle' &&
1248
+ el.classList.contains('dot');
1249
+
1250
+ const onFocusIn = (e: FocusEvent) => {
1251
+ if (!isDot(e.target)) return;
1252
+ const dot = e.target as SVGCircleElement;
1253
+ dot.style.opacity = '1';
1254
+ const { left, top, width, height } = dot.getBoundingClientRect();
1255
+ const cx = left + width / 2;
1256
+ const cy = top + height / 2;
1257
+ dot.dispatchEvent(
1258
+ new MouseEvent('mouseover', {
1259
+ bubbles: true,
1260
+ clientX: cx,
1261
+ clientY: cy,
1262
+ screenX: cx,
1263
+ screenY: cy,
1264
+ })
1265
+ );
1266
+ dot.dispatchEvent(
1267
+ new MouseEvent('mousemove', {
1268
+ bubbles: true,
1269
+ clientX: cx,
1270
+ clientY: cy,
1271
+ screenX: cx,
1272
+ screenY: cy,
1273
+ })
1274
+ );
1275
+ };
1276
+
1277
+ const onFocusOut = (e: FocusEvent) => {
1278
+ if (!isDot(e.target)) return;
1279
+ const dot = e.target;
1280
+ dot.style.opacity = '';
1281
+ dot.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
1282
+ };
1283
+
1284
+ const onKeyDown = (e: KeyboardEvent) => {
1285
+ if (!isDot(e.target)) return;
1286
+ if (e.key !== 'Enter' && e.key !== ' ') return;
1287
+ e.preventDefault();
1288
+ const dot = e.target;
1289
+ const { left, top, width, height } = dot.getBoundingClientRect();
1290
+ const cx = left + width / 2;
1291
+ const cy = top + height / 2;
1292
+ dot.dispatchEvent(
1293
+ new MouseEvent('click', {
1294
+ bubbles: true,
1295
+ clientX: cx,
1296
+ clientY: cy,
1297
+ })
1298
+ );
1299
+ };
1300
+
1301
+ const rafId = requestAnimationFrame(() => {
1302
+ wrapper
1303
+ .querySelectorAll<SVGCircleElement>('circle.dot')
1304
+ .forEach(dot => {
1305
+ if (!dot.hasAttribute('tabindex')) {
1306
+ dot.setAttribute('tabindex', '0');
1307
+ }
1308
+ });
1309
+ });
1191
1310
 
1192
- const groupsLength = Object.keys(buildColors()).length;
1311
+ wrapper.addEventListener('focusin', onFocusIn);
1312
+ wrapper.addEventListener('focusout', onFocusOut);
1313
+ wrapper.addEventListener('keydown', onKeyDown);
1314
+
1315
+ return () => {
1316
+ cancelAnimationFrame(rafId);
1317
+ wrapper.removeEventListener('focusin', onFocusIn);
1318
+ wrapper.removeEventListener('focusout', onFocusOut);
1319
+ wrapper.removeEventListener('keydown', onKeyDown);
1320
+ };
1321
+ }, [type]);
1322
+
1323
+ const groupsLength = Object.keys(colorScale).length;
1193
1324
 
1194
1325
  const showTable = chartToolbar?.showAsTable !== false;
1195
1326
 
1327
+ if (!ChartType) return null;
1328
+
1196
1329
  return (
1197
1330
  <FullscreenRoot ref={mergedRef} isInverse={isInverse} theme={theme}>
1198
1331
  <CarbonChartWrapper
@@ -25,7 +25,7 @@ export default {
25
25
 
26
26
  const Template: StoryFn<CarbonChartProps> = args => (
27
27
  <Card isInverse={args.isInverse} style={{ padding: '12px' }}>
28
- <CarbonChart {...args} />
28
+ <CarbonChart {...args} chartToolbar={{}} />
29
29
  </Card>
30
30
  );
31
31
 
@@ -25,7 +25,7 @@ export default {
25
25
 
26
26
  const Template: StoryFn<CarbonChartProps> = args => (
27
27
  <Card isInverse={args.isInverse} style={{ padding: '12px' }}>
28
- <CarbonChart {...args} />
28
+ <CarbonChart {...args} chartToolbar={{}} />
29
29
  </Card>
30
30
  );
31
31