Skip to content

Commit

Permalink
Navigator: add focus restoration (#38149)
Browse files Browse the repository at this point in the history
* `Navigator`: add `focusTargetSelector` to the options

* Use `focusTargetSelector` to restore focus when navigating back

* Update Storybook example to make use of `focusTargetSelector`

* Update the Preferences Modal to make use of `focusTargetSelector`

* Update the Global Styles Sidebar to make use of `focusTargetSelector`

* Update docs

* Assign fallback back to `push` options to avoid error when destructuring it

* Add focus restoration unit tests

* Merge wrapper refs

* CHANGELOG

* Fix `push` type by marking `options` as optional parameter

* Simplify stack operations: compute isInitial, use `usePrevious` for `focusTargetSelector`
  • Loading branch information
ciampo committed Feb 7, 2022
1 parent 6eff9a5 commit 957f8a6
Show file tree
Hide file tree
Showing 9 changed files with 175 additions and 40 deletions.
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
### Experimental

- Add basic history location support to `Navigator` ([#37416](https://github.com/WordPress/gutenberg/pull/37416)).
- Add focus restoration to `Navigator` ([#38149](https://github.com/WordPress/gutenberg/pull/38149)).

## 19.2.0 (2022-01-04)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@ The `navigator` instance has a few properties:

The `push` function allows navigating to a given path. The second argument can augment the navigation operations with different options.

There currently aren't any available options.
The available options are:

- `focusTargetSelector`: `string`. An optional property used to specify the CSS selector used to restore focus on the matching element when navigating back.

### `pop`: `() => void`

Expand Down
21 changes: 6 additions & 15 deletions packages/components/src/navigator/navigator-provider/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,28 +42,17 @@ function NavigatorProvider(
>( [
{
path: initialPath,
isBack: false,
isInitial: true,
},
] );

const push: NavigatorContextType[ 'push' ] = useCallback(
( path, options ) => {
// Force the `isBack` flag to `false` when navigating forward on both the
// previous and the new location.
// Also force the `isInitial` flag to `false` for the new location, to make
// sure it doesn't get overridden by mistake.
( path, options = {} ) => {
setLocationHistory( [
...locationHistory.slice( 0, -1 ),
{
...locationHistory[ locationHistory.length - 1 ],
isBack: false,
},
...locationHistory,
{
...options,
path,
isBack: false,
isInitial: false,
},
] );
},
Expand All @@ -72,7 +61,6 @@ function NavigatorProvider(

const pop: NavigatorContextType[ 'pop' ] = useCallback( () => {
if ( locationHistory.length > 1 ) {
// Force the `isBack` flag to `true` when navigating back.
setLocationHistory( [
...locationHistory.slice( 0, -2 ),
{
Expand All @@ -85,7 +73,10 @@ function NavigatorProvider(

const navigatorContextValue: NavigatorContextType = useMemo(
() => ( {
location: locationHistory[ locationHistory.length - 1 ],
location: {
...locationHistory[ locationHistory.length - 1 ],
isInitial: locationHistory.length === 1,
},
push,
pop,
} ),
Expand Down
60 changes: 50 additions & 10 deletions packages/components/src/navigator/navigator-screen/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ import { css } from '@emotion/react';
/**
* WordPress dependencies
*/
import { useContext, useEffect, useState, useMemo } from '@wordpress/element';
import { useReducedMotion, useFocusOnMount } from '@wordpress/compose';
import { focus } from '@wordpress/dom';
import { useContext, useEffect, useMemo, useRef } from '@wordpress/element';
import {
useReducedMotion,
useMergeRefs,
usePrevious,
} from '@wordpress/compose';
import { isRTL } from '@wordpress/i18n';

/**
Expand Down Expand Up @@ -47,7 +52,9 @@ function NavigatorScreen( props: Props, forwardedRef: Ref< any > ) {
const prefersReducedMotion = useReducedMotion();
const { location } = useContext( NavigatorContext );
const isMatch = location.path === path;
const ref = useFocusOnMount();
const wrapperRef = useRef< HTMLDivElement >( null );

const previousLocation = usePrevious( location );

const cx = useCx();
const classes = useMemo(
Expand All @@ -64,20 +71,53 @@ function NavigatorScreen( props: Props, forwardedRef: Ref< any > ) {
[ className, cx ]
);

// This flag is used to only apply the focus on mount when the actual path changes.
// It avoids the focus to happen on the first render.
const [ hasPathChanged, setHasPathChanged ] = useState( false );
// Focus restoration
const isInitialLocation = location.isInitial && ! location.isBack;
useEffect( () => {
setHasPathChanged( true );
}, [ path ] );
// Only attempt to restore focus:
// - if the current location is not the initial one (to avoid moving focus on page load)
// - when the screen becomes visible
// - if the wrapper ref has been assigned
if ( isInitialLocation || ! isMatch || ! wrapperRef.current ) {
return;
}

let elementToFocus: HTMLElement | null = null;

// When navigating back, if a selector is provided, use it to look for the
// target element (assumed to be a node inside the current NavigatorScreen)
if ( location.isBack && previousLocation?.focusTargetSelector ) {
elementToFocus = wrapperRef.current.querySelector(
previousLocation.focusTargetSelector
);
}

// If the previous query didn't run or find any element to focus, fallback
// to the first tabbable element in the screen (or the screen itself).
if ( ! elementToFocus ) {
const firstTabbable = ( focus.tabbable.find(
wrapperRef.current
) as HTMLElement[] )[ 0 ];

elementToFocus = firstTabbable ?? wrapperRef.current;
}

elementToFocus.focus();
}, [ isInitialLocation, isMatch ] );

const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] );

if ( ! isMatch ) {
return null;
}

if ( prefersReducedMotion ) {
return (
<View ref={ forwardedRef } className={ classes } { ...otherProps }>
<View
ref={ mergedWrapperRef }
className={ classes }
{ ...otherProps }
>
{ children }
</View>
);
Expand Down Expand Up @@ -120,7 +160,7 @@ function NavigatorScreen( props: Props, forwardedRef: Ref< any > ) {

return (
<motion.div
ref={ hasPathChanged ? ref : undefined }
ref={ mergedWrapperRef }
className={ classes }
{ ...otherProps }
{ ...animatedProps }
Expand Down
16 changes: 14 additions & 2 deletions packages/components/src/navigator/stories/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,23 @@ export default {

function NavigatorButton( { path, ...props } ) {
const { push } = useNavigator();
const dataAttrName = 'data-navigator-focusable-id';
const dataAttrValue = path;

const dataAttrCssSelector = `[${ dataAttrName }="${ dataAttrValue }"]`;

const buttonProps = {
...props,
[ dataAttrName ]: dataAttrValue,
};

return (
<Button
variant="secondary"
onClick={ () => push( path ) }
{ ...props }
onClick={ () =>
push( path, { focusTargetSelector: dataAttrCssSelector } )
}
{ ...buttonProps }
/>
);
}
Expand Down
68 changes: 60 additions & 8 deletions packages/components/src/navigator/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,30 @@ function NavigatorButton( { path, onClick, ...props } ) {
);
}

function NavigatorButtonWithFocusRestoration( { path, onClick, ...props } ) {
const { push } = useNavigator();
const dataAttrName = 'data-navigator-focusable-id';
const dataAttrValue = path;

const dataAttrCssSelector = `[${ dataAttrName }="${ dataAttrValue }"]`;

const buttonProps = {
...props,
[ dataAttrName ]: dataAttrValue,
};

return (
<button
onClick={ () => {
push( path, { focusTargetSelector: dataAttrCssSelector } );
// Used to spy on the values passed to `navigator.push`
onClick?.( { type: 'push', path } );
} }
{ ...buttonProps }
/>
);
}

function NavigatorBackButton( { onClick, ...props } ) {
const { pop } = useNavigator();
return (
Expand All @@ -65,28 +89,28 @@ const MyNavigation = ( {
<NavigatorProvider initialPath={ initialPath }>
<NavigatorScreen path={ PATHS.HOME }>
<p>This is the home screen.</p>
<NavigatorButton
path={ PATHS.CHILD }
onClick={ onNavigatorButtonClick }
>
Navigate to child screen.
</NavigatorButton>
<NavigatorButton
path={ PATHS.NOT_FOUND }
onClick={ onNavigatorButtonClick }
>
Navigate to non-existing screen.
</NavigatorButton>
<NavigatorButtonWithFocusRestoration
path={ PATHS.CHILD }
onClick={ onNavigatorButtonClick }
>
Navigate to child screen.
</NavigatorButtonWithFocusRestoration>
</NavigatorScreen>

<NavigatorScreen path={ PATHS.CHILD }>
<p>This is the child screen.</p>
<NavigatorButton
<NavigatorButtonWithFocusRestoration
path={ PATHS.NESTED }
onClick={ onNavigatorButtonClick }
>
Navigate to nested screen.
</NavigatorButton>
</NavigatorButtonWithFocusRestoration>
<NavigatorBackButton onClick={ onNavigatorButtonClick }>
Go back
</NavigatorBackButton>
Expand Down Expand Up @@ -298,4 +322,32 @@ describe( 'Navigator', () => {
type: 'push',
} );
} );

it( 'should restore focus correctly', () => {
render( <MyNavigation /> );

expect( getHomeScreen() ).toBeInTheDocument();

// Navigate to child screen
fireEvent.click( getToChildScreenButton() );

expect( getChildScreen() ).toBeInTheDocument();

// Navigate to nested screen
fireEvent.click( getToNestedScreenButton() );

expect( getNestedScreen() ).toBeInTheDocument();

// Navigate back to child screen, check that focus was correctly restored
fireEvent.click( getBackButton() );

expect( getChildScreen() ).toBeInTheDocument();
expect( getToNestedScreenButton() ).toHaveFocus();

// Navigate back to home screen, check that focus was correctly restored
fireEvent.click( getBackButton() );

expect( getHomeScreen() ).toBeInTheDocument();
expect( getToChildScreenButton() ).toHaveFocus();
} );
} );
6 changes: 4 additions & 2 deletions packages/components/src/navigator/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
*/
import type { ReactNode } from 'react';

type NavigateOptions = {};
type NavigateOptions = {
focusTargetSelector?: string;
};

export type NavigatorLocation = NavigateOptions & {
isInitial?: boolean;
Expand All @@ -13,7 +15,7 @@ export type NavigatorLocation = NavigateOptions & {

export type NavigatorContext = {
location: NavigatorLocation;
push: ( path: string, options: NavigateOptions ) => void;
push: ( path: string, options?: NavigateOptions ) => void;
pop: () => void;
};

Expand Down
20 changes: 19 additions & 1 deletion packages/edit-post/src/components/preferences-modal/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,25 @@ const PREFERENCES_MENU = 'preferences-menu';

function NavigationButton( { as: Tag = Button, path, ...props } ) {
const { push } = useNavigator();
return <Tag onClick={ () => push( path ) } { ...props } />;

const dataAttrName = 'data-navigator-focusable-id';
const dataAttrValue = path;

const dataAttrCssSelector = `[${ dataAttrName }="${ dataAttrValue }"]`;

const tagProps = {
...props,
[ dataAttrName ]: dataAttrValue,
};

return (
<Tag
onClick={ () =>
push( path, { focusTargetSelector: dataAttrCssSelector } )
}
{ ...tagProps }
/>
);
}

function NavigationBackButton( { as: Tag = Button, ...props } ) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,27 @@ function GenericNavigationButton( { icon, children, ...props } ) {
</Item>
);
}

function NavigationButton( { path, ...props } ) {
const { push } = useNavigator();

const dataAttrName = 'data-navigator-focusable-id';
const dataAttrValue = path;

const dataAttrCssSelector = `[${ dataAttrName }="${ dataAttrValue }"]`;

const buttonProps = {
...props,
[ dataAttrName ]: dataAttrValue,
};

return (
<GenericNavigationButton onClick={ () => push( path ) } { ...props } />
<GenericNavigationButton
onClick={ () =>
push( path, { focusTargetSelector: dataAttrCssSelector } )
}
{ ...buttonProps }
/>
);
}

Expand Down

0 comments on commit 957f8a6

Please sign in to comment.