@react-magma/charts 14.0.0-rc.2 → 14.0.0-rc.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.
@@ -38,5 +38,9 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
38
38
  * Type of Chart: area, bar, donut, line, etc.
39
39
  */
40
40
  type: CarbonChartType;
41
+ /**
42
+ * Text for the aria-label attribute for main SVG container, if provided
43
+ */
44
+ ariaLabel?: string;
41
45
  }
42
46
  export declare const CarbonChart: React.ForwardRefExoticComponent<CarbonChartProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,2 @@
1
+ import * as React from 'react';
2
+ export declare function useCarbonModalFocusManagement(wrapperRef: React.RefObject<HTMLDivElement>): void;
package/package.json CHANGED
@@ -1,7 +1,12 @@
1
1
  {
2
2
  "name": "@react-magma/charts",
3
- "version": "14.0.0-rc.2",
3
+ "version": "14.0.0-rc.4",
4
4
  "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/cengage/react-magma.git",
8
+ "directory": "packages/charts"
9
+ },
5
10
  "exports": {
6
11
  ".": {
7
12
  "import": "./dist/charts.modern.module.js",
@@ -45,7 +50,7 @@
45
50
  "identity-obj-proxy": "3.0.0",
46
51
  "react": "18.3.1",
47
52
  "react-dom": "18.3.1",
48
- "react-magma-dom": "^5.1.0-rc.47",
53
+ "react-magma-dom": "^5.1.0-rc.70",
49
54
  "react-magma-icons": "^3.2.4",
50
55
  "rollup": "^4.52.4",
51
56
  "rollup-plugin-postcss": "^4.0.2"
@@ -59,8 +64,8 @@
59
64
  "react-magma-icons": "^3.2.4"
60
65
  },
61
66
  "engines": {
62
- "node": ">=14.15.0",
63
- "npm": ">=7.3.0"
67
+ "node": ">=22.14.0",
68
+ "npm": ">=11.11.0"
64
69
  },
65
70
  "publishConfig": {
66
71
  "access": "public"
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
 
3
- import { render } from '@testing-library/react';
3
+ import { act, render } from '@testing-library/react';
4
4
  import { ThemeContext, magma } from 'react-magma-dom';
5
5
 
6
6
  import { CarbonChart, CarbonChartType } from '.';
@@ -11,6 +11,16 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({
11
11
  disconnect: jest.fn(),
12
12
  }));
13
13
 
14
+ // Capture MutationObserver callbacks so we can trigger them manually
15
+ let mutationObserverCallback;
16
+ global.MutationObserver = jest.fn().mockImplementation(callback => {
17
+ mutationObserverCallback = callback;
18
+ return {
19
+ observe: jest.fn(),
20
+ disconnect: jest.fn(),
21
+ };
22
+ });
23
+
14
24
  const dataSet = [
15
25
  {
16
26
  group: 'Qty',
@@ -579,4 +589,169 @@ describe('CarbonChart', () => {
579
589
  );
580
590
  });
581
591
  });
592
+
593
+ describe('Modal Focus Management', () => {
594
+ let wrapper;
595
+ let modal;
596
+ let closeButton;
597
+ let otherButton;
598
+
599
+ beforeEach(() => {
600
+ jest.useFakeTimers();
601
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
602
+ cb(0);
603
+ return 0;
604
+ });
605
+ const testId = 'focus-mgmt-test';
606
+ const { getByTestId } = render(
607
+ <CarbonChart
608
+ testId={testId}
609
+ dataSet={dataSet}
610
+ options={chartOptions}
611
+ type={CarbonChartType.bar}
612
+ />
613
+ );
614
+
615
+ wrapper = getByTestId(testId);
616
+
617
+ // Manually create Carbon modal DOM inside the wrapper
618
+ modal = document.createElement('div');
619
+ modal.className = 'cds--modal';
620
+
621
+ closeButton = document.createElement('button');
622
+ closeButton.className = 'cds--modal-close';
623
+ closeButton.textContent = 'Close';
624
+
625
+ otherButton = document.createElement('button');
626
+ otherButton.textContent = 'Copy';
627
+
628
+ modal.appendChild(closeButton);
629
+ modal.appendChild(otherButton);
630
+ wrapper.appendChild(modal);
631
+ });
632
+
633
+ afterEach(() => {
634
+ window.requestAnimationFrame.mockRestore();
635
+ jest.useRealTimers();
636
+ });
637
+
638
+ // Simulate Carbon's handleShowModal: sets aria-modal, role, style
639
+ function simulateModalOpen() {
640
+ modal.setAttribute('aria-modal', 'true');
641
+ modal.setAttribute('role', 'dialog');
642
+ modal.style.visibility = 'visible';
643
+ modal.style.opacity = '1';
644
+ act(() => {
645
+ mutationObserverCallback([
646
+ { type: 'attributes', attributeName: 'aria-modal', target: modal },
647
+ { type: 'attributes', attributeName: 'style', target: modal },
648
+ ]);
649
+ });
650
+ jest.runAllTimers();
651
+ act(() => {
652
+ jest.advanceTimersByTime(0);
653
+ });
654
+ }
655
+
656
+ // Simulate Carbon's handleHideModal: removes aria-modal, role, sets hidden
657
+ function simulateModalClose() {
658
+ modal.removeAttribute('aria-modal');
659
+ modal.removeAttribute('role');
660
+ modal.style.visibility = 'hidden';
661
+ modal.style.opacity = '0';
662
+ act(() => {
663
+ mutationObserverCallback([
664
+ { type: 'attributes', attributeName: 'aria-modal', target: modal },
665
+ { type: 'attributes', attributeName: 'style', target: modal },
666
+ ]);
667
+ });
668
+ }
669
+
670
+ it('should move focus to the close button when modal opens', () => {
671
+ const triggerButton = document.createElement('button');
672
+ triggerButton.textContent = 'Show as table';
673
+ wrapper.appendChild(triggerButton);
674
+ triggerButton.focus();
675
+
676
+ simulateModalOpen();
677
+
678
+ expect(document.activeElement).toBe(closeButton);
679
+ });
680
+
681
+ it('should restore focus to the previously focused element when modal closes', () => {
682
+ const triggerButton = document.createElement('button');
683
+ triggerButton.textContent = 'Show as table';
684
+ wrapper.appendChild(triggerButton);
685
+ triggerButton.focus();
686
+
687
+ simulateModalOpen();
688
+ simulateModalClose();
689
+
690
+ expect(document.activeElement).toBe(triggerButton);
691
+ });
692
+
693
+ it('should move focus into modal on second open after close', () => {
694
+ const triggerButton = document.createElement('button');
695
+ triggerButton.textContent = 'Show as table';
696
+ wrapper.appendChild(triggerButton);
697
+ triggerButton.focus();
698
+
699
+ // First open/close cycle
700
+ simulateModalOpen();
701
+ simulateModalClose();
702
+ expect(document.activeElement).toBe(triggerButton);
703
+
704
+ // Second open - focus should move into modal again
705
+ simulateModalOpen();
706
+ expect(document.activeElement).toBe(closeButton);
707
+ });
708
+
709
+ it('should trap focus with Tab wrapping from last to first element', () => {
710
+ simulateModalOpen();
711
+
712
+ otherButton.focus();
713
+ expect(document.activeElement).toBe(otherButton);
714
+
715
+ const tabEvent = new KeyboardEvent('keydown', {
716
+ key: 'Tab',
717
+ bubbles: true,
718
+ });
719
+ Object.defineProperty(tabEvent, 'shiftKey', { value: false });
720
+ document.dispatchEvent(tabEvent);
721
+
722
+ expect(document.activeElement).toBe(closeButton);
723
+ });
724
+
725
+ it('should trap focus with Shift+Tab wrapping from first to last element', () => {
726
+ simulateModalOpen();
727
+
728
+ closeButton.focus();
729
+ expect(document.activeElement).toBe(closeButton);
730
+
731
+ const shiftTabEvent = new KeyboardEvent('keydown', {
732
+ key: 'Tab',
733
+ shiftKey: true,
734
+ bubbles: true,
735
+ });
736
+ document.dispatchEvent(shiftTabEvent);
737
+
738
+ expect(document.activeElement).toBe(otherButton);
739
+ });
740
+
741
+ it('should redirect focus back to modal when focus escapes to an outside element', () => {
742
+ simulateModalOpen();
743
+ expect(document.activeElement).toBe(closeButton);
744
+
745
+ // Simulate overflow menu stealing focus (what Carbon does on second open)
746
+ const outsideButton = document.createElement('button');
747
+ outsideButton.className = 'cds--overflow-menu__trigger';
748
+ wrapper.appendChild(outsideButton);
749
+ outsideButton.focus();
750
+
751
+ // The redirect is deferred via setTimeout(0) to avoid re-entrancy issues
752
+ jest.runAllTimers();
753
+
754
+ expect(document.activeElement).toBe(closeButton);
755
+ });
756
+ });
582
757
  });
@@ -25,6 +25,7 @@ import styled from '@emotion/styled';
25
25
  import { transparentize } from 'polished';
26
26
  import { ThemeInterface, ThemeContext, useIsInverse } from 'react-magma-dom';
27
27
 
28
+ import { useCarbonModalFocusManagement } from '../../hooks/useCarbonModalFocusManagement';
28
29
  import './carbon-charts.css';
29
30
 
30
31
  export enum CarbonChartType {
@@ -64,6 +65,10 @@ export interface CarbonChartProps extends React.HTMLAttributes<HTMLDivElement> {
64
65
  * Type of Chart: area, bar, donut, line, etc.
65
66
  */
66
67
  type: CarbonChartType;
68
+ /**
69
+ * Text for the aria-label attribute for main SVG container, if provided
70
+ */
71
+ ariaLabel?: string;
67
72
  }
68
73
 
69
74
  const CarbonChartWrapper = styled.div<{
@@ -362,7 +367,6 @@ const CarbonChartWrapper = styled.div<{
362
367
  margin: 0;
363
368
  min-width: ${props => props.theme.spaceScale.spacing13};
364
369
  overflow: hidden;
365
- padding:;
366
370
  position: relative;
367
371
  right: ${props => props.theme.spaceScale.spacing04};
368
372
  text-align: center;
@@ -540,10 +544,26 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
540
544
  type,
541
545
  dataSet,
542
546
  options,
547
+ ariaLabel,
543
548
  ...rest
544
549
  } = props;
545
550
  const theme = React.useContext(ThemeContext) as ThemeInterface;
546
551
  const isInverse = useIsInverse(isInverseProp);
552
+ const internalRef = React.useRef<HTMLDivElement | null>(null);
553
+
554
+ const mergedRef = React.useCallback(
555
+ (node: HTMLDivElement | null) => {
556
+ internalRef.current = node;
557
+ if (typeof ref === 'function') {
558
+ ref(node);
559
+ } else if (ref) {
560
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
561
+ }
562
+ },
563
+ [ref]
564
+ );
565
+
566
+ useCarbonModalFocusManagement(internalRef);
547
567
  const allCharts = {
548
568
  area: AreaChart,
549
569
  areaStacked: StackedAreaChart,
@@ -609,9 +629,11 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
609
629
 
610
630
  // Adding aria-label to main SVG container
611
631
  React.useEffect(() => {
612
- document.querySelectorAll('.graph-frame ').forEach(div => {
613
- div.setAttribute('aria-label', 'Interactive chart');
614
- });
632
+ if (ariaLabel) {
633
+ document.querySelectorAll('.graph-frame ').forEach(div => {
634
+ div.setAttribute('aria-label', ariaLabel);
635
+ });
636
+ }
615
637
  });
616
638
 
617
639
  const groupsLength = Object.keys(buildColors()).length;
@@ -619,7 +641,7 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
619
641
  return (
620
642
  <CarbonChartWrapper
621
643
  data-testid={testId}
622
- ref={ref}
644
+ ref={mergedRef}
623
645
  isInverse={isInverse}
624
646
  theme={theme}
625
647
  className="carbon-chart-wrapper"
@@ -0,0 +1,173 @@
1
+ import * as React from 'react';
2
+
3
+ const FOCUSABLE_SELECTOR =
4
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
5
+
6
+ function getFocusableElements(container: HTMLElement): HTMLElement[] {
7
+ return Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
8
+ (el): el is HTMLElement => {
9
+ const style = window.getComputedStyle(el);
10
+ return (
11
+ style.display !== 'none' &&
12
+ style.visibility !== 'hidden' &&
13
+ !el.hasAttribute('disabled')
14
+ );
15
+ }
16
+ );
17
+ }
18
+
19
+ function findVisibleModal(wrapper: HTMLElement): HTMLElement | null {
20
+ const modal = wrapper.querySelector<HTMLElement>('.cds--modal');
21
+ if (!modal) return null;
22
+
23
+ const isVisible =
24
+ modal.getAttribute('aria-modal') === 'true' ||
25
+ modal.style.visibility === 'visible' ||
26
+ modal.classList.contains('is-visible');
27
+
28
+ return isVisible ? modal : null;
29
+ }
30
+
31
+ export function useCarbonModalFocusManagement(
32
+ wrapperRef: React.RefObject<HTMLDivElement>
33
+ ): void {
34
+ const previouslyFocusedElement = React.useRef<Element | null>(null);
35
+ const keydownHandler = React.useRef<((e: KeyboardEvent) => void) | null>(
36
+ null
37
+ );
38
+ const focusinHandler = React.useRef<((e: FocusEvent) => void) | null>(null);
39
+ const currentModal = React.useRef<HTMLElement | null>(null);
40
+
41
+ React.useEffect(() => {
42
+ const wrapper = wrapperRef.current;
43
+ if (!wrapper) return;
44
+
45
+ function focusModalCloseButton(modal: HTMLElement) {
46
+ const closeButton = modal.querySelector<HTMLElement>('.cds--modal-close');
47
+ if (closeButton) {
48
+ closeButton.focus();
49
+ } else {
50
+ const focusable = getFocusableElements(modal);
51
+ if (focusable.length > 0) {
52
+ focusable[0].focus();
53
+ }
54
+ }
55
+ }
56
+
57
+ function handleModalOpen(modal: HTMLElement) {
58
+ currentModal.current = modal;
59
+ previouslyFocusedElement.current = document.activeElement;
60
+
61
+ // Permanent guard: redirect focus back into modal whenever it escapes
62
+ // (e.g. Carbon's overflow menu returning focus to its trigger).
63
+ focusinHandler.current = (event: FocusEvent) => {
64
+ const target = event.target as HTMLElement;
65
+ if (!modal.contains(target)) {
66
+ setTimeout(() => {
67
+ if (currentModal.current === modal) {
68
+ focusModalCloseButton(modal);
69
+ }
70
+ }, 0);
71
+ }
72
+ };
73
+ document.addEventListener('focusin', focusinHandler.current);
74
+
75
+ let pollAttempts = 0;
76
+ const pollAndFocus = () => {
77
+ if (currentModal.current !== modal) return;
78
+ if (modal.contains(document.activeElement)) return;
79
+
80
+ const closeBtn = modal.querySelector<HTMLElement>('.cds--modal-close');
81
+ if (
82
+ closeBtn &&
83
+ window.getComputedStyle(closeBtn).visibility !== 'hidden'
84
+ ) {
85
+ closeBtn.focus();
86
+ return;
87
+ }
88
+
89
+ if (++pollAttempts < 30) {
90
+ requestAnimationFrame(pollAndFocus);
91
+ }
92
+ };
93
+ requestAnimationFrame(pollAndFocus);
94
+
95
+ keydownHandler.current = (event: KeyboardEvent) => {
96
+ if (event.key !== 'Tab') return;
97
+
98
+ const focusable = getFocusableElements(modal);
99
+ if (focusable.length === 0) {
100
+ event.preventDefault();
101
+ return;
102
+ }
103
+
104
+ if (focusable.length === 1) {
105
+ event.preventDefault();
106
+ if (focusable[0] !== document.activeElement) {
107
+ focusable[0].focus();
108
+ }
109
+ return;
110
+ }
111
+
112
+ const firstItem = focusable[0];
113
+ const lastItem = focusable[focusable.length - 1];
114
+
115
+ if (!event.shiftKey && document.activeElement === lastItem) {
116
+ event.preventDefault();
117
+ firstItem.focus();
118
+ } else if (event.shiftKey && document.activeElement === firstItem) {
119
+ event.preventDefault();
120
+ lastItem.focus();
121
+ }
122
+ };
123
+
124
+ document.addEventListener('keydown', keydownHandler.current);
125
+ }
126
+
127
+ function handleModalClose() {
128
+ // Null out currentModal first so any pending setTimeout redirects
129
+ // (scheduled by the focusin guard) see a closed modal and bail out.
130
+ currentModal.current = null;
131
+
132
+ if (focusinHandler.current) {
133
+ document.removeEventListener('focusin', focusinHandler.current);
134
+ focusinHandler.current = null;
135
+ }
136
+
137
+ if (keydownHandler.current) {
138
+ document.removeEventListener('keydown', keydownHandler.current);
139
+ keydownHandler.current = null;
140
+ }
141
+
142
+ if (previouslyFocusedElement.current instanceof HTMLElement) {
143
+ previouslyFocusedElement.current.focus();
144
+ }
145
+ }
146
+
147
+ const observer = new MutationObserver(() => {
148
+ const visibleModal = findVisibleModal(wrapper);
149
+
150
+ if (visibleModal && !currentModal.current) {
151
+ handleModalOpen(visibleModal);
152
+ } else if (!visibleModal && currentModal.current) {
153
+ handleModalClose();
154
+ }
155
+ });
156
+
157
+ observer.observe(wrapper, {
158
+ attributes: true,
159
+ attributeFilter: ['class', 'style', 'aria-modal'],
160
+ subtree: true,
161
+ });
162
+
163
+ return () => {
164
+ observer.disconnect();
165
+ if (keydownHandler.current) {
166
+ document.removeEventListener('keydown', keydownHandler.current);
167
+ }
168
+ if (focusinHandler.current) {
169
+ document.removeEventListener('focusin', focusinHandler.current);
170
+ }
171
+ };
172
+ }, [wrapperRef]);
173
+ }