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
56 changes: 56 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,56 @@
/**
* 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() {
const { children, className } = this.props;

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

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

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

/**
* Internal dependencies
*/
import Provider, { Consumer } from './context';

/**
* Returns true if the given object is component-like. An object is component-
* like if it is an instance of wp.element.Component, or is a function.
*
* @param {*} object Object to test.
*
* @return {boolean} Whether object is component-like.
*/
function isComponentLike( object ) {
return (
object instanceof Component ||
typeof object === 'function'
);
}

/**
* Higher Order Component used to be used to wrap disposable elements like
* sidebars, modals, dropdowns. When mounting the wrapped component, we track a
* reference to the current active element so we know where to restore focus
* when the component is unmounted.
*
* @param {WPElement} WrappedComponent The disposable component.
* @param {(WPComponent|Object)} options The component to be enhanced with
* focus return behavior, or an object
* describing the component and the
* focus return characteristics.
*
* @return {Component} Component with the focus restauration behaviour.
*/
export default createHigherOrderComponent(
( WrappedComponent ) => {
return class extends Component {
function withFocusReturn( options ) {
// Normalize as overloaded form `withFocusReturn( options )( Component )`
// or as `withFocusReturn( Component )`.
if ( isComponentLike( options ) ) {
return withFocusReturn( {} )( options );
}

const { onFocusReturn = stubTrue } = options;

return function( WrappedComponent ) {
class FocusReturn extends Component {
constructor() {
super( ...arguments );

Expand All @@ -27,13 +63,31 @@ export default createHigherOrderComponent(

componentWillUnmount() {
const { activeElementOnMount, isFocused } = this;
if ( ! activeElementOnMount ) {

if ( ! isFocused ) {
return;
}

// 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 { body, activeElement } = document;
if ( isFocused || null === activeElement || body === activeElement ) {
activeElementOnMount.focus();
const stack = [
...this.props.focusHistory,
activeElementOnMount,
];

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

Expand All @@ -47,6 +101,15 @@ export default createHigherOrderComponent(
</div>
);
}
};
}, 'withFocusReturn'
);
}

return ( props ) => (
<Consumer>
{ ( context ) => <FocusReturn { ...props } { ...context } /> }
</Consumer>
);
};
}

export default createHigherOrderComponent( withFocusReturn, 'withFocusReturn' );
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 );
} );
} );
} );
2 changes: 1 addition & 1 deletion packages/components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,6 @@ export { default as withConstrainedTabbing } from './higher-order/with-constrain
export { default as withFallbackStyles } from './higher-order/with-fallback-styles';
export { default as withFilters } from './higher-order/with-filters';
export { default as withFocusOutside } from './higher-order/with-focus-outside';
export { default as withFocusReturn } from './higher-order/with-focus-return';
export { default as withFocusReturn, Provider as FocusReturnProvider } from './higher-order/with-focus-return';
export { default as withNotices } from './higher-order/with-notices';
export { default as withSpokenMessages } from './higher-order/with-spoken-messages';
12 changes: 9 additions & 3 deletions packages/edit-post/src/components/layout/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { Button, Popover, ScrollLock, navigateRegions } from '@wordpress/components';
import {
Button,
Popover,
ScrollLock,
FocusReturnProvider,
navigateRegions,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { PreserveScrollInReorder } from '@wordpress/block-editor';
import {
Expand Down Expand Up @@ -66,7 +72,7 @@ function Layout( {
tabIndex: -1,
};
return (
<div className={ className }>
<FocusReturnProvider className={ className }>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if I use two of these providers? Say each BlockEditorProvider uses one?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if I use two of these providers? Say each BlockEditorProvider uses one?

Generally, I'd say it should probably be avoided, if possible. The component should fall under a "one-per-app" recommendation.

That said, in practice it shouldn't be too problematic. Any withFocusReturn-enhanced component would rely on its closest provider as a source to determine where focus returns. The only potential problem is that the highest provider would still detect focus changes into an area technically governed by another provider, so they're not mutually exclusive. It's hard to imagine it would result in any unexpected behavior, though.

<FullscreenMode />
<BrowserURL />
<UnsavedChangesWarning />
Expand Down Expand Up @@ -126,7 +132,7 @@ function Layout( {
) }
<Popover.Slot />
<PluginArea />
</div>
</FocusReturnProvider>
);
}

Expand Down
41 changes: 27 additions & 14 deletions packages/edit-post/src/components/sidebar/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,32 +17,45 @@ const { Fill, Slot } = createSlotFill( 'Sidebar' );
*
* @return {Object} The rendered sidebar.
*/
const Sidebar = ( { children, label } ) => {
function Sidebar( { children, label, className } ) {
return (
<div
className={ classnames( 'edit-post-sidebar', className ) }
role="region"
aria-label={ label }
tabIndex="-1"
>
{ children }
</div>
);
}

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

function AnimatedSidebarFill( props ) {
return (
<Fill>
<Animate type="slide-in" options={ { origin: 'left' } }>
{ ( { className } ) => (
<div
className={ classnames( 'edit-post-sidebar', className ) }
role="region"
aria-label={ label }
tabIndex="-1"
>
{ children }
</div>
) }
{ () => <Sidebar { ...props } /> }
</Animate>
</Fill>
);
};
}

const WrappedSidebar = compose(
withSelect( ( select, { name } ) => ( {
isActive: select( 'core/edit-post' ).getActiveGeneralSidebarName() === name,
} ) ),
ifCondition( ( { isActive } ) => isActive ),
withFocusReturn,
)( Sidebar );
)( AnimatedSidebarFill );

WrappedSidebar.Slot = Slot;

Expand Down