@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.
- package/dist/charts.js +154 -1
- package/dist/charts.js.map +1 -1
- package/dist/charts.modern.module.js +154 -1
- package/dist/charts.modern.module.js.map +1 -1
- package/dist/charts.umd.js +965 -485
- package/dist/charts.umd.js.map +1 -1
- package/dist/hooks/useCarbonModalFocusManagement.d.ts +2 -0
- package/package.json +10 -5
- package/src/components/CarbonChart/CarbonChart.test.js +176 -1
- package/src/components/CarbonChart/CarbonChart.tsx +17 -1
- package/src/hooks/useCarbonModalFocusManagement.ts +173 -0
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-magma/charts",
|
|
3
|
-
"version": "13.0.2
|
|
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.
|
|
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.
|
|
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.
|
|
60
|
-
"npm": ">=
|
|
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={
|
|
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
|
+
}
|