Skip to content

Commit

Permalink
Add parent navigation support for the navigator component (#47883)
Browse files Browse the repository at this point in the history
Co-authored-by: Marco Ciampini <[email protected]>
  • Loading branch information
youknowriad and ciampo authored Feb 13, 2023
1 parent 4008678 commit 378290a
Show file tree
Hide file tree
Showing 23 changed files with 546 additions and 68 deletions.
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 @@ -11,6 +11,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,
...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 ] );
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

1 comment on commit 378290a

@github-actions
Copy link

Choose a reason for hiding this comment

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

Flaky tests detected in 378290a.
Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

🔍 Workflow run URL: https://github.com/WordPress/gutenberg/actions/runs/4161397330
📝 Reported issues:

Please sign in to comment.