@react-magma/charts 14.0.0-rc.3 → 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.
@@ -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.3",
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 {
@@ -548,6 +549,21 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
548
549
  } = props;
549
550
  const theme = React.useContext(ThemeContext) as ThemeInterface;
550
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);
551
567
  const allCharts = {
552
568
  area: AreaChart,
553
569
  areaStacked: StackedAreaChart,
@@ -625,7 +641,7 @@ export const CarbonChart = React.forwardRef<HTMLDivElement, CarbonChartProps>(
625
641
  return (
626
642
  <CarbonChartWrapper
627
643
  data-testid={testId}
628
- ref={ref}
644
+ ref={mergedRef}
629
645
  isInverse={isInverse}
630
646
  theme={theme}
631
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
+ }