@react-magma/charts 13.0.2-next.1 → 13.0.2

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.
@@ -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": "13.0.2-next.1",
3
+ "version": "13.0.2",
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",
@@ -42,7 +47,7 @@
42
47
  "identity-obj-proxy": "^3.0.0",
43
48
  "react": "^17.0.2",
44
49
  "react-dom": "^17.0.2",
45
- "react-magma-dom": "^4.11.1-next.0",
50
+ "react-magma-dom": "^4.12.0",
46
51
  "react-magma-icons": "^3.2.4",
47
52
  "rollup": "^4.52.4",
48
53
  "rollup-plugin-postcss": "^4.0.2"
@@ -52,12 +57,12 @@
52
57
  "@emotion/styled": "^11.13.0",
53
58
  "react": "^17.0.2",
54
59
  "react-dom": "^17.0.2",
55
- "react-magma-dom": "^4.11.1-next.0",
60
+ "react-magma-dom": "^4.12.0-next.3",
56
61
  "react-magma-icons": "^3.2.4"
57
62
  },
58
63
  "engines": {
59
- "node": ">=14.15.0",
60
- "npm": ">=7.3.0"
64
+ "node": ">=22.14.0",
65
+ "npm": ">=11.11.0"
61
66
  },
62
67
  "publishConfig": {
63
68
  "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 {
@@ -547,6 +548,21 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
547
548
  } = props;
548
549
  const theme = React.useContext(ThemeContext);
549
550
  const isInverse = useIsInverse(isInverseProp);
551
+ const internalRef = React.useRef<HTMLDivElement | null>(null);
552
+
553
+ const mergedRef = React.useCallback(
554
+ (node: HTMLDivElement | null) => {
555
+ internalRef.current = node;
556
+ if (typeof ref === 'function') {
557
+ ref(node);
558
+ } else if (ref) {
559
+ (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
560
+ }
561
+ },
562
+ [ref]
563
+ );
564
+
565
+ useCarbonModalFocusManagement(internalRef);
550
566
  const allCharts = {
551
567
  area: AreaChart,
552
568
  areaStacked: StackedAreaChart,
@@ -623,7 +639,7 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
623
639
  return (
624
640
  <CarbonChartWrapper
625
641
  data-testid={testId}
626
- ref={ref}
642
+ ref={mergedRef}
627
643
  isInverse={isInverse}
628
644
  theme={theme}
629
645
  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
+ }