@react-magma/charts 14.0.0-next.3 → 14.0.0-next.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/charts.js +698 -69
  2. package/dist/charts.js.map +1 -1
  3. package/dist/charts.modern.module.js +694 -72
  4. package/dist/charts.modern.module.js.map +1 -1
  5. package/dist/charts.umd.js +2451 -434
  6. package/dist/charts.umd.js.map +1 -1
  7. package/dist/components/CarbonChart/CarbonChart.d.ts +46 -0
  8. package/dist/components/CarbonChart/CarbonChartBar.stories.d.ts +27 -9
  9. package/dist/components/ChartTable/ChartDataTable.d.ts +19 -0
  10. package/dist/components/ChartTable/ChartFullscreenButton.d.ts +26 -0
  11. package/dist/components/ChartTable/ChartMoreOptionsButton.d.ts +18 -0
  12. package/dist/components/ChartTable/ChartTable.stories.d.ts +116 -0
  13. package/dist/components/ChartTable/ChartTableButton.d.ts +24 -0
  14. package/dist/components/ChartTable/ChartTableModal.d.ts +44 -0
  15. package/dist/components/ChartTable/ChartToolbar.d.ts +19 -0
  16. package/dist/components/ChartTable/chartToolbarI18n.d.ts +17 -0
  17. package/dist/components/ChartTable/index.d.ts +14 -0
  18. package/dist/components/LineChart/DataTable.d.ts +1 -1
  19. package/dist/index.d.ts +1 -0
  20. package/package.json +5 -5
  21. package/src/components/CarbonChart/CarbonChart.test.js +473 -5
  22. package/src/components/CarbonChart/CarbonChart.tsx +935 -60
  23. package/src/components/CarbonChart/CarbonChartArea.stories.tsx +1 -1
  24. package/src/components/CarbonChart/CarbonChartAreaStacked.stories.tsx +1 -1
  25. package/src/components/CarbonChart/CarbonChartBar.stories.tsx +9 -2
  26. package/src/components/CarbonChart/CarbonChartBarFloating.stories.tsx +1 -1
  27. package/src/components/CarbonChart/CarbonChartBarGrouped.stories.tsx +1 -1
  28. package/src/components/CarbonChart/CarbonChartBarStacked.stories.tsx +1 -1
  29. package/src/components/CarbonChart/CarbonChartBoxplot.stories.tsx +1 -1
  30. package/src/components/CarbonChart/CarbonChartBubble.stories.tsx +1 -1
  31. package/src/components/CarbonChart/CarbonChartBullet.stories.tsx +1 -1
  32. package/src/components/CarbonChart/CarbonChartCombo.stories.tsx +1 -1
  33. package/src/components/CarbonChart/CarbonChartDonut.stories.tsx +3 -1
  34. package/src/components/CarbonChart/CarbonChartGauge.stories.tsx +1 -1
  35. package/src/components/CarbonChart/CarbonChartHistogram.stories.tsx +1 -1
  36. package/src/components/CarbonChart/CarbonChartLine.stories.tsx +1 -1
  37. package/src/components/CarbonChart/CarbonChartLollipop.stories.tsx +1 -1
  38. package/src/components/CarbonChart/CarbonChartMeter.stories.tsx +1 -1
  39. package/src/components/CarbonChart/CarbonChartPie.stories.tsx +1 -1
  40. package/src/components/CarbonChart/CarbonChartRadar.stories.tsx +1 -1
  41. package/src/components/CarbonChart/CarbonChartScatter.stories.tsx +1 -1
  42. package/src/components/CarbonChart/CarbonChartSparkline.stories.tsx +1 -1
  43. package/src/components/CarbonChart/CarbonChartStep.stories.tsx +1 -1
  44. package/src/components/ChartTable/ChartDataTable.tsx +72 -0
  45. package/src/components/ChartTable/ChartFullscreenButton.tsx +59 -0
  46. package/src/components/ChartTable/ChartMoreOptionsButton.tsx +48 -0
  47. package/src/components/ChartTable/ChartTable.stories.tsx +152 -0
  48. package/src/components/ChartTable/ChartTable.test.js +444 -0
  49. package/src/components/ChartTable/ChartTableButton.tsx +55 -0
  50. package/src/components/ChartTable/ChartTableModal.tsx +135 -0
  51. package/src/components/ChartTable/ChartToolbar.tsx +50 -0
  52. package/src/components/ChartTable/chartToolbarI18n.ts +59 -0
  53. package/src/components/ChartTable/index.ts +23 -0
  54. package/src/components/LineChart/DataTable.tsx +3 -3
  55. package/src/components/LineChart/LineChart.tsx +1 -1
  56. package/src/index.ts +1 -0
@@ -1,7 +1,14 @@
1
1
  import React from 'react';
2
2
 
3
- import { act, render } from '@testing-library/react';
4
- import { ThemeContext, magma } from 'react-magma-dom';
3
+ import { act, render, screen, fireEvent } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import {
6
+ ThemeContext,
7
+ magma,
8
+ DropdownMenuItem,
9
+ I18nContext,
10
+ defaultI18n,
11
+ } from 'react-magma-dom';
5
12
 
6
13
  import { CarbonChart, CarbonChartType } from '.';
7
14
 
@@ -11,10 +18,14 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({
11
18
  disconnect: jest.fn(),
12
19
  }));
13
20
 
14
- // Capture MutationObserver callbacks so we can trigger them manually
15
- let mutationObserverCallback;
21
+ // Capture MutationObserver callbacks so we can trigger them manually.
22
+ // Multiple observers may be created per component; we collect all of them and
23
+ // call each one so that no observer is silently skipped.
24
+ let mutationObserverCallbacks = [];
25
+ const mutationObserverCallback = mutations =>
26
+ mutationObserverCallbacks.forEach(cb => cb(mutations));
16
27
  global.MutationObserver = jest.fn().mockImplementation(callback => {
17
- mutationObserverCallback = callback;
28
+ mutationObserverCallbacks.push(callback);
18
29
  return {
19
30
  observe: jest.fn(),
20
31
  disconnect: jest.fn(),
@@ -588,6 +599,32 @@ describe('CarbonChart', () => {
588
599
  }
589
600
  );
590
601
  });
602
+
603
+ it('should move focus back after closing the More options dropdown and chart toolbar', () => {
604
+ const testId = 'modal-footer-border-test';
605
+ const { getByTestId, getByText } = render(
606
+ <ThemeContext.Provider value={magma}>
607
+ <CarbonChart
608
+ testId={testId}
609
+ dataSet={dataSet}
610
+ options={chartOptions}
611
+ type={CarbonChartType.bar}
612
+ isInverse={false}
613
+ chartToolbar={{}}
614
+ />
615
+ </ThemeContext.Provider>
616
+ );
617
+
618
+ const moreOptionsButton = getByTestId('chart-more-options-button');
619
+ userEvent.click(moreOptionsButton);
620
+
621
+ expect(getByText('Download as CSV')).toBeVisible();
622
+ expect(getByText('Download as PNG')).toBeVisible();
623
+ expect(getByText('Download as JPG')).toBeVisible();
624
+
625
+ userEvent.keyboard('{esc}');
626
+ expect(moreOptionsButton).toHaveFocus();
627
+ });
591
628
  });
592
629
 
593
630
  describe('Modal Focus Management', () => {
@@ -597,6 +634,7 @@ describe('CarbonChart', () => {
597
634
  let otherButton;
598
635
 
599
636
  beforeEach(() => {
637
+ mutationObserverCallbacks = [];
600
638
  jest.useFakeTimers();
601
639
  jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
602
640
  cb(0);
@@ -754,4 +792,434 @@ describe('CarbonChart', () => {
754
792
  expect(document.activeElement).toBe(closeButton);
755
793
  });
756
794
  });
795
+
796
+ describe('chartToolbar prop', () => {
797
+ const toolbarProps = {
798
+ dataSet,
799
+ options: chartOptions,
800
+ type: CarbonChartType.bar,
801
+ chartToolbar: {},
802
+ };
803
+
804
+ it('should render the show-as-table button with aria-haspopup="dialog"', () => {
805
+ render(<CarbonChart {...toolbarProps} />);
806
+
807
+ const button = screen.getByRole('button', {
808
+ name: chartOptions.title,
809
+ });
810
+ expect(button).toHaveAttribute('aria-haspopup', 'dialog');
811
+ expect(button).toHaveAttribute('aria-expanded', 'false');
812
+ });
813
+
814
+ it('should render the fullscreen button without aria-haspopup', () => {
815
+ render(<CarbonChart {...toolbarProps} />);
816
+
817
+ const button = screen.getByRole('button', {
818
+ name: `View ${chartOptions.title} full screen`,
819
+ });
820
+ expect(button).not.toHaveAttribute('aria-haspopup');
821
+ });
822
+
823
+ it('should open the table modal when show-as-table button is clicked', () => {
824
+ render(<CarbonChart {...toolbarProps} />);
825
+
826
+ fireEvent.click(screen.getByRole('button', { name: chartOptions.title }));
827
+
828
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
829
+ expect(
830
+ screen.getByRole('heading', {
831
+ level: 2,
832
+ name: `Tabular representation ${chartOptions.title}`,
833
+ })
834
+ ).toBeInTheDocument();
835
+ expect(
836
+ screen.getByRole('columnheader', { name: 'Group' })
837
+ ).toBeInTheDocument();
838
+ expect(
839
+ screen.getByRole('columnheader', { name: 'Value' })
840
+ ).toBeInTheDocument();
841
+ });
842
+
843
+ it('should render chart data in the table modal', () => {
844
+ render(<CarbonChart {...toolbarProps} />);
845
+
846
+ fireEvent.click(screen.getByRole('button', { name: chartOptions.title }));
847
+
848
+ expect(screen.getByRole('cell', { name: 'Qty' })).toBeInTheDocument();
849
+ expect(screen.getByRole('cell', { name: '65000' })).toBeInTheDocument();
850
+ });
851
+
852
+ it('should set aria-expanded to true when modal is open', () => {
853
+ render(<CarbonChart {...toolbarProps} />);
854
+
855
+ const tableButton = screen.getByRole('button', {
856
+ name: chartOptions.title,
857
+ });
858
+ fireEvent.click(tableButton);
859
+
860
+ expect(tableButton).toHaveAttribute('aria-expanded', 'true');
861
+ });
862
+
863
+ it('should not render the table button when showAsTable is false', () => {
864
+ render(
865
+ <CarbonChart {...toolbarProps} chartToolbar={{ showAsTable: false }} />
866
+ );
867
+
868
+ expect(
869
+ screen.queryByRole('button', { name: chartOptions.title })
870
+ ).not.toBeInTheDocument();
871
+ });
872
+
873
+ it('should not render the fullscreen button when fullscreen is false', () => {
874
+ render(
875
+ <CarbonChart {...toolbarProps} chartToolbar={{ fullscreen: false }} />
876
+ );
877
+
878
+ expect(
879
+ screen.queryByRole('button', { name: /full screen/i })
880
+ ).not.toBeInTheDocument();
881
+ });
882
+
883
+ it('should render the chart title as h2 by default', () => {
884
+ render(<CarbonChart {...toolbarProps} />);
885
+
886
+ expect(
887
+ screen.getByRole('heading', { level: 2, name: chartOptions.title })
888
+ ).toBeInTheDocument();
889
+ });
890
+
891
+ it('should render the chart title at the level set by titleLevel', () => {
892
+ render(
893
+ <CarbonChart {...toolbarProps} chartToolbar={{ titleLevel: 3 }} />
894
+ );
895
+
896
+ expect(
897
+ screen.getByRole('heading', { level: 3, name: chartOptions.title })
898
+ ).toBeInTheDocument();
899
+ expect(
900
+ screen.queryByRole('heading', { level: 2, name: chartOptions.title })
901
+ ).not.toBeInTheDocument();
902
+ });
903
+
904
+ it('should always render the more options dropdown with built-in download items', () => {
905
+ render(<CarbonChart {...toolbarProps} />);
906
+
907
+ const moreBtn = screen.getByRole('button', { name: 'More options' });
908
+ expect(moreBtn).toBeInTheDocument();
909
+
910
+ fireEvent.click(moreBtn);
911
+ expect(screen.getByText('Download as CSV')).toBeVisible();
912
+ expect(screen.getByText('Download as PNG')).toBeVisible();
913
+ expect(screen.getByText('Download as JPG')).toBeVisible();
914
+ });
915
+
916
+ it('should render additional moreOptions items below built-in downloads', () => {
917
+ render(
918
+ <CarbonChart
919
+ {...toolbarProps}
920
+ chartToolbar={{
921
+ moreOptions: <DropdownMenuItem>Custom Action</DropdownMenuItem>,
922
+ }}
923
+ />
924
+ );
925
+
926
+ const moreBtn = screen.getByRole('button', { name: 'More options' });
927
+ fireEvent.click(moreBtn);
928
+ expect(screen.getByText('Download as CSV')).toBeVisible();
929
+ expect(screen.getByText('Download as PNG')).toBeVisible();
930
+ expect(screen.getByText('Download as JPG')).toBeVisible();
931
+ expect(screen.getByText('Custom Action')).toBeVisible();
932
+ });
933
+
934
+ it('should support custom table columns', () => {
935
+ render(
936
+ <CarbonChart
937
+ {...toolbarProps}
938
+ chartToolbar={{
939
+ tableColumns: [
940
+ { header: 'Category', key: 'group' },
941
+ { header: 'Count', key: 'value' },
942
+ ],
943
+ }}
944
+ />
945
+ );
946
+
947
+ fireEvent.click(screen.getByRole('button', { name: chartOptions.title }));
948
+
949
+ expect(
950
+ screen.getByRole('columnheader', { name: 'Category' })
951
+ ).toBeInTheDocument();
952
+ expect(
953
+ screen.getByRole('columnheader', { name: 'Count' })
954
+ ).toBeInTheDocument();
955
+ });
956
+ });
957
+
958
+ describe('dot keyboard accessibility', () => {
959
+ let rafCallbacks;
960
+
961
+ beforeEach(() => {
962
+ rafCallbacks = [];
963
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
964
+ rafCallbacks.push(cb);
965
+ return rafCallbacks.length - 1;
966
+ });
967
+ });
968
+
969
+ afterEach(() => {
970
+ window.requestAnimationFrame.mockRestore();
971
+ rafCallbacks = [];
972
+ });
973
+
974
+ function renderChart() {
975
+ const { getByTestId } = render(
976
+ <CarbonChart
977
+ testId="dot-tab-test"
978
+ dataSet={dataSet}
979
+ options={chartOptions}
980
+ type={CarbonChartType.scatter}
981
+ />
982
+ );
983
+ return getByTestId('dot-tab-test');
984
+ }
985
+
986
+ function addDot(wrapper) {
987
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
988
+ const dot = document.createElementNS(
989
+ 'http://www.w3.org/2000/svg',
990
+ 'circle'
991
+ );
992
+ dot.classList.add('dot');
993
+ svg.appendChild(dot);
994
+ wrapper.appendChild(svg);
995
+ return dot;
996
+ }
997
+
998
+ describe('tabbing', () => {
999
+ it('should stamp tabindex="0" on circle.dot elements so they are reachable by Tab', () => {
1000
+ const wrapper = renderChart();
1001
+ const dot = addDot(wrapper);
1002
+
1003
+ // The last captured RAF is the dots-stamping callback (no ariaLabel provided)
1004
+ act(() => rafCallbacks[rafCallbacks.length - 1](0));
1005
+
1006
+ expect(dot).toHaveAttribute('tabindex', '0');
1007
+ });
1008
+
1009
+ it('should not overwrite an existing tabindex on circle.dot', () => {
1010
+ const wrapper = renderChart();
1011
+ const dot = addDot(wrapper);
1012
+ dot.setAttribute('tabindex', '-1');
1013
+
1014
+ act(() => rafCallbacks[rafCallbacks.length - 1](0));
1015
+
1016
+ expect(dot).toHaveAttribute('tabindex', '-1');
1017
+ });
1018
+ });
1019
+
1020
+ describe('focus on dot content', () => {
1021
+ it('should set opacity to 1 when a dot receives focus', () => {
1022
+ const wrapper = renderChart();
1023
+ const dot = addDot(wrapper);
1024
+
1025
+ fireEvent.focusIn(dot);
1026
+
1027
+ expect(dot.style.opacity).toBe('1');
1028
+ });
1029
+
1030
+ it('should dispatch mouseover on dot focusin to reveal tooltip data', () => {
1031
+ const wrapper = renderChart();
1032
+ const dot = addDot(wrapper);
1033
+
1034
+ const mouseoverSpy = jest.fn();
1035
+ dot.addEventListener('mouseover', mouseoverSpy);
1036
+
1037
+ fireEvent.focusIn(dot);
1038
+
1039
+ expect(mouseoverSpy).toHaveBeenCalledTimes(1);
1040
+ });
1041
+
1042
+ it('should dispatch mousemove on dot focusin', () => {
1043
+ const wrapper = renderChart();
1044
+ const dot = addDot(wrapper);
1045
+
1046
+ const mousemoveSpy = jest.fn();
1047
+ dot.addEventListener('mousemove', mousemoveSpy);
1048
+
1049
+ fireEvent.focusIn(dot);
1050
+
1051
+ expect(mousemoveSpy).toHaveBeenCalledTimes(1);
1052
+ });
1053
+
1054
+ it('should not change opacity for non-dot circle elements on focusin', () => {
1055
+ const wrapper = renderChart();
1056
+ const nonDot = document.createElementNS(
1057
+ 'http://www.w3.org/2000/svg',
1058
+ 'circle'
1059
+ );
1060
+ wrapper.appendChild(nonDot);
1061
+
1062
+ fireEvent.focusIn(nonDot);
1063
+
1064
+ expect(nonDot.style.opacity).toBe('');
1065
+ });
1066
+ });
1067
+
1068
+ describe('data visibility', () => {
1069
+ it('should reset dot opacity when dot loses focus', () => {
1070
+ const wrapper = renderChart();
1071
+ const dot = addDot(wrapper);
1072
+
1073
+ fireEvent.focusIn(dot);
1074
+ expect(dot.style.opacity).toBe('1');
1075
+
1076
+ fireEvent.focusOut(dot);
1077
+ expect(dot.style.opacity).toBe('');
1078
+ });
1079
+
1080
+ it('should dispatch mouseout on dot focusout to hide tooltip', () => {
1081
+ const wrapper = renderChart();
1082
+ const dot = addDot(wrapper);
1083
+
1084
+ const mouseoutSpy = jest.fn();
1085
+ dot.addEventListener('mouseout', mouseoutSpy);
1086
+
1087
+ fireEvent.focusIn(dot);
1088
+ fireEvent.focusOut(dot);
1089
+
1090
+ expect(mouseoutSpy).toHaveBeenCalledTimes(1);
1091
+ });
1092
+ });
1093
+ });
1094
+
1095
+ describe('Legend accessibility wrap', () => {
1096
+ const testId = 'legend-a11y';
1097
+
1098
+ beforeEach(() => {
1099
+ mutationObserverCallbacks = [];
1100
+ });
1101
+
1102
+ it('wraps the Carbon legend in a fieldset with a legend caption derived from the chart title', () => {
1103
+ const { getByTestId } = render(
1104
+ <CarbonChart
1105
+ testId={testId}
1106
+ dataSet={dataSet}
1107
+ options={chartOptions}
1108
+ type={CarbonChartType.bar}
1109
+ />
1110
+ );
1111
+ const wrapper = getByTestId(testId);
1112
+
1113
+ const fieldset = wrapper.querySelector('.cds--cc--legend-fieldset');
1114
+ expect(fieldset).not.toBeNull();
1115
+ expect(fieldset.tagName).toBe('FIELDSET');
1116
+ expect(fieldset.querySelector('.cds--cc--legend')).not.toBeNull();
1117
+
1118
+ const caption = fieldset.querySelector('legend');
1119
+ expect(caption).not.toBeNull();
1120
+ expect(caption.textContent).toBe(
1121
+ `${chartOptions.title}. Checking these checkboxes will update the chart.`
1122
+ );
1123
+ });
1124
+
1125
+ it('uses ariaLabel for the legend caption when provided', () => {
1126
+ const ariaLabel = 'Quarterly sales chart';
1127
+ const { getByTestId } = render(
1128
+ <CarbonChart
1129
+ testId={testId}
1130
+ ariaLabel={ariaLabel}
1131
+ dataSet={dataSet}
1132
+ options={chartOptions}
1133
+ type={CarbonChartType.bar}
1134
+ />
1135
+ );
1136
+ const wrapper = getByTestId(testId);
1137
+
1138
+ const caption = wrapper.querySelector(
1139
+ '.cds--cc--legend-fieldset > legend'
1140
+ );
1141
+ expect(caption).not.toBeNull();
1142
+ expect(caption.textContent).toBe(
1143
+ `${ariaLabel}. Checking these checkboxes will update the chart.`
1144
+ );
1145
+ });
1146
+
1147
+ it('does not duplicate the fieldset when the mutation observer fires again with no DOM changes', () => {
1148
+ const { getByTestId } = render(
1149
+ <CarbonChart
1150
+ testId={testId}
1151
+ dataSet={dataSet}
1152
+ options={chartOptions}
1153
+ type={CarbonChartType.bar}
1154
+ />
1155
+ );
1156
+ const wrapper = getByTestId(testId);
1157
+
1158
+ const before = wrapper.querySelectorAll('.cds--cc--legend-fieldset');
1159
+ expect(before).toHaveLength(1);
1160
+
1161
+ act(() => {
1162
+ mutationObserverCallback?.();
1163
+ mutationObserverCallback?.();
1164
+ });
1165
+
1166
+ const after = wrapper.querySelectorAll('.cds--cc--legend-fieldset');
1167
+ expect(after).toHaveLength(1);
1168
+ expect(after[0]).toBe(before[0]);
1169
+ });
1170
+
1171
+ it('replaces Carbon\'s generic "Data groups" aria-label with semantic list roles on the legend and its items', () => {
1172
+ const { getByTestId } = render(
1173
+ <CarbonChart
1174
+ testId={testId}
1175
+ dataSet={dataSet}
1176
+ options={chartOptions}
1177
+ type={CarbonChartType.bar}
1178
+ />
1179
+ );
1180
+ const wrapper = getByTestId(testId);
1181
+
1182
+ const legend = wrapper.querySelector('.cds--cc--legend');
1183
+ expect(legend).not.toBeNull();
1184
+ expect(legend.getAttribute('aria-label')).toBeNull();
1185
+ expect(legend.getAttribute('role')).toBe('list');
1186
+
1187
+ const items = legend.querySelectorAll('.legend-item');
1188
+ expect(items.length).toBeGreaterThan(0);
1189
+ items.forEach(item => {
1190
+ expect(item.getAttribute('role')).toBe('listitem');
1191
+ });
1192
+ });
1193
+
1194
+ it('uses translated legend instructions from I18nContext', () => {
1195
+ const translatedInstructions =
1196
+ 'Marquer ces cases mettra à jour le graphique.';
1197
+ const i18nValue = {
1198
+ ...defaultI18n,
1199
+ charts: {
1200
+ ...defaultI18n.charts,
1201
+ toolbar: { legendInstructions: translatedInstructions },
1202
+ },
1203
+ };
1204
+
1205
+ const { getByTestId } = render(
1206
+ <I18nContext.Provider value={i18nValue}>
1207
+ <CarbonChart
1208
+ testId={testId}
1209
+ dataSet={dataSet}
1210
+ options={chartOptions}
1211
+ type={CarbonChartType.bar}
1212
+ />
1213
+ </I18nContext.Provider>
1214
+ );
1215
+ const wrapper = getByTestId(testId);
1216
+
1217
+ const caption = wrapper.querySelector(
1218
+ '.cds--cc--legend-fieldset > legend'
1219
+ );
1220
+ expect(caption.textContent).toBe(
1221
+ `${chartOptions.title}. ${translatedInstructions}`
1222
+ );
1223
+ });
1224
+ });
757
1225
  });