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

Add parent navigation support for the navigator component #47883

Merged
merged 15 commits into from
Feb 13, 2023
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,12 @@
"markdown_source": "../packages/components/src/navigator/navigator-screen/README.md",
"parent": "components"
},
{
"title": "NavigatorToParentButton",
"slug": "navigator-to-parent-button",
"markdown_source": "../packages/components/src/navigator/navigator-to-parent-button/README.md",
"parent": "components"
},
{
"title": "Notice",
"slug": "notice",
Expand Down
1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- `ColorPalette`, `GradientPicker`, `PaletteEdit`, `ToolsPanel`: add new props to set a custom heading level ([43848](https://github.com/WordPress/gutenberg/pull/43848) and [#47788](https://github.com/WordPress/gutenberg/pull/47788)).
- `ColorPalette`: ensure text label contrast checking works with CSS variables ([#47373](https://github.com/WordPress/gutenberg/pull/47373)).
- `Navigator`: Support dynamic paths with parameters ([#47827](https://github.com/WordPress/gutenberg/pull/47827)).
- `Navigator`: Support hierarchical paths navigation and add `NavigatorToParentButton` component ([#47883](https://github.com/WordPress/gutenberg/pull/47883)).

### Internal

Expand Down
1 change: 1 addition & 0 deletions packages/components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export {
NavigatorScreen as __experimentalNavigatorScreen,
NavigatorButton as __experimentalNavigatorButton,
NavigatorBackButton as __experimentalNavigatorBackButton,
NavigatorToParentButton as __experimentalNavigatorToParentButton,
useNavigator as __experimentalUseNavigator,
} from './navigator';
export { default as Notice } from './notice';
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/navigator/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const initialContextValue: NavigatorContextType = {
location: {},
goTo: () => {},
goBack: () => {},
goToParent: () => {},
addScreen: () => {},
removeScreen: () => {},
params: {},
Expand Down
1 change: 1 addition & 0 deletions packages/components/src/navigator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export { NavigatorProvider } from './navigator-provider';
export { NavigatorScreen } from './navigator-screen';
export { NavigatorButton } from './navigator-button';
export { NavigatorBackButton } from './navigator-back-button';
export { NavigatorToParentButton } from './navigator-to-parent-button';
export { default as useNavigator } from './use-navigator';
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,6 @@ The `NavigatorBackButton` component can be used to navigate to a screen and shou

Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example.

## Props

The component accepts the following props:

### `onClick`: `React.MouseEventHandler< HTMLElement >`

The callback called in response to a `click` event.

- Required: No

### `path`: `string`

The path of the screen to navigate to.

- Required: Yes

### Inherited props

`NavigatorBackButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href`.
`NavigatorBackButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`.
15 changes: 10 additions & 5 deletions packages/components/src/navigator/navigator-back-button/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,31 @@ import { useCallback } from '@wordpress/element';
import { useContextSystem, WordPressComponentProps } from '../../ui/context';
import Button from '../../button';
import useNavigator from '../use-navigator';
import type { NavigatorBackButtonProps } from '../types';
import type { NavigatorBackButtonHookProps } from '../types';

export function useNavigatorBackButton(
props: WordPressComponentProps< NavigatorBackButtonProps, 'button' >
props: WordPressComponentProps< NavigatorBackButtonHookProps, 'button' >
) {
const {
onClick,
as = Button,
goToParent: goToParentProp = false,
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
...otherProps
} = useContextSystem( props, 'NavigatorBackButton' );

const { goBack } = useNavigator();
const { goBack, goToParent } = useNavigator();
const handleClick: React.MouseEventHandler< HTMLButtonElement > =
useCallback(
( e ) => {
e.preventDefault();
goBack();
if ( goToParentProp ) {
goToParent();
} else {
goBack();
}
onClick?.( e );
},
[ goBack, onClick ]
[ goToParentProp, goToParent, goBack, onClick ]
);

return {
Expand Down
25 changes: 21 additions & 4 deletions packages/components/src/navigator/navigator-provider/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>

The `NavigatorProvider` component allows rendering nested views/panels/menus (via the [`NavigatorScreen` component](/packages/components/src/navigator/navigator-screen/README.md)) and navigate between these different states (via the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) and [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) components or the `useNavigator` hook). The Global Styles sidebar is an example of this.
The `NavigatorProvider` component allows rendering nested views/panels/menus (via the [`NavigatorScreen` component](/packages/components/src/navigator/navigator-screen/README.md)) and navigate between these different states (via the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md), [`NavigatorToParentButton`](/packages/components/src/navigator/navigator-to-parent-button/README.md) and [`NavigatorBackButton`](/packages/components/src/navigator/navigator-back-button/README.md) components or the `useNavigator` hook). The Global Styles sidebar is an example of this.

## Usage

Expand All @@ -13,7 +13,7 @@ import {
__experimentalNavigatorProvider as NavigatorProvider,
__experimentalNavigatorScreen as NavigatorScreen,
__experimentalNavigatorButton as NavigatorButton,
__experimentalNavigatorBackButton as NavigatorBackButton,
__experimentalNavigatorToParentButton as NavigatorToParentButton,
} from '@wordpress/components';

const MyNavigation = () => (
Expand All @@ -27,13 +27,21 @@ const MyNavigation = () => (

<NavigatorScreen path="/child">
<p>This is the child screen.</p>
<NavigatorBackButton>
<NavigatorToParentButton>
Go back
</NavigatorBackButton>
</NavigatorToParentButton>
</NavigatorScreen>
</NavigatorProvider>
);
```
**Important note**

Parent/child navigation only works if the path you define are hierarchical, following a URL-like scheme where each path segment is separated by the `/` character.
For example:
- `/` is the root of all paths. There should always be a screen with `path="/"`.
- `/parent/child` is a child of `/parent`.
- `/parent/child/grand-child` is a child of `/parent/child`.
- `/parent/:param` is a child of `/parent` as well.

## Props

Expand All @@ -58,6 +66,15 @@ The `goTo` function allows navigating to a given path. The second argument can a
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.
- `isBack`: `boolean`. An optional property used to specify whether the navigation should be considered as backwards (thus enabling focus restoration when possible, and causing the animation to be backwards too)

### `goToParent`: `() => void;`

The `goToParent` function allows navigating to the parent screen.

Parent/child navigation only works if the path you define are hierarchical (see note above).

When a match is not found, the function will try to recursively navigate the path hierarchy until a matching screen (or the root `/`) are found.

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

Expand Down
102 changes: 81 additions & 21 deletions packages/components/src/navigator/navigator-provider/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
useCallback,
useReducer,
useRef,
useEffect,
} from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';

Expand All @@ -33,11 +34,13 @@ import type {
NavigatorContext as NavigatorContextType,
Screen,
} from '../types';
import { patternMatch } from '../utils/router';
import { patternMatch, findParent } from '../utils/router';

type MatchedPath = ReturnType< typeof patternMatch >;
type ScreenAction = { type: string; screen: Screen };

const MAX_HISTORY_LENGTH = 50;

function screensReducer(
state: Screen[] = [],
action: ScreenAction
Expand Down Expand Up @@ -66,7 +69,15 @@ function UnconnectedNavigatorProvider(
path: initialPath,
},
] );
const currentLocationHistory = useRef< NavigatorLocation[] >( [] );
const [ screens, dispatch ] = useReducer( screensReducer, [] );
const currentScreens = useRef< Screen[] >( [] );
useEffect( () => {
currentScreens.current = screens;
}, [ screens ] );
useEffect( () => {
currentLocationHistory.current = locationHistory;
}, [ locationHistory ] );
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I hate to do this kind of useEffect. This is basically the perfect use case for the React's proposed useEvent. I'm looking forward to using it.

const currentMatch = useRef< MatchedPath >();
const matchedPath = useMemo( () => {
let currentPath: string | undefined;
Expand Down Expand Up @@ -115,15 +126,47 @@ function UnconnectedNavigatorProvider(
[]
);

const goBack: NavigatorContextType[ 'goBack' ] = useCallback( () => {
setLocationHistory( ( prevLocationHistory ) => {
if ( prevLocationHistory.length <= 1 ) {
return prevLocationHistory;
}
return [
...prevLocationHistory.slice( 0, -2 ),
{
...prevLocationHistory[ prevLocationHistory.length - 2 ],
isBack: true,
hasRestoredFocus: false,
},
];
} );
}, [] );

const goTo: NavigatorContextType[ 'goTo' ] = useCallback(
( path, options = {} ) => {
setLocationHistory( ( prevLocationHistory ) => {
const { focusTargetSelector, ...restOptions } = options;
const {
focusTargetSelector,
isBack = false,
...restOptions
} = options;

const isNavigatingToPreviousPath =
isBack &&
currentLocationHistory.current.length > 1 &&
currentLocationHistory.current[
currentLocationHistory.current.length - 2
].path === path;

if ( isNavigatingToPreviousPath ) {
goBack();
return;
}

setLocationHistory( ( prevLocationHistory ) => {
const newLocation = {
...restOptions,
path,
isBack: false,
isBack,
hasRestoredFocus: false,
};

Expand All @@ -132,7 +175,12 @@ function UnconnectedNavigatorProvider(
}

return [
...prevLocationHistory.slice( 0, -1 ),
...prevLocationHistory.slice(
prevLocationHistory.length > MAX_HISTORY_LENGTH - 1
? 1
: 0,
-1
),
// Assign `focusTargetSelector` to the previous location in history
// (the one we just navigated from).
{
Expand All @@ -145,24 +193,27 @@ function UnconnectedNavigatorProvider(
];
} );
},
[]
[ goBack ]
);

const goBack: NavigatorContextType[ 'goBack' ] = useCallback( () => {
setLocationHistory( ( prevLocationHistory ) => {
if ( prevLocationHistory.length <= 1 ) {
return prevLocationHistory;
const goToParent: NavigatorContextType[ 'goToParent' ] =
useCallback( () => {
const currentPath =
currentLocationHistory.current[
currentLocationHistory.current.length - 1
].path;
if ( currentPath === undefined ) {
return;
}
return [
...prevLocationHistory.slice( 0, -2 ),
{
...prevLocationHistory[ prevLocationHistory.length - 2 ],
isBack: true,
hasRestoredFocus: false,
},
];
} );
}, [] );
const parentPath = findParent(
currentPath,
currentScreens.current
);
if ( parentPath === undefined ) {
return;
}
goTo( parentPath, { isBack: true } );
}, [ goTo ] );

const navigatorContextValue: NavigatorContextType = useMemo(
() => ( {
Expand All @@ -174,10 +225,19 @@ function UnconnectedNavigatorProvider(
match: matchedPath ? matchedPath.id : undefined,
goTo,
goBack,
goToParent,
addScreen,
removeScreen,
} ),
[ locationHistory, matchedPath, goTo, goBack, addScreen, removeScreen ]
[
locationHistory,
matchedPath,
goTo,
goBack,
goToParent,
addScreen,
removeScreen,
]
);

const cx = useCx();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# `NavigatorToParentButton`

<div class="callout callout-alert">
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
</div>

The `NavigatorToParentButton` component can be used to navigate to a screen and should be used in combination with the [`NavigatorProvider`](/packages/components/src/navigator/navigator-provider/README.md), the [`NavigatorScreen`](/packages/components/src/navigator/navigator-screen/README.md) and the [`NavigatorButton`](/packages/components/src/navigator/navigator-button/README.md) components (or the `useNavigator` hook).

## Usage

Refer to [the `NavigatorProvider` component](/packages/components/src/navigator/navigator-provider/README.md#usage) for a usage example.

### Inherited props

`NavigatorToParentButton` also inherits all of the [`Button` props](/packages/components/src/button/README.md#props), except for `href` and `target`.
Loading