@samitouri / QOS-React / commits / 4c8c98ab9b

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