UNPKG

8.63 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 {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>();
40let hasSetupGlobalListeners = false;
41let hasEventBeforeFocus = false;
42let hasBlurredWindowRecently = false;
43
44// Only Tab or Esc keys will make focus visible on text input elements
45const FOCUS_VISIBLE_INPUT_KEYS = {
46 Tab: true,
47 Escape: true
48};
49
50function triggerChangeHandlers(modality: Modality, e: HandlerEvent) {
51 for (let handler of changeHandlers) {
52 handler(modality, e);
53 }
54}
55
56/**
57 * Helper function to determine if a KeyboardEvent is unmodified and could make keyboard focus styles visible.
58 */
59function isValidKey(e: KeyboardEvent) {
60 // Control and Shift keys trigger when navigating back to the tab with keyboard.
61 return !(e.metaKey || (!isMac() && e.altKey) || e.ctrlKey || e.key === 'Control' || e.key === 'Shift' || e.key === 'Meta');
62}
63
64
65function handleKeyboardEvent(e: KeyboardEvent) {
66 hasEventBeforeFocus = true;
67 if (isValidKey(e)) {
68 currentModality = 'keyboard';
69 triggerChangeHandlers('keyboard', e);
70 }
71}
72
73function handlePointerEvent(e: PointerEvent | MouseEvent) {
74 currentModality = 'pointer';
75 if (e.type === 'mousedown' || e.type === 'pointerdown') {
76 hasEventBeforeFocus = true;
77 triggerChangeHandlers('pointer', e);
78 }
79}
80
81function handleClickEvent(e: MouseEvent) {
82 if (isVirtualClick(e)) {
83 hasEventBeforeFocus = true;
84 currentModality = 'virtual';
85 }
86}
87
88function handleFocusEvent(e: FocusEvent) {
89 // Firefox fires two extra focus events when the user first clicks into an iframe:
90 // first on the window, then on the document. We ignore these events so they don't
91 // cause keyboard focus rings to appear.
92 if (e.target === window || e.target === document) {
93 return;
94 }
95
96 // If a focus event occurs without a preceding keyboard or pointer event, switch to virtual modality.
97 // This occurs, for example, when navigating a form with the next/previous buttons on iOS.
98 if (!hasEventBeforeFocus && !hasBlurredWindowRecently) {
99 currentModality = 'virtual';
100 triggerChangeHandlers('virtual', e);
101 }
102
103 hasEventBeforeFocus = false;
104 hasBlurredWindowRecently = false;
105}
106
107function handleWindowBlur() {
108 // When the window is blurred, reset state. This is necessary when tabbing out of the window,
109 // for example, since a subsequent focus event won't be fired.
110 hasEventBeforeFocus = false;
111 hasBlurredWindowRecently = true;
112}
113
114/**
115 * Setup global event listeners to control when keyboard focus style should be visible.
116 */
117function setupGlobalFocusEvents() {
118 if (typeof window === 'undefined' || hasSetupGlobalListeners) {
119 return;
120 }
121
122 // Programmatic focus() calls shouldn't affect the current input modality.
123 // However, we need to detect other cases when a focus event occurs without
124 // a preceding user event (e.g. screen reader focus). Overriding the focus
125 // method on HTMLElement.prototype is a bit hacky, but works.
126 let focus = HTMLElement.prototype.focus;
127 HTMLElement.prototype.focus = function () {
128 hasEventBeforeFocus = true;
129 focus.apply(this, arguments as unknown as [options?: FocusOptions | undefined]);
130 };
131
132 document.addEventListener('keydown', handleKeyboardEvent, true);
133 document.addEventListener('keyup', handleKeyboardEvent, true);
134 document.addEventListener('click', handleClickEvent, true);
135
136 // Register focus events on the window so they are sure to happen
137 // before React's event listeners (registered on the document).
138 window.addEventListener('focus', handleFocusEvent, true);
139 window.addEventListener('blur', handleWindowBlur, false);
140
141 if (typeof PointerEvent !== 'undefined') {
142 document.addEventListener('pointerdown', handlePointerEvent, true);
143 document.addEventListener('pointermove', handlePointerEvent, true);
144 document.addEventListener('pointerup', handlePointerEvent, true);
145 } else {
146 document.addEventListener('mousedown', handlePointerEvent, true);
147 document.addEventListener('mousemove', handlePointerEvent, true);
148 document.addEventListener('mouseup', handlePointerEvent, true);
149 }
150
151 hasSetupGlobalListeners = true;
152}
153
154if (typeof document !== 'undefined') {
155 if (document.readyState !== 'loading') {
156 setupGlobalFocusEvents();
157 } else {
158 document.addEventListener('DOMContentLoaded', setupGlobalFocusEvents);
159 }
160}
161
162/**
163 * If true, keyboard focus is visible.
164 */
165export function isFocusVisible(): boolean {
166 return currentModality !== 'pointer';
167}
168
169export function getInteractionModality(): Modality | null {
170 return currentModality;
171}
172
173export function setInteractionModality(modality: Modality) {
174 currentModality = modality;
175 triggerChangeHandlers(modality, null);
176}
177
178/**
179 * Keeps state of the current modality.
180 */
181export function useInteractionModality(): Modality | null {
182 setupGlobalFocusEvents();
183
184 let [modality, setModality] = useState(currentModality);
185 useEffect(() => {
186 let handler = () => {
187 setModality(currentModality);
188 };
189
190 changeHandlers.add(handler);
191 return () => {
192 changeHandlers.delete(handler);
193 };
194 }, []);
195
196 return useIsSSR() ? null : modality;
197}
198
199const nonTextInputTypes = new Set([
200 'checkbox',
201 'radio',
202 'range',
203 'color',
204 'file',
205 'image',
206 'button',
207 'submit',
208 'reset'
209]);
210
211/**
212 * If this is attached to text input component, return if the event is a focus event (Tab/Escape keys pressed) so that
213 * focus visible style can be properly set.
214 */
215function 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]);
221}
222
223/**
224 * Manages focus visible state for the page, and subscribes individual components for updates.
225 */
226export function useFocusVisible(props: FocusVisibleProps = {}): FocusVisibleResult {
227 let {isTextInput, autoFocus} = props;
228 let [isFocusVisibleState, setFocusVisible] = useState(autoFocus || isFocusVisible());
229 useFocusVisibleListener((isFocusVisible) => {
230 setFocusVisible(isFocusVisible);
231 }, [isTextInput], {isTextInput});
232
233 return {isFocusVisible: isFocusVisibleState};
234}
235
236/**
237 * Listens for trigger change and reports if focus is visible (i.e., modality is not pointer).
238 */
239export function useFocusVisibleListener(fn: FocusVisibleHandler, deps: ReadonlyArray<any>, opts?: {isTextInput?: boolean}): void {
240 setupGlobalFocusEvents();
241
242 useEffect(() => {
243 let handler = (modality: Modality, e: HandlerEvent) => {
244 if (!isKeyboardFocusEvent(!!(opts?.isTextInput), modality, e)) {
245 return;
246 }
247 fn(isFocusVisible());
248 };
249 changeHandlers.add(handler);
250 return () => {
251 changeHandlers.delete(handler);
252 };
253 // eslint-disable-next-line react-hooks/exhaustive-deps
254 }, deps);
255}