Skip to content

Commit

Permalink
Components: Return focus by stack memory
Browse files Browse the repository at this point in the history
  • Loading branch information
aduth committed Mar 15, 2019
1 parent a203b08 commit 5586d29
Show file tree
Hide file tree
Showing 9 changed files with 101 additions and 92 deletions.
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

0 comments on commit 5586d29

Please sign in to comment.