* Refactor panel with withFocusOutside

* Remove react-click-outside dependency

* Handle PR feedback

* Handle PR feedback-2
This commit is contained in:
Hsing-Yu Flowers 2021-02-16 15:01:11 -05:00 committed by GitHub
parent 452494a4fe
commit 1fc78d93c9
6 changed files with 385 additions and 103 deletions

View File

@ -2,8 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import clickOutside from 'react-click-outside';
import { Component, lazy, Suspense } from '@wordpress/element';
import { Component, lazy } from '@wordpress/element';
import { Button } from '@wordpress/components';
import { compose } from '@wordpress/compose';
import { withDispatch, withSelect } from '@wordpress/data';
@ -12,7 +11,7 @@ import CrossIcon from 'gridicons/dist/cross-small';
import classnames from 'classnames';
import { Icon, help as helpIcon } from '@wordpress/icons';
import { getAdminLink } from '@woocommerce/wc-admin-settings';
import { H, Section, Spinner } from '@woocommerce/components';
import { H, Section } from '@woocommerce/components';
import { OPTIONS_STORE_NAME } from '@woocommerce/data';
import { getHistory, getNewPath } from '@woocommerce/navigation';
import { recordEvent } from '@woocommerce/tracks';
@ -28,6 +27,7 @@ import { Tabs } from './tabs';
import { SetupProgress } from './setup-progress';
import { DisplayOptions } from './display-options';
import { HighlightTooltip } from './highlight-tooltip';
import { Panel } from './panel';
const HelpPanel = lazy( () =>
import( /* webpackChunkName: "activity-panels-help" */ './panels/help' )
@ -133,6 +133,7 @@ export class ActivityPanel extends Component {
isEmbedded,
setupTaskListComplete,
setupTaskListHidden,
updateOptions,
} = this.props;
const isPerformingSetupTask = this.isPerformingSetupTask();
@ -167,6 +168,27 @@ export class ActivityPanel extends Component {
name: 'setup',
title: __( 'Store Setup', 'woocommerce-admin' ),
icon: <SetupProgress />,
onClick: () => {
const currentLocation = window.location.href;
const homescreenLocation = getAdminLink(
'admin.php?page=wc-admin'
);
// Don't navigate if we're already on the homescreen, this will cause an infinite loop
if ( currentLocation !== homescreenLocation ) {
// Ensure that if the user is trying to get to the task list they can see it even if
// it was dismissed.
if ( setupTaskListHidden === 'no' ) {
this.redirectToHomeScreen();
} else {
updateOptions( {
woocommerce_task_list_hidden: 'no',
} ).then( this.redirectToHomeScreen );
}
}
return null;
},
}
: null;
@ -201,77 +223,6 @@ export class ActivityPanel extends Component {
}
}
renderPanel() {
const { updateOptions, setupTaskListHidden } = this.props;
const { isPanelOpen, currentTab, isPanelSwitching } = this.state;
const tab = find( this.getTabs(), { name: currentTab } );
if ( ! tab ) {
return (
<div className="woocommerce-layout__activity-panel-wrapper" />
);
}
const clearPanel = () => {
this.clearPanel();
};
if ( currentTab === 'display-options' ) {
return null;
}
if ( currentTab === 'setup' ) {
const currentLocation = window.location.href;
const homescreenLocation = getAdminLink(
'admin.php?page=wc-admin'
);
// Don't navigate if we're already on the homescreen, this will cause an infinite loop
if ( currentLocation !== homescreenLocation ) {
// Ensure that if the user is trying to get to the task list they can see it even if
// it was dismissed.
if ( setupTaskListHidden === 'no' ) {
this.redirectToHomeScreen();
} else {
updateOptions( {
woocommerce_task_list_hidden: 'no',
} ).then( this.redirectToHomeScreen );
}
}
return null;
}
const classNames = classnames(
'woocommerce-layout__activity-panel-wrapper',
{
'is-open': isPanelOpen,
'is-switching': isPanelSwitching,
}
);
return (
<div
className={ classNames }
tabIndex={ 0 }
role="tabpanel"
aria-label={ tab.title }
onTransitionEnd={ clearPanel }
onAnimationEnd={ clearPanel }
>
<div
className="woocommerce-layout__activity-panel-content"
key={ 'activity-panel-' + currentTab }
id={ 'activity-panel-' + currentTab }
>
<Suspense fallback={ <Spinner /> }>
{ this.getPanelContent( currentTab ) }
</Suspense>
</div>
</div>
);
}
redirectToHomeScreen() {
if ( isWCAdmin( window.location.href ) ) {
getHistory().push( getNewPath( {}, '/', {} ) );
@ -373,10 +324,22 @@ export class ActivityPanel extends Component {
tabOpen={ isPanelOpen }
selectedTab={ currentTab }
onTabClick={ ( tab, tabOpen ) => {
if ( tab.onClick ) {
tab.onClick();
return;
}
this.togglePanel( tab, tabOpen );
} }
/>
{ this.renderPanel() }
<Panel
isPanelOpen
currentTab
isPanelSwitching
tab={ find( this.getTabs(), { name: currentTab } ) }
content={ this.getPanelContent( currentTab ) }
closePanel={ () => this.closePanel() }
clearPanel={ () => this.clearPanel() }
/>
</div>
</Section>
{ showHelpHighlightTooltip ? (
@ -431,6 +394,5 @@ export default compose(
} ),
withDispatch( ( dispatch ) => ( {
updateOptions: dispatch( OPTIONS_STORE_NAME ).updateOptions,
} ) ),
clickOutside
} ) )
)( ActivityPanel );

View File

@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { Suspense } from '@wordpress/element';
import classnames from 'classnames';
import { Spinner } from '@woocommerce/components';
/**
* Internal dependencies
*/
import useFocusOnMount from '../../hooks/useFocusOnMount';
import useFocusOutside from '../../hooks/useFocusOutside';
export const Panel = ( {
content,
isPanelOpen,
currentTab,
isPanelSwitching,
tab,
closePanel,
clearPanel,
} ) => {
const handleFocusOutside = ( event ) => {
const isClickOnModalOrSnackbar =
event.target.closest(
'.woocommerce-inbox-dismiss-confirmation_modal'
) || event.target.closest( '.components-snackbar__action' );
if ( isPanelOpen && ! isClickOnModalOrSnackbar ) {
closePanel();
}
};
const ref = useFocusOnMount();
const useFocusOutsideProps = useFocusOutside( handleFocusOutside );
if ( ! tab ) {
return <div className="woocommerce-layout__activity-panel-wrapper" />;
}
if ( ! content ) {
return null;
}
const classNames = classnames(
'woocommerce-layout__activity-panel-wrapper',
{
'is-open': isPanelOpen,
'is-switching': isPanelSwitching,
}
);
return (
<div
className={ classNames }
tabIndex={ 0 }
role="tabpanel"
aria-label={ tab.title }
onTransitionEnd={ clearPanel }
onAnimationEnd={ clearPanel }
{ ...useFocusOutsideProps }
ref={ ref }
>
<div
className="woocommerce-layout__activity-panel-content"
key={ 'activity-panel-' + currentTab }
id={ 'activity-panel-' + currentTab }
>
<Suspense fallback={ <Spinner /> }>{ content }</Suspense>
</div>
</div>
);
};
export default Panel;

View File

@ -0,0 +1,62 @@
/**
* This hook was directly copied from https://github.com/WordPress/gutenberg/blob/master/packages/compose/src/hooks/use-focus-on-mount/index.js
* to avoid its absence in older versions of WordPress.
*
* This can be removed once the minimum supported version of WordPress includes this hook.
*/
/**
* External dependencies
*/
import { useRef, useEffect, useCallback } from '@wordpress/element';
import { focus } from '@wordpress/dom';
/**
* Hook used to focus the first tabbable element on mount.
*
* @param {boolean|string} focusOnMount Focus on mount mode.
* @return {Function} Ref callback.
*
* @example
* ```js
* import { useFocusOnMount } from '@wordpress/compose';
*
* const WithFocusOnMount = () => {
* const ref = useFocusOnMount()
* return (
* <div ref={ ref }>
* <Button />
* <Button />
* </div>
* );
* }
* ```
*/
export default function useFocusOnMount( focusOnMount = 'firstElement' ) {
const focusOnMountRef = useRef( focusOnMount );
useEffect( () => {
focusOnMountRef.current = focusOnMount;
}, [ focusOnMount ] );
return useCallback( ( node ) => {
if ( ! node || focusOnMountRef.current === false ) {
return;
}
if ( node.contains( node.ownerDocument.activeElement ) ) {
return;
}
let target = node;
if ( focusOnMountRef.current === 'firstElement' ) {
const firstTabbable = focus.tabbable.find( node )[ 0 ];
if ( firstTabbable ) {
target = firstTabbable;
}
}
target.focus();
}, [] );
}

View File

@ -0,0 +1,187 @@
/**
* External dependencies
*/
import { includes } from 'lodash';
import { useCallback, useEffect, useRef } from '@wordpress/element';
/**
* Input types which are classified as button types, for use in considering
* whether element is a (focus-normalized) button.
*
* @type {string[]}
*/
const INPUT_BUTTON_TYPES = [ 'button', 'submit' ];
/**
* @typedef {HTMLButtonElement | HTMLLinkElement | HTMLInputElement} FocusNormalizedButton
*/
// Disable reason: Rule doesn't support predicate return types
/* eslint-disable jsdoc/valid-types */
/**
* Returns true if the given element is a button element subject to focus
* normalization, or false otherwise.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*
* @param {EventTarget} eventTarget The target from a mouse or touch event.
*
* @return {eventTarget is FocusNormalizedButton} Whether element is a button.
*/
function isFocusNormalizedButton( eventTarget ) {
if ( ! ( eventTarget instanceof window.HTMLElement ) ) {
return false;
}
switch ( eventTarget.nodeName ) {
case 'A':
case 'BUTTON':
return true;
case 'INPUT':
return includes(
INPUT_BUTTON_TYPES,
/** @type {HTMLInputElement} */ ( eventTarget ).type
);
}
return false;
}
/* eslint-enable jsdoc/valid-types */
/**
* @typedef {import('react').SyntheticEvent} SyntheticEvent
*/
/**
* @callback EventCallback
* @param {SyntheticEvent} event input related event.
*/
/**
* @typedef FocusOutsideReactElement
* @property {EventCallback} handleFocusOutside callback for a focus outside event.
*/
/**
* @typedef {import('react').MutableRefObject<FocusOutsideReactElement | undefined>} FocusOutsideRef
*/
/**
* @typedef {Object} FocusOutsideReturnValue
* @property {EventCallback} onFocus An event handler for focus events.
* @property {EventCallback} onBlur An event handler for blur events.
* @property {EventCallback} onMouseDown An event handler for mouse down events.
* @property {EventCallback} onMouseUp An event handler for mouse up events.
* @property {EventCallback} onTouchStart An event handler for touch start events.
* @property {EventCallback} onTouchEnd An event handler for touch end events.
*/
/**
* A react hook that can be used to check whether focus has moved outside the
* element the event handlers are bound to.
*
* @param {EventCallback} onFocusOutside A callback triggered when focus moves outside
* the element the event handlers are bound to.
*
* @return {FocusOutsideReturnValue} An object containing event handlers. Bind the event handlers
* to a wrapping element element to capture when focus moves
* outside that element.
*/
export default function useFocusOutside( onFocusOutside ) {
const currentOnFocusOutside = useRef( onFocusOutside );
useEffect( () => {
currentOnFocusOutside.current = onFocusOutside;
}, [ onFocusOutside ] );
const preventBlurCheck = useRef( false );
/**
* @type {import('react').MutableRefObject<number | undefined>}
*/
const blurCheckTimeoutId = useRef();
/**
* Cancel a blur check timeout.
*/
const cancelBlurCheck = useCallback( () => {
clearTimeout( blurCheckTimeoutId.current );
}, [] );
// Cancel blur checks on unmount.
useEffect( () => {
return () => cancelBlurCheck();
}, [] );
// Cancel a blur check if the callback or ref is no longer provided.
useEffect( () => {
if ( ! onFocusOutside ) {
cancelBlurCheck();
}
}, [ onFocusOutside, cancelBlurCheck ] );
/**
* Handles a mousedown or mouseup event to respectively assign and
* unassign a flag for preventing blur check on button elements. Some
* browsers, namely Firefox and Safari, do not emit a focus event on
* button elements when clicked, while others do. The logic here
* intends to normalize this as treating click on buttons as focus.
*
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/button#Clicking_and_focus
*
* @param {SyntheticEvent} event Event for mousedown or mouseup.
*/
const normalizeButtonFocus = useCallback( ( event ) => {
const { type, target } = event;
const isInteractionEnd = includes( [ 'mouseup', 'touchend' ], type );
if ( isInteractionEnd ) {
preventBlurCheck.current = false;
} else if ( isFocusNormalizedButton( target ) ) {
preventBlurCheck.current = true;
}
}, [] );
/**
* A callback triggered when a blur event occurs on the element the handler
* is bound to.
*
* Calls the `onFocusOutside` callback in an immediate timeout if focus has
* move outside the bound element and is still within the document.
*
* @param {SyntheticEvent} event Blur event.
*/
const queueBlurCheck = useCallback( ( event ) => {
// React does not allow using an event reference asynchronously
// due to recycling behavior, except when explicitly persisted.
event.persist();
// Skip blur check if clicking button. See `normalizeButtonFocus`.
if ( preventBlurCheck.current ) {
return;
}
blurCheckTimeoutId.current = 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() ) {
event.preventDefault();
return;
}
if ( typeof currentOnFocusOutside.current === 'function' ) {
currentOnFocusOutside.current( event );
}
}, 0 );
}, [] );
return {
onFocus: cancelBlurCheck,
onMouseDown: normalizeButtonFocus,
onMouseUp: normalizeButtonFocus,
onTouchStart: normalizeButtonFocus,
onTouchEnd: normalizeButtonFocus,
onBlur: queueBlurCheck,
};
}

View File

@ -9065,8 +9065,7 @@
"integrity": "sha512-/YFkF0wwYmF0M58wPVrTtFopVwqdsmMAcrgQGnUFIj3JwXQumKR1co99DhDkjJm9vDMYXFTniwKqwvhBxdviSw==",
"dev": true,
"requires": {
"@babel/runtime-corejs2": "7.7.4",
"locutus": "2.0.11"
"@babel/runtime-corejs2": "7.7.4"
},
"dependencies": {
"@babel/runtime-corejs2": {
@ -9075,8 +9074,7 @@
"integrity": "sha512-hKNcmHQbBSJFnZ82ewYtWDZ3fXkP/l1XcfRtm7c8gHPM/DMecJtFFBEp7KMLZTuHwwb7RfemHdsEnd7L916Z6A==",
"dev": true,
"requires": {
"core-js": "^2.6.5",
"regenerator-runtime": "^0.13.2"
"core-js": "^2.6.5"
}
}
}
@ -10198,11 +10196,7 @@
"resolved": "/mnt/renovate/gh/woocommerce/woocommerce-admin/packages/navigation",
"dev": true,
"requires": {
"@babel/runtime-corejs2": "7.12.5",
"@woocommerce/experimental": "file:packages/experimental",
"history": "4.10.1",
"lodash": "4.17.15",
"qs": "6.9.6"
"lodash": "4.17.15"
},
"dependencies": {
"lodash": {
@ -14790,6 +14784,15 @@
"integrity": "sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw==",
"optional": true
},
"bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"optional": true,
"requires": {
"file-uri-to-path": "1.0.0"
}
},
"bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@ -20544,6 +20547,12 @@
}
}
},
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"optional": true
},
"filelist": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.2.tgz",
@ -25925,6 +25934,7 @@
"integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==",
"optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
}
@ -32591,21 +32601,6 @@
"object-assign": "^4.1.0"
}
},
"react-click-outside": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/react-click-outside/-/react-click-outside-3.0.1.tgz",
"integrity": "sha512-d0KWFvBt+esoZUF15rL2UBB7jkeAqLU8L/Ny35oLK6fW6mIbOv/ChD+ExF4sR9PD26kVx+9hNfD0FTIqRZEyRQ==",
"requires": {
"hoist-non-react-statics": "^2.1.1"
},
"dependencies": {
"hoist-non-react-statics": {
"version": "2.5.5",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz",
"integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw=="
}
}
},
"react-color": {
"version": "2.19.3",
"resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz",
@ -38224,6 +38219,7 @@
"dev": true,
"optional": true,
"requires": {
"bindings": "^1.5.0",
"nan": "^2.12.1"
}
}
@ -39349,4 +39345,4 @@
"dev": true
}
}
}
}

View File

@ -114,9 +114,9 @@
"github-label-sync": "2.0.0",
"gridicons": "3.3.1",
"interpolate-components": "1.1.1",
"memize": "^1.1.0",
"memoize-one": "5.1.1",
"qs": "6.9.6",
"react-click-outside": "3.0.1",
"react-dates": "17.2.0",
"react-router-dom": "5.2.0",
"react-transition-group": "4.4.1",