@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.
- package/dist/charts.js +161 -6
- package/dist/charts.js.map +1 -1
- package/dist/charts.modern.module.js +161 -6
- package/dist/charts.modern.module.js.map +1 -1
- package/dist/charts.umd.js +741 -383
- package/dist/charts.umd.js.map +1 -1
- package/dist/components/CarbonChart/CarbonChart.d.ts +4 -0
- package/dist/hooks/useCarbonModalFocusManagement.d.ts +2 -0
- package/package.json +9 -4
- package/src/components/CarbonChart/CarbonChart.test.js +176 -1
- package/src/components/CarbonChart/CarbonChart.tsx +27 -5
- package/src/hooks/useCarbonModalFocusManagement.ts +173 -0
|
@@ -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>>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@react-magma/charts",
|
|
3
|
-
"version": "14.0.0-rc.
|
|
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.
|
|
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.
|
|
63
|
-
"npm": ">=
|
|
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
|
-
|
|
613
|
-
|
|
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={
|
|
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
|
+
}
|