[react-interactions] Refine custom active element blur logic (#17354)
Dominic Gannaway committed
Nov 13, 2019 at 20:46 UTC
a61886b16b5183d63b85dbd92e5808f82be93208
10 files changed
+178
-63
packages/react-art/src/ReactARTHostConfig.js
+4
@@ -465,3 +465,7 @@ export function unmountFundamentalComponent(fundamentalInstance) {
465
export function getInstanceFromNode(node) {
466
throw new Error('Not yet implemented.');
467
}
468
+
469
+export function beforeRemoveInstance(instance) {
470
+ // noop
471
+}
packages/react-dom/src/client/ReactDOMHostConfig.js
+49
-18
@@ -104,6 +104,12 @@ export type ChildSet = void; // Unused
104
export type TimeoutHandle = TimeoutID;
105
export type NoTimeout = -1;
106
107
+type SelectionInformation = {|
108
+ blurredActiveElement: null | HTMLElement,
109
+ focusedElem: null | HTMLElement,
110
+ selectionRange: mixed,
111
+|};
112
+
113
import {
114
enableSuspenseServerRenderer,
115
enableFlareAPI,
@@ -127,7 +133,7 @@ const SUSPENSE_FALLBACK_START_DATA = '$!';
133
const STYLE = 'style';
134
135
let eventsEnabled: ?boolean = null;
130
-let selectionInformation: ?mixed = null;
136
+let selectionInformation: null | SelectionInformation = null;
137
138
function shouldAutoFocusHostComponent(type: string, props: Props): boolean {
139
switch (type) {
@@ -205,6 +211,13 @@ export function prepareForCommit(containerInfo: Container): void {
211
212
export function resetAfterCommit(containerInfo: Container): void {
213
restoreSelection(selectionInformation);
214
+ if (enableFlareAPI) {
215
+ const blurredActiveElement = (selectionInformation: any)
216
+ .blurredActiveElement;
217
+ if (blurredActiveElement !== null) {
218
+ dispatchActiveElementBlur(blurredActiveElement);
219
+ }
220
+ }
221
selectionInformation = null;
222
ReactBrowserEventEmitterSetEnabled(eventsEnabled);
223
eventsEnabled = null;
@@ -452,30 +465,50 @@ export function insertInContainerBefore(
465
}
466
}
467
468
+function dispatchFlareDetachedBlurEvent(
469
+ elementDetached: boolean,
470
+ targetInstance: null | Object,
471
+ target: Element | Document,
472
+): void {
473
+ // Simlulate the custom event to the React Flare responder system.
474
+ dispatchEventForResponderEventSystem(
475
+ 'blur',
476
+ targetInstance,
477
+ ({
478
+ elementDetached,
479
+ target,
480
+ timeStamp: Date.now(),
481
+ }: any),
482
+ target,
483
+ RESPONDER_EVENT_SYSTEM | IS_PASSIVE,
484
+ );
485
+}
486
+
487
+function dispatchBeforeActiveElementBlur(element: HTMLElement): void {
488
+ const targtInstance = getClosestInstanceFromNode(element);
489
+ ((selectionInformation: any): SelectionInformation).blurredActiveElement = element;
490
+ dispatchFlareDetachedBlurEvent(false, targtInstance, element);
491
+}
492
+
493
+function dispatchActiveElementBlur(
494
+ node: Instance | TextInstance | SuspenseInstance,
495
+): void {
496
+ dispatchFlareDetachedBlurEvent(true, null, ((node: any): HTMLElement));
497
+}
498
+
499
// This is a specific event for the React Flare
500
// event system, so event responders can act
501
// accordingly to a DOM node being unmounted that
502
// previously had active document focus.
459
-function dispatchDetachedVisibleNodeEvent(
460
- child: Instance | TextInstance | SuspenseInstance,
503
+export function beforeRemoveInstance(
504
+ instance: Instance | TextInstance | SuspenseInstance,
505
): void {
506
if (
507
enableFlareAPI &&
508
selectionInformation &&
465
- child === selectionInformation.focusedElem
509
+ instance === selectionInformation.focusedElem
510
) {
467
- const targetFiber = getClosestInstanceFromNode(child);
468
- // Simlulate a blur event to the React Flare responder system.
469
- dispatchEventForResponderEventSystem(
470
- 'detachedvisiblenode',
471
- targetFiber,
472
- ({
473
- target: child,
474
- timeStamp: Date.now(),
475
- }: any),
476
- ((child: any): Document | Element),
477
- RESPONDER_EVENT_SYSTEM | IS_PASSIVE,
478
- );
511
+ dispatchBeforeActiveElementBlur(((instance: any): HTMLElement));
512
}
513
}
514
@@ -483,7 +516,6 @@ export function removeChild(
516
parentInstance: Instance,
517
child: Instance | TextInstance | SuspenseInstance,
518
): void {
486
- dispatchDetachedVisibleNodeEvent(child);
519
parentInstance.removeChild(child);
520
}
521
@@ -494,7 +526,6 @@ export function removeChildFromContainer(
526
if (container.nodeType === COMMENT_NODE) {
527
(container.parentNode: any).removeChild(child);
528
} else {
497
- dispatchDetachedVisibleNodeEvent(child);
529
container.removeChild(child);
530
}
531
}
packages/react-dom/src/client/ReactInputSelection.js
+2
@@ -100,6 +100,8 @@ export function hasSelectionCapabilities(elem) {
100
export function getSelectionInformation() {
101
const focusedElem = getActiveElementDeep();
102
return {
103
+ // Used by Flare
104
+ blurredActiveElement: null,
105
focusedElem: focusedElem,
106
selectionRange: hasSelectionCapabilities(focusedElem)
107
? getSelection(focusedElem)
packages/react-interactions/events/src/dom/Focus.js
+50
-27
@@ -45,12 +45,7 @@ type FocusProps = {
45
onFocusVisibleChange: boolean => void,
46
};
47
48
-type FocusEventType =
49
- | 'focus'
50
- | 'blur'
51
- | 'focuschange'
52
- | 'focusvisiblechange'
53
- | 'detachedvisiblenode';
48
+type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange';
49
50
type FocusWithinProps = {
51
disabled?: boolean,
@@ -58,7 +53,8 @@ type FocusWithinProps = {
53
onBlurWithin?: (e: FocusEvent) => void,
54
onFocusWithinChange?: boolean => void,
55
onFocusWithinVisibleChange?: boolean => void,
61
- onDetachedVisibleNode?: (e: FocusEvent) => void,
56
+ onBeforeFocusedElementDetached?: (e: FocusEvent) => void,
57
+ onFocusedElementDetached?: (e: FocusEvent) => void,
58
};
59
60
type FocusWithinEventType =
@@ -66,7 +62,8 @@ type FocusWithinEventType =
62
| 'focuswithinchange'
63
| 'blurwithin'
64
| 'focuswithin'
69
- | 'detachedvisiblenode';
65
+ | 'focusedelementdetached'
66
+ | 'beforefocusedelementdetached';
67
68
/**
69
* Shared between Focus and FocusWithin
@@ -79,14 +76,22 @@ const isMac =
76
? /^Mac/.test(window.navigator.platform)
77
: false;
78
82
-const targetEventTypes = ['focus', 'blur', 'detachedvisiblenode'];
79
+const targetEventTypes = ['focus', 'blur'];
80
81
const hasPointerEvents =
82
typeof window !== 'undefined' && window.PointerEvent != null;
83
84
const rootEventTypes = hasPointerEvents
88
- ? ['keydown', 'keyup', 'pointermove', 'pointerdown', 'pointerup']
89
- : ['keydown', 'keyup', 'mousedown', 'touchmove', 'touchstart', 'touchend'];
85
+ ? ['keydown', 'keyup', 'pointermove', 'pointerdown', 'pointerup', 'blur']
86
+ : [
87
+ 'keydown',
88
+ 'keyup',
89
+ 'mousedown',
90
+ 'touchmove',
91
+ 'touchstart',
92
+ 'touchend',
93
+ 'blur',
94
+ ];
95
96
function isFunction(obj): boolean {
97
return typeof obj === 'function';
@@ -504,6 +509,23 @@ const focusWithinResponderImpl = {
509
break;
510
}
511
case 'blur': {
512
+ if ((nativeEvent: any).elementDetached === false) {
513
+ const onBeforeFocusedElementDetached = (props.onBeforeFocusedElementDetached: any);
514
+ if (isFunction(onBeforeFocusedElementDetached)) {
515
+ const syntheticEvent = createFocusEvent(
516
+ context,
517
+ 'beforefocusedelementdetached',
518
+ event.target,
519
+ state.pointerType,
520
+ );
521
+ context.dispatchEvent(
522
+ syntheticEvent,
523
+ onBeforeFocusedElementDetached,
524
+ DiscreteEvent,
525
+ );
526
+ }
527
+ return;
528
+ }
529
if (
530
state.isFocused &&
531
!context.isTargetWithinResponder(relatedTarget)
@@ -514,22 +536,6 @@ const focusWithinResponderImpl = {
536
}
537
break;
538
}
517
- case 'detachedvisiblenode': {
518
- const onDetachedVisibleNode = (props.onDetachedVisibleNode: any);
519
- if (isFunction(onDetachedVisibleNode)) {
520
- const syntheticEvent = createFocusEvent(
521
- context,
522
- 'detachedvisiblenode',
523
- event.target,
524
- state.pointerType,
525
- );
526
- context.dispatchEvent(
527
- syntheticEvent,
528
- onDetachedVisibleNode,
529
- DiscreteEvent,
530
- );
531
- }
532
- }
539
}
540
},
541
onRootEvent(
@@ -538,6 +544,23 @@ const focusWithinResponderImpl = {
544
props: FocusWithinProps,
545
state: FocusState,
546
): void {
547
+ if ((event.nativeEvent: any).elementDetached === true) {
548
+ const onFocusedElementDetached = (props.onFocusedElementDetached: any);
549
+ if (isFunction(onFocusedElementDetached)) {
550
+ const syntheticEvent = createFocusEvent(
551
+ context,
552
+ 'focusedelementdetached',
553
+ event.target,
554
+ state.pointerType,
555
+ );
556
+ context.dispatchEvent(
557
+ syntheticEvent,
558
+ onFocusedElementDetached,
559
+ DiscreteEvent,
560
+ );
561
+ }
562
+ return;
563
+ }
564
handleRootEvent(event, context, state, isFocusVisible => {
565
if (state.isFocused && state.isFocusVisible !== isFocusVisible) {
566
state.isFocusVisible = isFocusVisible;
packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js
+58
-18
@@ -262,37 +262,77 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => {
262
});
263
});
264
265
- describe('onDetachedVisibleNode', () => {
266
- let onDetachedVisibleNode, ref, innerRef, innerRef2;
267
-
268
- const Component = ({show}) => {
269
- const listener = useFocusWithin({
270
- onDetachedVisibleNode,
271
- });
272
- return (
273
- <div ref={ref} listeners={listener}>
274
- {show && <input ref={innerRef} />}
275
- <div ref={innerRef2} />
276
- </div>
277
- );
278
- };
265
+ describe('onBeforeFocusedElementDetached/onFocusedElementDetached', () => {
266
+ let onBeforeFocusedElementDetached,
267
+ onFocusedElementDetached,
268
+ ref,
269
+ innerRef,
270
+ innerRef2;
271
272
beforeEach(() => {
281
- onDetachedVisibleNode = jest.fn();
273
+ onBeforeFocusedElementDetached = jest.fn();
274
+ onFocusedElementDetached = jest.fn();
275
ref = React.createRef();
276
innerRef = React.createRef();
277
innerRef2 = React.createRef();
285
- ReactDOM.render(<Component show={true} />, container);
278
});
279
280
it('is called after a focused element is unmounted', () => {
281
+ const Component = ({show}) => {
282
+ const listener = useFocusWithin({
283
+ onBeforeFocusedElementDetached,
284
+ onFocusedElementDetached,
285
+ });
286
+ return (
287
+ <div ref={ref} listeners={listener}>
288
+ {show && <input ref={innerRef} />}
289
+ <div ref={innerRef2} />
290
+ </div>
291
+ );
292
+ };
293
+
294
+ ReactDOM.render(<Component show={true} />, container);
295
+
296
+ const inner = innerRef.current;
297
+ const target = createEventTarget(inner);
298
+ target.keydown({key: 'Tab'});
299
+ target.focus();
300
+ expect(onBeforeFocusedElementDetached).toHaveBeenCalledTimes(0);
301
+ expect(onFocusedElementDetached).toHaveBeenCalledTimes(0);
302
+ ReactDOM.render(<Component show={false} />, container);
303
+ expect(onBeforeFocusedElementDetached).toHaveBeenCalledTimes(1);
304
+ expect(onFocusedElementDetached).toHaveBeenCalledTimes(1);
305
+ });
306
+
307
+ it('is called after a nested focused element is unmounted', () => {
308
+ const Component = ({show}) => {
309
+ const listener = useFocusWithin({
310
+ onBeforeFocusedElementDetached,
311
+ onFocusedElementDetached,
312
+ });
313
+ return (
314
+ <div ref={ref} listeners={listener}>
315
+ {show && (
316
+ <div>
317
+ <input ref={innerRef} />
318
+ </div>
319
+ )}
320
+ <div ref={innerRef2} />
321
+ </div>
322
+ );
323
+ };
324
+
325
+ ReactDOM.render(<Component show={true} />, container);
326
+
327
const inner = innerRef.current;
328
const target = createEventTarget(inner);
329
target.keydown({key: 'Tab'});
330
target.focus();
293
- expect(onDetachedVisibleNode).toHaveBeenCalledTimes(0);
331
+ expect(onBeforeFocusedElementDetached).toHaveBeenCalledTimes(0);
332
+ expect(onFocusedElementDetached).toHaveBeenCalledTimes(0);
333
ReactDOM.render(<Component show={false} />, container);
295
- expect(onDetachedVisibleNode).toHaveBeenCalledTimes(1);
334
+ expect(onBeforeFocusedElementDetached).toHaveBeenCalledTimes(1);
335
+ expect(onFocusedElementDetached).toHaveBeenCalledTimes(1);
336
});
337
});
338
packages/react-native-renderer/src/ReactFabricHostConfig.js
+4
@@ -495,3 +495,7 @@ export function cloneFundamentalInstance(fundamentalInstance) {
495
export function getInstanceFromNode(node) {
496
throw new Error('Not yet implemented.');
497
}
498
+
499
+export function beforeRemoveInstance(instance) {
500
+ // noop
501
+}
packages/react-native-renderer/src/ReactNativeHostConfig.js
+4
@@ -534,3 +534,7 @@ export function unmountFundamentalComponent(fundamentalInstance) {
534
export function getInstanceFromNode(node) {
535
throw new Error('Not yet implemented.');
536
}
537
+
538
+export function beforeRemoveInstance(instance) {
539
+ // noop
540
+}
packages/react-reconciler/src/ReactFiberCommitWork.js
+2
@@ -105,6 +105,7 @@ import {
105
updateFundamentalComponent,
106
commitHydratedContainer,
107
commitHydratedSuspenseInstance,
108
+ beforeRemoveInstance,
109
} from './ReactFiberHostConfig';
110
import {
111
captureCommitPhaseError,
@@ -808,6 +809,7 @@ function commitUnmount(
809
dependencies.responders = null;
810
}
811
}
812
+ beforeRemoveInstance(current.stateNode);
813
}
814
safelyDetachRef(current);
815
return;
packages/react-reconciler/src/forks/ReactFiberHostConfig.custom.js
+1
@@ -70,6 +70,7 @@ export const mountFundamentalComponent =
70
export const shouldUpdateFundamentalComponent =
71
$$$hostConfig.shouldUpdateFundamentalComponent;
72
export const getInstanceFromNode = $$$hostConfig.getInstanceFromNode;
73
+export const beforeRemoveInstance = $$$hostConfig.beforeRemoveInstance;
74
75
// -------------------
76
// Mutation
packages/react-test-renderer/src/ReactTestHostConfig.js
+4
@@ -367,3 +367,7 @@ export function getInstanceFromNode(mockNode: Object) {
367
}
368
return null;
369
}
370
+
371
+export function beforeRemoveInstance(instance) {
372
+ // noop
373
+}