UNPKG

12.8 kBPlain TextView Raw
1/*
2 * Copyright 2020 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13// Portions of the code in this file are based on code from react.
14// Original licensing for the following can be found in the
15// NOTICE file in the root directory of this source tree.
16// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
17
18import {getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils';
19import {useEffect, useState} from 'react';
20import {useIsSSR} from '@react-aria/ssr';
21
22export type Modality = 'keyboard' | 'pointer' | 'virtual';
23type HandlerEvent = PointerEvent | MouseEvent | KeyboardEvent | FocusEvent | null;
24type Handler = (modality: Modality, e: HandlerEvent) => void;
25export type FocusVisibleHandler = (isFocusVisible: boolean) => void;
26export interface FocusVisibleProps {
27 /** Whether the element is a text input. */
28 isTextInput?: boolean,
29 /** Whether the element will be auto focused. */
30 autoFocus?: boolean
31}
32
33export interface FocusVisibleResult {
34 /** Whether keyboard focus is visible globally. */
35 isFocusVisible: boolean
36}
37
38let currentModality: null | Modality = null;
39let changeHandlers = new Set<Handler>();
40interface GlobalListenerData {
41 focus: () => void
42}
43export let hasSetupGlobalListeners = new Map<Window, GlobalListenerData>(); // We use a map here to support setting event listeners across multiple document objects.
44let hasEventBeforeFocus = false;
45let hasBlurredWindowRecently = false;
46
47// Only Tab or Esc keys will make focus visible on text input elements
48const FOCUS_VISIBLE_INPUT_KEYS = {
49 Tab: true,
50 Escape: true
51};
52
53function triggerChangeHandlers(modality: Modality, e: HandlerEvent) {
54 for (let handler of changeHandlers) {
55 handler(modality, e);
56 }
57}
58
59/**
60 * Helper function to determine if a KeyboardEvent is unmodified and could make keyboard focus styles visible.
61 */
62function isValidKey(e: KeyboardEvent) {
63 // Control and Shift keys trigger when navigating back to the tab with keyboard.
64 return !(e.metaKey || (!isMac() && e.altKey) || e.ctrlKey || e.key === 'Control' || e.key === 'Shift' || e.key === 'Meta');
65}
66
67
68function handleKeyboardEvent(e: KeyboardEvent) {
69 hasEventBeforeFocus = true;
70 if (isValidKey(e)) {
71 currentModality = 'keyboard';
72 triggerChangeHandlers('keyboard', e);
73 }
74}
75
76function handlePointerEvent(e: PointerEvent | MouseEvent) {
77 currentModality = 'pointer';
78 if (e.type === 'mousedown' || e.type === 'pointerdown') {
79 hasEventBeforeFocus = true;
80 triggerChangeHandlers('pointer', e);
81 }
82}
83
84function handleClickEvent(e: MouseEvent) {
85 if (isVirtualClick(e)) {
86 hasEventBeforeFocus = true;
87 currentModality = 'virtual';
88 }
89}
90
91function handleFocusEvent(e: FocusEvent) {
92 // Firefox fires two extra focus events when the user first clicks into an iframe:
93 // first on the window, then on the document. We ignore these events so they don't
94 // cause keyboard focus rings to appear.
95 if (e.target === window || e.target === document) {
96 return;
97 }
98
99 // If a focus event occurs without a preceding keyboard or pointer event, switch to virtual modality.
100 // This occurs, for example, when navigating a form with the next/previous buttons on iOS.
101 if (!hasEventBeforeFocus && !hasBlurredWindowRecently) {
102 currentModality = 'virtual';
103 triggerChangeHandlers('virtual', e);
104 }
105
106 hasEventBeforeFocus = false;
107 hasBlurredWindowRecently = false;
108}
109
110function handleWindowBlur() {
111 // When the window is blurred, reset state. This is necessary when tabbing out of the window,
112 // for example, since a subsequent focus event won't be fired.
113 hasEventBeforeFocus = false;
114 hasBlurredWindowRecently = true;
115}
116
117/**
118 * Setup global event listeners to control when keyboard focus style should be visible.
119 */
120function setupGlobalFocusEvents(element?: HTMLElement | null) {
121 if (typeof window === 'undefined' || hasSetupGlobalListeners.get(getOwnerWindow(element))) {
122 return;
123 }
124
125 const windowObject = getOwnerWindow(element);
126 const documentObject = getOwnerDocument(element);
127
128 // Programmatic focus() calls shouldn't affect the current input modality.
129 // However, we need to detect other cases when a focus event occurs without
130 // a preceding user event (e.g. screen reader focus). Overriding the focus
131 // method on HTMLElement.prototype is a bit hacky, but works.
132 let focus = windowObject.HTMLElement.prototype.focus;
133 windowObject.HTMLElement.prototype.focus = function () {
134 hasEventBeforeFocus = true;
135 focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]);
136 };
137
138 documentObject.addEventListener('keydown', handleKeyboardEvent, true);
139 documentObject.addEventListener('keyup', handleKeyboardEvent, true);
140 documentObject.addEventListener('click', handleClickEvent, true);
141
142 // Register focus events on the window so they are sure to happen
143 // before React's event listeners (registered on the document).
144 windowObject.addEventListener('focus', handleFocusEvent, true);
145 windowObject.addEventListener('blur', handleWindowBlur, false);
146
147 if (typeof PointerEvent !== 'undefined') {
148 documentObject.addEventListener('pointerdown', handlePointerEvent, true);
149 documentObject.addEventListener('pointermove', handlePointerEvent, true);
150 documentObject.addEventListener('pointerup', handlePointerEvent, true);
151 } else {
152 documentObject.addEventListener('mousedown', handlePointerEvent, true);
153 documentObject.addEventListener('mousemove', handlePointerEvent, true);
154 documentObject.addEventListener('mouseup', handlePointerEvent, true);
155 }
156
157 // Add unmount handler
158 windowObject.addEventListener('beforeunload', () => {
159 tearDownWindowFocusTracking(element);
160 }, {once: true});
161
162 hasSetupGlobalListeners.set(windowObject, {focus});
163}
164
165const tearDownWindowFocusTracking = (element, loadListener?: () => void) => {
166 const windowObject = getOwnerWindow(element);
167 const documentObject = getOwnerDocument(element);
168 if (loadListener) {
169 documentObject.removeEventListener('DOMContentLoaded', loadListener);
170 }
171 if (!hasSetupGlobalListeners.has(windowObject)) {
172 return;
173 }
174 windowObject.HTMLElement.prototype.focus = hasSetupGlobalListeners.get(windowObject)!.focus;
175
176 documentObject.removeEventListener('keydown', handleKeyboardEvent, true);
177 documentObject.removeEventListener('keyup', handleKeyboardEvent, true);
178 documentObject.removeEventListener('click', handleClickEvent, true);
179 windowObject.removeEventListener('focus', handleFocusEvent, true);
180 windowObject.removeEventListener('blur', handleWindowBlur, false);
181
182 if (typeof PointerEvent !== 'undefined') {
183 documentObject.removeEventListener('pointerdown', handlePointerEvent, true);
184 documentObject.removeEventListener('pointermove', handlePointerEvent, true);
185 documentObject.removeEventListener('pointerup', handlePointerEvent, true);
186 } else {
187 documentObject.removeEventListener('mousedown', handlePointerEvent, true);
188 documentObject.removeEventListener('mousemove', handlePointerEvent, true);
189 documentObject.removeEventListener('mouseup', handlePointerEvent, true);
190 }
191
192 hasSetupGlobalListeners.delete(windowObject);
193};
194
195/**
196 * EXPERIMENTAL
197 * Adds a window (i.e. iframe) to the list of windows that are being tracked for focus visible.
198 *
199 * Sometimes apps render portions of their tree into an iframe. In this case, we cannot accurately track if the focus
200 * is visible because we cannot see interactions inside the iframe. If you have this in your application's architecture,
201 * then this function will attach event listeners inside the iframe. You should call `addWindowFocusTracking` with an
202 * element from inside the window you wish to add. We'll retrieve the relevant elements based on that.
203 * Note, you do not need to call this for the default window, as we call it for you.
204 *
205 * When you are ready to stop listening, but you do not wish to unmount the iframe, you may call the cleanup function
206 * returned by `addWindowFocusTracking`. Otherwise, when you unmount the iframe, all listeners and state will be cleaned
207 * up automatically for you.
208 *
209 * @param element @default document.body - The element provided will be used to get the window to add.
210 * @returns A function to remove the event listeners and cleanup the state.
211 */
212export function addWindowFocusTracking(element?: HTMLElement | null): () => void {
213 const documentObject = getOwnerDocument(element);
214 let loadListener;
215 if (documentObject.readyState !== 'loading') {
216 setupGlobalFocusEvents(element);
217 } else {
218 loadListener = () => {
219 setupGlobalFocusEvents(element);
220 };
221 documentObject.addEventListener('DOMContentLoaded', loadListener);
222 }
223
224 return () => tearDownWindowFocusTracking(element, loadListener);
225}
226
227// Server-side rendering does not have the document object defined
228// eslint-disable-next-line no-restricted-globals
229if (typeof document !== 'undefined') {
230 addWindowFocusTracking();
231}
232
233/**
234 * If true, keyboard focus is visible.
235 */
236export function isFocusVisible(): boolean {
237 return currentModality !== 'pointer';
238}
239
240export function getInteractionModality(): Modality | null {
241 return currentModality;
242}
243
244export function setInteractionModality(modality: Modality) {
245 currentModality = modality;
246 triggerChangeHandlers(modality, null);
247}
248
249/**
250 * Keeps state of the current modality.
251 */
252export function useInteractionModality(): Modality | null {
253 setupGlobalFocusEvents();
254
255 let [modality, setModality] = useState(currentModality);
256 useEffect(() => {
257 let handler = () => {
258 setModality(currentModality);
259 };
260
261 changeHandlers.add(handler);
262 return () => {
263 changeHandlers.delete(handler);
264 };
265 }, []);
266
267 return useIsSSR() ? null : modality;
268}
269
270const nonTextInputTypes = new Set([
271 'checkbox',
272 'radio',
273 'range',
274 'color',
275 'file',
276 'image',
277 'button',
278 'submit',
279 'reset'
280]);
281
282/**
283 * If this is attached to text input component, return if the event is a focus event (Tab/Escape keys pressed) so that
284 * focus visible style can be properly set.
285 */
286function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) {
287 const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLInputElement : HTMLInputElement;
288 const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLTextAreaElement : HTMLTextAreaElement;
289 const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLElement : HTMLElement;
290 const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).KeyboardEvent : KeyboardEvent;
291
292 isTextInput = isTextInput ||
293 (e?.target instanceof IHTMLInputElement && !nonTextInputTypes.has(e?.target?.type)) ||
294 e?.target instanceof IHTMLTextAreaElement ||
295 (e?.target instanceof IHTMLElement && e?.target.isContentEditable);
296 return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]);
297}
298
299/**
300 * Manages focus visible state for the page, and subscribes individual components for updates.
301 */
302export function useFocusVisible(props: FocusVisibleProps = {}): FocusVisibleResult {
303 let {isTextInput, autoFocus} = props;
304 let [isFocusVisibleState, setFocusVisible] = useState(autoFocus || isFocusVisible());
305 useFocusVisibleListener((isFocusVisible) => {
306 setFocusVisible(isFocusVisible);
307 }, [isTextInput], {isTextInput});
308
309 return {isFocusVisible: isFocusVisibleState};
310}
311
312/**
313 * Listens for trigger change and reports if focus is visible (i.e., modality is not pointer).
314 */
315export function useFocusVisibleListener(fn: FocusVisibleHandler, deps: ReadonlyArray<any>, opts?: {isTextInput?: boolean}): void {
316 setupGlobalFocusEvents();
317
318 useEffect(() => {
319 let handler = (modality: Modality, e: HandlerEvent) => {
320 if (!isKeyboardFocusEvent(!!(opts?.isTextInput), modality, e)) {
321 return;
322 }
323 fn(isFocusVisible());
324 };
325 changeHandlers.add(handler);
326 return () => {
327 changeHandlers.delete(handler);
328 };
329 // eslint-disable-next-line react-hooks/exhaustive-deps
330 }, deps);
331}