Sync latest useFocus/useFocusWithin changes (#19397)
Dominic Gannaway committed
Jul 17, 2020 at 16:26 UTC
4c8c98ab9b669faf0280922932c51680ce6491bb
2 files changed
+148
-152
packages/react-interactions/events/src/dom/create-event-handle/Focus.js
+121
-152
@@ -10,25 +10,27 @@
10
import * as React from 'react';
11
import useEvent from './useEvent';
12
13
-const {useCallback, useEffect, useRef} = React;
13
+const {useCallback, useEffect, useLayoutEffect, useRef} = React;
14
15
-type UseFocusOptions = {|
15
+type FocusEvent = SyntheticEvent<EventTarget>;
16
+
17
+type UseFocusOptions = {
18
disabled?: boolean,
17
- onBlur?: (SyntheticEvent<EventTarget>) => void,
18
- onFocus?: (SyntheticEvent<EventTarget>) => void,
19
- onFocusChange?: boolean => void,
20
- onFocusVisibleChange?: boolean => void,
21
-|};
19
+ onBlur?: ?(FocusEvent) => void,
20
+ onFocus?: ?(FocusEvent) => void,
21
+ onFocusChange?: ?(boolean) => void,
22
+ onFocusVisibleChange?: ?(boolean) => void,
23
+};
24
23
-type UseFocusWithinOptions = {|
25
+type UseFocusWithinOptions = {
26
disabled?: boolean,
25
- onAfterBlurWithin?: (SyntheticEvent<EventTarget>) => void,
26
- onBeforeBlurWithin?: (SyntheticEvent<EventTarget>) => void,
27
- onBlurWithin?: (SyntheticEvent<EventTarget>) => void,
28
- onFocusWithin?: (SyntheticEvent<EventTarget>) => void,
27
+ onAfterBlurWithin?: FocusEvent => void,
28
+ onBeforeBlurWithin?: FocusEvent => void,
29
+ onBlurWithin?: FocusEvent => void,
30
+ onFocusWithin?: FocusEvent => void,
31
onFocusWithinChange?: boolean => void,
32
onFocusWithinVisibleChange?: boolean => void,
31
-|};
33
+};
34
35
const isMac =
36
typeof window !== 'undefined' && window.navigator != null
@@ -66,12 +68,22 @@ const hasPointerEvents =
68
69
const globalFocusVisibleEvents = hasPointerEvents
70
? ['keydown', 'pointermove', 'pointerdown', 'pointerup']
69
- : ['keydown', 'mousedown', 'touchmove', 'touchstart', 'touchend'];
71
+ : [
72
+ 'keydown',
73
+ 'mousedown',
74
+ 'mousemove',
75
+ 'mouseup',
76
+ 'touchmove',
77
+ 'touchstart',
78
+ 'touchend',
79
+ ];
80
+
81
+const passiveObject = {passive: true};
82
+const passiveObjectWithPriority = {passive: true, priority: 0};
83
84
// Global state for tracking focus visible and emulation of mouse
85
let isGlobalFocusVisible = true;
86
let hasTrackedGlobalFocusVisible = false;
74
-let isEmulatingMouseEvents = false;
87
88
function trackGlobalFocusVisible() {
89
globalFocusVisibleEvents.forEach(type => {
@@ -83,130 +95,76 @@ function trackGlobalFocusVisible() {
95
});
96
}
97
86
-function handleGlobalFocusVisibleEvent(
87
- nativeEvent: MouseEvent | TouchEvent | KeyboardEvent,
88
-): void {
89
- const {type} = nativeEvent;
90
-
91
- switch (type) {
92
- case 'pointermove':
93
- case 'pointerdown':
94
- case 'pointerup': {
95
- isGlobalFocusVisible = false;
96
- break;
97
- }
98
-
99
- case 'keydown': {
100
- const {metaKey, altKey, ctrlKey} = nativeEvent;
101
- const validKey = !(metaKey || (!isMac && altKey) || ctrlKey);
102
-
103
- if (validKey) {
104
- isGlobalFocusVisible = true;
105
- }
106
- break;
107
- }
98
+function isValidKey(nativeEvent: KeyboardEvent): boolean {
99
+ const {metaKey, altKey, ctrlKey} = nativeEvent;
100
+ return !(metaKey || (!isMac && altKey) || ctrlKey);
101
+}
102
109
- // fallbacks for no PointerEvent support
110
- case 'touchmove':
111
- case 'touchstart':
112
- case 'touchend': {
113
- isEmulatingMouseEvents = true;
114
- isGlobalFocusVisible = false;
115
- break;
116
- }
117
- case 'mousedown': {
118
- if (!isEmulatingMouseEvents) {
119
- isGlobalFocusVisible = false;
120
- } else {
121
- isEmulatingMouseEvents = false;
122
- }
123
- break;
124
- }
103
+function isTextInput(nativeEvent: KeyboardEvent): boolean {
104
+ const {key, target} = nativeEvent;
105
+ if (key === 'Tab' || key === 'Esacpe') {
106
+ return false;
107
}
108
+ const {isContentEditable, tagName} = (target: any);
109
+ return tagName === 'INPUT' || tagName === 'TEXTAREA' || isContentEditable;
110
}
111
128
-const passiveObject = {passive: true};
129
-
130
-function handleFocusVisibleTargetEvent(
131
- type: string,
132
- focusTarget: EventTarget,
133
- callback: boolean => void,
112
+function handleGlobalFocusVisibleEvent(
113
+ nativeEvent: MouseEvent | TouchEvent | KeyboardEvent,
114
): void {
135
- isGlobalFocusVisible = false;
136
-
137
- // Focus should stop being visible if a pointer is used on the element
138
- // after it was focused using a keyboard.
139
- if (
140
- focusTarget !== null &&
141
- (type === 'mousedown' || type === 'touchstart' || type === 'pointerdown')
142
- ) {
143
- callback(false);
115
+ if (nativeEvent.type === 'keydown') {
116
+ if (isValidKey(((nativeEvent: any): KeyboardEvent))) {
117
+ isGlobalFocusVisible = true;
118
+ }
119
+ } else {
120
+ const nodeName = (nativeEvent.target: any).nodeName;
121
+ // Safari calls mousemove/pointermove when you tab out of the active
122
+ // Safari frame.
123
+ if (nodeName === 'HTML') {
124
+ return;
125
+ }
126
+ // Handle all the other mouse/touch/pointer events
127
+ isGlobalFocusVisible = false;
128
}
129
}
130
131
function handleFocusVisibleTargetEvents(
132
event: SyntheticEvent<EventTarget>,
149
- focusTarget,
133
callback,
134
): void {
152
- const {type} = event;
153
-
154
- switch (type) {
155
- case 'pointermove':
156
- case 'pointerdown':
157
- case 'pointerup': {
158
- handleFocusVisibleTargetEvent(type, focusTarget, callback);
159
- break;
160
- }
161
-
162
- case 'keydown':
163
- case 'keyup': {
164
- const {metaKey, altKey, ctrlKey} = (event: any);
165
- const validKey = !(metaKey || (!isMac && altKey) || ctrlKey);
166
-
167
- if (validKey) {
168
- if (focusTarget !== null) {
169
- callback(true);
170
- }
171
- }
172
- break;
173
- }
174
-
175
- // fallbacks for no PointerEvent support
176
- case 'touchmove':
177
- case 'touchstart':
178
- case 'touchend': {
179
- handleFocusVisibleTargetEvent(type, focusTarget, callback);
180
- break;
181
- }
182
- case 'mousedown': {
183
- if (!isEmulatingMouseEvents) {
184
- handleFocusVisibleTargetEvent(type, focusTarget, callback);
185
- }
186
- break;
135
+ if (event.type === 'keydown') {
136
+ const {nativeEvent} = (event: any);
137
+ if (isValidKey(nativeEvent) && !isTextInput(nativeEvent)) {
138
+ callback(true);
139
}
140
+ } else {
141
+ callback(false);
142
}
143
}
144
145
function isRelatedTargetWithin(
192
- focusWithinTarget: Node,
193
- relatedTarget: null | Node,
146
+ focusWithinTarget: Object,
147
+ relatedTarget: null | EventTarget,
148
): boolean {
149
if (relatedTarget == null) {
150
return false;
151
}
198
- // To support experimental scopes, which can be the target:
199
- const containsNode = (focusWithinTarget: any).containsNode;
200
- if (typeof containsNode === 'function') {
201
- return containsNode(relatedTarget);
202
- }
203
- return focusWithinTarget.contains(relatedTarget);
152
+ // As the focusWithinTarget can be a Scope Instance (experimental API),
153
+ // we need to use the containsNode() method. Otherwise, focusWithinTarget
154
+ // must be a Node, which means we can use the contains() method.
155
+ return typeof focusWithinTarget.containsNode === 'function'
156
+ ? focusWithinTarget.containsNode(relatedTarget)
157
+ : focusWithinTarget.contains(relatedTarget);
158
}
159
206
-function setFocusVisibleListeners(focusVisibleHandles, focusTarget, callback) {
160
+function setFocusVisibleListeners(
161
+ focusVisibleHandles,
162
+ focusTarget: EventTarget,
163
+ callback,
164
+) {
165
focusVisibleHandles.forEach(focusVisibleHandle => {
166
focusVisibleHandle.setListener(focusTarget, event =>
209
- handleFocusVisibleTargetEvents(event, focusTarget, callback),
167
+ handleFocusVisibleTargetEvents(event, callback),
168
);
169
});
170
}
@@ -215,13 +173,11 @@ function useFocusVisibleInputHandles() {
173
return [
174
useEvent('mousedown', passiveObject),
175
useEvent(hasPointerEvents ? 'pointerdown' : 'touchstart', passiveObject),
218
- useEvent(hasPointerEvents ? 'pointermove' : 'touchmove', passiveObject),
219
- useEvent(hasPointerEvents ? 'pointerup' : 'touchend', passiveObject),
176
useEvent('keydown', passiveObject),
177
];
178
}
179
224
-function useFocusLifecycles(stateRef) {
180
+function useFocusLifecycles() {
181
useEffect(() => {
182
if (!hasTrackedGlobalFocusVisible) {
183
hasTrackedGlobalFocusVisible = true;
@@ -241,16 +197,18 @@ export function useFocus(
197
}: UseFocusOptions,
198
): void {
199
// Setup controlled state for this useFocus hook
244
- const stateRef = useRef({isFocused: false, isFocusVisible: false});
245
- const focusHandle = useEvent('focusin', passiveObject);
246
- const blurHandle = useEvent('focusout', passiveObject);
200
+ const stateRef = useRef<null | {isFocused: boolean, isFocusVisible: boolean}>(
201
+ {isFocused: false, isFocusVisible: false},
202
+ );
203
+ const focusHandle = useEvent('focusin', passiveObjectWithPriority);
204
+ const blurHandle = useEvent('focusout', passiveObjectWithPriority);
205
const focusVisibleHandles = useFocusVisibleInputHandles();
206
249
- useEffect(() => {
207
+ useLayoutEffect(() => {
208
const focusTarget = focusTargetRef.current;
209
const state = stateRef.current;
210
253
- if (focusTarget !== null && state !== null) {
211
+ if (focusTarget !== null && state !== null && focusTarget.nodeType === 1) {
212
// Handle focus visible
213
setFocusVisibleListeners(
214
focusVisibleHandles,
@@ -266,12 +224,10 @@ export function useFocus(
224
);
225
226
// Handle focus
269
- focusHandle.setListener(focusTarget, event => {
270
- if (disabled) {
227
+ focusHandle.setListener(focusTarget, (event: FocusEvent) => {
228
+ if (disabled === true) {
229
return;
230
}
273
- // Limit focus events to the direct child of the event component.
274
- // Browser focus is not expected to bubble.
231
if (!state.isFocused && focusTarget === event.target) {
232
state.isFocused = true;
233
state.isFocusVisible = isGlobalFocusVisible;
@@ -284,13 +240,12 @@ export function useFocus(
240
if (state.isFocusVisible && onFocusVisibleChange) {
241
onFocusVisibleChange(true);
242
}
287
- isEmulatingMouseEvents = false;
243
}
244
});
245
246
// Handle blur
292
- blurHandle.setListener(focusTarget, event => {
293
- if (disabled) {
247
+ blurHandle.setListener(focusTarget, (event: FocusEvent) => {
248
+ if (disabled === true) {
249
return;
250
}
251
if (state.isFocused) {
@@ -306,19 +261,28 @@ export function useFocus(
261
onFocusVisibleChange(false);
262
}
263
}
309
- isEmulatingMouseEvents = false;
264
});
265
}
312
- }, [disabled, onBlur, onFocus, onFocusChange, onFocusVisibleChange]);
266
+ }, [
267
+ blurHandle,
268
+ disabled,
269
+ focusHandle,
270
+ focusTargetRef,
271
+ focusVisibleHandles,
272
+ onBlur,
273
+ onFocus,
274
+ onFocusChange,
275
+ onFocusVisibleChange,
276
+ ]);
277
278
// Mount/Unmount logic
315
- useFocusLifecycles(stateRef);
279
+ useFocusLifecycles();
280
}
281
318
-export function useFocusWithin(
282
+export function useFocusWithin<T>(
283
focusWithinTargetRef:
320
- | {current: null | Node}
321
- | ((focusWithinTarget: null | Node) => void),
284
+ | {current: null | T}
285
+ | ((focusWithinTarget: null | T) => void),
286
{
287
disabled,
288
onAfterBlurWithin,
@@ -328,19 +292,19 @@ export function useFocusWithin(
292
onFocusWithinChange,
293
onFocusWithinVisibleChange,
294
}: UseFocusWithinOptions,
331
-): (focusWithinTarget: null | Node) => void {
295
+): (focusWithinTarget: null | T) => void {
296
// Setup controlled state for this useFocus hook
297
const stateRef = useRef<null | {isFocused: boolean, isFocusVisible: boolean}>(
298
{isFocused: false, isFocusVisible: false},
299
);
336
- const focusHandle = useEvent('focusin', passiveObject);
337
- const blurHandle = useEvent('focusout', passiveObject);
300
+ const focusHandle = useEvent('focusin', passiveObjectWithPriority);
301
+ const blurHandle = useEvent('focusout', passiveObjectWithPriority);
302
const afterBlurHandle = useEvent('afterblur', passiveObject);
303
const beforeBlurHandle = useEvent('beforeblur', passiveObject);
304
const focusVisibleHandles = useFocusVisibleInputHandles();
305
306
const useFocusWithinRef = useCallback(
343
- (focusWithinTarget: null | Node) => {
307
+ (focusWithinTarget: null | T) => {
308
// Handle the incoming focusTargetRef. It can be either a function ref
309
// or an object ref.
310
if (typeof focusWithinTargetRef === 'function') {
@@ -354,6 +318,7 @@ export function useFocusWithin(
318
// Handle focus visible
319
setFocusVisibleListeners(
320
focusVisibleHandles,
321
+ // $FlowFixMe focusWithinTarget is not null here
322
focusWithinTarget,
323
isFocusVisible => {
324
if (state.isFocused && state.isFocusVisible !== isFocusVisible) {
@@ -366,7 +331,8 @@ export function useFocusWithin(
331
);
332
333
// Handle focus
369
- focusHandle.setListener(focusWithinTarget, event => {
334
+ // $FlowFixMe focusWithinTarget is not null here
335
+ focusHandle.setListener(focusWithinTarget, (event: FocusEvent) => {
336
if (disabled) {
337
return;
338
}
@@ -392,15 +358,15 @@ export function useFocusWithin(
358
});
359
360
// Handle blur
395
- blurHandle.setListener(focusWithinTarget, event => {
361
+ // $FlowFixMe focusWithinTarget is not null here
362
+ blurHandle.setListener(focusWithinTarget, (event: FocusEvent) => {
363
if (disabled) {
364
return;
365
}
399
- const {relatedTarget} = (event: any);
366
+ const {relatedTarget} = (event.nativeEvent: any);
367
368
if (
369
state.isFocused &&
403
- // $FlowFixMe: focusWithinTarget is never null
370
!isRelatedTargetWithin(focusWithinTarget, relatedTarget)
371
) {
372
state.isFocused = false;
@@ -418,7 +384,8 @@ export function useFocusWithin(
384
385
// Handle before blur. This is a special
386
// React provided event.
421
- beforeBlurHandle.setListener(focusWithinTarget, event => {
387
+ // $FlowFixMe focusWithinTarget is not null here
388
+ beforeBlurHandle.setListener(focusWithinTarget, (event: FocusEvent) => {
389
if (disabled) {
390
return;
391
}
@@ -426,13 +393,16 @@ export function useFocusWithin(
393
onBeforeBlurWithin(event);
394
// Add an "afterblur" listener on document. This is a special
395
// React provided event.
429
- afterBlurHandle.setListener(document, afterBlurEvent => {
430
- if (onAfterBlurWithin) {
431
- onAfterBlurWithin(afterBlurEvent);
432
- }
433
- // Clear listener on document
434
- afterBlurHandle.setListener(document, null);
435
- });
396
+ afterBlurHandle.setListener(
397
+ document,
398
+ (afterBlurEvent: FocusEvent) => {
399
+ if (onAfterBlurWithin) {
400
+ onAfterBlurWithin(afterBlurEvent);
401
+ }
402
+ // Clear listener on document
403
+ afterBlurHandle.setListener(document, null);
404
+ },
405
+ );
406
}
407
});
408
}
@@ -443,7 +413,6 @@ export function useFocusWithin(
413
blurHandle,
414
disabled,
415
focusHandle,
446
- focusVisibleHandles,
416
focusWithinTargetRef,
417
onAfterBlurWithin,
418
onBeforeBlurWithin,
packages/react-interactions/events/src/dom/create-event-handle/__tests__/useFocusWithin-test.internal.js
+27
@@ -297,6 +297,33 @@ describe.each(table)(`useFocus`, hasPointerEvents => {
297
});
298
});
299
300
+ // @gate experimental
301
+ it('should correctly handle focus visibility when typing into an input', () => {
302
+ const onFocusWithinVisibleChange = jest.fn();
303
+ const ref = React.createRef();
304
+ const inputRef = React.createRef();
305
+ const Component = () => {
306
+ const focusWithinRef = useFocusWithin(ref, {
307
+ onFocusWithinVisibleChange,
308
+ });
309
+ return (
310
+ <div ref={focusWithinRef}>
311
+ <input ref={inputRef} type="text" />
312
+ </div>
313
+ );
314
+ };
315
+ act(() => {
316
+ ReactDOM.render(<Component />, container);
317
+ });
318
+
319
+ const target = createEventTarget(inputRef.current);
320
+ // focus the target
321
+ target.pointerdown();
322
+ target.focus();
323
+ target.keydown({key: 'a'});
324
+ expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(0);
325
+ });
326
+
327
describe('onBeforeBlurWithin', () => {
328
let onBeforeBlurWithin, onAfterBlurWithin, ref, innerRef, innerRef2;
329