@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.
- package/dist/charts.js +574 -7
- package/dist/charts.js.map +1 -1
- package/dist/charts.modern.module.js +569 -9
- package/dist/charts.modern.module.js.map +1 -1
- package/dist/charts.umd.js +6711 -366
- package/dist/charts.umd.js.map +1 -1
- package/dist/components/CarbonChart/CarbonChart.d.ts +41 -0
- package/dist/components/ChartTable/ChartDataTable.d.ts +19 -0
- package/dist/components/ChartTable/ChartFullscreenButton.d.ts +26 -0
- package/dist/components/ChartTable/ChartMoreOptionsButton.d.ts +18 -0
- package/dist/components/ChartTable/ChartTable.test.d.ts +1 -0
- package/dist/components/ChartTable/ChartTableButton.d.ts +24 -0
- package/dist/components/ChartTable/ChartTableModal.d.ts +44 -0
- package/dist/components/ChartTable/ChartToolbar.d.ts +19 -0
- package/dist/components/ChartTable/chartToolbarI18n.d.ts +16 -0
- package/dist/components/ChartTable/index.d.ts +14 -0
- package/dist/hooks/useCarbonModalFocusManagement.d.ts +2 -0
- package/dist/index.d.ts +1 -0
- package/package.json +11 -6
- package/src/components/CarbonChart/CarbonChart.test.js +318 -2
- package/src/components/CarbonChart/CarbonChart.tsx +640 -15
- package/src/components/ChartTable/ChartDataTable.tsx +75 -0
- package/src/components/ChartTable/ChartFullscreenButton.tsx +59 -0
- package/src/components/ChartTable/ChartMoreOptionsButton.tsx +48 -0
- package/src/components/ChartTable/ChartTable.stories.tsx +152 -0
- package/src/components/ChartTable/ChartTable.test.tsx +452 -0
- package/src/components/ChartTable/ChartTableButton.tsx +56 -0
- package/src/components/ChartTable/ChartTableModal.tsx +135 -0
- package/src/components/ChartTable/ChartToolbar.tsx +51 -0
- package/src/components/ChartTable/chartToolbarI18n.ts +55 -0
- package/src/components/ChartTable/index.ts +23 -0
- package/src/hooks/useCarbonModalFocusManagement.ts +173 -0
- 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 {
|
|
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
|
-
<
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
);
|