@react-magma/charts 13.0.4-next.0 → 13.1.0

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 (35) hide show
  1. package/dist/charts.js +447 -27
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.modern.module.js +443 -30
  4. package/dist/charts.modern.module.js.map +1 -1
  5. package/dist/charts.umd.js +1576 -214
  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.stories.d.ts +116 -0
  12. package/dist/components/ChartTable/ChartTable.test.d.ts +1 -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 +16 -0
  17. package/dist/components/ChartTable/index.d.ts +14 -0
  18. package/dist/components/LineChart/DataTable.d.ts +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/package.json +5 -5
  21. package/src/components/CarbonChart/CarbonChart.test.js +143 -2
  22. package/src/components/CarbonChart/CarbonChart.tsx +603 -15
  23. package/src/components/ChartTable/ChartDataTable.tsx +72 -0
  24. package/src/components/ChartTable/ChartFullscreenButton.tsx +59 -0
  25. package/src/components/ChartTable/ChartMoreOptionsButton.tsx +47 -0
  26. package/src/components/ChartTable/ChartTable.stories.tsx +152 -0
  27. package/src/components/ChartTable/ChartTable.test.tsx +444 -0
  28. package/src/components/ChartTable/ChartTableButton.tsx +55 -0
  29. package/src/components/ChartTable/ChartTableModal.tsx +135 -0
  30. package/src/components/ChartTable/ChartToolbar.tsx +50 -0
  31. package/src/components/ChartTable/chartToolbarI18n.ts +55 -0
  32. package/src/components/ChartTable/index.ts +23 -0
  33. package/src/components/LineChart/DataTable.tsx +3 -3
  34. package/src/components/LineChart/LineChart.tsx +1 -1
  35. 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,10 +24,30 @@ 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
+ DropdownDivider,
29
+ DropdownMenuItem,
30
+ ThemeInterface,
31
+ ThemeContext,
32
+ useIsInverse,
33
+ } from 'react-magma-dom';
34
+ import {
35
+ FullscreenExitIcon,
36
+ FullscreenIcon,
37
+ MoreVertIcon,
38
+ TableIcon,
39
+ } from 'react-magma-icons';
27
40
 
28
41
  import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
29
42
  import './carbon-charts.css';
43
+ import {
44
+ ChartFullscreenButton,
45
+ ChartMoreOptionsButton,
46
+ ChartTableButton,
47
+ ChartTableModal,
48
+ } from '../ChartTable';
49
+ import type { ChartDataTableColumn } from '../ChartTable';
50
+ import { useChartToolbarI18n } from '../ChartTable/chartToolbarI18n';
30
51
 
31
52
  export enum CarbonChartType {
32
53
  area = 'area',
@@ -49,6 +70,41 @@ export enum CarbonChartType {
49
70
  combo = 'combo',
50
71
  }
51
72
 
73
+ export interface ChartToolbarConfig {
74
+ /**
75
+ * When true, renders a "Show as table" button that opens a Magma Modal
76
+ * with the chart data in an accessible table.
77
+ * @default true
78
+ */
79
+ showAsTable?: boolean;
80
+ /**
81
+ * When true, renders a fullscreen toggle button.
82
+ * @default true
83
+ */
84
+ fullscreen?: boolean;
85
+ /**
86
+ * Additional menu items rendered inside a "More options" dropdown,
87
+ * below the built-in "Download as CSV", "Download as PNG", and "Download as JPG" items.
88
+ * Pass DropdownMenuItem elements.
89
+ */
90
+ moreOptions?: React.ReactNode;
91
+ /**
92
+ * Custom column definitions for the table modal.
93
+ * If omitted, columns are auto-derived from the dataset object keys.
94
+ */
95
+ tableColumns?: ChartDataTableColumn[];
96
+ /**
97
+ * First line of the modal heading.
98
+ * @default "Tabular representation"
99
+ */
100
+ tableHeaderLabel?: string;
101
+ /**
102
+ * Heading level for the modal header.
103
+ * @default 2
104
+ */
105
+ tableHeaderLevel?: 1 | 2 | 3 | 4 | 5 | 6;
106
+ }
107
+
52
108
  export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
53
109
  dataSet: Array<Object>;
54
110
  isInverse?: boolean;
@@ -69,13 +125,55 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
69
125
  * Text for the aria-label attribute for main SVG container, if provided
70
126
  */
71
127
  ariaLabel?: string;
128
+ /**
129
+ * When provided, renders an accessible Magma toolbar above the chart with
130
+ * "Show as table", fullscreen, and "More options" buttons. Carbon's built-in
131
+ * toolbar is automatically disabled.
132
+ */
133
+ chartToolbar?: ChartToolbarConfig;
72
134
  }
73
135
 
136
+ const ChartContentWrapper = styled.div`
137
+ height: 100%;
138
+ position: relative;
139
+ `;
140
+
141
+ const FullscreenRoot = styled.div<{
142
+ isInverse?: boolean;
143
+ theme: ThemeInterface;
144
+ }>`
145
+ height: 100%;
146
+ width: 100%;
147
+
148
+ &:fullscreen,
149
+ &:-webkit-full-screen {
150
+ background: ${props =>
151
+ props.isInverse
152
+ ? props.theme.colors.primary700
153
+ : props.theme.colors.neutral100};
154
+ .cds--chart-holder {
155
+ height: 100vh !important;
156
+ }
157
+ }
158
+ `;
159
+
74
160
  const CarbonChartWrapper = styled.div<{
75
161
  isInverse?: boolean;
76
162
  groupsLength: number;
77
163
  theme: ThemeInterface;
78
164
  }>`
165
+ &:fullscreen,
166
+ &:-webkit-full-screen {
167
+ background: ${props =>
168
+ props.isInverse
169
+ ? props.theme.colors.primary700
170
+ : props.theme.colors.neutral100};
171
+
172
+ .cds--chart-holder {
173
+ height: 100vh !important;
174
+ }
175
+ }
176
+
79
177
  .cds--data-table thead tr th {
80
178
  background: ${props =>
81
179
  props.isInverse ? props.theme.colors.primary700 : ''} !important;
@@ -131,9 +229,9 @@ const CarbonChartWrapper = styled.div<{
131
229
  }
132
230
  }
133
231
 
134
- p,
135
- div,
136
- text,
232
+ .chart-holder p,
233
+ .chart-holder div,
234
+ .chart-holder text,
137
235
  .cds--cc--axes g.axis .axis-title,
138
236
  .cds--cc--title p.title,
139
237
  .cds--cc--axes g.axis g.tick text {
@@ -488,6 +586,10 @@ const CarbonChartWrapper = styled.div<{
488
586
  }
489
587
  }
490
588
 
589
+ &.has-magma-toolbar .cds--cc--title p.title {
590
+ visibility: hidden;
591
+ }
592
+
491
593
  svg:not(:root) {
492
594
  overflow: visible;
493
595
  }
@@ -535,6 +637,389 @@ interface ColorsObject {
535
637
  [key: string]: string;
536
638
  }
537
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
+ if (/^[=+\-@\t\r]/.test(trimmed)) {
722
+ return `'${value}`;
723
+ }
724
+ return value;
725
+ }
726
+
727
+ function downloadCsv(dataSet: Array<Record<string, unknown>>, title: string) {
728
+ if (!dataSet.length) return;
729
+ const keys = Object.keys(dataSet[0]);
730
+ const header = keys.map(k => sanitizeCsvValue(k)).join(',');
731
+ const rows = dataSet.map(row =>
732
+ keys
733
+ .map(k => {
734
+ const v = row[k];
735
+ const s = sanitizeCsvValue(String(v ?? ''));
736
+ return s.includes(',') || s.includes('"') || s.includes('\n')
737
+ ? `"${s.replace(/"/g, '""')}"`
738
+ : s;
739
+ })
740
+ .join(',')
741
+ );
742
+ const csv = [header, ...rows].join('\n');
743
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
744
+ const url = URL.createObjectURL(blob);
745
+ const a = document.createElement('a');
746
+ a.href = url;
747
+ a.download = `${title || 'chart-data'}.csv`;
748
+ a.click();
749
+ URL.revokeObjectURL(url);
750
+ }
751
+
752
+ function inlineStyles(source: Element, target: Element) {
753
+ const computed = window.getComputedStyle(source);
754
+ if (target instanceof HTMLElement || target instanceof SVGElement) {
755
+ target.setAttribute(
756
+ 'style',
757
+ Array.from(computed)
758
+ .map(prop => `${prop}:${computed.getPropertyValue(prop)}`)
759
+ .join(';')
760
+ );
761
+ }
762
+ for (let i = 0; i < source.children.length; i++) {
763
+ if (target.children[i]) {
764
+ inlineStyles(source.children[i], target.children[i]);
765
+ }
766
+ }
767
+ }
768
+
769
+ interface LegendItem {
770
+ label: string;
771
+ color: string;
772
+ }
773
+
774
+ function readLegendItems(wrapper: HTMLElement): LegendItem[] {
775
+ const items: LegendItem[] = [];
776
+ wrapper.querySelectorAll('.legend-item').forEach(item => {
777
+ const checkbox = item.querySelector<HTMLElement>('.checkbox');
778
+ const label = item.querySelector('p');
779
+ if (checkbox && label) {
780
+ items.push({
781
+ color: checkbox.style.background || checkbox.style.backgroundColor,
782
+ label: label.textContent || '',
783
+ });
784
+ }
785
+ });
786
+ return items;
787
+ }
788
+
789
+ function drawLegend(
790
+ ctx: CanvasRenderingContext2D,
791
+ items: LegendItem[],
792
+ startY: number,
793
+ canvasWidth: number,
794
+ scale: number
795
+ ) {
796
+ const fontSize = 13 * scale;
797
+ const swatchSize = 12 * scale;
798
+ const gap = 8 * scale;
799
+ const itemGap = 16 * scale;
800
+ const paddingX = 16 * scale;
801
+
802
+ ctx.font = `${fontSize}px sans-serif`;
803
+ ctx.textBaseline = 'middle';
804
+
805
+ let x = paddingX;
806
+ let y = startY;
807
+
808
+ for (const item of items) {
809
+ const textWidth = ctx.measureText(item.label).width;
810
+ const itemWidth = swatchSize + gap + textWidth + itemGap;
811
+
812
+ if (x + itemWidth > canvasWidth - paddingX && x > paddingX) {
813
+ x = paddingX;
814
+ y += fontSize + gap;
815
+ }
816
+
817
+ ctx.fillStyle = item.color;
818
+ ctx.fillRect(x, y - swatchSize / 2, swatchSize, swatchSize);
819
+
820
+ ctx.fillStyle = '#161616';
821
+ ctx.fillText(item.label, x + swatchSize + gap, y);
822
+
823
+ x += itemWidth;
824
+ }
825
+
826
+ return y + fontSize + gap;
827
+ }
828
+
829
+ function downloadImage(
830
+ wrapperRef: React.RefObject<HTMLDivElement | null>,
831
+ title: string,
832
+ format: 'png' | 'jpg'
833
+ ) {
834
+ const wrapper = wrapperRef.current;
835
+ if (!wrapper) return;
836
+ const svg = wrapper.querySelector('svg.layout-svg-wrapper');
837
+ if (!svg) return;
838
+
839
+ const svgRect = svg.getBoundingClientRect();
840
+ const legendItems = readLegendItems(wrapper);
841
+ const scale = 2;
842
+
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++;
864
+ }
865
+ x += itemWidth;
866
+ }
867
+ legendHeight = rows * (fontSize + gap) + gap * 2;
868
+ }
869
+
870
+ const width = svgRect.width * scale;
871
+ const height = svgRect.height * scale + legendHeight;
872
+
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');
878
+
879
+ inlineStyles(svg, clone);
880
+
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
+ }
907
+
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);
917
+ };
918
+ img.src = url;
919
+ }
920
+
921
+ interface InternalToolbarProps {
922
+ config: ChartToolbarConfig;
923
+ dataSet: Array<Record<string, unknown>>;
924
+ isInverse: boolean;
925
+ isTableOpen: boolean;
926
+ isFullscreen: boolean;
927
+ onOpenTable: (event: React.MouseEvent<HTMLButtonElement>) => void;
928
+ onToggleFullscreen: () => void;
929
+ theme: ThemeInterface;
930
+ title: string;
931
+ wrapperRef: React.RefObject<HTMLDivElement | null>;
932
+ }
933
+
934
+ function CarbonChartToolbar({
935
+ config,
936
+ dataSet,
937
+ isInverse,
938
+ isTableOpen,
939
+ isFullscreen,
940
+ onOpenTable,
941
+ onToggleFullscreen,
942
+ theme,
943
+ title,
944
+ wrapperRef,
945
+ }: InternalToolbarProps) {
946
+ const t = useChartToolbarI18n();
947
+ const showTable = config.showAsTable !== false;
948
+ const showFullscreen = config.fullscreen !== false;
949
+ const resolvedTitle = title || t.defaultTitle;
950
+
951
+ const handleDownloadCsv = React.useCallback(() => {
952
+ downloadCsv(dataSet, title);
953
+ }, [dataSet, title]);
954
+
955
+ const handleDownloadPng = React.useCallback(() => {
956
+ downloadImage(wrapperRef, title, 'png');
957
+ }, [wrapperRef, title]);
958
+
959
+ const handleDownloadJpg = React.useCallback(() => {
960
+ downloadImage(wrapperRef, title, 'jpg');
961
+ }, [wrapperRef, title]);
962
+
963
+ const moreOptionsContent = (
964
+ <>
965
+ <DropdownMenuItem onClick={handleDownloadCsv}>
966
+ {t.downloadAsCsv}
967
+ </DropdownMenuItem>
968
+ <DropdownMenuItem onClick={handleDownloadPng}>
969
+ {t.downloadAsPng}
970
+ </DropdownMenuItem>
971
+ <DropdownMenuItem onClick={handleDownloadJpg}>
972
+ {t.downloadAsJpg}
973
+ </DropdownMenuItem>
974
+ {config.moreOptions && (
975
+ <>
976
+ <DropdownDivider />
977
+ {config.moreOptions}
978
+ </>
979
+ )}
980
+ </>
981
+ );
982
+
983
+ return (
984
+ <ToolbarWrapper
985
+ isFullscreen={isFullscreen}
986
+ isInverse={isInverse}
987
+ theme={theme}
988
+ >
989
+ <ChartTitle isInverse={isInverse} theme={theme}>
990
+ {resolvedTitle}
991
+ </ChartTitle>
992
+ <ToolbarActions theme={theme}>
993
+ {showTable && (
994
+ <ChartTableButton
995
+ ariaLabel={resolvedTitle}
996
+ icon={<TableIcon size={20} />}
997
+ isInverse={isInverse}
998
+ isTableOpen={isTableOpen}
999
+ onClick={onOpenTable}
1000
+ />
1001
+ )}
1002
+ {showFullscreen && (
1003
+ <ChartFullscreenButton
1004
+ ariaLabel={`${isFullscreen ? 'Exit' : 'View'} ${resolvedTitle} full screen`}
1005
+ icon={<FullscreenIcon size={20} />}
1006
+ exitIcon={<FullscreenExitIcon size={20} />}
1007
+ isInverse={isInverse}
1008
+ isFullscreen={isFullscreen}
1009
+ onClick={onToggleFullscreen}
1010
+ />
1011
+ )}
1012
+ <ChartMoreOptionsButton
1013
+ icon={<MoreVertIcon size={20} />}
1014
+ isInverse={isInverse}
1015
+ >
1016
+ {moreOptionsContent}
1017
+ </ChartMoreOptionsButton>
1018
+ </ToolbarActions>
1019
+ </ToolbarWrapper>
1020
+ );
1021
+ }
1022
+
538
1023
  export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
539
1024
  (props, ref) => {
540
1025
  const {
@@ -544,12 +1029,18 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
544
1029
  dataSet,
545
1030
  options,
546
1031
  ariaLabel,
1032
+ chartToolbar,
547
1033
  ...rest
548
1034
  } = props;
549
1035
  const theme = React.useContext(ThemeContext);
550
1036
  const isInverse = useIsInverse(isInverseProp);
1037
+ const toolbarI18n = useChartToolbarI18n();
551
1038
  const internalRef = React.useRef<HTMLDivElement | null>(null);
552
1039
 
1040
+ const [isTableOpen, setIsTableOpen] = React.useState(false);
1041
+ const [isFullscreen, setIsFullscreen] = React.useState(false);
1042
+ const lastTableTriggerRef = React.useRef<HTMLButtonElement | null>(null);
1043
+
553
1044
  const mergedRef = React.useCallback(
554
1045
  (node: HTMLDivElement | null) => {
555
1046
  internalRef.current = node;
@@ -562,6 +1053,69 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
562
1053
  [ref]
563
1054
  );
564
1055
 
1056
+ const fullscreenEnabled = chartToolbar
1057
+ ? chartToolbar.fullscreen !== false
1058
+ : false;
1059
+
1060
+ const savedHeightRef = React.useRef<string>('');
1061
+
1062
+ React.useEffect(() => {
1063
+ if (!fullscreenEnabled) return;
1064
+
1065
+ const onFullscreenChange = () => {
1066
+ const isFs = !!document.fullscreenElement;
1067
+ setIsFullscreen(isFs);
1068
+
1069
+ const chartHolder =
1070
+ internalRef.current?.querySelector<HTMLElement>('.cds--chart-holder');
1071
+ if (chartHolder) {
1072
+ if (isFs) {
1073
+ savedHeightRef.current = chartHolder.style.height;
1074
+ chartHolder.style.height = '100vh';
1075
+ } else {
1076
+ chartHolder.style.height = savedHeightRef.current;
1077
+ }
1078
+ }
1079
+ };
1080
+ document.addEventListener('fullscreenchange', onFullscreenChange);
1081
+ return () =>
1082
+ document.removeEventListener('fullscreenchange', onFullscreenChange);
1083
+ }, [fullscreenEnabled]);
1084
+
1085
+ const openTableModal = React.useCallback(
1086
+ (event: React.MouseEvent<HTMLButtonElement>) => {
1087
+ lastTableTriggerRef.current = event.currentTarget;
1088
+ setIsTableOpen(true);
1089
+ },
1090
+ []
1091
+ );
1092
+
1093
+ const closeTableModal = React.useCallback(() => {
1094
+ setIsTableOpen(false);
1095
+ setTimeout(() => {
1096
+ lastTableTriggerRef.current?.focus();
1097
+ }, 0);
1098
+ }, []);
1099
+
1100
+ const toggleFullscreen = React.useCallback(() => {
1101
+ if (!document.fullscreenElement && internalRef.current) {
1102
+ internalRef.current.requestFullscreen().catch(() => {
1103
+ // Fullscreen request may be denied by the browser or user agent
1104
+ });
1105
+ } else if (document.fullscreenElement) {
1106
+ document.exitFullscreen().catch(() => {
1107
+ // Exit fullscreen may fail if already exited
1108
+ });
1109
+ }
1110
+ }, []);
1111
+
1112
+ const chartTitle: string =
1113
+ (options as any).title || toolbarI18n.defaultTitle;
1114
+
1115
+ const handleModalDownloadCsv = React.useCallback(() => {
1116
+ downloadCsv(dataSet as Array<Record<string, unknown>>, chartTitle);
1117
+ }, [dataSet, chartTitle]);
1118
+
565
1119
  useCarbonModalFocusManagement(internalRef);
566
1120
  const allCharts = {
567
1121
  area: AreaChart,
@@ -621,6 +1175,7 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
621
1175
  type: 'none',
622
1176
  },
623
1177
  },
1178
+ ...(chartToolbar ? { toolbar: { enabled: false } } : {}),
624
1179
  };
625
1180
 
626
1181
  const ChartType = allCharts[type] as any;
@@ -636,18 +1191,51 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
636
1191
 
637
1192
  const groupsLength = Object.keys(buildColors()).length;
638
1193
 
1194
+ const showTable = chartToolbar?.showAsTable !== false;
1195
+
639
1196
  return (
640
- <CarbonChartWrapper
641
- data-testid={testId}
642
- ref={mergedRef}
643
- isInverse={isInverse}
644
- theme={theme}
645
- className="carbon-chart-wrapper"
646
- groupsLength={groupsLength < 6 ? groupsLength : 14}
647
- {...rest}
648
- >
649
- <ChartType data={dataSet} options={newOptions} />
650
- </CarbonChartWrapper>
1197
+ <FullscreenRoot ref={mergedRef} isInverse={isInverse} theme={theme}>
1198
+ <CarbonChartWrapper
1199
+ data-testid={testId}
1200
+ isInverse={isInverse}
1201
+ theme={theme}
1202
+ className={`carbon-chart-wrapper${chartToolbar ? ' has-magma-toolbar' : ''}`}
1203
+ groupsLength={groupsLength < 6 ? groupsLength : 14}
1204
+ {...rest}
1205
+ >
1206
+ <ChartContentWrapper>
1207
+ {chartToolbar && (
1208
+ <CarbonChartToolbar
1209
+ config={chartToolbar}
1210
+ dataSet={dataSet as Array<Record<string, unknown>>}
1211
+ isInverse={isInverse}
1212
+ isTableOpen={isTableOpen}
1213
+ isFullscreen={isFullscreen}
1214
+ onOpenTable={openTableModal}
1215
+ onToggleFullscreen={toggleFullscreen}
1216
+ theme={theme}
1217
+ title={chartTitle}
1218
+ wrapperRef={internalRef}
1219
+ />
1220
+ )}
1221
+ <ChartType data={dataSet} options={newOptions} />
1222
+ </ChartContentWrapper>
1223
+ </CarbonChartWrapper>
1224
+ {chartToolbar && showTable && (
1225
+ <ChartTableModal
1226
+ columns={chartToolbar.tableColumns}
1227
+ portalContainer={isFullscreen ? internalRef.current : undefined}
1228
+ dataSet={dataSet as Array<Record<string, React.ReactNode>>}
1229
+ headerLabel={chartToolbar.tableHeaderLabel}
1230
+ headerLevel={chartToolbar.tableHeaderLevel}
1231
+ isInverse={isInverse}
1232
+ isOpen={isTableOpen}
1233
+ onClose={closeTableModal}
1234
+ onDownloadCsv={handleModalDownloadCsv}
1235
+ title={chartTitle}
1236
+ />
1237
+ )}
1238
+ </FullscreenRoot>
651
1239
  );
652
1240
  }
653
1241
  );