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
46 changes: 46 additions & 0 deletions packages/components/src/higher-order/with-focus-return/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
# withFocusReturn

`withFocusReturn` is a higher-order component used typically in scenarios of short-lived elements (modals, dropdowns) where, upon the element's unmounting, focus should be restored to the focused element which had initiated it being rendered.

Optionally, it can be used in combination with a `FocusRenderProvider` which, when rendered toward the top of an application, will remember a history of elements focused during a session. This can provide safeguards for scenarios where one short-lived element triggers the creation of another (e.g. a dropdown menu triggering a modal display). The combined effect of `FocusRenderProvider` and `withFocusReturn` is that focus will be returned to the most recent focused element which is still present in the document.

## Usage

### `withFocusReturn`

```jsx
import { withFocusReturn, TextControl, Button } from '@wordpress/components';
import { withState } from '@wordpress/compose';
Expand Down Expand Up @@ -39,3 +45,43 @@ const MyComponentWithFocusReturn = withState( {
);
} );
```

`withFocusReturn` can optionally be called as a higher-order function creator. Provided an options object, a new higher-order function is returned.

Currently, the following options are supported:

#### `onFocusReturn`

An optional function which allows the developer to customize the focus return behavior. A return value of `false` should be returned from this function to indicate that the default focus return behavior should be skipped.

- Type: `Function`
- Required: No

_Example:_

```jsx
function MyComponent() {
return <textarea />;
}

const EnhancedMyComponent = withFocusReturn( {
onFocusReturn() {
document.getElementById( 'other-input' ).focus();
return false;
},
} )( MyComponent );
```

### `FocusReturnProvider`

```jsx
import { FocusReturnProvider } from '@wordpress/components';

function App() {
return (
<FocusReturnProvider>
{ /* ... */ }
</FocusReturnProvider>
);
}
```
71 changes: 71 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,71 @@
/**
* External dependencies
*/
import { uniq } from 'lodash';

/**
* 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;

// Push the focused element to the history stack, keeping only unique
// members but preferring the _last_ occurrence of any duplicates.
// Lodash's `uniq` behavior favors the first occurrence, so the array
// is temporarily reversed prior to it being called upon. Uniqueness
// helps avoid situations where, such as in a constrained tabbing area,
// the user changes focus enough within a transient element that the
// stack may otherwise only consist of members pending destruction, at
// which point focus might have been lost.
const nextFocusHistory = uniq(
[ ...focusHistory, event.target ]
.slice( -1 * MAX_STACK_LENGTH )
.reverse()
).reverse();

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 };
103 changes: 89 additions & 14 deletions packages/components/src/higher-order/with-focus-return/index.js
Original file line number Diff line number Diff line change
@@ -1,39 +1,105 @@
/**
* External dependencies
*/
import { stubTrue, without } 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 ) ) {
const WrappedComponent = options;
return withFocusReturn( {} )( WrappedComponent );
}

const { onFocusReturn = stubTrue } = options;

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

this.setIsFocusedTrue = () => this.isFocused = true;
this.setIsFocusedFalse = () => this.isFocused = false;
this.ownFocusedElements = new Set;
this.activeElementOnMount = document.activeElement;
this.setIsFocusedFalse = () => this.isFocused = false;
this.setIsFocusedTrue = ( event ) => {
this.ownFocusedElements.add( event.target );
this.isFocused = true;
};
}

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

if ( ! isFocused ) {
return;
}

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

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

Expand All @@ -47,6 +113,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 @@ -2,21 +2,22 @@
* External dependencies
*/
import renderer from 'react-test-renderer';
import { mount } from 'enzyme';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { Component, createElement } from '@wordpress/element';

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

class Test extends Component {
render() {
return (
<div className="test">Testing</div>
<div className="test"><textarea /></div>
);
}
}
Expand Down Expand Up @@ -47,7 +48,7 @@ describe( 'withFocusReturn()', () => {
const wrappedElementShallow = wrappedElement.children[ 0 ];
expect( wrappedElementShallow.props.className ).toBe( 'test' );
expect( wrappedElementShallow.type ).toBe( 'div' );
expect( wrappedElementShallow.children[ 0 ] ).toBe( 'Testing' );
expect( wrappedElementShallow.children[ 0 ].type ).toBe( 'textarea' );
} );

it( 'should pass additional props through to the wrapped element', () => {
Expand All @@ -71,17 +72,47 @@ describe( 'withFocusReturn()', () => {
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 );
it( 'should switch focus back when unmounted while having focus', () => {
const wrapper = mount( <Composite /> );
wrapper.find( 'textarea' ).at( 0 ).simulate( 'focus' );

// Should return to the activeElement saved with this component.
mountedComposite.unmount();
wrapper.unmount();
expect( document.activeElement ).toBe( activeElement );
} );

it( 'should switch focus to the most recent still-available focus target', () => {
const container = document.createElement( 'div' );
document.body.appendChild( container );
const wrapper = mount(
createElement(
( props ) => (
<Provider>
<input name="first" />
{ props.renderSecondInput && <input name="second" /> }
{ props.renderComposite && <Composite /> }
</Provider>
),
{ renderSecondInput: true }
),
{ attachTo: container }
);

function focus( selector ) {
const childWrapper = wrapper.find( selector );
const childNode = childWrapper.getDOMNode();
childWrapper.simulate( 'focus', { target: childNode } );
}

focus( 'input[name="first"]' );
jest.spyOn( wrapper.find( 'input[name="first"]' ).getDOMNode(), 'focus' );
focus( 'input[name="second"]' );
wrapper.setProps( { renderComposite: true } );
focus( 'textarea' );
wrapper.setProps( { renderSecondInput: false } );
wrapper.setProps( { renderComposite: false } );

expect( wrapper.find( 'input[name="first"]' ).getDOMNode().focus ).toHaveBeenCalled();
} );
} );
} );
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
Loading