1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 |
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 | import {getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils';
|
19 | import {useEffect, useState} from 'react';
|
20 | import {useIsSSR} from '@react-aria/ssr';
|
21 |
|
22 | export type Modality = 'keyboard' | 'pointer' | 'virtual';
|
23 | type HandlerEvent = PointerEvent | MouseEvent | KeyboardEvent | FocusEvent | null;
|
24 | type Handler = (modality: Modality, e: HandlerEvent) => void;
|
25 | export type FocusVisibleHandler = (isFocusVisible: boolean) => void;
|
26 | export interface FocusVisibleProps {
|
27 |
|
28 | isTextInput?: boolean,
|
29 |
|
30 | autoFocus?: boolean
|
31 | }
|
32 |
|
33 | export interface FocusVisibleResult {
|
34 |
|
35 | isFocusVisible: boolean
|
36 | }
|
37 |
|
38 | let currentModality: null | Modality = null;
|
39 | let changeHandlers = new Set<Handler>();
|
40 | interface GlobalListenerData {
|
41 | focus: () => void
|
42 | }
|
43 | export let hasSetupGlobalListeners = new Map<Window, GlobalListenerData>();
|
44 | let hasEventBeforeFocus = false;
|
45 | let hasBlurredWindowRecently = false;
|
46 |
|
47 |
|
48 | const FOCUS_VISIBLE_INPUT_KEYS = {
|
49 | Tab: true,
|
50 | Escape: true
|
51 | };
|
52 |
|
53 | function triggerChangeHandlers(modality: Modality, e: HandlerEvent) {
|
54 | for (let handler of changeHandlers) {
|
55 | handler(modality, e);
|
56 | }
|
57 | }
|
58 |
|
59 |
|
60 |
|
61 |
|
62 | function isValidKey(e: KeyboardEvent) {
|
63 |
|
64 | return !(e.metaKey || (!isMac() && e.altKey) || e.ctrlKey || e.key === 'Control' || e.key === 'Shift' || e.key === 'Meta');
|
65 | }
|
66 |
|
67 |
|
68 | function handleKeyboardEvent(e: KeyboardEvent) {
|
69 | hasEventBeforeFocus = true;
|
70 | if (isValidKey(e)) {
|
71 | currentModality = 'keyboard';
|
72 | triggerChangeHandlers('keyboard', e);
|
73 | }
|
74 | }
|
75 |
|
76 | function 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 |
|
84 | function handleClickEvent(e: MouseEvent) {
|
85 | if (isVirtualClick(e)) {
|
86 | hasEventBeforeFocus = true;
|
87 | currentModality = 'virtual';
|
88 | }
|
89 | }
|
90 |
|
91 | function handleFocusEvent(e: FocusEvent) {
|
92 |
|
93 |
|
94 |
|
95 | if (e.target === window || e.target === document) {
|
96 | return;
|
97 | }
|
98 |
|
99 |
|
100 |
|
101 | if (!hasEventBeforeFocus && !hasBlurredWindowRecently) {
|
102 | currentModality = 'virtual';
|
103 | triggerChangeHandlers('virtual', e);
|
104 | }
|
105 |
|
106 | hasEventBeforeFocus = false;
|
107 | hasBlurredWindowRecently = false;
|
108 | }
|
109 |
|
110 | function handleWindowBlur() {
|
111 |
|
112 |
|
113 | hasEventBeforeFocus = false;
|
114 | hasBlurredWindowRecently = true;
|
115 | }
|
116 |
|
117 |
|
118 |
|
119 |
|
120 | function 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 |
|
129 |
|
130 |
|
131 |
|
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 |
|
143 |
|
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 |
|
158 | windowObject.addEventListener('beforeunload', () => {
|
159 | tearDownWindowFocusTracking(element);
|
160 | }, {once: true});
|
161 |
|
162 | hasSetupGlobalListeners.set(windowObject, {focus});
|
163 | }
|
164 |
|
165 | const 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 |
|
197 |
|
198 |
|
199 |
|
200 |
|
201 |
|
202 |
|
203 |
|
204 |
|
205 |
|
206 |
|
207 |
|
208 |
|
209 |
|
210 |
|
211 |
|
212 | export 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 |
|
228 |
|
229 | if (typeof document !== 'undefined') {
|
230 | addWindowFocusTracking();
|
231 | }
|
232 |
|
233 |
|
234 |
|
235 |
|
236 | export function isFocusVisible(): boolean {
|
237 | return currentModality !== 'pointer';
|
238 | }
|
239 |
|
240 | export function getInteractionModality(): Modality | null {
|
241 | return currentModality;
|
242 | }
|
243 |
|
244 | export function setInteractionModality(modality: Modality) {
|
245 | currentModality = modality;
|
246 | triggerChangeHandlers(modality, null);
|
247 | }
|
248 |
|
249 |
|
250 |
|
251 |
|
252 | export 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 |
|
270 | const 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 |
|
284 |
|
285 |
|
286 | function 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 |
|
301 |
|
302 | export 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 |
|
314 |
|
315 | export 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 |
|
330 | }, deps);
|
331 | }
|