diff --git a/docs/manifest.json b/docs/manifest.json
index 8092db9d2e1f99..2f6e3338e1c046 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -1067,6 +1067,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",
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 8a2dee2bb4bdd6..126b6e44d43b12 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -10,6 +10,7 @@
- `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
diff --git a/packages/components/src/index.js b/packages/components/src/index.js
index a97682c3e49761..90ed86944dbec4 100644
--- a/packages/components/src/index.js
+++ b/packages/components/src/index.js
@@ -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';
diff --git a/packages/components/src/navigator/context.ts b/packages/components/src/navigator/context.ts
index fd069b616a7f89..e195621b03d205 100644
--- a/packages/components/src/navigator/context.ts
+++ b/packages/components/src/navigator/context.ts
@@ -12,6 +12,7 @@ const initialContextValue: NavigatorContextType = {
location: {},
goTo: () => {},
goBack: () => {},
+ goToParent: () => {},
addScreen: () => {},
removeScreen: () => {},
params: {},
diff --git a/packages/components/src/navigator/index.ts b/packages/components/src/navigator/index.ts
index 49f5655dc4b39c..74c69a0daa9c31 100644
--- a/packages/components/src/navigator/index.ts
+++ b/packages/components/src/navigator/index.ts
@@ -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';
diff --git a/packages/components/src/navigator/navigator-back-button/README.md b/packages/components/src/navigator/navigator-back-button/README.md
index d1470276975979..01d4221be536e5 100644
--- a/packages/components/src/navigator/navigator-back-button/README.md
+++ b/packages/components/src/navigator/navigator-back-button/README.md
@@ -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`.
diff --git a/packages/components/src/navigator/navigator-back-button/hook.ts b/packages/components/src/navigator/navigator-back-button/hook.ts
index 5e7adabf3d9bb7..437c60731cc953 100644
--- a/packages/components/src/navigator/navigator-back-button/hook.ts
+++ b/packages/components/src/navigator/navigator-back-button/hook.ts
@@ -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 {
diff --git a/packages/components/src/navigator/navigator-provider/README.md b/packages/components/src/navigator/navigator-provider/README.md
index 6f90cf31198e9d..8be27a65101843 100644
--- a/packages/components/src/navigator/navigator-provider/README.md
+++ b/packages/components/src/navigator/navigator-provider/README.md
@@ -4,7 +4,7 @@
This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
-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
@@ -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 = () => (
@@ -27,13 +27,21 @@ const MyNavigation = () => (
This is the child screen.
-
+
Go back
-
+
);
```
+**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
@@ -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`
diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator-provider/component.tsx
index 77447d6e97fca0..28e710fa577b2d 100644
--- a/packages/components/src/navigator/navigator-provider/component.tsx
+++ b/packages/components/src/navigator/navigator-provider/component.tsx
@@ -13,6 +13,7 @@ import {
useCallback,
useReducer,
useRef,
+ useEffect,
} from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
@@ -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
@@ -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;
@@ -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,
};
@@ -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).
{
@@ -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(
() => ( {
@@ -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();
diff --git a/packages/components/src/navigator/navigator-to-parent-button/README.md b/packages/components/src/navigator/navigator-to-parent-button/README.md
new file mode 100644
index 00000000000000..62dacc3dfa4ea5
--- /dev/null
+++ b/packages/components/src/navigator/navigator-to-parent-button/README.md
@@ -0,0 +1,15 @@
+# `NavigatorToParentButton`
+
+
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes.
+
+
+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`.
diff --git a/packages/components/src/navigator/navigator-to-parent-button/component.tsx b/packages/components/src/navigator/navigator-to-parent-button/component.tsx
new file mode 100644
index 00000000000000..5dd8ab1624ae91
--- /dev/null
+++ b/packages/components/src/navigator/navigator-to-parent-button/component.tsx
@@ -0,0 +1,65 @@
+/**
+ * External dependencies
+ */
+import type { ForwardedRef } from 'react';
+
+/**
+ * Internal dependencies
+ */
+import { contextConnect, WordPressComponentProps } from '../../ui/context';
+import { View } from '../../view';
+import { useNavigatorBackButton } from '../navigator-back-button/hook';
+import type { NavigatorToParentButtonProps } from '../types';
+
+function UnconnectedNavigatorToParentButton(
+ props: WordPressComponentProps< NavigatorToParentButtonProps, 'button' >,
+ forwardedRef: ForwardedRef< any >
+) {
+ const navigatorToParentButtonProps = useNavigatorBackButton( {
+ ...props,
+ goToParent: true,
+ } );
+
+ return ;
+}
+
+/*
+ * The `NavigatorToParentButton` component can be used to navigate to a screen and
+ * should be used in combination with the `NavigatorProvider`, the
+ * `NavigatorScreen` and the `NavigatorButton` components (or the `useNavigator`
+ * hook).
+ *
+ * @example
+ * ```jsx
+ * import {
+ * __experimentalNavigatorProvider as NavigatorProvider,
+ * __experimentalNavigatorScreen as NavigatorScreen,
+ * __experimentalNavigatorButton as NavigatorButton,
+ * __experimentalNavigatorToParentButton as NavigatorToParentButton,
+ * } from '@wordpress/components';
+ *
+ * const MyNavigation = () => (
+ *
+ *
+ *