Skip to content

Commit d383033

Browse files
committed
chore: Globally handle focus-visible management for application spanning multiple iframes
1 parent ed3ca20 commit d383033

File tree

3 files changed

+179
-26
lines changed

3 files changed

+179
-26
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import React, { useState, useRef } from 'react';
5+
import ReactDOM from 'react-dom';
6+
import { render, fireEvent } from '@testing-library/react';
7+
import { useFocusVisible } from '..';
8+
9+
function Fixture() {
10+
const ref = useRef<HTMLButtonElement | null>(null);
11+
useFocusVisible(ref);
12+
return <button ref={ref}>Test</button>;
13+
}
14+
15+
function FramePortal({ children }: { children: React.ReactNode }) {
16+
const [iframeElement, setIframeElement] = useState<HTMLIFrameElement | null>(null);
17+
18+
return (
19+
<iframe ref={setIframeElement}>
20+
{iframeElement?.contentDocument && ReactDOM.createPortal(children, iframeElement.contentDocument.body)}
21+
</iframe>
22+
);
23+
}
24+
25+
test('should disable focus by default', () => {
26+
render(
27+
<FramePortal>
28+
<Fixture />
29+
</FramePortal>
30+
);
31+
const frame = window.frames[0]!;
32+
expect(frame.document.body).not.toHaveAttribute('data-awsui-focus-visible');
33+
});
34+
35+
test('should enable focus when keyboard interaction happened in the main document', () => {
36+
render(
37+
<>
38+
{/* Component in the main document to make sure listeners are added here too */}
39+
<Fixture />
40+
<FramePortal>
41+
<Fixture />
42+
</FramePortal>
43+
</>
44+
);
45+
const frame = window.frames[0]!;
46+
fireEvent.keyDown(document.body);
47+
expect(document.body).toHaveAttribute('data-awsui-focus-visible', 'true');
48+
expect(frame.document.body).toHaveAttribute('data-awsui-focus-visible', 'true');
49+
});
50+
51+
test('should enable focus when keyboard interaction happened in the frame document', () => {
52+
render(
53+
<>
54+
{/* Component in the main document to make sure listeners are added here too */}
55+
<Fixture />
56+
<FramePortal>
57+
<Fixture />
58+
</FramePortal>
59+
</>
60+
);
61+
const frame = window.frames[0]!;
62+
fireEvent.keyDown(frame.document.body);
63+
expect(document.body).toHaveAttribute('data-awsui-focus-visible', 'true');
64+
expect(frame.document.body).toHaveAttribute('data-awsui-focus-visible', 'true');
65+
});
66+
67+
test('should disable focus when mouse is used in the main document after keyboard', () => {
68+
render(
69+
<>
70+
{/* Component in the main document to make sure listeners are added here too */}
71+
<Fixture />
72+
<FramePortal>
73+
<Fixture />
74+
</FramePortal>
75+
</>
76+
);
77+
const frame = window.frames[0]!;
78+
fireEvent.keyDown(document.body);
79+
fireEvent.mouseDown(document.body);
80+
expect(document.body).not.toHaveAttribute('data-awsui-focus-visible');
81+
expect(frame.document.body).not.toHaveAttribute('data-awsui-focus-visible');
82+
});
83+
84+
test('should disable focus when mouse is used in the frame document after keyboard', () => {
85+
render(
86+
<FramePortal>
87+
<Fixture />
88+
</FramePortal>
89+
);
90+
const frame = window.frames[0]!;
91+
fireEvent.keyDown(frame.document.body);
92+
fireEvent.mouseDown(frame.document.body);
93+
expect(frame.document.body).not.toHaveAttribute('data-awsui-focus-visible');
94+
});
95+
96+
test('should remove listeners only from frame when the frame is unmounted', () => {
97+
const outerAddEventListenerSpy = jest.spyOn(document, 'addEventListener');
98+
const { rerender } = render(
99+
<>
100+
<Fixture />
101+
<FramePortal>
102+
<span />
103+
</FramePortal>
104+
</>
105+
);
106+
107+
const frame = window.frames[0]!;
108+
const innerAddEventListenerSpy = jest.spyOn(frame.document, 'addEventListener');
109+
rerender(
110+
<>
111+
<Fixture />
112+
<FramePortal>
113+
<Fixture />
114+
</FramePortal>
115+
</>
116+
);
117+
118+
expect(outerAddEventListenerSpy).toHaveBeenCalledTimes(2);
119+
const outerSignal = (outerAddEventListenerSpy.mock.calls[0][2] as AddEventListenerOptions).signal!;
120+
expect(outerSignal.aborted).toBe(false);
121+
122+
expect(innerAddEventListenerSpy).toHaveBeenCalledTimes(2);
123+
const innerSignal = (innerAddEventListenerSpy.mock.calls[0][2] as AddEventListenerOptions).signal!;
124+
expect(innerSignal.aborted).toBe(false);
125+
126+
rerender(
127+
<>
128+
<Fixture />
129+
<FramePortal>
130+
<span />
131+
</FramePortal>
132+
</>
133+
);
134+
expect(innerSignal.aborted).toBe(true);
135+
});

src/internal/focus-visible/__tests__/index.test.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,20 +59,26 @@ test('should work with multiple components', () => {
5959
});
6060

6161
test('should add listeners only once', () => {
62-
jest.spyOn(document, 'addEventListener');
63-
jest.spyOn(document, 'removeEventListener');
62+
const addEventListenerSpy = jest.spyOn(document, 'addEventListener');
6463
const { rerender } = render(
6564
<>
6665
<Fixture />
6766
<Fixture />
6867
</>
6968
);
70-
expect(document.addEventListener).toHaveBeenCalledTimes(2);
71-
expect(document.removeEventListener).toHaveBeenCalledTimes(0);
69+
70+
expect(addEventListenerSpy).toHaveBeenCalledTimes(2);
71+
const signal = (addEventListenerSpy.mock.calls[0][2] as AddEventListenerOptions).signal!;
72+
expect(signal.aborted).toBe(false);
73+
74+
addEventListenerSpy.mockClear();
7275
rerender(<Fixture />);
73-
expect(document.removeEventListener).toHaveBeenCalledTimes(0);
76+
expect(addEventListenerSpy).toHaveBeenCalledTimes(0);
77+
expect(signal.aborted).toBe(false);
78+
7479
rerender(<span />);
75-
expect(document.removeEventListener).toHaveBeenCalledTimes(2);
80+
expect(addEventListenerSpy).toHaveBeenCalledTimes(0);
81+
expect(signal.aborted).toBe(true);
7682
});
7783

7884
test('should initialize late components with updated state', () => {
Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,28 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4-
import { useEffect } from 'react';
4+
import React, { useEffect } from 'react';
55
import { isModifierKey } from '../keycode';
66

7+
const frames = new Map<Document, { componentsCount: number; abortController: AbortController }>();
8+
79
function setIsKeyboard(active: boolean) {
810
if (active) {
11+
// For backwards compatibility if a ref isn't provided to useFocusVisible();
912
document.body.setAttribute('data-awsui-focus-visible', 'true');
13+
for (const currentDocument of frames.keys()) {
14+
currentDocument.body.setAttribute('data-awsui-focus-visible', 'true');
15+
}
1016
} else {
1117
document.body.removeAttribute('data-awsui-focus-visible');
18+
for (const currentDocument of frames.keys()) {
19+
currentDocument.body.removeAttribute('data-awsui-focus-visible');
20+
}
1221
}
1322
}
1423

1524
function handleMousedown() {
16-
return setIsKeyboard(false);
25+
setIsKeyboard(false);
1726
}
1827

1928
function handleKeydown(event: KeyboardEvent) {
@@ -22,29 +31,32 @@ function handleKeydown(event: KeyboardEvent) {
2231
}
2332
}
2433

25-
let componentsCount = 0;
26-
27-
function addListeners() {
28-
document.addEventListener('mousedown', handleMousedown);
29-
document.addEventListener('keydown', handleKeydown);
34+
function addListeners(currentDocument: Document): AbortController {
35+
const abortController = new AbortController();
36+
currentDocument.addEventListener('mousedown', handleMousedown, { signal: abortController.signal });
37+
currentDocument.addEventListener('keydown', handleKeydown, { signal: abortController.signal });
38+
return abortController;
3039
}
3140

32-
function removeListeners() {
33-
document.removeEventListener('mousedown', handleMousedown);
34-
document.removeEventListener('keydown', handleKeydown);
35-
}
36-
37-
export function useFocusVisible() {
41+
export function useFocusVisible(componentRef?: React.RefObject<HTMLElement | null>) {
3842
useEffect(() => {
39-
if (componentsCount === 0) {
40-
addListeners();
43+
const currentDocument = componentRef?.current?.ownerDocument ?? document;
44+
45+
let frame = frames.get(currentDocument);
46+
if (frame) {
47+
frame.componentsCount++;
48+
} else {
49+
const abortController = addListeners(currentDocument);
50+
frame = { componentsCount: 1, abortController };
51+
frames.set(currentDocument, frame);
4152
}
42-
componentsCount++;
53+
4354
return () => {
44-
componentsCount--;
45-
if (componentsCount === 0) {
46-
removeListeners();
55+
frame.componentsCount--;
56+
if (frame.componentsCount === 0) {
57+
frame.abortController.abort();
58+
frames.delete(currentDocument);
4759
}
4860
};
49-
}, []);
61+
}, [componentRef]);
5062
}

0 commit comments

Comments
 (0)