Skip to content

Commit 216cb7c

Browse files
authored
Setup focus event listeners in correct window object for useFocusVisible (#5588)
* Setup focus event listeners in correct window object for useFocusVisible
1 parent da30e16 commit 216cb7c

File tree

3 files changed

+222
-29
lines changed

3 files changed

+222
-29
lines changed

packages/@react-aria/interactions/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export {
1717
isFocusVisible,
1818
getInteractionModality,
1919
setInteractionModality,
20+
setupFocus,
2021
useInteractionModality,
2122
useFocusVisible,
2223
useFocusVisibleListener

packages/@react-aria/interactions/src/useFocusVisible.ts

Lines changed: 67 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
// NOTICE file in the root directory of this source tree.
1616
// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
1717

18-
import {isMac, isVirtualClick} from '@react-aria/utils';
18+
import {getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils';
1919
import {useEffect, useState} from 'react';
2020
import {useIsSSR} from '@react-aria/ssr';
2121

@@ -37,7 +37,7 @@ export interface FocusVisibleResult {
3737

3838
let currentModality: null | Modality = null;
3939
let changeHandlers = new Set<Handler>();
40-
let hasSetupGlobalListeners = false;
40+
export let hasSetupGlobalListeners = new Map<Window, boolean>(); // We use a map here to support setting event listeners across multiple document objects.
4141
let hasEventBeforeFocus = false;
4242
let hasBlurredWindowRecently = false;
4343

@@ -114,49 +114,84 @@ function handleWindowBlur() {
114114
/**
115115
* Setup global event listeners to control when keyboard focus style should be visible.
116116
*/
117-
function setupGlobalFocusEvents() {
118-
if (typeof window === 'undefined' || hasSetupGlobalListeners) {
117+
function setupGlobalFocusEvents(element?: HTMLElement | null) {
118+
if (typeof window === 'undefined' || hasSetupGlobalListeners.get(getOwnerWindow(element))) {
119119
return;
120120
}
121121

122+
const windowObject = getOwnerWindow(element);
123+
const documentObject = getOwnerDocument(element);
124+
122125
// Programmatic focus() calls shouldn't affect the current input modality.
123126
// However, we need to detect other cases when a focus event occurs without
124127
// a preceding user event (e.g. screen reader focus). Overriding the focus
125128
// method on HTMLElement.prototype is a bit hacky, but works.
126-
let focus = HTMLElement.prototype.focus;
127-
HTMLElement.prototype.focus = function () {
129+
let focus = windowObject.HTMLElement.prototype.focus;
130+
windowObject.HTMLElement.prototype.focus = function () {
128131
hasEventBeforeFocus = true;
129132
focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]);
130133
};
131134

132-
document.addEventListener('keydown', handleKeyboardEvent, true);
133-
document.addEventListener('keyup', handleKeyboardEvent, true);
134-
document.addEventListener('click', handleClickEvent, true);
135+
documentObject.addEventListener('keydown', handleKeyboardEvent, true);
136+
documentObject.addEventListener('keyup', handleKeyboardEvent, true);
137+
documentObject.addEventListener('click', handleClickEvent, true);
135138

136139
// Register focus events on the window so they are sure to happen
137140
// before React's event listeners (registered on the document).
138-
window.addEventListener('focus', handleFocusEvent, true);
139-
window.addEventListener('blur', handleWindowBlur, false);
141+
windowObject.addEventListener('focus', handleFocusEvent, true);
142+
windowObject.addEventListener('blur', handleWindowBlur, false);
140143

141144
if (typeof PointerEvent !== 'undefined') {
142-
document.addEventListener('pointerdown', handlePointerEvent, true);
143-
document.addEventListener('pointermove', handlePointerEvent, true);
144-
document.addEventListener('pointerup', handlePointerEvent, true);
145+
documentObject.addEventListener('pointerdown', handlePointerEvent, true);
146+
documentObject.addEventListener('pointermove', handlePointerEvent, true);
147+
documentObject.addEventListener('pointerup', handlePointerEvent, true);
145148
} else {
146-
document.addEventListener('mousedown', handlePointerEvent, true);
147-
document.addEventListener('mousemove', handlePointerEvent, true);
148-
document.addEventListener('mouseup', handlePointerEvent, true);
149+
documentObject.addEventListener('mousedown', handlePointerEvent, true);
150+
documentObject.addEventListener('mousemove', handlePointerEvent, true);
151+
documentObject.addEventListener('mouseup', handlePointerEvent, true);
149152
}
150153

151-
hasSetupGlobalListeners = true;
154+
// Add unmount handler
155+
windowObject.addEventListener('beforeunload', () => {
156+
documentObject.removeEventListener('keydown', handleKeyboardEvent, true);
157+
documentObject.removeEventListener('keyup', handleKeyboardEvent, true);
158+
documentObject.removeEventListener('click', handleClickEvent, true);
159+
windowObject.removeEventListener('focus', handleFocusEvent, true);
160+
windowObject.removeEventListener('blur', handleWindowBlur, false);
161+
162+
if (typeof PointerEvent !== 'undefined') {
163+
documentObject.removeEventListener('pointerdown', handlePointerEvent, true);
164+
documentObject.removeEventListener('pointermove', handlePointerEvent, true);
165+
documentObject.removeEventListener('pointerup', handlePointerEvent, true);
166+
} else {
167+
documentObject.removeEventListener('mousedown', handlePointerEvent, true);
168+
documentObject.removeEventListener('mousemove', handlePointerEvent, true);
169+
documentObject.removeEventListener('mouseup', handlePointerEvent, true);
170+
}
171+
172+
if (hasSetupGlobalListeners.has(windowObject)) {
173+
hasSetupGlobalListeners.delete(windowObject);
174+
}
175+
}, {once: true});
176+
177+
hasSetupGlobalListeners.set(windowObject, true);
152178
}
153179

154-
if (typeof document !== 'undefined') {
155-
if (document.readyState !== 'loading') {
156-
setupGlobalFocusEvents();
180+
export const setupFocus = (element?: HTMLElement | null) => {
181+
const documentObject = getOwnerDocument(element);
182+
if (documentObject.readyState !== 'loading') {
183+
setupGlobalFocusEvents(element);
157184
} else {
158-
document.addEventListener('DOMContentLoaded', setupGlobalFocusEvents);
185+
documentObject.addEventListener('DOMContentLoaded', () =>
186+
setupGlobalFocusEvents(element)
187+
);
159188
}
189+
};
190+
191+
// Server-side rendering does not have the document object defined
192+
// eslint-disable-next-line no-restricted-globals
193+
if (typeof document !== 'undefined') {
194+
setupFocus();
160195
}
161196

162197
/**
@@ -213,11 +248,16 @@ const nonTextInputTypes = new Set([
213248
* focus visible style can be properly set.
214249
*/
215250
function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) {
216-
isTextInput = isTextInput ||
217-
(e?.target instanceof HTMLInputElement && !nonTextInputTypes.has(e?.target?.type)) ||
218-
e?.target instanceof HTMLTextAreaElement ||
219-
(e?.target instanceof HTMLElement && e?.target.isContentEditable);
220-
return !(isTextInput && modality === 'keyboard' && e instanceof KeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]);
251+
const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLInputElement : HTMLInputElement;
252+
const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLTextAreaElement : HTMLTextAreaElement;
253+
const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLElement : HTMLElement;
254+
const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).KeyboardEvent : KeyboardEvent;
255+
256+
isTextInput = isTextInput ||
257+
(e?.target instanceof IHTMLInputElement && !nonTextInputTypes.has(e?.target?.type)) ||
258+
e?.target instanceof IHTMLTextAreaElement ||
259+
(e?.target instanceof IHTMLElement && e?.target.isContentEditable);
260+
return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]);
221261
}
222262

223263
/**

packages/@react-aria/interactions/test/useFocusVisible.test.js

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12-
import {act, fireEvent, render, renderHook, screen} from '@react-spectrum/test-utils';
12+
import {act, fireEvent, render, renderHook, screen, waitFor} from '@react-spectrum/test-utils';
13+
import {hasSetupGlobalListeners} from '../src/useFocusVisible';
1314
import React from 'react';
14-
import {useFocusVisible, useFocusVisibleListener} from '../';
15+
import {render as ReactDOMRender} from 'react-dom';
16+
import {setupFocus, useFocusVisible, useFocusVisibleListener} from '../';
1517

1618
function Example(props) {
1719
const {isFocusVisible} = useFocusVisible();
@@ -90,6 +92,156 @@ describe('useFocusVisible', function () {
9092

9193
expect(el.textContent).toBe('example');
9294
});
95+
96+
describe('Setups global event listeners in a different window', () => {
97+
let iframe;
98+
let iframeRoot;
99+
beforeEach(() => {
100+
iframe = document.createElement('iframe');
101+
window.document.body.appendChild(iframe);
102+
iframeRoot = iframe.contentWindow.document.createElement('div');
103+
iframe.contentWindow.document.body.appendChild(iframeRoot);
104+
});
105+
106+
afterEach(() => {
107+
fireEvent(iframe.contentWindow, new Event('beforeunload'));
108+
iframe.remove();
109+
});
110+
111+
it('sets up focus listener in a different window', async function () {
112+
ReactDOMRender(<Example id="iframe-example" />, iframeRoot);
113+
await waitFor(() => {
114+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy();
115+
});
116+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]');
117+
118+
// Focus in iframe before setupFocus should not do anything
119+
fireEvent.focus(iframe.contentWindow.document.body);
120+
expect(el.textContent).toBe('example');
121+
122+
// Setup focus in iframe
123+
setupFocus(iframeRoot);
124+
expect(el.textContent).toBe('example');
125+
126+
// Focus in iframe after setupFocus
127+
fireEvent.focus(iframe.contentWindow.document.body);
128+
expect(el.textContent).toBe('example-focusVisible');
129+
});
130+
131+
it('removes event listeners on beforeunload', async function () {
132+
ReactDOMRender(<Example id="iframe-example" />, iframeRoot);
133+
setupFocus(iframeRoot);
134+
135+
await waitFor(() => {
136+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy();
137+
});
138+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]');
139+
expect(el.textContent).toBe('example-focusVisible');
140+
141+
fireEvent.mouseDown(el);
142+
expect(el.textContent).toBe('example');
143+
144+
// Focus events after beforeunload no longer work
145+
fireEvent(iframe.contentWindow, new Event('beforeunload'));
146+
fireEvent.focus(iframe.contentWindow.document.body);
147+
expect(el.textContent).toBe('example');
148+
});
149+
150+
it('removes the window object from the hasSetupGlobalListeners object on beforeunload', async function () {
151+
ReactDOMRender(<Example id="iframe-example" />, iframeRoot);
152+
expect(hasSetupGlobalListeners.size).toBe(1);
153+
expect(hasSetupGlobalListeners.get(window)).toBeTruthy();
154+
expect(hasSetupGlobalListeners.get(iframe.contentWindow)).toBeFalsy();
155+
156+
// After setup focus
157+
setupFocus(iframeRoot);
158+
expect(hasSetupGlobalListeners.size).toBe(2);
159+
expect(hasSetupGlobalListeners.get(window)).toBeTruthy();
160+
expect(hasSetupGlobalListeners.get(iframe.contentWindow)).toBeTruthy();
161+
162+
// After unmount
163+
fireEvent(iframe.contentWindow, new Event('beforeunload'));
164+
expect(hasSetupGlobalListeners.size).toBe(1);
165+
expect(hasSetupGlobalListeners.get(window)).toBeTruthy();
166+
expect(hasSetupGlobalListeners.get(iframe.contentWindow)).toBeFalsy();
167+
});
168+
169+
it('returns positive isFocusVisible result after toggling browser tabs after keyboard navigation', async function () {
170+
ReactDOMRender(<Example id="iframe-example" />, iframeRoot);
171+
setupFocus(iframeRoot);
172+
173+
// Fire focus in iframe
174+
await waitFor(() => {
175+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy();
176+
});
177+
fireEvent.focus(iframe.contentWindow.document.body);
178+
179+
// Iframe event listeners
180+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]');
181+
expect(el.textContent).toBe('example-focusVisible');
182+
183+
// Toggling browser tabs should have the same behavior since the iframe is on the same tab as before.
184+
fireEvent.keyDown(el, {key: 'Tab'});
185+
toggleBrowserTabs();
186+
expect(el.textContent).toBe('example-focusVisible');
187+
});
188+
189+
it('returns negative isFocusVisible result after toggling browser tabs without prior keyboard navigation', async function () {
190+
ReactDOMRender(<Example id="iframe-example" />, iframeRoot);
191+
setupFocus(iframeRoot);
192+
193+
// Fire focus in iframe
194+
await waitFor(() => {
195+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy();
196+
});
197+
fireEvent.focus(iframe.contentWindow.document.body);
198+
199+
// Iframe event listeners
200+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]');
201+
expect(el.textContent).toBe('example-focusVisible');
202+
203+
fireEvent.mouseDown(el);
204+
expect(el.textContent).toBe('example');
205+
});
206+
207+
it('returns positive isFocusVisible result after toggling browser window after keyboard navigation', async function () {
208+
ReactDOMRender(<Example id="iframe-example" />, iframeRoot);
209+
setupFocus(iframeRoot);
210+
211+
// Fire focus in iframe
212+
await waitFor(() => {
213+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy();
214+
});
215+
fireEvent.focus(iframe.contentWindow.document.body);
216+
217+
// Iframe event listeners
218+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]');
219+
expect(el.textContent).toBe('example-focusVisible');
220+
221+
fireEvent.keyDown(el, {key: 'Tab'});
222+
toggleBrowserWindow();
223+
expect(el.textContent).toBe('example-focusVisible');
224+
});
225+
226+
it('returns negative isFocusVisible result after toggling browser window without prior keyboard navigation', async function () {
227+
ReactDOMRender(<Example id="iframe-example" />, iframeRoot);
228+
setupFocus(iframeRoot);
229+
230+
// Fire focus in iframe
231+
await waitFor(() => {
232+
expect(document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]')).toBeTruthy();
233+
});
234+
fireEvent.focus(iframe.contentWindow.document.body);
235+
236+
// Iframe event listeners
237+
const el = document.querySelector('iframe').contentWindow.document.body.querySelector('div[id="iframe-example"]');
238+
expect(el.textContent).toBe('example-focusVisible');
239+
240+
fireEvent.mouseDown(el);
241+
toggleBrowserWindow();
242+
expect(el.textContent).toBe('example');
243+
});
244+
});
93245
});
94246

95247
describe('useFocusVisibleListener', function () {

0 commit comments

Comments
 (0)