Skip to content

Commit

Permalink
Add a useFocusReturn hook (#27572)
Browse files Browse the repository at this point in the history
  • Loading branch information
youknowriad committed Dec 10, 2020
1 parent 395fa96 commit a110765
Show file tree
Hide file tree
Showing 14 changed files with 518 additions and 623 deletions.
16 changes: 0 additions & 16 deletions packages/components/src/higher-order/with-focus-return/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

`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 `FocusReturnProvider` 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 `FocusReturnProvider` and `withFocusReturn` is that focus will be returned to the most recent focused element which is still present in the document.

## Usage

### `withFocusReturn`
Expand Down Expand Up @@ -71,17 +69,3 @@ const EnhancedMyComponent = withFocusReturn( {
},
} )( MyComponent );
```

### `FocusReturnProvider`

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

function App() {
return (
<FocusReturnProvider>
{ /* ... */ }
</FocusReturnProvider>
);
}
```
66 changes: 0 additions & 66 deletions packages/components/src/higher-order/with-focus-return/context.js

This file was deleted.

114 changes: 27 additions & 87 deletions packages/components/src/higher-order/with-focus-return/index.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,9 @@
/**
* 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';
import { createHigherOrderComponent, useFocusReturn } from '@wordpress/compose';
import deprecated from '@wordpress/deprecated';

/**
* Returns true if the given object is component-like. An object is component-
Expand All @@ -37,86 +28,35 @@ function isComponentLike( object ) {
* describing the component and the
* focus return characteristics.
*
* @return {WPComponent} Component with the focus restauration behaviour.
* @return {Function} Higher Order Component with the focus restauration behaviour.
*/
function withFocusReturn( options ) {
// Normalize as overloaded form `withFocusReturn( options )( Component )`
// or as `withFocusReturn( Component )`.
export default createHigherOrderComponent( ( options ) => {
const HoC = ( { onFocusReturn } = {} ) => ( WrappedComponent ) => {
const WithFocusReturn = ( props ) => {
const ref = useFocusReturn( onFocusReturn );
return (
<div ref={ ref }>
<WrappedComponent { ...props } />
</div>
);
};

return WithFocusReturn;
};

if ( isComponentLike( options ) ) {
const WrappedComponent = options;
return withFocusReturn( {} )( WrappedComponent );
return HoC()( WrappedComponent );
}

const { onFocusReturn = stubTrue } = options;

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

this.ownFocusedElements = new Set();
this.setIsFocusedFalse = () => ( this.isFocused = false );
this.setIsFocusedTrue = ( event ) => {
this.ownFocusedElements.add( event.target );
this.isFocused = true;
};
}
return HoC( options );
}, 'withFocusReturn' );

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

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 stack = without(
this.props.focusHistory,
...ownFocusedElements
);

let candidate;

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

render() {
return (
<div
onFocus={ this.setIsFocusedTrue }
onBlur={ this.setIsFocusedFalse }
>
<WrappedComponent { ...this.props.childProps } />
</div>
);
}
}

return ( props ) => (
<Consumer>
{ ( context ) => (
<FocusReturn
childProps={ props }
focusHistory={ context }
/>
) }
</Consumer>
);
};
}
export const Provider = ( { children } ) => {
deprecated( 'wp.components.FocusReturnProvider component', {
hint:
'This provider is not used anymore. You can just remove it from your codebase',
} );

export default createHigherOrderComponent( withFocusReturn, 'withFocusReturn' );
export { Provider };
return children;
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* External dependencies
*/
import renderer from 'react-test-renderer';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';

/**
* WordPress dependencies
Expand All @@ -12,7 +12,7 @@ import { Component } from '@wordpress/element';
/**
* Internal dependencies
*/
import withFocusReturn, { Provider } from '../';
import withFocusReturn from '../';

class Test extends Component {
render() {
Expand Down Expand Up @@ -70,16 +70,11 @@ describe( 'withFocusReturn()', () => {
} );

it( 'should not switch focus back to the bound focus element', () => {
const { unmount } = render(
<Provider>
<Composite />
</Provider>,
{
container: document.body.appendChild(
document.createElement( 'div' )
),
}
);
const { unmount } = render( <Composite />, {
container: document.body.appendChild(
document.createElement( 'div' )
),
} );

// Change activeElement.
switchFocusTo.focus();
Expand All @@ -91,67 +86,23 @@ describe( 'withFocusReturn()', () => {
} );

it( 'should switch focus back when unmounted while having focus', () => {
const { container, unmount } = render(
<Provider>
<Composite />
</Provider>,
{
container: document.body.appendChild(
document.createElement( 'div' )
),
}
);
const { container, unmount } = render( <Composite />, {
container: document.body.appendChild(
document.createElement( 'div' )
),
} );

const textarea = container.querySelector( 'textarea' );
fireEvent.focusIn( textarea, { target: textarea } );
textarea.focus();
expect( document.activeElement ).toBe( textarea );

// Should return to the activeElement saved with this component.
unmount();
render( <div></div>, {
container,
} );
expect( document.activeElement ).toBe( activeElement );
} );

it( 'should switch focus to the most recent still-available focus target', () => {
const TestComponent = ( props ) => (
<Provider>
<input name="first" />
{ props.renderSecondInput && <input name="second" /> }
{ props.renderComposite && <Composite /> }
</Provider>
);

const { container, rerender } = render(
<TestComponent renderSecondInput />,
{
container: document.body.appendChild(
document.createElement( 'div' )
),
}
);

const firstInput = container.querySelector( 'input[name="first"]' );
firstInput.focus();

const secondInput = container.querySelector(
'input[name="second"]'
);
secondInput.focus();

expect( document.activeElement ).toBe( secondInput );

rerender( <TestComponent renderSecondInput renderComposite /> );
const textarea = container.querySelector( 'textarea' );
textarea.focus();

expect( document.activeElement ).toBe( textarea );

rerender( <TestComponent renderComposite /> );

expect( document.activeElement ).toBe( textarea );

rerender( <TestComponent /> );

expect( document.activeElement ).toBe( firstInput );
} );
} );
} );
Loading

0 comments on commit a110765

Please sign in to comment.