UNPKG

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