@react-magma/charts 14.0.0-next.2 → 14.0.0-next.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.
Files changed (58) hide show
  1. package/dist/charts.js +851 -69
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.modern.module.js +847 -72
  4. package/dist/charts.modern.module.js.map +1 -1
  5. package/dist/charts.umd.js +3088 -680
  6. package/dist/charts.umd.js.map +1 -1
  7. package/dist/components/CarbonChart/CarbonChart.d.ts +46 -0
  8. package/dist/components/CarbonChart/CarbonChartBar.stories.d.ts +27 -9
  9. package/dist/components/ChartTable/ChartDataTable.d.ts +19 -0
  10. package/dist/components/ChartTable/ChartFullscreenButton.d.ts +26 -0
  11. package/dist/components/ChartTable/ChartMoreOptionsButton.d.ts +18 -0
  12. package/dist/components/ChartTable/ChartTable.stories.d.ts +116 -0
  13. package/dist/components/ChartTable/ChartTableButton.d.ts +24 -0
  14. package/dist/components/ChartTable/ChartTableModal.d.ts +44 -0
  15. package/dist/components/ChartTable/ChartToolbar.d.ts +19 -0
  16. package/dist/components/ChartTable/chartToolbarI18n.d.ts +17 -0
  17. package/dist/components/ChartTable/index.d.ts +14 -0
  18. package/dist/components/LineChart/DataTable.d.ts +1 -1
  19. package/dist/hooks/useCarbonModalFocusManagement.d.ts +2 -0
  20. package/dist/index.d.ts +1 -0
  21. package/package.json +5 -5
  22. package/src/components/CarbonChart/CarbonChart.test.js +645 -2
  23. package/src/components/CarbonChart/CarbonChart.tsx +950 -59
  24. package/src/components/CarbonChart/CarbonChartArea.stories.tsx +1 -1
  25. package/src/components/CarbonChart/CarbonChartAreaStacked.stories.tsx +1 -1
  26. package/src/components/CarbonChart/CarbonChartBar.stories.tsx +9 -2
  27. package/src/components/CarbonChart/CarbonChartBarFloating.stories.tsx +1 -1
  28. package/src/components/CarbonChart/CarbonChartBarGrouped.stories.tsx +1 -1
  29. package/src/components/CarbonChart/CarbonChartBarStacked.stories.tsx +1 -1
  30. package/src/components/CarbonChart/CarbonChartBoxplot.stories.tsx +1 -1
  31. package/src/components/CarbonChart/CarbonChartBubble.stories.tsx +1 -1
  32. package/src/components/CarbonChart/CarbonChartBullet.stories.tsx +1 -1
  33. package/src/components/CarbonChart/CarbonChartCombo.stories.tsx +1 -1
  34. package/src/components/CarbonChart/CarbonChartDonut.stories.tsx +3 -1
  35. package/src/components/CarbonChart/CarbonChartGauge.stories.tsx +1 -1
  36. package/src/components/CarbonChart/CarbonChartHistogram.stories.tsx +1 -1
  37. package/src/components/CarbonChart/CarbonChartLine.stories.tsx +1 -1
  38. package/src/components/CarbonChart/CarbonChartLollipop.stories.tsx +1 -1
  39. package/src/components/CarbonChart/CarbonChartMeter.stories.tsx +1 -1
  40. package/src/components/CarbonChart/CarbonChartPie.stories.tsx +1 -1
  41. package/src/components/CarbonChart/CarbonChartRadar.stories.tsx +1 -1
  42. package/src/components/CarbonChart/CarbonChartScatter.stories.tsx +1 -1
  43. package/src/components/CarbonChart/CarbonChartSparkline.stories.tsx +1 -1
  44. package/src/components/CarbonChart/CarbonChartStep.stories.tsx +1 -1
  45. package/src/components/ChartTable/ChartDataTable.tsx +72 -0
  46. package/src/components/ChartTable/ChartFullscreenButton.tsx +59 -0
  47. package/src/components/ChartTable/ChartMoreOptionsButton.tsx +48 -0
  48. package/src/components/ChartTable/ChartTable.stories.tsx +152 -0
  49. package/src/components/ChartTable/ChartTable.test.js +444 -0
  50. package/src/components/ChartTable/ChartTableButton.tsx +55 -0
  51. package/src/components/ChartTable/ChartTableModal.tsx +135 -0
  52. package/src/components/ChartTable/ChartToolbar.tsx +50 -0
  53. package/src/components/ChartTable/chartToolbarI18n.ts +59 -0
  54. package/src/components/ChartTable/index.ts +23 -0
  55. package/src/components/LineChart/DataTable.tsx +3 -3
  56. package/src/components/LineChart/LineChart.tsx +1 -1
  57. package/src/hooks/useCarbonModalFocusManagement.ts +173 -0
  58. package/src/index.ts +1 -0
@@ -1,3 +1,4 @@
1
+ /* eslint-disable complexity */
1
2
  import * as React from 'react';
2
3
 
3
4
  import {
@@ -23,9 +24,33 @@ import {
23
24
  } from '@carbon/charts-react';
24
25
  import styled from '@emotion/styled';
25
26
  import { transparentize } from 'polished';
26
- import { ThemeInterface, ThemeContext, useIsInverse } from 'react-magma-dom';
27
+ import {
28
+ Announce,
29
+ DropdownDivider,
30
+ DropdownMenuItem,
31
+ ThemeInterface,
32
+ ThemeContext,
33
+ useIsInverse,
34
+ VisuallyHidden,
35
+ } from 'react-magma-dom';
36
+ import {
37
+ FullscreenExitIcon,
38
+ FullscreenIcon,
39
+ MoreVertIcon,
40
+ TableIcon,
41
+ } from 'react-magma-icons';
27
42
 
43
+ import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
44
+ // @ts-ignore
28
45
  import './carbon-charts.css';
46
+ import {
47
+ ChartFullscreenButton,
48
+ ChartMoreOptionsButton,
49
+ ChartTableButton,
50
+ ChartTableModal,
51
+ } from '../ChartTable';
52
+ import type { ChartDataTableColumn } from '../ChartTable';
53
+ import { useChartToolbarI18n } from '../ChartTable/chartToolbarI18n';
29
54
 
30
55
  export enum CarbonChartType {
31
56
  area = 'area',
@@ -48,6 +73,46 @@ export enum CarbonChartType {
48
73
  combo = 'combo',
49
74
  }
50
75
 
76
+ export interface ChartToolbarConfig {
77
+ /**
78
+ * When true, renders a "Show as table" button that opens a Magma Modal
79
+ * with the chart data in an accessible table.
80
+ * @default true
81
+ */
82
+ showAsTable?: boolean;
83
+ /**
84
+ * When true, renders a fullscreen toggle button.
85
+ * @default true
86
+ */
87
+ fullscreen?: boolean;
88
+ /**
89
+ * Additional menu items rendered inside a "More options" dropdown,
90
+ * below the built-in "Download as CSV", "Download as PNG", and "Download as JPG" items.
91
+ * Pass DropdownMenuItem elements.
92
+ */
93
+ moreOptions?: React.ReactNode;
94
+ /**
95
+ * Custom column definitions for the table modal.
96
+ * If omitted, columns are auto-derived from the dataset object keys.
97
+ */
98
+ tableColumns?: ChartDataTableColumn[];
99
+ /**
100
+ * First line of the modal heading.
101
+ * @default "Tabular representation"
102
+ */
103
+ tableHeaderLabel?: string;
104
+ /**
105
+ * Heading level for the modal header.
106
+ * @default 2
107
+ */
108
+ tableHeaderLevel?: 1 | 2 | 3 | 4 | 5 | 6;
109
+ /**
110
+ * Heading level for the chart title.
111
+ * @default 2
112
+ */
113
+ titleLevel?: 1 | 2 | 3 | 4 | 5 | 6;
114
+ }
115
+
51
116
  export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
52
117
  dataSet: Array<Object>;
53
118
  isInverse?: boolean;
@@ -68,13 +133,72 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
68
133
  * Text for the aria-label attribute for main SVG container, if provided
69
134
  */
70
135
  ariaLabel?: string;
136
+ /**
137
+ * When provided, renders an accessible Magma toolbar above the chart with
138
+ * "Show as table", fullscreen, and "More options" buttons. Carbon's built-in
139
+ * toolbar is automatically disabled.
140
+ */
141
+ chartToolbar?: ChartToolbarConfig;
71
142
  }
72
143
 
144
+ const ChartContentWrapper = styled.div`
145
+ height: 100%;
146
+ position: relative;
147
+ `;
148
+
149
+ const FullscreenRoot = styled.div<{
150
+ isInverse?: boolean;
151
+ theme: ThemeInterface;
152
+ }>`
153
+ height: 100%;
154
+ width: 100%;
155
+
156
+ &:fullscreen,
157
+ &:-webkit-full-screen {
158
+ background: ${props =>
159
+ props.isInverse
160
+ ? props.theme.colors.primary700
161
+ : props.theme.colors.neutral100};
162
+ .cds--chart-holder {
163
+ height: 100vh !important;
164
+ }
165
+ }
166
+ `;
167
+
73
168
  const CarbonChartWrapper = styled.div<{
74
169
  isInverse?: boolean;
75
170
  groupsLength: number;
76
171
  theme: ThemeInterface;
77
172
  }>`
173
+ .cds--cc--legend-fieldset {
174
+ border: 0;
175
+ margin: 0;
176
+ min-inline-size: 0;
177
+ padding: 0;
178
+ }
179
+
180
+ .cds--cc--legend-fieldset > legend {
181
+ clip: rect(1px, 1px, 1px, 1px);
182
+ height: 1px;
183
+ overflow: hidden;
184
+ position: absolute;
185
+ top: auto;
186
+ white-space: nowrap;
187
+ width: 1px;
188
+ }
189
+
190
+ &:fullscreen,
191
+ &:-webkit-full-screen {
192
+ background: ${props =>
193
+ props.isInverse
194
+ ? props.theme.colors.primary700
195
+ : props.theme.colors.neutral100};
196
+
197
+ .cds--chart-holder {
198
+ height: 100vh !important;
199
+ }
200
+ }
201
+
78
202
  .cds--data-table thead tr th {
79
203
  background: ${props =>
80
204
  props.isInverse ? props.theme.colors.primary700 : ''} !important;
@@ -130,9 +254,9 @@ const CarbonChartWrapper = styled.div<{
130
254
  }
131
255
  }
132
256
 
133
- p,
134
- div,
135
- text,
257
+ .chart-holder p,
258
+ .chart-holder div,
259
+ .chart-holder text,
136
260
  .cds--cc--axes g.axis .axis-title,
137
261
  .cds--cc--title p.title,
138
262
  .cds--cc--axes g.axis g.tick text {
@@ -391,6 +515,9 @@ const CarbonChartWrapper = styled.div<{
391
515
  : props.theme.colors.focus} !important;
392
516
  outline-offset: 0;
393
517
  }
518
+ circle.dot:focus {
519
+ outline: none !important;
520
+ }
394
521
  .cds--overflow-menu-options__btn:focus,
395
522
  .cds--overflow-menu:focus,
396
523
  .cds--overflow-menu__trigger:focus,
@@ -487,10 +614,25 @@ const CarbonChartWrapper = styled.div<{
487
614
  }
488
615
  }
489
616
 
617
+ &.has-magma-toolbar .cds--cc--title p.title {
618
+ visibility: hidden;
619
+ }
620
+
490
621
  svg:not(:root) {
491
622
  overflow: visible;
492
623
  }
493
624
 
625
+ circle.dot:focus {
626
+ outline: none;
627
+ stroke: ${props =>
628
+ props.isInverse
629
+ ? props.theme.colors.focusInverse
630
+ : props.theme.colors.focus} !important;
631
+ stroke-width: 6px !important;
632
+ stroke-opacity: 1 !important;
633
+ paint-order: stroke fill;
634
+ }
635
+
494
636
  .cds--cc--chart-wrapper text {
495
637
  font-size: ${props => props.theme.typeScale.size02.fontSize};
496
638
  }
@@ -534,6 +676,436 @@ interface ColorsObject {
534
676
  [key: string]: string;
535
677
  }
536
678
 
679
+ interface ExtendedChartOptions extends ChartOptions {
680
+ title?: string;
681
+ colors?: string[];
682
+ }
683
+
684
+ const ToolbarWrapper = styled.div<{
685
+ isFullscreen?: boolean;
686
+ isInverse?: boolean;
687
+ theme: ThemeInterface;
688
+ }>`
689
+ align-items: center;
690
+ display: flex;
691
+ justify-content: space-between;
692
+ left: ${props => (props.isFullscreen ? '2em' : '0')};
693
+ position: absolute;
694
+ right: ${props => (props.isFullscreen ? '2em' : '0')};
695
+ top: ${props => (props.isFullscreen ? '2em' : '0')};
696
+ z-index: 2;
697
+
698
+ button {
699
+ color: ${props =>
700
+ props.isInverse
701
+ ? props.theme.colors.neutral100
702
+ : props.theme.colors.primary500};
703
+
704
+ &:focus {
705
+ outline: 2px solid
706
+ ${props =>
707
+ props.isInverse
708
+ ? props.theme.colors.focusInverse
709
+ : props.theme.colors.focus};
710
+ outline-offset: 0;
711
+ }
712
+ }
713
+ `;
714
+
715
+ const ChartTitle = styled.h2<{
716
+ isInverse?: boolean;
717
+ theme: ThemeInterface;
718
+ }>`
719
+ font-family: ${props => props.theme.bodyFont} !important;
720
+ font-size: 16px !important;
721
+ font-weight: 700 !important;
722
+ letter-spacing: normal !important;
723
+ line-height: 1.25 !important;
724
+ margin: 0 !important;
725
+ color: ${props =>
726
+ props.isInverse
727
+ ? props.theme.colors.neutral100
728
+ : props.theme.colors.neutral700} !important;
729
+ `;
730
+
731
+ const ToolbarActions = styled.div<{ theme: ThemeInterface }>`
732
+ align-items: center;
733
+ display: flex;
734
+ gap: ${props => props.theme.spaceScale.spacing02};
735
+
736
+ > * button,
737
+ > button {
738
+ height: 2rem;
739
+ min-height: 2rem;
740
+ min-width: 2rem;
741
+ padding: 4px;
742
+ width: 2rem;
743
+ }
744
+
745
+ [role='tooltip'] {
746
+ padding: ${props => props.theme.spaceScale.spacing03}
747
+ ${props => props.theme.spaceScale.spacing04};
748
+ text-align: center;
749
+ white-space: nowrap;
750
+ }
751
+
752
+ [data-testid='dropdownContent'] {
753
+ padding: ${props => props.theme.spaceScale.spacing03} 0;
754
+ }
755
+
756
+ [role='menuitem'],
757
+ [data-testid='dropdownMenuItem'] {
758
+ padding: ${props => props.theme.spaceScale.spacing03}
759
+ ${props => props.theme.spaceScale.spacing05};
760
+ }
761
+ `;
762
+
763
+ function sanitizeCsvValue(value: string): string {
764
+ const trimmed = value.trimStart();
765
+ if (/^[=+\-@\t\r]/.test(trimmed)) {
766
+ return `'${value}`;
767
+ }
768
+ return value;
769
+ }
770
+
771
+ function downloadCsv(dataSet: Array<Record<string, unknown>>, title: string) {
772
+ if (!dataSet.length) return;
773
+ const keys = Object.keys(dataSet[0]);
774
+ const header = keys.map(k => sanitizeCsvValue(k)).join(',');
775
+ const rows = dataSet.map(row =>
776
+ keys
777
+ .map(k => {
778
+ const v = row[k];
779
+ const s = sanitizeCsvValue(String(v ?? ''));
780
+ return s.includes(',') || s.includes('"') || s.includes('\n')
781
+ ? `"${s.replace(/"/g, '""')}"`
782
+ : s;
783
+ })
784
+ .join(',')
785
+ );
786
+ const csv = [header, ...rows].join('\n');
787
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
788
+ const url = URL.createObjectURL(blob);
789
+ const a = document.createElement('a');
790
+ a.href = url;
791
+ a.download = `${title || 'chart-data'}.csv`;
792
+ a.click();
793
+ URL.revokeObjectURL(url);
794
+ }
795
+
796
+ function inlineStyles(source: Element, target: Element) {
797
+ const computed = window.getComputedStyle(source);
798
+ if (target instanceof HTMLElement || target instanceof SVGElement) {
799
+ target.setAttribute(
800
+ 'style',
801
+ Array.from(computed)
802
+ .map(prop => `${prop}:${computed.getPropertyValue(prop)}`)
803
+ .join(';')
804
+ );
805
+ }
806
+ for (let i = 0; i < source.children.length; i++) {
807
+ if (target.children[i]) {
808
+ inlineStyles(source.children[i], target.children[i]);
809
+ }
810
+ }
811
+ }
812
+
813
+ interface LegendItem {
814
+ label: string;
815
+ color: string;
816
+ }
817
+
818
+ function readLegendItems(wrapper: HTMLElement): LegendItem[] {
819
+ const items: LegendItem[] = [];
820
+ wrapper.querySelectorAll('.legend-item').forEach(item => {
821
+ const checkbox = item.querySelector<HTMLElement>('.checkbox');
822
+ const label = item.querySelector('p');
823
+ if (checkbox && label) {
824
+ items.push({
825
+ color: checkbox.style.background || checkbox.style.backgroundColor,
826
+ label: label.textContent || '',
827
+ });
828
+ }
829
+ });
830
+ return items;
831
+ }
832
+
833
+ function drawLegend(
834
+ ctx: CanvasRenderingContext2D,
835
+ items: LegendItem[],
836
+ startY: number,
837
+ canvasWidth: number,
838
+ scale: number
839
+ ) {
840
+ const fontSize = 13 * scale;
841
+ const swatchSize = 12 * scale;
842
+ const gap = 8 * scale;
843
+ const itemGap = 16 * scale;
844
+ const paddingX = 16 * scale;
845
+
846
+ ctx.font = `${fontSize}px sans-serif`;
847
+ ctx.textBaseline = 'middle';
848
+
849
+ let x = paddingX;
850
+ let y = startY;
851
+
852
+ for (const item of items) {
853
+ const textWidth = ctx.measureText(item.label).width;
854
+ const itemWidth = swatchSize + gap + textWidth + itemGap;
855
+
856
+ if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
857
+ x = paddingX;
858
+ y += fontSize + gap;
859
+ }
860
+
861
+ ctx.fillStyle = item.color;
862
+ ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
863
+
864
+ ctx.fillStyle = '#161616';
865
+ ctx.fillText(item.label, x + swatchSize + gap, y);
866
+
867
+ x += itemWidth;
868
+ }
869
+
870
+ return y + fontSize + gap;
871
+ }
872
+
873
+ function downloadImage(
874
+ wrapperRef: React.RefObject<HTMLDivElement | null>,
875
+ title: string,
876
+ format: 'png' | 'jpg'
877
+ ) {
878
+ const wrapper = wrapperRef.current;
879
+ if (!wrapper) return;
880
+ const svg = wrapper.querySelector('svg.layout-svg-wrapper');
881
+ if (!svg) return;
882
+
883
+ const svgRect = svg.getBoundingClientRect();
884
+ const legendItems = readLegendItems(wrapper);
885
+
886
+ const doWork = () => {
887
+ const scale = 2;
888
+
889
+ // Measure legend height
890
+ const tempCanvas = document.createElement('canvas');
891
+ const tempCtx = tempCanvas.getContext('2d');
892
+ const fontSize = 13 * scale;
893
+ const swatchSize = 12 * scale;
894
+ const gap = 8 * scale;
895
+ const itemGap = 16 * scale;
896
+ const paddingX = 16 * scale;
897
+ const canvasWidth = svgRect.width * scale;
898
+
899
+ let legendHeight = 0;
900
+ if (legendItems.length > 0 && tempCtx) {
901
+ tempCtx.font = `${fontSize}px sans-serif`;
902
+ let x = paddingX;
903
+ let rows = 1;
904
+ for (const item of legendItems) {
905
+ const textWidth = tempCtx.measureText(item.label).width;
906
+ const itemWidth = swatchSize + gap + textWidth + itemGap;
907
+ if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
908
+ x = paddingX;
909
+ rows++;
910
+ }
911
+ x += itemWidth;
912
+ }
913
+ legendHeight = rows * (fontSize + gap) + gap * 2;
914
+ }
915
+
916
+ const width = svgRect.width * scale;
917
+ const height = svgRect.height * scale + legendHeight;
918
+
919
+ const clone = svg.cloneNode(true) as SVGSVGElement;
920
+ clone.setAttribute('width', String(svgRect.width));
921
+ clone.setAttribute('height', String(svgRect.height));
922
+ clone.setAttribute('viewBox', `0 0 ${svgRect.width} ${svgRect.height}`);
923
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
924
+
925
+ inlineStyles(svg, clone);
926
+
927
+ const serializer = new XMLSerializer();
928
+ const svgString = serializer.serializeToString(clone);
929
+ const svgBlob = new Blob([svgString], {
930
+ type: 'image/svg+xml;charset=utf-8',
931
+ });
932
+ const url = URL.createObjectURL(svgBlob);
933
+
934
+ const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
935
+ const ext = format === 'jpg' ? 'jpg' : 'png';
936
+
937
+ const img = new Image();
938
+ img.onload = () => {
939
+ const canvas = document.createElement('canvas');
940
+ canvas.width = width;
941
+ canvas.height = height;
942
+ const ctx = canvas.getContext('2d');
943
+ if (!ctx) return;
944
+
945
+ ctx.fillStyle = '#ffffff';
946
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
947
+ ctx.drawImage(img, 0, 0, svgRect.width * scale, svgRect.height * scale);
948
+ URL.revokeObjectURL(url);
949
+
950
+ if (legendItems.length > 0) {
951
+ drawLegend(
952
+ ctx,
953
+ legendItems,
954
+ svgRect.height * scale + gap,
955
+ width,
956
+ scale
957
+ );
958
+ }
959
+
960
+ canvas.toBlob(blob => {
961
+ if (!blob) return;
962
+ const imgUrl = URL.createObjectURL(blob);
963
+ const a = document.createElement('a');
964
+ a.href = imgUrl;
965
+ a.download = `${title || 'chart'}.${ext}`;
966
+ a.click();
967
+ URL.revokeObjectURL(imgUrl);
968
+ }, mimeType);
969
+ };
970
+ img.onerror = () => URL.revokeObjectURL(url);
971
+ img.src = url;
972
+ };
973
+
974
+ // Defer to idle time with a 2-second deadline so it always runs.
975
+ if (typeof requestIdleCallback === 'function') {
976
+ requestIdleCallback(doWork, { timeout: 2000 });
977
+ } else {
978
+ setTimeout(doWork, 0);
979
+ }
980
+ }
981
+
982
+ const ALL_CHARTS: Record<CarbonChartType, React.ComponentType<any>> = {
983
+ area: AreaChart,
984
+ areaStacked: StackedAreaChart,
985
+ bar: SimpleBarChart,
986
+ barGrouped: GroupedBarChart,
987
+ barStacked: StackedBarChart,
988
+ donut: DonutChart,
989
+ line: LineChart,
990
+ lollipop: LollipopChart,
991
+ pie: PieChart,
992
+ radar: RadarChart,
993
+ boxplot: BoxplotChart,
994
+ bubble: BubbleChart,
995
+ bullet: BulletChart,
996
+ gauge: GaugeChart,
997
+ histogram: HistogramChart,
998
+ meter: MeterChart,
999
+ scatter: ScatterChart,
1000
+ combo: ComboChart,
1001
+ };
1002
+
1003
+ interface InternalToolbarProps {
1004
+ config: ChartToolbarConfig;
1005
+ dataSet: Array<Record<string, unknown>>;
1006
+ isInverse: boolean;
1007
+ isTableOpen: boolean;
1008
+ isFullscreen: boolean;
1009
+ onOpenTable: (event: React.MouseEvent<HTMLButtonElement>) => void;
1010
+ onToggleFullscreen: () => void;
1011
+ theme: ThemeInterface;
1012
+ title: string;
1013
+ wrapperRef: React.RefObject<HTMLDivElement | null>;
1014
+ }
1015
+
1016
+ function CarbonChartToolbar({
1017
+ config,
1018
+ dataSet,
1019
+ isInverse,
1020
+ isTableOpen,
1021
+ isFullscreen,
1022
+ onOpenTable,
1023
+ onToggleFullscreen,
1024
+ theme,
1025
+ title,
1026
+ wrapperRef,
1027
+ }: InternalToolbarProps) {
1028
+ const t = useChartToolbarI18n();
1029
+ const showTable = config.showAsTable !== false;
1030
+ const showFullscreen = config.fullscreen !== false;
1031
+ const resolvedTitle = title || t.defaultTitle;
1032
+
1033
+ const handleDownloadCsv = React.useCallback(() => {
1034
+ downloadCsv(dataSet, title);
1035
+ }, [dataSet, title]);
1036
+
1037
+ const handleDownloadPng = React.useCallback(() => {
1038
+ downloadImage(wrapperRef, title, 'png');
1039
+ }, [wrapperRef, title]);
1040
+
1041
+ const handleDownloadJpg = React.useCallback(() => {
1042
+ downloadImage(wrapperRef, title, 'jpg');
1043
+ }, [wrapperRef, title]);
1044
+
1045
+ const moreOptionsContent = (
1046
+ <>
1047
+ <DropdownMenuItem onClick={handleDownloadCsv}>
1048
+ {t.downloadAsCsv}
1049
+ </DropdownMenuItem>
1050
+ <DropdownMenuItem onClick={handleDownloadPng}>
1051
+ {t.downloadAsPng}
1052
+ </DropdownMenuItem>
1053
+ <DropdownMenuItem onClick={handleDownloadJpg}>
1054
+ {t.downloadAsJpg}
1055
+ </DropdownMenuItem>
1056
+ {config.moreOptions && (
1057
+ <>
1058
+ <DropdownDivider />
1059
+ {config.moreOptions}
1060
+ </>
1061
+ )}
1062
+ </>
1063
+ );
1064
+
1065
+ return (
1066
+ <ToolbarWrapper
1067
+ isFullscreen={isFullscreen}
1068
+ isInverse={isInverse}
1069
+ theme={theme}
1070
+ >
1071
+ <ChartTitle
1072
+ as={`h${config.titleLevel ?? 2}` as keyof JSX.IntrinsicElements}
1073
+ isInverse={isInverse}
1074
+ theme={theme}
1075
+ >
1076
+ {resolvedTitle}
1077
+ </ChartTitle>
1078
+ <ToolbarActions theme={theme}>
1079
+ {showTable && (
1080
+ <ChartTableButton
1081
+ ariaLabel={resolvedTitle}
1082
+ icon={<TableIcon size={20} />}
1083
+ isInverse={isInverse}
1084
+ isTableOpen={isTableOpen}
1085
+ onClick={onOpenTable}
1086
+ />
1087
+ )}
1088
+ {showFullscreen && (
1089
+ <ChartFullscreenButton
1090
+ ariaLabel={`${isFullscreen ? 'Exit' : 'View'} ${resolvedTitle} full screen`}
1091
+ icon={<FullscreenIcon size={20} />}
1092
+ exitIcon={<FullscreenExitIcon size={20} />}
1093
+ isInverse={isInverse}
1094
+ isFullscreen={isFullscreen}
1095
+ onClick={onToggleFullscreen}
1096
+ />
1097
+ )}
1098
+ <ChartMoreOptionsButton
1099
+ icon={<MoreVertIcon size={20} />}
1100
+ isInverse={isInverse}
1101
+ >
1102
+ {moreOptionsContent}
1103
+ </ChartMoreOptionsButton>
1104
+ </ToolbarActions>
1105
+ </ToolbarWrapper>
1106
+ );
1107
+ }
1108
+
537
1109
  export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
538
1110
  (props, ref) => {
539
1111
  const {
@@ -543,61 +1115,188 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
543
1115
  dataSet,
544
1116
  options,
545
1117
  ariaLabel,
1118
+ chartToolbar,
546
1119
  ...rest
547
1120
  } = props;
548
1121
  const theme = React.useContext(ThemeContext);
549
1122
  const isInverse = useIsInverse(isInverseProp);
550
- const allCharts = {
551
- area: AreaChart,
552
- areaStacked: StackedAreaChart,
553
- bar: SimpleBarChart,
554
- barGrouped: GroupedBarChart,
555
- barStacked: StackedBarChart,
556
- donut: DonutChart,
557
- line: LineChart,
558
- lollipop: LollipopChart,
559
- pie: PieChart,
560
- radar: RadarChart,
561
- boxplot: BoxplotChart,
562
- bubble: BubbleChart,
563
- bullet: BulletChart,
564
- gauge: GaugeChart,
565
- histogram: HistogramChart,
566
- meter: MeterChart,
567
- scatter: ScatterChart,
568
- combo: ComboChart,
569
- };
1123
+ const toolbarI18n = useChartToolbarI18n();
1124
+ const internalRef = React.useRef<HTMLDivElement | null>(null);
570
1125
 
571
- function buildColors() {
572
- const scaleColorsObj: ColorsObject = {};
1126
+ const [isTableOpen, setIsTableOpen] = React.useState(false);
1127
+ const [isFullscreen, setIsFullscreen] = React.useState(false);
1128
+ const [legendAnnouncement, setLegendAnnouncement] = React.useState('');
1129
+ const lastTableTriggerRef = React.useRef<HTMLButtonElement | null>(null);
1130
+ const legendAnnouncedRef = React.useRef(false);
573
1131
 
574
- const allGroups = dataSet.map(item => {
575
- return 'group' in item ? item['group'] : null;
1132
+ const mergedRef = React.useCallback(
1133
+ (node: HTMLDivElement | null) => {
1134
+ internalRef.current = node;
1135
+ if (typeof ref === 'function') {
1136
+ ref(node);
1137
+ } else if (ref) {
1138
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
1139
+ }
1140
+ },
1141
+ [ref]
1142
+ );
1143
+
1144
+ const fullscreenEnabled = chartToolbar
1145
+ ? chartToolbar.fullscreen !== false
1146
+ : false;
1147
+
1148
+ const savedHeightRef = React.useRef<string>('');
1149
+
1150
+ React.useEffect(() => {
1151
+ if (!fullscreenEnabled) return;
1152
+
1153
+ const onFullscreenChange = () => {
1154
+ const isFs = !!document.fullscreenElement;
1155
+ setIsFullscreen(isFs);
1156
+
1157
+ const chartHolder =
1158
+ internalRef.current?.querySelector<HTMLElement>('.cds--chart-holder');
1159
+ if (chartHolder) {
1160
+ if (isFs) {
1161
+ savedHeightRef.current = chartHolder.style.height;
1162
+ chartHolder.style.height = '100vh';
1163
+ } else {
1164
+ chartHolder.style.height = savedHeightRef.current;
1165
+ }
1166
+ }
1167
+ };
1168
+ document.addEventListener('fullscreenchange', onFullscreenChange);
1169
+ return () => {
1170
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
1171
+ // Restore height if the component unmounts while in fullscreen.
1172
+ const chartHolder =
1173
+ internalRef.current?.querySelector<HTMLElement>('.cds--chart-holder');
1174
+ if (chartHolder && document.fullscreenElement) {
1175
+ chartHolder.style.height = savedHeightRef.current;
1176
+ }
1177
+ };
1178
+ }, [fullscreenEnabled]);
1179
+
1180
+ const openTableModal = React.useCallback(
1181
+ (event: React.MouseEvent<HTMLButtonElement>) => {
1182
+ lastTableTriggerRef.current = event.currentTarget;
1183
+ setIsTableOpen(true);
1184
+ },
1185
+ []
1186
+ );
1187
+
1188
+ const closeTableModal = React.useCallback(() => {
1189
+ setIsTableOpen(false);
1190
+ requestAnimationFrame(() => {
1191
+ lastTableTriggerRef.current?.focus();
576
1192
  });
577
- const uniqueGroups = allGroups.filter(
578
- (g, index) => allGroups.indexOf(g) === index
579
- );
580
- const customColors = ((options as any).colors as string[]) || [];
1193
+ }, []);
1194
+
1195
+ const toggleFullscreen = React.useCallback(() => {
1196
+ if (!document.fullscreenElement && internalRef.current) {
1197
+ internalRef.current.requestFullscreen().catch(() => {
1198
+ // Fullscreen request may be denied by the browser or user agent
1199
+ });
1200
+ } else if (document.fullscreenElement) {
1201
+ document.exitFullscreen().catch(() => {
1202
+ // Exit fullscreen may fail if already exited
1203
+ });
1204
+ }
1205
+ }, []);
1206
+
1207
+ const chartTitle: string =
1208
+ (options as ExtendedChartOptions).title || toolbarI18n.defaultTitle;
1209
+
1210
+ const legendLabel = `${ariaLabel || chartTitle}. ${toolbarI18n.legendInstructions}`;
1211
+
1212
+ React.useEffect(() => {
1213
+ const container = internalRef.current;
1214
+
1215
+ if (!container) return;
1216
+
1217
+ const applyListSemantics = (legend: Element) => {
1218
+ legend.removeAttribute('aria-label');
1219
+ legend.setAttribute('role', 'list');
1220
+ legend
1221
+ .querySelectorAll('.legend-item')
1222
+ .forEach(item => item.setAttribute('role', 'listitem'));
1223
+ };
1224
+
1225
+ const wrapLegend = () => {
1226
+ const legend = container.querySelector('.cds--cc--legend');
1227
+
1228
+ if (!legend) return;
1229
+
1230
+ applyListSemantics(legend);
1231
+
1232
+ const parent = legend.parentElement;
1233
+
1234
+ if (parent?.tagName === 'FIELDSET') {
1235
+ const existingCaption = parent.querySelector('legend');
1236
+
1237
+ if (existingCaption && existingCaption.textContent !== legendLabel) {
1238
+ existingCaption.textContent = legendLabel;
1239
+ }
1240
+
1241
+ return;
1242
+ }
1243
+
1244
+ const fieldset = document.createElement('fieldset');
1245
+ const caption = document.createElement('legend');
1246
+
1247
+ fieldset.className = 'cds--cc--legend-fieldset';
1248
+ caption.textContent = legendLabel;
1249
+ legend.before(fieldset);
1250
+ fieldset.append(caption, legend);
1251
+ };
1252
+
1253
+ wrapLegend();
1254
+ const observer = new MutationObserver(wrapLegend);
1255
+
1256
+ observer.observe(container, { childList: true, subtree: true });
1257
+
1258
+ return () => observer.disconnect();
1259
+ }, [legendLabel]);
1260
+
1261
+ const handleModalDownloadCsv = React.useCallback(() => {
1262
+ downloadCsv(dataSet as Array<Record<string, unknown>>, chartTitle);
1263
+ }, [dataSet, chartTitle]);
1264
+
1265
+ useCarbonModalFocusManagement(internalRef);
1266
+
1267
+ const colorScale = React.useMemo(() => {
1268
+ const scaleColorsObj: ColorsObject = {};
1269
+ const customColors = (options as ExtendedChartOptions).colors || [];
581
1270
  const allColors = [...customColors, ...theme.chartColors];
582
1271
  const allInverseColors = [...customColors, ...theme.chartColorsInverse];
1272
+ const allGroups = dataSet.map(item =>
1273
+ 'group' in item ? (item as Record<string, unknown>)['group'] : null
1274
+ );
1275
+ const uniqueGroups = Array.from(new Set(allGroups));
583
1276
 
584
- uniqueGroups.forEach((group, i) => {
585
- if (uniqueGroups.length <= allColors.length) {
586
- return (scaleColorsObj[group || ('null' as any)] = isInverse
1277
+ if (uniqueGroups.length <= allColors.length) {
1278
+ for (let i = 0; i < uniqueGroups.length; i++) {
1279
+ const group = uniqueGroups[i];
1280
+ scaleColorsObj[String(group ?? 'null')] = isInverse
587
1281
  ? allInverseColors[i]
588
- : allColors[i]);
1282
+ : allColors[i];
589
1283
  }
590
- return {};
591
- });
1284
+ }
592
1285
 
593
1286
  return scaleColorsObj;
594
- }
1287
+ }, [
1288
+ dataSet,
1289
+ options,
1290
+ theme.chartColors,
1291
+ theme.chartColorsInverse,
1292
+ isInverse,
1293
+ ]);
595
1294
 
596
1295
  const newOptions = {
597
1296
  ...options,
598
1297
  theme: isInverse ? ChartTheme.G100 : ChartTheme.WHITE,
599
1298
  color: {
600
- scale: buildColors(),
1299
+ scale: colorScale,
601
1300
  },
602
1301
  tooltip: {
603
1302
  ...(options?.tooltip || {}),
@@ -605,33 +1304,225 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
605
1304
  type: 'none',
606
1305
  },
607
1306
  },
1307
+ ...(chartToolbar ? { toolbar: { enabled: false } } : {}),
608
1308
  };
609
1309
 
610
- const ChartType = allCharts[type] as any;
1310
+ const ChartType = ALL_CHARTS[type];
1311
+
1312
+ React.useEffect(() => {
1313
+ if (!ariaLabel) return;
1314
+ const rafId = requestAnimationFrame(() => {
1315
+ const svgEl = internalRef.current?.querySelector('.graph-frame');
1316
+ if (!svgEl) return;
1317
+ svgEl.setAttribute('aria-label', ariaLabel);
1318
+ });
1319
+ return () => cancelAnimationFrame(rafId);
1320
+ }, [ariaLabel]);
1321
+
1322
+ // Make Carbon Charts data points keyboard-focusable.
1323
+ React.useEffect(() => {
1324
+ const wrapper = internalRef.current;
1325
+ if (!wrapper) return;
1326
+
1327
+ const isDot = (el: EventTarget | null): el is SVGCircleElement =>
1328
+ el instanceof Element &&
1329
+ el.nodeName.toLowerCase() === 'circle' &&
1330
+ el.classList.contains('dot');
1331
+
1332
+ const onFocusIn = (e: FocusEvent) => {
1333
+ if (!isDot(e.target)) return;
1334
+ const dot = e.target as SVGCircleElement;
1335
+ dot.style.opacity = '1';
1336
+ const { left, top, width, height } = dot.getBoundingClientRect();
1337
+ const cx = left + width / 2;
1338
+ const cy = top + height / 2;
1339
+ dot.dispatchEvent(
1340
+ new MouseEvent('mouseover', {
1341
+ bubbles: true,
1342
+ clientX: cx,
1343
+ clientY: cy,
1344
+ screenX: cx,
1345
+ screenY: cy,
1346
+ })
1347
+ );
1348
+ dot.dispatchEvent(
1349
+ new MouseEvent('mousemove', {
1350
+ bubbles: true,
1351
+ clientX: cx,
1352
+ clientY: cy,
1353
+ screenX: cx,
1354
+ screenY: cy,
1355
+ })
1356
+ );
1357
+ };
1358
+
1359
+ const onFocusOut = (e: FocusEvent) => {
1360
+ if (!isDot(e.target)) return;
1361
+ const dot = e.target;
1362
+ dot.style.opacity = '';
1363
+ dot.dispatchEvent(new MouseEvent('mouseout', { bubbles: true }));
1364
+ };
1365
+
1366
+ const onKeyDown = (e: KeyboardEvent) => {
1367
+ if (!isDot(e.target)) return;
1368
+ if (e.key !== 'Enter' && e.key !== ' ') return;
1369
+ e.preventDefault();
1370
+ const dot = e.target;
1371
+ const { left, top, width, height } = dot.getBoundingClientRect();
1372
+ const cx = left + width / 2;
1373
+ const cy = top + height / 2;
1374
+ dot.dispatchEvent(
1375
+ new MouseEvent('click', {
1376
+ bubbles: true,
1377
+ clientX: cx,
1378
+ clientY: cy,
1379
+ })
1380
+ );
1381
+ };
1382
+
1383
+ const rafId = requestAnimationFrame(() => {
1384
+ wrapper
1385
+ .querySelectorAll<SVGCircleElement>('circle.dot')
1386
+ .forEach(dot => {
1387
+ if (!dot.hasAttribute('tabindex')) {
1388
+ dot.setAttribute('tabindex', '0');
1389
+ }
1390
+ });
1391
+ });
1392
+
1393
+ wrapper.addEventListener('focusin', onFocusIn);
1394
+ wrapper.addEventListener('focusout', onFocusOut);
1395
+ wrapper.addEventListener('keydown', onKeyDown);
1396
+
1397
+ return () => {
1398
+ cancelAnimationFrame(rafId);
1399
+ wrapper.removeEventListener('focusin', onFocusIn);
1400
+ wrapper.removeEventListener('focusout', onFocusOut);
1401
+ wrapper.removeEventListener('keydown', onKeyDown);
1402
+ };
1403
+ }, [type]);
611
1404
 
612
- // Adding aria-label to main SVG container
613
1405
  React.useEffect(() => {
614
- if (ariaLabel) {
615
- document.querySelectorAll('.graph-frame ').forEach(div => {
616
- div.setAttribute('aria-label', ariaLabel);
1406
+ const timer = setTimeout(() => {
1407
+ if (internalRef.current) {
1408
+ internalRef.current
1409
+ .querySelectorAll<SVGGElement>('g[aria-label]')
1410
+ .forEach(g => {
1411
+ const role = g.getAttribute('role');
1412
+
1413
+ if (!role) {
1414
+ g.setAttribute('role', 'img');
1415
+ } else if (role === 'graphics-object group') {
1416
+ g.setAttribute('role', 'graphics-object');
1417
+ }
1418
+ });
1419
+ }
1420
+ }, 0);
1421
+
1422
+ return () => clearTimeout(timer);
1423
+ }, [type, dataSet]);
1424
+
1425
+ React.useEffect(() => {
1426
+ const wrapper = internalRef.current;
1427
+
1428
+ if (!wrapper) return;
1429
+
1430
+ const observer = new MutationObserver(() => {
1431
+ const allItems = wrapper.querySelectorAll<HTMLElement>(
1432
+ '.legend-item:not(.additional)'
1433
+ );
1434
+
1435
+ if (allItems.length <= 1) return;
1436
+
1437
+ const activeLabels: string[] = [];
1438
+
1439
+ allItems.forEach(el => {
1440
+ const checkbox = el.querySelector<HTMLElement>('.checkbox');
1441
+ const label = el.querySelector('p');
1442
+
1443
+ if (checkbox?.getAttribute('aria-checked') === 'true' && label) {
1444
+ activeLabels.push(label.textContent?.trim() || '');
1445
+ }
617
1446
  });
618
- }
619
- });
620
1447
 
621
- const groupsLength = Object.keys(buildColors()).length;
1448
+ if (activeLabels.length === 1) {
1449
+ if (!legendAnnouncedRef.current) {
1450
+ legendAnnouncedRef.current = true;
1451
+ setLegendAnnouncement(`Only ${activeLabels[0]} is selected`);
1452
+ }
1453
+ } else if (activeLabels.length === allItems.length) {
1454
+ legendAnnouncedRef.current = false;
1455
+ setLegendAnnouncement('All items selected');
1456
+ } else {
1457
+ legendAnnouncedRef.current = false;
1458
+ setLegendAnnouncement('');
1459
+ }
1460
+ });
1461
+
1462
+ observer.observe(wrapper, {
1463
+ subtree: true,
1464
+ attributes: true,
1465
+ attributeFilter: ['aria-checked'],
1466
+ });
1467
+
1468
+ return () => observer.disconnect();
1469
+ }, [type, dataSet]);
1470
+
1471
+ const groupsLength = Object.keys(colorScale).length;
1472
+
1473
+ const showTable = chartToolbar?.showAsTable !== false;
1474
+
1475
+ if (!ChartType) return null;
622
1476
 
623
1477
  return (
624
- <CarbonChartWrapper
625
- data-testid={testId}
626
- ref={ref}
627
- isInverse={isInverse}
628
- theme={theme}
629
- className="carbon-chart-wrapper"
630
- groupsLength={groupsLength < 6 ? groupsLength : 14}
631
- {...rest}
632
- >
633
- <ChartType data={dataSet} options={newOptions} />
634
- </CarbonChartWrapper>
1478
+ <FullscreenRoot ref={mergedRef} isInverse={isInverse} theme={theme}>
1479
+ <VisuallyHidden>
1480
+ <Announce>{legendAnnouncement}</Announce>
1481
+ </VisuallyHidden>
1482
+ <CarbonChartWrapper
1483
+ data-testid={testId}
1484
+ isInverse={isInverse}
1485
+ theme={theme}
1486
+ className={`carbon-chart-wrapper${chartToolbar ? ' has-magma-toolbar' : ''}`}
1487
+ groupsLength={groupsLength < 6 ? groupsLength : 14}
1488
+ role="region"
1489
+ aria-label={ariaLabel || chartTitle}
1490
+ aria-roledescription="chart"
1491
+ {...rest}
1492
+ >
1493
+ <ChartContentWrapper>
1494
+ {chartToolbar && (
1495
+ <CarbonChartToolbar
1496
+ config={chartToolbar}
1497
+ dataSet={dataSet as Array<Record<string, unknown>>}
1498
+ isInverse={isInverse}
1499
+ isTableOpen={isTableOpen}
1500
+ isFullscreen={isFullscreen}
1501
+ onOpenTable={openTableModal}
1502
+ onToggleFullscreen={toggleFullscreen}
1503
+ theme={theme}
1504
+ title={chartTitle}
1505
+ wrapperRef={internalRef}
1506
+ />
1507
+ )}
1508
+ <ChartType data={dataSet} options={newOptions} />
1509
+ </ChartContentWrapper>
1510
+ </CarbonChartWrapper>
1511
+ {chartToolbar && showTable && (
1512
+ <ChartTableModal
1513
+ columns={chartToolbar.tableColumns}
1514
+ portalContainer={isFullscreen ? internalRef.current : undefined}
1515
+ dataSet={dataSet as Array<Record<string, React.ReactNode>>}
1516
+ headerLabel={chartToolbar.tableHeaderLabel}
1517
+ headerLevel={chartToolbar.tableHeaderLevel}
1518
+ isInverse={isInverse}
1519
+ isOpen={isTableOpen}
1520
+ onClose={closeTableModal}
1521
+ onDownloadCsv={handleModalDownloadCsv}
1522
+ title={chartTitle}
1523
+ />
1524
+ )}
1525
+ </FullscreenRoot>
635
1526
  );
636
1527
  }
637
1528
  );