@react-magma/charts 14.0.0-rc.4 → 14.0.0-rc.5

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 (31) hide show
  1. package/dist/charts.js +421 -7
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.modern.module.js +416 -9
  4. package/dist/charts.modern.module.js.map +1 -1
  5. package/dist/charts.umd.js +6205 -264
  6. package/dist/charts.umd.js.map +1 -1
  7. package/dist/components/CarbonChart/CarbonChart.d.ts +41 -0
  8. package/dist/components/ChartTable/ChartDataTable.d.ts +19 -0
  9. package/dist/components/ChartTable/ChartFullscreenButton.d.ts +26 -0
  10. package/dist/components/ChartTable/ChartMoreOptionsButton.d.ts +18 -0
  11. package/dist/components/ChartTable/ChartTable.test.d.ts +1 -0
  12. package/dist/components/ChartTable/ChartTableButton.d.ts +24 -0
  13. package/dist/components/ChartTable/ChartTableModal.d.ts +44 -0
  14. package/dist/components/ChartTable/ChartToolbar.d.ts +19 -0
  15. package/dist/components/ChartTable/chartToolbarI18n.d.ts +16 -0
  16. package/dist/components/ChartTable/index.d.ts +14 -0
  17. package/dist/index.d.ts +1 -0
  18. package/package.json +4 -4
  19. package/src/components/CarbonChart/CarbonChart.test.js +143 -2
  20. package/src/components/CarbonChart/CarbonChart.tsx +624 -15
  21. package/src/components/ChartTable/ChartDataTable.tsx +75 -0
  22. package/src/components/ChartTable/ChartFullscreenButton.tsx +59 -0
  23. package/src/components/ChartTable/ChartMoreOptionsButton.tsx +48 -0
  24. package/src/components/ChartTable/ChartTable.stories.tsx +152 -0
  25. package/src/components/ChartTable/ChartTable.test.tsx +452 -0
  26. package/src/components/ChartTable/ChartTableButton.tsx +56 -0
  27. package/src/components/ChartTable/ChartTableModal.tsx +135 -0
  28. package/src/components/ChartTable/ChartToolbar.tsx +51 -0
  29. package/src/components/ChartTable/chartToolbarI18n.ts +55 -0
  30. package/src/components/ChartTable/index.ts +23 -0
  31. package/src/index.ts +1 -0
@@ -23,10 +23,30 @@ import {
23
23
  } from '@carbon/charts-react';
24
24
  import styled from '@emotion/styled';
25
25
  import { transparentize } from 'polished';
26
- import { ThemeInterface, ThemeContext, useIsInverse } from 'react-magma-dom';
26
+ import {
27
+ DropdownDivider,
28
+ DropdownMenuItem,
29
+ ThemeInterface,
30
+ ThemeContext,
31
+ useIsInverse,
32
+ } from 'react-magma-dom';
33
+ import {
34
+ FullscreenExitIcon,
35
+ FullscreenIcon,
36
+ MoreVertIcon,
37
+ TableIcon,
38
+ } from 'react-magma-icons';
27
39
 
28
40
  import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
29
41
  import './carbon-charts.css';
42
+ import {
43
+ ChartFullscreenButton,
44
+ ChartMoreOptionsButton,
45
+ ChartTableButton,
46
+ ChartTableModal,
47
+ } from '../ChartTable';
48
+ import type { ChartDataTableColumn } from '../ChartTable';
49
+ import { useChartToolbarI18n } from '../ChartTable/chartToolbarI18n';
30
50
 
31
51
  export enum CarbonChartType {
32
52
  area = 'area',
@@ -49,6 +69,41 @@ export enum CarbonChartType {
49
69
  combo = 'combo',
50
70
  }
51
71
 
72
+ export interface ChartToolbarConfig {
73
+ /**
74
+ * When true, renders a "Show as table" button that opens a Magma Modal
75
+ * with the chart data in an accessible table.
76
+ * @default true
77
+ */
78
+ showAsTable?: boolean;
79
+ /**
80
+ * When true, renders a fullscreen toggle button.
81
+ * @default true
82
+ */
83
+ fullscreen?: boolean;
84
+ /**
85
+ * Additional menu items rendered inside a "More options" dropdown,
86
+ * below the built-in "Download as CSV", "Download as PNG", and "Download as JPG" items.
87
+ * Pass DropdownMenuItem elements.
88
+ */
89
+ moreOptions?: React.ReactNode;
90
+ /**
91
+ * Custom column definitions for the table modal.
92
+ * If omitted, columns are auto-derived from the dataset object keys.
93
+ */
94
+ tableColumns?: ChartDataTableColumn[];
95
+ /**
96
+ * First line of the modal heading.
97
+ * @default "Tabular representation"
98
+ */
99
+ tableHeaderLabel?: string;
100
+ /**
101
+ * Heading level for the modal header.
102
+ * @default 2
103
+ */
104
+ tableHeaderLevel?: 1 | 2 | 3 | 4 | 5 | 6;
105
+ }
106
+
52
107
  export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
53
108
  dataSet: Array<object>;
54
109
  isInverse?: boolean;
@@ -69,13 +124,55 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
69
124
  * Text for the aria-label attribute for main SVG container, if provided
70
125
  */
71
126
  ariaLabel?: string;
127
+ /**
128
+ * When provided, renders an accessible Magma toolbar above the chart with
129
+ * "Show as table", fullscreen, and "More options" buttons. Carbon's built-in
130
+ * toolbar is automatically disabled.
131
+ */
132
+ chartToolbar?: ChartToolbarConfig;
72
133
  }
73
134
 
135
+ const ChartContentWrapper = styled.div`
136
+ height: 100%;
137
+ position: relative;
138
+ `;
139
+
140
+ const FullscreenRoot = styled.div<{
141
+ isInverse?: boolean;
142
+ theme: ThemeInterface;
143
+ }>`
144
+ height: 100%;
145
+ width: 100%;
146
+
147
+ &:fullscreen,
148
+ &:-webkit-full-screen {
149
+ background: ${props =>
150
+ props.isInverse
151
+ ? props.theme.colors.primary700
152
+ : props.theme.colors.neutral100};
153
+ .cds--chart-holder {
154
+ height: 100vh !important;
155
+ }
156
+ }
157
+ `;
158
+
74
159
  const CarbonChartWrapper = styled.div<{
75
160
  isInverse?: boolean;
76
161
  groupsLength: number;
77
162
  theme: ThemeInterface;
78
163
  }>`
164
+ &:fullscreen,
165
+ &:-webkit-full-screen {
166
+ background: ${props =>
167
+ props.isInverse
168
+ ? props.theme.colors.primary700
169
+ : props.theme.colors.neutral100};
170
+
171
+ .cds--chart-holder {
172
+ height: 100vh !important;
173
+ }
174
+ }
175
+
79
176
  .cds--data-table thead tr th {
80
177
  background: ${props =>
81
178
  props.isInverse ? props.theme.colors.primary700 : ''} !important;
@@ -131,9 +228,9 @@ const CarbonChartWrapper = styled.div<{
131
228
  }
132
229
  }
133
230
 
134
- p,
135
- div,
136
- text,
231
+ .chart-holder p,
232
+ .chart-holder div,
233
+ .chart-holder text,
137
234
  .cds--cc--axes g.axis .axis-title,
138
235
  .cds--cc--title p.title,
139
236
  .cds--cc--axes g.axis g.tick text {
@@ -489,6 +586,10 @@ const CarbonChartWrapper = styled.div<{
489
586
  }
490
587
  }
491
588
 
589
+ &.has-magma-toolbar .cds--cc--title p.title {
590
+ visibility: hidden;
591
+ }
592
+
492
593
  svg:not(:root) {
493
594
  overflow: visible;
494
595
  }
@@ -536,6 +637,407 @@ interface ColorsObject {
536
637
  [key: string]: string;
537
638
  }
538
639
 
640
+ const ToolbarWrapper = styled.div<{
641
+ isFullscreen?: boolean;
642
+ isInverse?: boolean;
643
+ theme: ThemeInterface;
644
+ }>`
645
+ align-items: center;
646
+ display: flex;
647
+ justify-content: space-between;
648
+ left: ${props => (props.isFullscreen ? '2em' : '0')};
649
+ position: absolute;
650
+ right: ${props => (props.isFullscreen ? '2em' : '0')};
651
+ top: ${props => (props.isFullscreen ? '2em' : '0')};
652
+ z-index: 2;
653
+
654
+ button {
655
+ color: ${props =>
656
+ props.isInverse
657
+ ? props.theme.colors.neutral100
658
+ : props.theme.colors.primary500};
659
+
660
+ &:focus {
661
+ outline: 2px solid
662
+ ${props =>
663
+ props.isInverse
664
+ ? props.theme.colors.focusInverse
665
+ : props.theme.colors.focus};
666
+ outline-offset: 0;
667
+ }
668
+ }
669
+ `;
670
+
671
+ const ChartTitle = styled.h2<{
672
+ isInverse?: boolean;
673
+ theme: ThemeInterface;
674
+ }>`
675
+ font-family: ${props => props.theme.bodyFont} !important;
676
+ font-size: 16px !important;
677
+ font-weight: 700 !important;
678
+ letter-spacing: normal !important;
679
+ line-height: 1.25 !important;
680
+ margin: 0 !important;
681
+ color: ${props =>
682
+ props.isInverse
683
+ ? props.theme.colors.neutral100
684
+ : props.theme.colors.neutral700} !important;
685
+ `;
686
+
687
+ const ToolbarActions = styled.div<{ theme: ThemeInterface }>`
688
+ align-items: center;
689
+ display: flex;
690
+ gap: ${props => props.theme.spaceScale.spacing02};
691
+
692
+ > * button,
693
+ > button {
694
+ height: 2rem;
695
+ min-height: 2rem;
696
+ min-width: 2rem;
697
+ padding: 4px;
698
+ width: 2rem;
699
+ }
700
+
701
+ [role='tooltip'] {
702
+ padding: ${props => props.theme.spaceScale.spacing03}
703
+ ${props => props.theme.spaceScale.spacing04};
704
+ text-align: center;
705
+ white-space: nowrap;
706
+ }
707
+
708
+ [data-testid='dropdownContent'] {
709
+ padding: ${props => props.theme.spaceScale.spacing03} 0;
710
+ }
711
+
712
+ [role='menuitem'],
713
+ [data-testid='dropdownMenuItem'] {
714
+ padding: ${props => props.theme.spaceScale.spacing03}
715
+ ${props => props.theme.spaceScale.spacing05};
716
+ }
717
+ `;
718
+
719
+ function sanitizeCsvValue(value: string): string {
720
+ const trimmed = value.trimStart();
721
+
722
+ if (/^[=+\-@\t\r]/.test(trimmed)) {
723
+ return `'${value}`;
724
+ }
725
+
726
+ return value;
727
+ }
728
+
729
+ function downloadCsv(dataSet: Array<Record<string, unknown>>, title: string) {
730
+ if (!dataSet.length) return;
731
+ const keys = Object.keys(dataSet[0]);
732
+ const header = keys.map(k => sanitizeCsvValue(k)).join(',');
733
+ const rows = dataSet.map(row =>
734
+ keys
735
+ .map(k => {
736
+ const v = row[k];
737
+ const s = sanitizeCsvValue(String(v ?? ''));
738
+
739
+ return s.includes(',') || s.includes('"') || s.includes('\n')
740
+ ? `"${s.replace(/"/g, '""')}"`
741
+ : s;
742
+ })
743
+ .join(',')
744
+ );
745
+ const csv = [header, ...rows].join('\n');
746
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
747
+ const url = URL.createObjectURL(blob);
748
+ const a = document.createElement('a');
749
+
750
+ a.href = url;
751
+ a.download = `${title || 'chart-data'}.csv`;
752
+ a.click();
753
+ URL.revokeObjectURL(url);
754
+ }
755
+
756
+ function inlineStyles(source: Element, target: Element) {
757
+ const computed = window.getComputedStyle(source);
758
+
759
+ if (target instanceof HTMLElement || target instanceof SVGElement) {
760
+ target.setAttribute(
761
+ 'style',
762
+ Array.from(computed)
763
+ .map(prop => `${prop}:${computed.getPropertyValue(prop)}`)
764
+ .join(';')
765
+ );
766
+ }
767
+ for (let i = 0; i < source.children.length; i++) {
768
+ if (target.children[i]) {
769
+ inlineStyles(source.children[i], target.children[i]);
770
+ }
771
+ }
772
+ }
773
+
774
+ interface LegendItem {
775
+ label: string;
776
+ color: string;
777
+ }
778
+
779
+ function readLegendItems(wrapper: HTMLElement): LegendItem[] {
780
+ const items: LegendItem[] = [];
781
+
782
+ wrapper.querySelectorAll('.legend-item').forEach(item => {
783
+ const checkbox = item.querySelector<HTMLElement>('.checkbox');
784
+ const label = item.querySelector('p');
785
+
786
+ if (checkbox && label) {
787
+ items.push({
788
+ color: checkbox.style.background || checkbox.style.backgroundColor,
789
+ label: label.textContent || '',
790
+ });
791
+ }
792
+ });
793
+
794
+ return items;
795
+ }
796
+
797
+ function drawLegend(
798
+ ctx: CanvasRenderingContext2D,
799
+ items: LegendItem[],
800
+ startY: number,
801
+ canvasWidth: number,
802
+ scale: number
803
+ ) {
804
+ const fontSize = 13 * scale;
805
+ const swatchSize = 12 * scale;
806
+ const gap = 8 * scale;
807
+ const itemGap = 16 * scale;
808
+ const paddingX = 16 * scale;
809
+
810
+ ctx.font = `${fontSize}px sans-serif`;
811
+ ctx.textBaseline = 'middle';
812
+
813
+ let x = paddingX;
814
+ let y = startY;
815
+
816
+ for (const item of items) {
817
+ const textWidth = ctx.measureText(item.label).width;
818
+ const itemWidth = swatchSize + gap + textWidth + itemGap;
819
+
820
+ if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
821
+ x = paddingX;
822
+ y += fontSize + gap;
823
+ }
824
+
825
+ ctx.fillStyle = item.color;
826
+ ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
827
+
828
+ ctx.fillStyle = '#161616';
829
+ ctx.fillText(item.label, x + swatchSize + gap, y);
830
+
831
+ x += itemWidth;
832
+ }
833
+
834
+ return y + fontSize + gap;
835
+ }
836
+
837
+ function downloadImage(
838
+ wrapperRef: React.RefObject<HTMLDivElement | null>,
839
+ title: string,
840
+ format: 'png' | 'jpg'
841
+ ) {
842
+ const wrapper = wrapperRef.current;
843
+
844
+ if (!wrapper) return;
845
+ const svg = wrapper.querySelector('svg.layout-svg-wrapper');
846
+
847
+ if (!svg) return;
848
+
849
+ const svgRect = svg.getBoundingClientRect();
850
+ const legendItems = readLegendItems(wrapper);
851
+ const scale = 2;
852
+
853
+ // Measure legend height
854
+ const tempCanvas = document.createElement('canvas');
855
+ const tempCtx = tempCanvas.getContext('2d');
856
+ const fontSize = 13 * scale;
857
+ const swatchSize = 12 * scale;
858
+ const gap = 8 * scale;
859
+ const itemGap = 16 * scale;
860
+ const paddingX = 16 * scale;
861
+ const canvasWidth = svgRect.width * scale;
862
+
863
+ let legendHeight = 0;
864
+
865
+ if (legendItems.length > 0 && tempCtx) {
866
+ tempCtx.font = `${fontSize}px sans-serif`;
867
+ let x = paddingX;
868
+ let rows = 1;
869
+
870
+ for (const item of legendItems) {
871
+ const textWidth = tempCtx.measureText(item.label).width;
872
+ const itemWidth = swatchSize + gap + textWidth + itemGap;
873
+
874
+ if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
875
+ x = paddingX;
876
+ rows++;
877
+ }
878
+ x += itemWidth;
879
+ }
880
+ legendHeight = rows * (fontSize + gap) + gap * 2;
881
+ }
882
+
883
+ const width = svgRect.width * scale;
884
+ const height = svgRect.height * scale + legendHeight;
885
+
886
+ const clone = svg.cloneNode(true) as SVGSVGElement;
887
+
888
+ clone.setAttribute('width', String(svgRect.width));
889
+ clone.setAttribute('height', String(svgRect.height));
890
+ clone.setAttribute('viewBox', `0 0 ${svgRect.width} ${svgRect.height}`);
891
+ clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
892
+
893
+ inlineStyles(svg, clone);
894
+
895
+ const serializer = new XMLSerializer();
896
+ const svgString = serializer.serializeToString(clone);
897
+ const svgBlob = new Blob([svgString], {
898
+ type: 'image/svg+xml;charset=utf-8',
899
+ });
900
+ const url = URL.createObjectURL(svgBlob);
901
+
902
+ const mimeType = format === 'jpg' ? 'image/jpeg' : 'image/png';
903
+ const ext = format === 'jpg' ? 'jpg' : 'png';
904
+
905
+ const img = new Image();
906
+
907
+ img.onload = () => {
908
+ const canvas = document.createElement('canvas');
909
+
910
+ canvas.width = width;
911
+ canvas.height = height;
912
+ const ctx = canvas.getContext('2d');
913
+
914
+ if (!ctx) return;
915
+
916
+ ctx.fillStyle = '#ffffff';
917
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
918
+ ctx.drawImage(img, 0, 0, svgRect.width * scale, svgRect.height * scale);
919
+ URL.revokeObjectURL(url);
920
+
921
+ if (legendItems.length > 0) {
922
+ drawLegend(ctx, legendItems, svgRect.height * scale + gap, width, scale);
923
+ }
924
+
925
+ canvas.toBlob(blob => {
926
+ if (!blob) return;
927
+ const imgUrl = URL.createObjectURL(blob);
928
+ const a = document.createElement('a');
929
+
930
+ a.href = imgUrl;
931
+ a.download = `${title || 'chart'}.${ext}`;
932
+ a.click();
933
+ URL.revokeObjectURL(imgUrl);
934
+ }, mimeType);
935
+ };
936
+ img.src = url;
937
+ }
938
+
939
+ interface InternalToolbarProps {
940
+ config: ChartToolbarConfig;
941
+ dataSet: Array<Record<string, unknown>>;
942
+ isInverse: boolean;
943
+ isTableOpen: boolean;
944
+ isFullscreen: boolean;
945
+ onOpenTable: (event: React.MouseEvent<HTMLButtonElement>) => void;
946
+ onToggleFullscreen: () => void;
947
+ theme: ThemeInterface;
948
+ title: string;
949
+ wrapperRef: React.RefObject<HTMLDivElement | null>;
950
+ }
951
+
952
+ function CarbonChartToolbar({
953
+ config,
954
+ dataSet,
955
+ isInverse,
956
+ isTableOpen,
957
+ isFullscreen,
958
+ onOpenTable,
959
+ onToggleFullscreen,
960
+ theme,
961
+ title,
962
+ wrapperRef,
963
+ }: InternalToolbarProps) {
964
+ const t = useChartToolbarI18n();
965
+ const showTable = config.showAsTable !== false;
966
+ const showFullscreen = config.fullscreen !== false;
967
+ const resolvedTitle = title || t.defaultTitle;
968
+
969
+ const handleDownloadCsv = React.useCallback(() => {
970
+ downloadCsv(dataSet, title);
971
+ }, [dataSet, title]);
972
+
973
+ const handleDownloadPng = React.useCallback(() => {
974
+ downloadImage(wrapperRef, title, 'png');
975
+ }, [wrapperRef, title]);
976
+
977
+ const handleDownloadJpg = React.useCallback(() => {
978
+ downloadImage(wrapperRef, title, 'jpg');
979
+ }, [wrapperRef, title]);
980
+
981
+ const moreOptionsContent = (
982
+ <>
983
+ <DropdownMenuItem onClick={handleDownloadCsv}>
984
+ {t.downloadAsCsv}
985
+ </DropdownMenuItem>
986
+ <DropdownMenuItem onClick={handleDownloadPng}>
987
+ {t.downloadAsPng}
988
+ </DropdownMenuItem>
989
+ <DropdownMenuItem onClick={handleDownloadJpg}>
990
+ {t.downloadAsJpg}
991
+ </DropdownMenuItem>
992
+ {config.moreOptions && (
993
+ <>
994
+ <DropdownDivider />
995
+ {config.moreOptions}
996
+ </>
997
+ )}
998
+ </>
999
+ );
1000
+
1001
+ return (
1002
+ <ToolbarWrapper
1003
+ isFullscreen={isFullscreen}
1004
+ isInverse={isInverse}
1005
+ theme={theme}
1006
+ >
1007
+ <ChartTitle isInverse={isInverse} theme={theme}>
1008
+ {resolvedTitle}
1009
+ </ChartTitle>
1010
+ <ToolbarActions theme={theme}>
1011
+ {showTable && (
1012
+ <ChartTableButton
1013
+ ariaLabel={resolvedTitle}
1014
+ icon={<TableIcon size={20} />}
1015
+ isInverse={isInverse}
1016
+ isTableOpen={isTableOpen}
1017
+ onClick={onOpenTable}
1018
+ />
1019
+ )}
1020
+ {showFullscreen && (
1021
+ <ChartFullscreenButton
1022
+ ariaLabel={`${isFullscreen ? 'Exit' : 'View'} ${resolvedTitle} full screen`}
1023
+ icon={<FullscreenIcon size={20} />}
1024
+ exitIcon={<FullscreenExitIcon size={20} />}
1025
+ isInverse={isInverse}
1026
+ isFullscreen={isFullscreen}
1027
+ onClick={onToggleFullscreen}
1028
+ />
1029
+ )}
1030
+ <ChartMoreOptionsButton
1031
+ icon={<MoreVertIcon size={20} />}
1032
+ isInverse={isInverse}
1033
+ >
1034
+ {moreOptionsContent}
1035
+ </ChartMoreOptionsButton>
1036
+ </ToolbarActions>
1037
+ </ToolbarWrapper>
1038
+ );
1039
+ }
1040
+
539
1041
  export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
540
1042
  (props, ref) => {
541
1043
  const {
@@ -545,12 +1047,18 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
545
1047
  dataSet,
546
1048
  options,
547
1049
  ariaLabel,
1050
+ chartToolbar,
548
1051
  ...rest
549
1052
  } = props;
550
1053
  const theme = React.useContext(ThemeContext) as ThemeInterface;
551
1054
  const isInverse = useIsInverse(isInverseProp);
1055
+ const toolbarI18n = useChartToolbarI18n();
552
1056
  const internalRef = React.useRef<HTMLDivElement | null>(null);
553
1057
 
1058
+ const [isTableOpen, setIsTableOpen] = React.useState(false);
1059
+ const [isFullscreen, setIsFullscreen] = React.useState(false);
1060
+ const lastTableTriggerRef = React.useRef<HTMLButtonElement | null>(null);
1061
+
554
1062
  const mergedRef = React.useCallback(
555
1063
  (node: HTMLDivElement | null) => {
556
1064
  internalRef.current = node;
@@ -563,6 +1071,73 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
563
1071
  [ref]
564
1072
  );
565
1073
 
1074
+ const fullscreenEnabled = chartToolbar
1075
+ ? chartToolbar.fullscreen !== false
1076
+ : false;
1077
+
1078
+ const savedHeightRef = React.useRef<string>('');
1079
+
1080
+ React.useEffect(() => {
1081
+ if (!fullscreenEnabled) return;
1082
+
1083
+ const onFullscreenChange = () => {
1084
+ const isFs = !!document.fullscreenElement;
1085
+
1086
+ setIsFullscreen(isFs);
1087
+
1088
+ const chartHolder =
1089
+ internalRef.current?.querySelector<HTMLElement>('.cds--chart-holder');
1090
+
1091
+ if (chartHolder) {
1092
+ if (isFs) {
1093
+ savedHeightRef.current = chartHolder.style.height;
1094
+ chartHolder.style.height = '100vh';
1095
+ } else {
1096
+ chartHolder.style.height = savedHeightRef.current;
1097
+ }
1098
+ }
1099
+ };
1100
+
1101
+ document.addEventListener('fullscreenchange', onFullscreenChange);
1102
+
1103
+ return () =>
1104
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
1105
+ }, [fullscreenEnabled]);
1106
+
1107
+ const openTableModal = React.useCallback(
1108
+ (event: React.MouseEvent<HTMLButtonElement>) => {
1109
+ lastTableTriggerRef.current = event.currentTarget;
1110
+ setIsTableOpen(true);
1111
+ },
1112
+ []
1113
+ );
1114
+
1115
+ const closeTableModal = React.useCallback(() => {
1116
+ setIsTableOpen(false);
1117
+ setTimeout(() => {
1118
+ lastTableTriggerRef.current?.focus();
1119
+ }, 0);
1120
+ }, []);
1121
+
1122
+ const toggleFullscreen = React.useCallback(() => {
1123
+ if (!document.fullscreenElement && internalRef.current) {
1124
+ internalRef.current.requestFullscreen().catch(() => {
1125
+ // Fullscreen request may be denied by the browser or user agent
1126
+ });
1127
+ } else if (document.fullscreenElement) {
1128
+ document.exitFullscreen().catch(() => {
1129
+ // Exit fullscreen may fail if already exited
1130
+ });
1131
+ }
1132
+ }, []);
1133
+
1134
+ const chartTitle: string =
1135
+ (options as any).title || toolbarI18n.defaultTitle;
1136
+
1137
+ const handleModalDownloadCsv = React.useCallback(() => {
1138
+ downloadCsv(dataSet as Array<Record<string, unknown>>, chartTitle);
1139
+ }, [dataSet, chartTitle]);
1140
+
566
1141
  useCarbonModalFocusManagement(internalRef);
567
1142
  const allCharts = {
568
1143
  area: AreaChart,
@@ -623,6 +1198,7 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
623
1198
  type: 'none',
624
1199
  },
625
1200
  },
1201
+ ...(chartToolbar ? { toolbar: { enabled: false } } : {}),
626
1202
  };
627
1203
 
628
1204
  const ChartType = allCharts[type] as any;
@@ -638,18 +1214,51 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
638
1214
 
639
1215
  const groupsLength = Object.keys(buildColors()).length;
640
1216
 
1217
+ const showTable = chartToolbar?.showAsTable !== false;
1218
+
641
1219
  return (
642
- <CarbonChartWrapper
643
- data-testid={testId}
644
- ref={mergedRef}
645
- isInverse={isInverse}
646
- theme={theme}
647
- className="carbon-chart-wrapper"
648
- groupsLength={groupsLength < 6 ? groupsLength : 14}
649
- {...rest}
650
- >
651
- <ChartType data={dataSet} options={newOptions} />
652
- </CarbonChartWrapper>
1220
+ <FullscreenRoot ref={mergedRef} isInverse={isInverse} theme={theme}>
1221
+ <CarbonChartWrapper
1222
+ data-testid={testId}
1223
+ isInverse={isInverse}
1224
+ theme={theme}
1225
+ className={`carbon-chart-wrapper${chartToolbar ? ' has-magma-toolbar' : ''}`}
1226
+ groupsLength={groupsLength < 6 ? groupsLength : 14}
1227
+ {...rest}
1228
+ >
1229
+ <ChartContentWrapper>
1230
+ {chartToolbar && (
1231
+ <CarbonChartToolbar
1232
+ config={chartToolbar}
1233
+ dataSet={dataSet as Array<Record<string, unknown>>}
1234
+ isInverse={isInverse}
1235
+ isTableOpen={isTableOpen}
1236
+ isFullscreen={isFullscreen}
1237
+ onOpenTable={openTableModal}
1238
+ onToggleFullscreen={toggleFullscreen}
1239
+ theme={theme}
1240
+ title={chartTitle}
1241
+ wrapperRef={internalRef}
1242
+ />
1243
+ )}
1244
+ <ChartType data={dataSet} options={newOptions} />
1245
+ </ChartContentWrapper>
1246
+ </CarbonChartWrapper>
1247
+ {chartToolbar && showTable && (
1248
+ <ChartTableModal
1249
+ columns={chartToolbar.tableColumns}
1250
+ portalContainer={isFullscreen ? internalRef.current : undefined}
1251
+ dataSet={dataSet as Array<Record<string, React.ReactNode>>}
1252
+ headerLabel={chartToolbar.tableHeaderLabel}
1253
+ headerLevel={chartToolbar.tableHeaderLevel}
1254
+ isInverse={isInverse}
1255
+ isOpen={isTableOpen}
1256
+ onClose={closeTableModal}
1257
+ onDownloadCsv={handleModalDownloadCsv}
1258
+ title={chartTitle}
1259
+ />
1260
+ )}
1261
+ </FullscreenRoot>
653
1262
  );
654
1263
  }
655
1264
  );