Skip to content

Commit bd4ad12

Browse files
authored
chore: Globally handle focus-visible management for application spanning multiple iframes (#143)
1 parent 1474b56 commit bd4ad12

File tree

3 files changed

+179
-28
lines changed

3 files changed

+179
-28
lines changed
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
expect(outerSignal.aborted).toBe(false);
136+
});

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: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,25 @@
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) {
9-
document.body.setAttribute('data-awsui-focus-visible', 'true');
11+
for (const currentDocument of frames.keys()) {
12+
currentDocument.body.setAttribute('data-awsui-focus-visible', 'true');
13+
}
1014
} else {
11-
document.body.removeAttribute('data-awsui-focus-visible');
15+
for (const currentDocument of frames.keys()) {
16+
currentDocument.body.removeAttribute('data-awsui-focus-visible');
17+
}
1218
}
1319
}
1420

1521
function handleMousedown() {
16-
return setIsKeyboard(false);
22+
setIsKeyboard(false);
1723
}
1824

1925
function handleKeydown(event: KeyboardEvent) {
@@ -22,29 +28,32 @@ function handleKeydown(event: KeyboardEvent) {
2228
}
2329
}
2430

25-
let componentsCount = 0;
26-
27-
function addListeners() {
28-
document.addEventListener('mousedown', handleMousedown);
29-
document.addEventListener('keydown', handleKeydown);
31+
function addListeners(currentDocument: Document): AbortController {
32+
const abortController = new AbortController();
33+
currentDocument.addEventListener('mousedown', handleMousedown, { signal: abortController.signal });
34+
currentDocument.addEventListener('keydown', handleKeydown, { signal: abortController.signal });
35+
return abortController;
3036
}
3137

32-
function removeListeners() {
33-
document.removeEventListener('mousedown', handleMousedown);
34-
document.removeEventListener('keydown', handleKeydown);
35-
}
36-
37-
export function useFocusVisible() {
38+
export function useFocusVisible(componentRef?: React.RefObject<HTMLElement | null>) {
3839
useEffect(() => {
39-
if (componentsCount === 0) {
40-
addListeners();
40+
const currentDocument = componentRef?.current?.ownerDocument ?? document;
41+
42+
let frame = frames.get(currentDocument);
43+
if (frame) {
44+
frame.componentsCount++;
45+
} else {
46+
const abortController = addListeners(currentDocument);
47+
frame = { componentsCount: 1, abortController };
48+
frames.set(currentDocument, frame);
4149
}
42-
componentsCount++;
50+
4351
return () => {
44-
componentsCount--;
45-
if (componentsCount === 0) {
46-
removeListeners();
52+
frame.componentsCount--;
53+
if (frame.componentsCount === 0) {
54+
frame.abortController.abort();
55+
frames.delete(currentDocument);
4756
}
4857
};
49-
}, []);
58+
}, [componentRef]);
5059
}

0 commit comments

Comments
 (0)