From 8a99121d66400860656491e01c4093e12b1f5a5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ella=20van=C2=A0Durpe?= Date: Tue, 17 Nov 2020 22:23:24 +0200 Subject: [PATCH] Focus outside: rewrite as hook and remove wrapper div in Popover --- .../higher-order/with-focus-outside/index.js | 135 ++++++++---------- .../components/src/popover/detect-outside.js | 21 --- packages/components/src/popover/index.js | 80 +++++------ 3 files changed, 101 insertions(+), 135 deletions(-) delete mode 100644 packages/components/src/popover/detect-outside.js diff --git a/packages/components/src/higher-order/with-focus-outside/index.js b/packages/components/src/higher-order/with-focus-outside/index.js index ca966eb048af37..f6c162d1b96426 100644 --- a/packages/components/src/higher-order/with-focus-outside/index.js +++ b/packages/components/src/higher-order/with-focus-outside/index.js @@ -1,21 +1,21 @@ /** - * External dependencies + * WordPress dependencies */ -import { includes } from 'lodash'; +import { useEffect, useRef } from '@wordpress/element'; +import { createHigherOrderComponent } from '@wordpress/compose'; /** - * WordPress dependencies + * @type {Set} */ -import { Component } from '@wordpress/element'; -import { createHigherOrderComponent } from '@wordpress/compose'; +const INTERACTION_END_TYPES = new Set( [ 'mouseup', 'touchend' ] ); /** * Input types which are classified as button types, for use in considering * whether element is a (focus-normalized) button. * - * @type {string[]} + * @type {Set} */ -const INPUT_BUTTON_TYPES = [ 'button', 'submit' ]; +const INPUT_BUTTON_TYPES = new Set( [ 'button', 'submit' ] ); /** * Returns true if the given element is a button element subject to focus @@ -32,65 +32,44 @@ function isFocusNormalizedButton( element ) { case 'A': case 'BUTTON': return true; - case 'INPUT': - return includes( INPUT_BUTTON_TYPES, element.type ); + return INPUT_BUTTON_TYPES.has( element.type ); } return false; } -export default createHigherOrderComponent( ( WrappedComponent ) => { - return class extends Component { - constructor() { - super( ...arguments ); +export function useFocusOutside( ref, callback ) { + useEffect( () => { + const element = ref.current; + const { ownerDocument } = element; + const { defaultView } = ownerDocument; - this.bindNode = this.bindNode.bind( this ); - this.cancelBlurCheck = this.cancelBlurCheck.bind( this ); - this.queueBlurCheck = this.queueBlurCheck.bind( this ); - this.normalizeButtonFocus = this.normalizeButtonFocus.bind( this ); - } - - componentWillUnmount() { - this.cancelBlurCheck(); - } - - bindNode( node ) { - if ( node ) { - this.node = node; - } else { - delete this.node; - this.cancelBlurCheck(); - } - } - - queueBlurCheck( event ) { - // React does not allow using an event reference asynchronously - // due to recycling behavior, except when explicitly persisted. - event.persist(); + let timerId; + let preventBlurCheck; + function queueBlurCheck( event ) { // Skip blur check if clicking button. See `normalizeButtonFocus`. - if ( this.preventBlurCheck ) { + if ( preventBlurCheck ) { return; } - this.blurCheckTimeout = setTimeout( () => { + timerId = defaultView.setTimeout( () => { // If document is not focused then focus should remain // inside the wrapped component and therefore we cancel // this blur event thereby leaving focus in place. // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus. - if ( ! document.hasFocus() ) { + if ( ! ownerDocument.hasFocus() ) { event.preventDefault(); return; } - if ( 'function' === typeof this.node.handleFocusOutside ) { - this.node.handleFocusOutside( event ); - } - }, 0 ); + + callback( event ); + } ); } - cancelBlurCheck() { - clearTimeout( this.blurCheckTimeout ); + function cancelBlurCheck() { + clearTimeout( timerId ); } /** @@ -104,39 +83,47 @@ export default createHigherOrderComponent( ( WrappedComponent ) => { * * @param {MouseEvent} event Event for mousedown or mouseup. */ - normalizeButtonFocus( event ) { + function normalizeButtonFocus( event ) { const { type, target } = event; - const isInteractionEnd = includes( - [ 'mouseup', 'touchend' ], - type - ); - - if ( isInteractionEnd ) { - this.preventBlurCheck = false; + if ( INTERACTION_END_TYPES.has( type ) ) { + preventBlurCheck = false; } else if ( isFocusNormalizedButton( target ) ) { - this.preventBlurCheck = true; + preventBlurCheck = true; } } - render() { - // Disable reason: See `normalizeButtonFocus` for browser-specific - // focus event normalization. + element.addEventListener( 'focus', cancelBlurCheck, true ); + element.addEventListener( 'blur', queueBlurCheck, true ); + element.addEventListener( 'mousedown', normalizeButtonFocus ); + element.addEventListener( 'mouseup', normalizeButtonFocus ); + element.addEventListener( 'touchstart', normalizeButtonFocus ); + element.addEventListener( 'touchend', normalizeButtonFocus ); + + return () => { + cancelBlurCheck(); + element.removeEventListener( 'focus', cancelBlurCheck ); + element.removeEventListener( 'blur', queueBlurCheck ); + element.removeEventListener( 'mousedown', normalizeButtonFocus ); + element.removeEventListener( 'mouseup', normalizeButtonFocus ); + element.removeEventListener( 'touchstart', normalizeButtonFocus ); + element.removeEventListener( 'touchend', normalizeButtonFocus ); + }; + }, [ callback ] ); +} - /* eslint-disable jsx-a11y/no-static-element-interactions */ - return ( -
- -
- ); - /* eslint-enable jsx-a11y/no-static-element-interactions */ - } - }; -}, 'withFocusOutside' ); +export default createHigherOrderComponent( + ( Component ) => ( props ) => { + const ref = useRef(); + const componentRef = useRef(); + useFocusOutside( ref, ( event ) => + componentRef.current.handleFocusOutside( event ) + ); + return ( +
+ +
+ ); + }, + 'withFocusOutside' +); diff --git a/packages/components/src/popover/detect-outside.js b/packages/components/src/popover/detect-outside.js deleted file mode 100644 index 92a3359d24055f..00000000000000 --- a/packages/components/src/popover/detect-outside.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * WordPress dependencies - */ -import { Component } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import withFocusOutside from '../higher-order/with-focus-outside'; - -class PopoverDetectOutside extends Component { - handleFocusOutside( event ) { - this.props.onFocusOutside( event ); - } - - render() { - return this.props.children; - } -} - -export default withFocusOutside( PopoverDetectOutside ); diff --git a/packages/components/src/popover/index.js b/packages/components/src/popover/index.js index a71c6cc6ad895f..fae681d7e6b5b4 100644 --- a/packages/components/src/popover/index.js +++ b/packages/components/src/popover/index.js @@ -24,12 +24,12 @@ import { close } from '@wordpress/icons'; import { computePopoverPosition } from './utils'; import withFocusReturn from '../higher-order/with-focus-return'; import withConstrainedTabbing from '../higher-order/with-constrained-tabbing'; -import PopoverDetectOutside from './detect-outside'; import Button from '../button'; import ScrollLock from '../scroll-lock'; import IsolatedEventContainer from '../isolated-event-container'; import { Slot, Fill, useSlot } from '../slot-fill'; import { useAnimate } from '../animate'; +import { useFocusOutside } from '../higher-order/with-focus-outside'; const FocusManaged = withConstrainedTabbing( withFocusReturn( ( { children } ) => children ) @@ -529,6 +529,8 @@ const Popover = ( { onClickOutside( clickEvent ); } + useFocusOutside( containerRef, handleOnFocusOutside ); + const animateClassName = useAnimate( { type: animate && animateOrigin ? 'appear' : null, origin: animateOrigin, @@ -538,47 +540,45 @@ const Popover = ( { // within popover as inferring close intent. let content = ( - - + { isExpanded && } + { isExpanded && ( +
+ + { headerTitle } + +
+ ) } +
- { isExpanded && } - { isExpanded && ( -
- - { headerTitle } - -
- ) } -
-
- { containerResizeListener } - { children } -
+
+ { containerResizeListener } + { children }
- - +
+ ); // Apply focus to element as long as focusOnMount is truthy; false is