diff --git a/packages/react-events/docs/Focus.md b/packages/react-events/docs/Focus.md index 1fef78e370f..470232d1f35 100644 --- a/packages/react-events/docs/Focus.md +++ b/packages/react-events/docs/Focus.md @@ -33,7 +33,7 @@ const Button = (props) => { ```js type FocusEvent = { target: Element, - type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange' + type: 'blur' | 'focus' | 'focuschange' | 'focusvisiblechange' | 'focuswithinchange' | 'focusvisiblewithinchange' } ``` @@ -60,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 3992ded6a07..087ecd3119c 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -22,16 +22,25 @@ type FocusProps = { 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, pointerType: PointerType, }; -type FocusEventType = 'focus' | 'blur' | 'focuschange' | 'focusvisiblechange'; +type FocusEventType = + | 'focus' + | 'blur' + | 'focuschange' + | 'focusvisiblechange' + | 'focuswithinchange' + | 'focusvisiblewithinchange'; type FocusEvent = {| target: Element | Document, @@ -125,6 +134,38 @@ function dispatchFocusInEvents( context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); } } +function dispatchFocusWithinEvents( + context: ReactDOMResponderContext, + 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.isLocalFocusVisible) { + const listener = () => { + props.onFocusVisibleWithinChange(true); + }; + const syntheticEvent = createFocusEvent( + context, + 'focusvisiblewithinchange', + target, + pointerType, + ); + context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); + } +} function dispatchFocusOutEvents( context: ReactDOMResponderContext, @@ -133,7 +174,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', @@ -142,7 +183,7 @@ function dispatchFocusOutEvents( ); context.dispatchEvent(syntheticEvent, props.onBlur, DiscreteEvent); } - if (props.onFocusChange) { + if (props.onFocusChange && state.isFocused) { const listener = () => { props.onFocusChange(false); }; @@ -154,17 +195,33 @@ 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: ReactDOMResponderContext, 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); }; @@ -175,8 +232,20 @@ function dispatchFocusVisibleOutEvent( pointerType, ); context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); - state.isLocalFocusVisible = false; } + if (props.onFocusVisibleWithinChange && state.isFocusWithin) { + const listener = () => { + props.onFocusVisibleWithinChange(false); + }; + const syntheticEvent = createFocusEvent( + context, + 'focusvisiblewithinchange', + target, + pointerType, + ); + context.dispatchEvent(syntheticEvent, listener, DiscreteEvent); + } + state.isLocalFocusVisible = false; } function unmountResponder( @@ -184,7 +253,7 @@ function unmountResponder( props: FocusProps, state: FocusState, ): void { - if (state.isFocused) { + if (state.isFocused || state.isFocusWithin) { dispatchFocusOutEvents(context, props, state); } } @@ -210,7 +279,7 @@ function handleRootPointerEvent( state.focusTarget === context.getEventCurrentTarget(event) && (type === 'mousedown' || type === 'touchstart' || type === 'pointerdown') ) { - dispatchFocusVisibleOutEvent(context, props, state); + dispatchFocusVisibleOutEvents(context, props, state); } } @@ -224,6 +293,7 @@ const FocusResponder = { focusTarget: null, isFocused: false, isLocalFocusVisible: false, + isFocusWithin: false, pointerType: '', }; }, @@ -247,22 +317,35 @@ 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. + if (!state.isFocused || !state.isFocusWithin) { state.focusTarget = context.getEventCurrentTarget(event); - if (state.focusTarget === target) { - state.isFocused = true; - state.isLocalFocusVisible = isGlobalFocusVisible; - dispatchFocusInEvents(context, props, state); - } + } + + 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; + dispatchFocusInEvents(context, props, state); + } + if (!state.isFocusWithin) { + state.isFocusWithin = true; + dispatchFocusWithinEvents(context, props, state); } break; } case 'blur': { - if (state.isFocused) { + // 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; + 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 ef2cced2f55..fd50d6930e7 100644 --- a/packages/react-events/src/__tests__/Focus-test.internal.js +++ b/packages/react-events/src/__tests__/Focus-test.internal.js @@ -237,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); @@ -258,17 +261,76 @@ 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, 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 on 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); + }); + + 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', () => { - let onFocusVisibleChange, ref; + let onFocusVisibleChange, ref, innerRef; beforeEach(() => { onFocusVisibleChange = jest.fn(); ref = React.createRef(); + innerRef = React.createRef(); const element = ( -
+
+ +
); ReactDOM.render(element, container); @@ -307,6 +369,92 @@ 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, innerRef2; + + beforeEach(() => { + onFocusVisibleWithinChange = jest.fn(); + ref = React.createRef(); + innerRef = React.createRef(); + innerRef2 = 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); + }); + + 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', () => {