From 405c85208c2218981c3288c4598cefa34bffeb2e Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sun, 9 Jun 2019 15:32:03 -0700 Subject: [PATCH 1/4] [Flare] Add Focus within prop --- packages/react-events/docs/Focus.md | 7 ++- packages/react-events/src/Focus.js | 9 +-- .../src/__tests__/Focus-test.internal.js | 62 +++++++++++++++++++ 3 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/react-events/docs/Focus.md b/packages/react-events/docs/Focus.md index 1fef78e370f..9d71eddaf71 100644 --- a/packages/react-events/docs/Focus.md +++ b/packages/react-events/docs/Focus.md @@ -4,8 +4,6 @@ The `Focus` module responds to focus and blur events on its child. Focus events are dispatched for all input types, with the exception of `onFocusVisibleChange` which is only dispatched when focusing with a keyboard. -Focus events do not propagate between `Focus` event responders. - ```js // Example const Button = (props) => { @@ -43,6 +41,11 @@ type FocusEvent = { Disables all `Focus` events. +### within: boolean = false + +By default, events are only fired for the immediate child of the `Focus` component. +When the `within` prop is set to `true`, events are fired for descendents as well. + ### onBlur: (e: FocusEvent) => void Called when the element loses focus. diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index 9244fd2fcba..1af1c0779cf 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -17,6 +17,7 @@ import {DiscreteEvent} from 'shared/ReactTypes'; type FocusProps = { disabled: boolean, + within: boolean, onBlur: (e: FocusEvent) => void, onFocus: (e: FocusEvent) => void, onFocusChange: boolean => void, @@ -228,7 +229,7 @@ const FocusResponder = { }; }, allowMultipleHostChildren: false, - stopLocalPropagation: true, + stopLocalPropagation: false, onEvent( event: ReactResponderEvent, context: ReactResponderContext, @@ -249,10 +250,10 @@ const FocusResponder = { switch (type) { case 'focus': { if (!state.isFocused) { - // Limit focus events to the direct child of the event component. - // Browser focus is not expected to bubble. + // Limit focus events to the direct child of the event component + // unless the `within` prop is set. Browser focus is not expected to bubble. state.focusTarget = context.getEventCurrentTarget(event); - if (state.focusTarget === target) { + if (props.within || state.focusTarget === target) { state.isFocused = true; state.isLocalFocusVisible = isGlobalFocusVisible; dispatchFocusInEvents(context, props, state); diff --git a/packages/react-events/src/__tests__/Focus-test.internal.js b/packages/react-events/src/__tests__/Focus-test.internal.js index ef2cced2f55..96499e63401 100644 --- a/packages/react-events/src/__tests__/Focus-test.internal.js +++ b/packages/react-events/src/__tests__/Focus-test.internal.js @@ -138,6 +138,21 @@ describe('Focus event responder', () => { expect(onFocus).not.toBeCalled(); }); + it('is called if descendants of target receive focus with the `within` prop set', () => { + const element = ( + +
+ +
+
+ ); + ReactDOM.render(element, container); + + const target = innerRef.current; + target.dispatchEvent(createFocusEvent('focus')); + expect(onFocus).toHaveBeenCalledTimes(1); + }); + it('is called with the correct pointerType using pointer events', () => { // Pointer mouse ref.current.dispatchEvent( @@ -351,6 +366,53 @@ describe('Focus event responder', () => { 'inner: onFocusChange', ]); }); + + it('propagate when the within prop is set', () => { + const events = []; + const innerRef = React.createRef(); + const outerRef = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + + const element = ( + +
+ +
+ +
+
+ ); + + ReactDOM.render(element, container); + + outerRef.current.dispatchEvent(createFocusEvent('focus')); + outerRef.current.dispatchEvent(createFocusEvent('blur')); + innerRef.current.dispatchEvent(createFocusEvent('focus')); + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(events).toEqual([ + 'outer: onFocus', + 'outer: onFocusChange', + 'outer: onBlur', + 'outer: onFocusChange', + 'inner: onFocus', + 'inner: onFocusChange', + 'outer: onFocus', + 'outer: onFocusChange', + 'inner: onBlur', + 'inner: onFocusChange', + 'outer: onBlur', + 'outer: onFocusChange', + ]); + }); }); it('expect displayName to show up for event component', () => { From 01dc492c2310f68bd257ffe162b89b11fc4cbea3 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 12 Jun 2019 11:51:20 -0700 Subject: [PATCH 2/4] Add onFocusWithinChange and onFocusVisibleWithinChange callbacks --- packages/react-events/docs/Focus.md | 19 +- packages/react-events/src/Focus.js | 108 +++++++++-- .../src/__tests__/Focus-test.internal.js | 176 ++++++++++++++---- 3 files changed, 241 insertions(+), 62 deletions(-) diff --git a/packages/react-events/docs/Focus.md b/packages/react-events/docs/Focus.md index 9d71eddaf71..470232d1f35 100644 --- a/packages/react-events/docs/Focus.md +++ b/packages/react-events/docs/Focus.md @@ -4,6 +4,8 @@ The `Focus` module responds to focus and blur events on its child. Focus events are dispatched for all input types, with the exception of `onFocusVisibleChange` which is only dispatched when focusing with a keyboard. +Focus events do not propagate between `Focus` event responders. + ```js // Example const Button = (props) => { @@ -31,7 +33,7 @@ const Button = (props) => { ```js type FocusEvent = { target: Element, - type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange' + type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange' | 'focuswithinchange' | 'focusvisiblewithinchange' } ``` @@ -41,11 +43,6 @@ type FocusEvent = { Disables all `Focus` events. -### within: boolean = false - -By default, events are only fired for the immediate child of the `Focus` component. -When the `within` prop is set to `true`, events are fired for descendents as well. - ### onBlur: (e: FocusEvent) => void Called when the element loses focus. @@ -63,3 +60,13 @@ Called when the element changes focus state (i.e., after `onBlur` and Called when the element receives or loses focus following keyboard navigation. This can be used to display focus styles only for keyboard interactions. + +### onFocusWithinChange: boolean => void + +Called when the element or a descendant changes focus state (i.e., after `onBlur` and +`onFocus`). + +### onFocusVisibleWithinChange: boolean => void + +Called when the element or a descendant receives or loses focus following keyboard navigation. +This can be used to display focus styles only for keyboard interactions. diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index 1af1c0779cf..e7f2772e282 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -17,22 +17,31 @@ import {DiscreteEvent} from 'shared/ReactTypes'; type FocusProps = { disabled: boolean, - within: boolean, onBlur: (e: FocusEvent) => void, onFocus: (e: FocusEvent) => void, onFocusChange: boolean => void, onFocusVisibleChange: boolean => void, + onFocusWithinChange: boolean => void, + onFocusVisibleWithinChange: boolean => void, }; type FocusState = { focusTarget: null | Element | Document, isFocused: boolean, isLocalFocusVisible: boolean, + isFocusWithin: boolean, + isFocusWithinVisible: boolean, pointerType: PointerType, }; type PointerType = '' | 'mouse' | 'keyboard' | 'pen' | 'touch'; -type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange'; +type FocusEventType = + | 'focus' + | 'blur' + | 'focuschange' + | 'focusvisiblechange' + | 'focuswithinchange' + | 'focusvisiblewithinchange'; type FocusEvent = {| target: Element | Document, @@ -126,6 +135,38 @@ function dispatchFocusInEvents( context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); } } +function dispatchFocusWithinEvents( + context: ReactResponderContext, + props: FocusProps, + state: FocusState, +) { + const pointerType = state.pointerType; + const target = ((state.focusTarget: any): Element | Document); + if (props.onFocusWithinChange) { + const listener = () => { + props.onFocusWithinChange(true); + }; + const syntheticEvent = createFocusEvent( + context, + 'focuswithinchange', + target, + pointerType, + ); + context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); + } + if (props.onFocusVisibleWithinChange && state.isFocusWithinVisible) { + const listener = () => { + props.onFocusVisibleWithinChange(true); + }; + const syntheticEvent = createFocusEvent( + context, + 'focusvisiblewithinchange', + target, + pointerType, + ); + context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); + } +} function dispatchFocusOutEvents( context: ReactResponderContext, @@ -134,7 +175,7 @@ function dispatchFocusOutEvents( ) { const pointerType = state.pointerType; const target = ((state.focusTarget: any): Element | Document); - if (props.onBlur) { + if (props.onBlur && state.isFocused) { const syntheticEvent = createFocusEvent( context, 'blur', @@ -143,7 +184,7 @@ function dispatchFocusOutEvents( ); context.dispatchEvent(syntheticEvent, props.onBlur, DiscreteEvent); } - if (props.onFocusChange) { + if (props.onFocusChange && state.isFocused) { const listener = () => { props.onFocusChange(false); }; @@ -155,10 +196,22 @@ function dispatchFocusOutEvents( ); context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); } - dispatchFocusVisibleOutEvent(context, props, state); + if (props.onFocusWithinChange && state.isFocusWithin) { + const listener = () => { + props.onFocusWithinChange(false); + }; + const syntheticEvent = createFocusEvent( + context, + 'focuswithinchange', + target, + pointerType, + ); + context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); + } + dispatchFocusVisibleOutEvents(context, props, state); } -function dispatchFocusVisibleOutEvent( +function dispatchFocusVisibleOutEvents( context: ReactResponderContext, props: FocusProps, state: FocusState, @@ -178,6 +231,19 @@ function dispatchFocusVisibleOutEvent( context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); state.isLocalFocusVisible = false; } + if (props.onFocusVisibleWithinChange && state.isFocusWithinVisible) { + const listener = () => { + props.onFocusVisibleWithinChange(false); + }; + const syntheticEvent = createFocusEvent( + context, + 'focusvisiblewithinchange', + target, + pointerType, + ); + context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); + state.isFocusWithinVisible = false; + } } function unmountResponder( @@ -185,7 +251,7 @@ function unmountResponder( props: FocusProps, state: FocusState, ): void { - if (state.isFocused) { + if (state.isFocused || state.isFocusWithin) { dispatchFocusOutEvents(context, props, state); } } @@ -211,7 +277,7 @@ function handleRootPointerEvent( state.focusTarget === context.getEventCurrentTarget(event) && (type === 'mousedown' || type === 'touchstart' || type === 'pointerdown') ) { - dispatchFocusVisibleOutEvent(context, props, state); + dispatchFocusVisibleOutEvents(context, props, state); } } @@ -225,6 +291,8 @@ const FocusResponder = { focusTarget: null, isFocused: false, isLocalFocusVisible: false, + isFocusWithin: false, + isFocusWithinVisible: false, pointerType: '', }; }, @@ -249,22 +317,28 @@ const FocusResponder = { switch (type) { case 'focus': { - if (!state.isFocused) { - // Limit focus events to the direct child of the event component - // unless the `within` prop is set. Browser focus is not expected to bubble. + if (!state.isFocused || !state.isFocusWithin) { state.focusTarget = context.getEventCurrentTarget(event); - if (props.within || state.focusTarget === target) { - state.isFocused = true; - state.isLocalFocusVisible = isGlobalFocusVisible; - dispatchFocusInEvents(context, props, state); - } + } + // Limit focus events to the direct child of the event component + // Browser focus is not expected to bubble. + if (!state.isFocused && state.focusTarget === target) { + state.isFocused = true; + state.isLocalFocusVisible = isGlobalFocusVisible; + dispatchFocusInEvents(context, props, state); + } + if (!state.isFocusWithin) { + state.isFocusWithin = true; + state.isFocusWithinVisible = isGlobalFocusVisible; + dispatchFocusWithinEvents(context, props, state); } break; } case 'blur': { - if (state.isFocused) { + if (state.isFocused || state.isFocusWithin) { dispatchFocusOutEvents(context, props, state); state.isFocused = false; + state.isFocusWithin = false; state.focusTarget = null; } break; diff --git a/packages/react-events/src/__tests__/Focus-test.internal.js b/packages/react-events/src/__tests__/Focus-test.internal.js index 96499e63401..1ea1d6414c1 100644 --- a/packages/react-events/src/__tests__/Focus-test.internal.js +++ b/packages/react-events/src/__tests__/Focus-test.internal.js @@ -138,21 +138,6 @@ describe('Focus event responder', () => { expect(onFocus).not.toBeCalled(); }); - it('is called if descendants of target receive focus with the `within` prop set', () => { - const element = ( - -
- -
- - ); - ReactDOM.render(element, container); - - const target = innerRef.current; - target.dispatchEvent(createFocusEvent('focus')); - expect(onFocus).toHaveBeenCalledTimes(1); - }); - it('is called with the correct pointerType using pointer events', () => { // Pointer mouse ref.current.dispatchEvent( @@ -252,14 +237,17 @@ describe('Focus event responder', () => { }); describe('onFocusChange', () => { - let onFocusChange, ref; + let onFocusChange, ref, innerRef; beforeEach(() => { onFocusChange = jest.fn(); ref = React.createRef(); + innerRef = React.createRef(); const element = ( -
+
+ +
); ReactDOM.render(element, container); @@ -273,17 +261,63 @@ describe('Focus event responder', () => { expect(onFocusChange).toHaveBeenCalledTimes(2); expect(onFocusChange).toHaveBeenCalledWith(false); }); + + it('is not called after "blur" and "focus" events on nested descendants', () => { + innerRef.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusChange).toHaveBeenCalledTimes(0); + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusChange).toHaveBeenCalledTimes(0); + }); + }); + + describe('onFocusWithinChange', () => { + let onFocusWithinChange, ref, innerRef; + + beforeEach(() => { + onFocusWithinChange = jest.fn(); + ref = React.createRef(); + innerRef = React.createRef(); + const element = ( + +
+ +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "blur" and "focus" events immediate child', () => { + ref.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusWithinChange).toHaveBeenCalledTimes(1); + expect(onFocusWithinChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusWithinChange).toHaveBeenCalledTimes(2); + expect(onFocusWithinChange).toHaveBeenCalledWith(false); + }); + + it('is called after "blur" and "focus" events on descendants', () => { + innerRef.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusWithinChange).toHaveBeenCalledTimes(1); + expect(onFocusWithinChange).toHaveBeenCalledWith(true); + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusWithinChange).toHaveBeenCalledTimes(2); + expect(onFocusWithinChange).toHaveBeenCalledWith(false); + }); }); describe('onFocusVisibleChange', () => { - let onFocusVisibleChange, ref; + let onFocusVisibleChange, ref, innerRef; beforeEach(() => { onFocusVisibleChange = jest.fn(); ref = React.createRef(); + innerRef = React.createRef(); const element = ( -
+
+ +
); ReactDOM.render(element, container); @@ -322,6 +356,77 @@ describe('Focus event responder', () => { ref.current.dispatchEvent(createFocusEvent('blur')); expect(onFocusVisibleChange).toHaveBeenCalledTimes(0); }); + + it('is not called after "blur" and "focus" events on nested descendants', () => { + container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'})); + innerRef.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusVisibleChange).toHaveBeenCalledTimes(0); + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusVisibleChange).toHaveBeenCalledTimes(0); + }); + }); + + describe('onFocusVisibleWithinChange', () => { + let onFocusVisibleWithinChange, ref, innerRef; + + beforeEach(() => { + onFocusVisibleWithinChange = jest.fn(); + ref = React.createRef(); + innerRef = React.createRef(); + const element = ( + +
+ +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "focus" and "blur" if keyboard navigation is active on immediate child', () => { + // use keyboard first + container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'})); + ref.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(1); + expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2); + expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(false); + }); + + it('is called after "focus" and "blur" if keyboard navigation is active on nested descendants', () => { + // use keyboard first + container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'})); + innerRef.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(1); + expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(true); + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2); + expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(false); + }); + + it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => { + // use keyboard first + container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'})); + innerRef.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(1); + expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(true); + // then use pointer on the target, focus should no longer be visible + innerRef.current.dispatchEvent(createPointerEvent('pointerdown')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2); + expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(false); + // onFocusVisibleChange should not be called again + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2); + }); + + it('is not called after "focus" and "blur" events without keyboard', () => { + innerRef.current.dispatchEvent(createPointerEvent('pointerdown')); + innerRef.current.dispatchEvent(createFocusEvent('focus')); + container.dispatchEvent(createPointerEvent('pointerdown')); + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(0); + }); }); describe('nested Focus components', () => { @@ -367,7 +472,7 @@ describe('Focus event responder', () => { ]); }); - it('propagate when the within prop is set', () => { + it('propagates focus within events', () => { const events = []; const innerRef = React.createRef(); const outerRef = React.createRef(); @@ -377,15 +482,14 @@ describe('Focus event responder', () => { const element = ( + onFocusWithinChange={createEventHandler( + 'outer: onFocusWithinChange', + )}>
+ onFocusWithinChange={createEventHandler( + 'inner: onFocusWithinChange', + )}>
@@ -399,18 +503,12 @@ describe('Focus event responder', () => { innerRef.current.dispatchEvent(createFocusEvent('focus')); innerRef.current.dispatchEvent(createFocusEvent('blur')); expect(events).toEqual([ - 'outer: onFocus', - 'outer: onFocusChange', - 'outer: onBlur', - 'outer: onFocusChange', - 'inner: onFocus', - 'inner: onFocusChange', - 'outer: onFocus', - 'outer: onFocusChange', - 'inner: onBlur', - 'inner: onFocusChange', - 'outer: onBlur', - 'outer: onFocusChange', + 'outer: onFocusWithinChange', + 'outer: onFocusWithinChange', + 'inner: onFocusWithinChange', + 'outer: onFocusWithinChange', + 'inner: onFocusWithinChange', + 'outer: onFocusWithinChange', ]); }); }); From a10171056c2a56bf4729c26245117f91b16a2e01 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 12 Jun 2019 13:33:27 -0700 Subject: [PATCH 3/4] Add test for multiple focus events --- .../src/__tests__/Focus-test.internal.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/react-events/src/__tests__/Focus-test.internal.js b/packages/react-events/src/__tests__/Focus-test.internal.js index 1ea1d6414c1..c9e35922f44 100644 --- a/packages/react-events/src/__tests__/Focus-test.internal.js +++ b/packages/react-events/src/__tests__/Focus-test.internal.js @@ -271,23 +271,25 @@ describe('Focus event responder', () => { }); describe('onFocusWithinChange', () => { - let onFocusWithinChange, ref, innerRef; + let onFocusWithinChange, ref, innerRef, innerRef2; beforeEach(() => { onFocusWithinChange = jest.fn(); ref = React.createRef(); innerRef = React.createRef(); + innerRef2 = React.createRef(); const element = (
); ReactDOM.render(element, container); }); - it('is called after "blur" and "focus" events immediate child', () => { + it('is called after "blur" and "focus" events on immediate child', () => { ref.current.dispatchEvent(createFocusEvent('focus')); expect(onFocusWithinChange).toHaveBeenCalledTimes(1); expect(onFocusWithinChange).toHaveBeenCalledWith(true); @@ -304,6 +306,17 @@ describe('Focus event responder', () => { expect(onFocusWithinChange).toHaveBeenCalledTimes(2); expect(onFocusWithinChange).toHaveBeenCalledWith(false); }); + + it('is only called once when focus moves within the subtree', () => { + innerRef.current.dispatchEvent(createFocusEvent('focus')); + innerRef2.current.dispatchEvent(createFocusEvent('focus')); + ref.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusWithinChange).toHaveBeenCalledTimes(1); + expect(onFocusWithinChange).toHaveBeenCalledWith(true); + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusWithinChange).toHaveBeenCalledTimes(2); + expect(onFocusWithinChange).toHaveBeenCalledWith(false); + }); }); describe('onFocusVisibleChange', () => { @@ -472,7 +485,7 @@ describe('Focus event responder', () => { ]); }); - it('propagates focus within events', () => { + it('allows focus within events to propagate', () => { const events = []; const innerRef = React.createRef(); const outerRef = React.createRef(); From d438ac9997589cc5dc6554a90c9c4e735d7d0ac5 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 19 Jun 2019 20:00:38 -0700 Subject: [PATCH 4/4] Updates --- packages/react-events/src/Focus.js | 28 +++++---- .../src/__tests__/Focus-test.internal.js | 59 ++++++------------- 2 files changed, 35 insertions(+), 52 deletions(-) diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index e4b1c28e1b8..087ecd3119c 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -31,7 +31,6 @@ type FocusState = { isFocused: boolean, isLocalFocusVisible: boolean, isFocusWithin: boolean, - isFocusWithinVisible: boolean, pointerType: PointerType, }; @@ -136,7 +135,7 @@ function dispatchFocusInEvents( } } function dispatchFocusWithinEvents( - context: ReactResponderContext, + context: ReactDOMResponderContext, props: FocusProps, state: FocusState, ) { @@ -154,7 +153,7 @@ function dispatchFocusWithinEvents( ); context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); } - if (props.onFocusVisibleWithinChange && state.isFocusWithinVisible) { + if (props.onFocusVisibleWithinChange && state.isLocalFocusVisible) { const listener = () => { props.onFocusVisibleWithinChange(true); }; @@ -216,9 +215,13 @@ function dispatchFocusVisibleOutEvents( props: FocusProps, state: FocusState, ) { + if (!state.isLocalFocusVisible) { + return; + } + const pointerType = state.pointerType; const target = ((state.focusTarget: any): Element | Document); - if (props.onFocusVisibleChange && state.isLocalFocusVisible) { + if (props.onFocusVisibleChange && state.isFocused) { const listener = () => { props.onFocusVisibleChange(false); }; @@ -229,9 +232,8 @@ function dispatchFocusVisibleOutEvents( pointerType, ); context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); - state.isLocalFocusVisible = false; } - if (props.onFocusVisibleWithinChange && state.isFocusWithinVisible) { + if (props.onFocusVisibleWithinChange && state.isFocusWithin) { const listener = () => { props.onFocusVisibleWithinChange(false); }; @@ -242,8 +244,8 @@ function dispatchFocusVisibleOutEvents( pointerType, ); context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); - state.isFocusWithinVisible = false; } + state.isLocalFocusVisible = false; } function unmountResponder( @@ -292,7 +294,6 @@ const FocusResponder = { isFocused: false, isLocalFocusVisible: false, isFocusWithin: false, - isFocusWithinVisible: false, pointerType: '', }; }, @@ -319,21 +320,28 @@ const FocusResponder = { if (!state.isFocused || !state.isFocusWithin) { state.focusTarget = context.getEventCurrentTarget(event); } + + state.isLocalFocusVisible = isGlobalFocusVisible; + // Limit focus events to the direct child of the event component // Browser focus is not expected to bubble. if (!state.isFocused && state.focusTarget === target) { state.isFocused = true; - state.isLocalFocusVisible = isGlobalFocusVisible; dispatchFocusInEvents(context, props, state); } if (!state.isFocusWithin) { state.isFocusWithin = true; - state.isFocusWithinVisible = isGlobalFocusVisible; dispatchFocusWithinEvents(context, props, state); } break; } case 'blur': { + // Ignore blur events when focus moves within the subtree + let relatedTarget = ((event.nativeEvent: any).relatedTarget: Element); + if (context.isTargetWithinEventComponent(relatedTarget)) { + break; + } + if (state.isFocused || state.isFocusWithin) { dispatchFocusOutEvents(context, props, state); state.isFocused = false; diff --git a/packages/react-events/src/__tests__/Focus-test.internal.js b/packages/react-events/src/__tests__/Focus-test.internal.js index c9e35922f44..fd50d6930e7 100644 --- a/packages/react-events/src/__tests__/Focus-test.internal.js +++ b/packages/react-events/src/__tests__/Focus-test.internal.js @@ -380,16 +380,18 @@ describe('Focus event responder', () => { }); describe('onFocusVisibleWithinChange', () => { - let onFocusVisibleWithinChange, ref, innerRef; + let onFocusVisibleWithinChange, ref, innerRef, innerRef2; beforeEach(() => { onFocusVisibleWithinChange = jest.fn(); ref = React.createRef(); innerRef = React.createRef(); + innerRef2 = React.createRef(); const element = ( ); @@ -440,6 +442,19 @@ describe('Focus event responder', () => { innerRef.current.dispatchEvent(createFocusEvent('blur')); expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(0); }); + + it('is only called once when focus moves within the subtree', () => { + // use keyboard first + container.dispatchEvent(createKeyboardEvent('keydown', {key: 'Tab'})); + innerRef.current.focus(); + innerRef2.current.focus(); + ref.current.focus(); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(1); + expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(true); + innerRef.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusVisibleWithinChange).toHaveBeenCalledTimes(2); + expect(onFocusVisibleWithinChange).toHaveBeenCalledWith(false); + }); }); describe('nested Focus components', () => { @@ -484,46 +499,6 @@ describe('Focus event responder', () => { 'inner: onFocusChange', ]); }); - - it('allows focus within events to propagate', () => { - const events = []; - const innerRef = React.createRef(); - const outerRef = React.createRef(); - const createEventHandler = msg => () => { - events.push(msg); - }; - - const element = ( - -
- -
- -
-
- ); - - ReactDOM.render(element, container); - - outerRef.current.dispatchEvent(createFocusEvent('focus')); - outerRef.current.dispatchEvent(createFocusEvent('blur')); - innerRef.current.dispatchEvent(createFocusEvent('focus')); - innerRef.current.dispatchEvent(createFocusEvent('blur')); - expect(events).toEqual([ - 'outer: onFocusWithinChange', - 'outer: onFocusWithinChange', - 'inner: onFocusWithinChange', - 'outer: onFocusWithinChange', - 'inner: onFocusWithinChange', - 'outer: onFocusWithinChange', - ]); - }); }); it('expect displayName to show up for event component', () => {