Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Focus outside: rewrite as hook and remove wrapper div in Popover #27050

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 61 additions & 74 deletions packages/components/src/higher-order/with-focus-outside/index.js
Original file line number Diff line number Diff line change
@@ -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<string>}
*/
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<string>}
*/
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
Expand All @@ -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 );
}

/**
Expand All @@ -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 (
<div
onFocus={ this.cancelBlurCheck }
onMouseDown={ this.normalizeButtonFocus }
onMouseUp={ this.normalizeButtonFocus }
onTouchStart={ this.normalizeButtonFocus }
onTouchEnd={ this.normalizeButtonFocus }
onBlur={ this.queueBlurCheck }
>
<WrappedComponent ref={ this.bindNode } { ...this.props } />
</div>
);
/* 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 (
<div ref={ ref }>
<Component { ...props } ref={ componentRef } />
</div>
);
},
'withFocusOutside'
);
21 changes: 0 additions & 21 deletions packages/components/src/popover/detect-outside.js

This file was deleted.

80 changes: 40 additions & 40 deletions packages/components/src/popover/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 )
Expand Down Expand Up @@ -529,6 +529,8 @@ const Popover = ( {
onClickOutside( clickEvent );
}

useFocusOutside( containerRef, handleOnFocusOutside );

const animateClassName = useAnimate( {
type: animate && animateOrigin ? 'appear' : null,
origin: animateOrigin,
Expand All @@ -538,47 +540,45 @@ const Popover = ( {
// within popover as inferring close intent.

let content = (
<PopoverDetectOutside onFocusOutside={ handleOnFocusOutside }>
<IsolatedEventContainer
className={ classnames(
'components-popover',
className,
animateClassName,
{
'is-expanded': isExpanded,
'is-without-arrow': noArrow,
'is-alternate': isAlternate,
}
) }
{ ...contentProps }
onKeyDown={ maybeClose }
ref={ containerRef }
<IsolatedEventContainer
className={ classnames(
'components-popover',
className,
animateClassName,
{
'is-expanded': isExpanded,
'is-without-arrow': noArrow,
'is-alternate': isAlternate,
}
) }
{ ...contentProps }
onKeyDown={ maybeClose }
ref={ containerRef }
>
{ isExpanded && <ScrollLock /> }
{ isExpanded && (
<div className="components-popover__header">
<span className="components-popover__header-title">
{ headerTitle }
</span>
<Button
className="components-popover__close"
icon={ close }
onClick={ onClose }
/>
</div>
) }
<div
ref={ contentRef }
className="components-popover__content"
tabIndex="-1"
>
{ isExpanded && <ScrollLock /> }
{ isExpanded && (
<div className="components-popover__header">
<span className="components-popover__header-title">
{ headerTitle }
</span>
<Button
className="components-popover__close"
icon={ close }
onClick={ onClose }
/>
</div>
) }
<div
ref={ contentRef }
className="components-popover__content"
tabIndex="-1"
>
<div style={ { position: 'relative' } }>
{ containerResizeListener }
{ children }
</div>
<div style={ { position: 'relative' } }>
{ containerResizeListener }
{ children }
</div>
</IsolatedEventContainer>
</PopoverDetectOutside>
</div>
</IsolatedEventContainer>
);

// Apply focus to element as long as focusOnMount is truthy; false is
Expand Down