@react-magma/charts 14.0.0-rc.3 → 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 (33) hide show
  1. package/dist/charts.js +574 -7
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.modern.module.js +569 -9
  4. package/dist/charts.modern.module.js.map +1 -1
  5. package/dist/charts.umd.js +6711 -366
  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/hooks/useCarbonModalFocusManagement.d.ts +2 -0
  18. package/dist/index.d.ts +1 -0
  19. package/package.json +11 -6
  20. package/src/components/CarbonChart/CarbonChart.test.js +318 -2
  21. package/src/components/CarbonChart/CarbonChart.tsx +640 -15
  22. package/src/components/ChartTable/ChartDataTable.tsx +75 -0
  23. package/src/components/ChartTable/ChartFullscreenButton.tsx +59 -0
  24. package/src/components/ChartTable/ChartMoreOptionsButton.tsx +48 -0
  25. package/src/components/ChartTable/ChartTable.stories.tsx +152 -0
  26. package/src/components/ChartTable/ChartTable.test.tsx +452 -0
  27. package/src/components/ChartTable/ChartTableButton.tsx +56 -0
  28. package/src/components/ChartTable/ChartTableModal.tsx +135 -0
  29. package/src/components/ChartTable/ChartToolbar.tsx +51 -0
  30. package/src/components/ChartTable/chartToolbarI18n.ts +55 -0
  31. package/src/components/ChartTable/index.ts +23 -0
  32. package/src/hooks/useCarbonModalFocusManagement.ts +173 -0
  33. package/src/index.ts +1 -0
@@ -23,9 +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
 
40
+ import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
28
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';
29
50
 
30
51
  export enum CarbonChartType {
31
52
  area = 'area',
@@ -48,6 +69,41 @@ export enum CarbonChartType {
48
69
  combo = 'combo',
49
70
  }
50
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
+
51
107
  export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
52
108
  dataSet: Array<object>;
53
109
  isInverse?: boolean;
@@ -68,13 +124,55 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
68
124
  * Text for the aria-label attribute for main SVG container, if provided
69
125
  */
70
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;
71
133
  }
72
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
+
73
159
  const CarbonChartWrapper = styled.div<{
74
160
  isInverse?: boolean;
75
161
  groupsLength: number;
76
162
  theme: ThemeInterface;
77
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
+
78
176
  .cds--data-table thead tr th {
79
177
  background: ${props =>
80
178
  props.isInverse ? props.theme.colors.primary700 : ''} !important;
@@ -130,9 +228,9 @@ const CarbonChartWrapper = styled.div<{
130
228
  }
131
229
  }
132
230
 
133
- p,
134
- div,
135
- text,
231
+ .chart-holder p,
232
+ .chart-holder div,
233
+ .chart-holder text,
136
234
  .cds--cc--axes g.axis .axis-title,
137
235
  .cds--cc--title p.title,
138
236
  .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,407 @@ 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
+
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
+
538
1041
  export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
539
1042
  (props, ref) => {
540
1043
  const {
@@ -544,10 +1047,98 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
544
1047
  dataSet,
545
1048
  options,
546
1049
  ariaLabel,
1050
+ chartToolbar,
547
1051
  ...rest
548
1052
  } = props;
549
1053
  const theme = React.useContext(ThemeContext) as ThemeInterface;
550
1054
  const isInverse = useIsInverse(isInverseProp);
1055
+ const toolbarI18n = useChartToolbarI18n();
1056
+ const internalRef = React.useRef<HTMLDivElement | null>(null);
1057
+
1058
+ const [isTableOpen, setIsTableOpen] = React.useState(false);
1059
+ const [isFullscreen, setIsFullscreen] = React.useState(false);
1060
+ const lastTableTriggerRef = React.useRef<HTMLButtonElement | null>(null);
1061
+
1062
+ const mergedRef = React.useCallback(
1063
+ (node: HTMLDivElement | null) => {
1064
+ internalRef.current = node;
1065
+ if (typeof ref === 'function') {
1066
+ ref(node);
1067
+ } else if (ref) {
1068
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
1069
+ }
1070
+ },
1071
+ [ref]
1072
+ );
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
+
1141
+ useCarbonModalFocusManagement(internalRef);
551
1142
  const allCharts = {
552
1143
  area: AreaChart,
553
1144
  areaStacked: StackedAreaChart,
@@ -607,6 +1198,7 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
607
1198
  type: 'none',
608
1199
  },
609
1200
  },
1201
+ ...(chartToolbar ? { toolbar: { enabled: false } } : {}),
610
1202
  };
611
1203
 
612
1204
  const ChartType = allCharts[type] as any;
@@ -622,18 +1214,51 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
622
1214
 
623
1215
  const groupsLength = Object.keys(buildColors()).length;
624
1216
 
1217
+ const showTable = chartToolbar?.showAsTable !== false;
1218
+
625
1219
  return (
626
- <CarbonChartWrapper
627
- data-testid={testId}
628
- ref={ref}
629
- isInverse={isInverse}
630
- theme={theme}
631
- className="carbon-chart-wrapper"
632
- groupsLength={groupsLength < 6 ? groupsLength : 14}
633
- {...rest}
634
- >
635
- <ChartType data={dataSet} options={newOptions} />
636
- </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>
637
1262
  );
638
1263
  }
639
1264
  );