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

Components: Add onFocusLoss option to withFocusReturn #14444

Merged
merged 11 commits into from
Mar 18, 2019
Prev Previous commit
Next Next commit
Components: Return focus by stack memory
  • Loading branch information
aduth committed Mar 15, 2019
commit 5586d29bda0a4928648315ff95c2e1b6a242b7f7
54 changes: 54 additions & 0 deletions packages/components/src/higher-order/with-focus-return/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* WordPress dependencies
*/
import { Component, createContext } from '@wordpress/element';

const { Provider, Consumer } = createContext( {
focusHistory: [],
} );

Provider.displayName = 'FocusReturnProvider';
Consumer.displayName = 'FocusReturnConsumer';

/**
* The maximum history length to capture for the focus stack. When exceeded,
* items should be shifted from the stack for each consecutive push.
*
* @type {number}
*/
const MAX_STACK_LENGTH = 100;

class FocusReturnProvider extends Component {
constructor() {
super( ...arguments );

this.onFocus = this.onFocus.bind( this );

this.state = {
focusHistory: [],
};
}

onFocus( event ) {
const { focusHistory } = this.state;
const nextFocusHistory = [
...focusHistory,
event.target,
].slice( -1 * MAX_STACK_LENGTH );

this.setState( { focusHistory: nextFocusHistory } );
}

render() {
return (
<Provider value={ this.state }>
<div onFocus={ this.onFocus }>
{ this.props.children }
</div>
</Provider>
);
}
}

export default FocusReturnProvider;
export { Consumer };
62 changes: 35 additions & 27 deletions packages/components/src/higher-order/with-focus-return/index.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
/**
* External dependencies
*/
import { stubTrue } from 'lodash';

/**
* WordPress dependencies
*/
import { Component, createContext } from '@wordpress/element';
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';

const { Provider, Consumer } = createContext( {
onFocusLoss: () => {},
} );

Provider.displayName = 'FocusReturnProvider';
Consumer.displayName = 'FocusReturnConsumer';
/**
* Internal dependencies
*/
import Provider, { Consumer } from './context';

/**
* Returns true if the given object is component-like. An object is component-
Expand All @@ -26,18 +29,6 @@ function isComponentLike( object ) {
);
}

/**
* Returns true if there is a focused element, or false otherwise.
*
* @return {boolean} Whether focused element exists.
*/
function hasFocusedElement() {
return (
null !== document.activeElement &&
document.body !== document.activeElement
);
}

/**
* Higher Order Component used to be used to wrap disposable elements like
* sidebars, modals, dropdowns. When mounting the wrapped component, we track a
Expand All @@ -58,6 +49,8 @@ function withFocusReturn( options ) {
return withFocusReturn( {} )( options );
}

const { onFocusReturn = stubTrue } = options;

return function( WrappedComponent ) {
class FocusReturn extends Component {
constructor() {
Expand All @@ -69,18 +62,33 @@ function withFocusReturn( options ) {
}

componentWillUnmount() {
const { onFocusLoss = this.props.onFocusLoss } = options;
const { activeElementOnMount, isFocused } = this;

if ( activeElementOnMount && ( isFocused || ! hasFocusedElement() ) ) {
activeElementOnMount.focus();
if ( ! isFocused ) {
return;
}

setTimeout( () => {
if ( ! hasFocusedElement() ) {
onFocusLoss();
// Defer to the component's own explicit focus return behavior,
// if specified. The function should return `false` to prevent
// the default behavior otherwise occurring here. This allows
// for support that the `onFocusReturn` decides to allow the
// default behavior to occur under some conditions.
if ( onFocusReturn() === false ) {
return;
}

const stack = [
...this.props.focusHistory,
activeElementOnMount,
];

let candidate;
while ( ( candidate = stack.pop() ) ) {
if ( document.body.contains( candidate ) ) {
candidate.focus();
return;
}
}, 0 );
}
}

render() {
Expand All @@ -104,4 +112,4 @@ function withFocusReturn( options ) {
}

export default createHigherOrderComponent( withFocusReturn, 'withFocusReturn' );
export { Provider, Consumer };
export { Provider };
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,5 @@ describe( 'withFocusReturn()', () => {
mountedComposite.unmount();
expect( document.activeElement ).toBe( switchFocusTo );
} );

it( 'should return focus to element associated with HOC', () => {
const mountedComposite = renderer.create( <Composite /> );
expect( getInstance( mountedComposite ).activeElementOnMount ).toBe( activeElement );

// Change activeElement.
document.activeElement.blur();
expect( document.activeElement ).toBe( document.body );

// Should return to the activeElement saved with this component.
mountedComposite.unmount();
expect( document.activeElement ).toBe( activeElement );
} );
} );
} );
17 changes: 3 additions & 14 deletions packages/components/src/modal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import ModalFrame from './frame';
import ModalHeader from './header';
import * as ariaHelper from './aria-helper';
import IsolatedEventContainer from '../isolated-event-container';
import { Provider as FocusReturnProvider } from '../higher-order/with-focus-return';

// Used to count the number of open modals.
let parentElement,
Expand Down Expand Up @@ -120,7 +119,6 @@ class Modal extends Component {
aria,
instanceId,
isDismissable,
onFocusLoss,
...otherProps
} = this.props;

Expand All @@ -129,7 +127,7 @@ class Modal extends Component {
// Disable reason: this stops mouse events from triggering tooltips and
// other elements underneath the modal overlay.
/* eslint-disable jsx-a11y/no-static-element-interactions */
let element = (
return createPortal(
<IsolatedEventContainer
className={ classnames( 'components-modal__screen-overlay', overlayClassName ) }
>
Expand Down Expand Up @@ -157,19 +155,10 @@ class Modal extends Component {
{ children }
</div>
</ModalFrame>
</IsolatedEventContainer>
</IsolatedEventContainer>,
this.node
);
/* eslint-enable jsx-a11y/no-static-element-interactions */

if ( onFocusLoss ) {
element = (
<FocusReturnProvider value={ { onFocusLoss } }>
{ element }
</FocusReturnProvider>
);
}

return createPortal( element, this.node );
}
}

Expand Down
20 changes: 0 additions & 20 deletions packages/edit-post/src/components/header/more-menu/modal.js

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { castArray } from 'lodash';
* WordPress dependencies
*/
import { Fragment } from '@wordpress/element';
import { KeyboardShortcuts } from '@wordpress/components';
import { Modal, KeyboardShortcuts } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { rawShortcut } from '@wordpress/keycodes';
import { withSelect, withDispatch } from '@wordpress/data';
Expand All @@ -17,7 +17,6 @@ import { compose } from '@wordpress/compose';
* Internal dependencies
*/
import shortcutConfig from './config';
import MoreMenuModal from '../header/more-menu/modal';

const MODAL_NAME = 'edit-post/keyboard-shortcut-help';

Expand Down Expand Up @@ -79,7 +78,7 @@ export function KeyboardShortcutHelpModal( { isModalActive, toggleModal } ) {
} }
/>
{ isModalActive && (
<MoreMenuModal
<Modal
className="edit-post-keyboard-shortcut-help"
title={ __( 'Keyboard Shortcuts' ) }
closeLabel={ __( 'Close' ) }
Expand All @@ -88,7 +87,7 @@ export function KeyboardShortcutHelpModal( { isModalActive, toggleModal } ) {
{ shortcutConfig.map( ( config, index ) => (
<ShortcutSection key={ index } { ...config } />
) ) }
</MoreMenuModal>
</Modal>
) }
</Fragment>
);
Expand Down
11 changes: 1 addition & 10 deletions packages/edit-post/src/components/layout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,16 +72,7 @@ function Layout( {
tabIndex: -1,
};
return (
<FocusReturnProvider
value={ {
onFocusLoss() {
const layout = document.querySelector( '.edit-post-header' );
if ( layout ) {
layout.focus();
}
},
} }
>
<FocusReturnProvider>
<div className={ className }>
<FullscreenMode />
<BrowserURL />
Expand Down
6 changes: 3 additions & 3 deletions packages/edit-post/src/components/options-modal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { get } from 'lodash';
/**
* WordPress dependencies
*/
import { Modal } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { withSelect, withDispatch } from '@wordpress/data';
import { compose } from '@wordpress/compose';
Expand All @@ -27,7 +28,6 @@ import {
EnablePanelOption,
} from './options';
import MetaBoxesSection from './meta-boxes-section';
import MoreMenuModal from '../header/more-menu/modal';

const MODAL_NAME = 'edit-post/options';

Expand All @@ -37,7 +37,7 @@ export function OptionsModal( { isModalActive, isViewable, closeModal } ) {
}

return (
<MoreMenuModal
<Modal
className="edit-post-options-modal"
title={ __( 'Options' ) }
closeLabel={ __( 'Close' ) }
Expand Down Expand Up @@ -73,7 +73,7 @@ export function OptionsModal( { isModalActive, isViewable, closeModal } ) {
</PageAttributesCheck>
</Section>
<MetaBoxesSection title={ __( 'Advanced Panels' ) } />
</MoreMenuModal>
</Modal>
);
}

Expand Down
3 changes: 2 additions & 1 deletion packages/edit-post/src/components/sidebar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ function Sidebar( { children, label, className } ) {
}

Sidebar = withFocusReturn( {
onFocusLoss() {
onFocusReturn() {
const button = document.querySelector( '.edit-post-header__settings [aria-label="Settings"]' );
if ( button ) {
button.focus();
return false;
}
},
} )( Sidebar );
Expand Down