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 = () => ( + * + * + *

This is the home screen.

+ * + * Navigate to child screen. + * + *
+ * + * + *

This is the child screen.

+ * + * Go to parent + * + *
+ *
+ * ); + * ``` + */ +export const NavigatorToParentButton = contextConnect( + UnconnectedNavigatorToParentButton, + 'NavigatorToParentButton' +); + +export default NavigatorToParentButton; diff --git a/packages/components/src/navigator/navigator-to-parent-button/index.ts b/packages/components/src/navigator/navigator-to-parent-button/index.ts new file mode 100644 index 00000000000000..f5218e456065e6 --- /dev/null +++ b/packages/components/src/navigator/navigator-to-parent-button/index.ts @@ -0,0 +1 @@ +export { default as NavigatorToParentButton } from './component'; diff --git a/packages/components/src/navigator/stories/index.tsx b/packages/components/src/navigator/stories/index.tsx index ffc7a87ae1cc17..4c8a968e881f19 100644 --- a/packages/components/src/navigator/stories/index.tsx +++ b/packages/components/src/navigator/stories/index.tsx @@ -15,6 +15,7 @@ import { NavigatorScreen, NavigatorButton, NavigatorBackButton, + NavigatorToParentButton, useNavigator, } from '..'; @@ -232,3 +233,68 @@ function ProductDetails() { ); } + +const NestedNavigatorTemplate: ComponentStory< typeof NavigatorProvider > = ( { + style, + ...props +} ) => ( + + + + + + Go to first child. + + + Go to second child. + + + + + + + + This is the first child + + Go back to parent + + + + + + + + This is the second child + + Go back to parent + + + Go to grand child. + + + + + + + + This is the grand child + + Go back to parent + + + + + +); + +export const NestedNavigator: ComponentStory< typeof NavigatorProvider > = + NestedNavigatorTemplate.bind( {} ); +NestedNavigator.args = { + initialPath: '/child2/grandchild', +}; diff --git a/packages/components/src/navigator/test/index.tsx b/packages/components/src/navigator/test/index.tsx index dbc2ba30ebdb37..927f6f3abf0e04 100644 --- a/packages/components/src/navigator/test/index.tsx +++ b/packages/components/src/navigator/test/index.tsx @@ -13,11 +13,13 @@ import { useState } from '@wordpress/element'; /** * Internal dependencies */ +import Button from '../../button'; import { NavigatorProvider, NavigatorScreen, NavigatorButton, NavigatorBackButton, + NavigatorToParentButton, useNavigator, } from '..'; @@ -75,6 +77,7 @@ const BUTTON_TEXT = { toInvalidHtmlPathScreen: 'Navigate to screen with an invalid HTML value as a path.', back: 'Go back', + backUsingGoTo: 'Go back using goTo', }; type CustomTestOnClickHandler = ( @@ -84,6 +87,7 @@ type CustomTestOnClickHandler = ( path: string; } | { type: 'goBack' } + | { type: 'goToParent' } ) => void; function CustomNavigatorButton( { @@ -105,6 +109,26 @@ function CustomNavigatorButton( { ); } +function CustomNavigatorGoToBackButton( { + path, + onClick, + ...props +}: Omit< ComponentPropsWithoutRef< typeof NavigatorButton >, 'onClick' > & { + onClick?: CustomTestOnClickHandler; +} ) { + const { goTo } = useNavigator(); + return ( + + + { BUTTON_TEXT.toChildScreen } + + + + +

{ SCREEN_TEXT.child }

+ { /* + * A button useful to test focus restoration. This button is the first + * tabbable item in the screen, but should not receive focus when + * navigating to screen as a result of a backwards navigation. + */ } + + + { BUTTON_TEXT.toNestedScreen } + + + { BUTTON_TEXT.back } + +
+ + +

{ SCREEN_TEXT.nested }

+ + { BUTTON_TEXT.back } + + + { BUTTON_TEXT.backUsingGoTo } + +
+ + + ); +}; + const getScreen = ( screenKey: keyof typeof SCREEN_TEXT ) => screen.getByText( SCREEN_TEXT[ screenKey ] ); const queryScreen = ( screenKey: keyof typeof SCREEN_TEXT ) => @@ -592,5 +699,43 @@ describe( 'Navigator', () => { getNavigationButton( 'toInvalidHtmlPathScreen' ) ).toHaveFocus(); } ); + + it( 'should restore focus while using goTo and goToParent', async () => { + const user = userEvent.setup(); + + render( ); + + expect( getScreen( 'home' ) ).toBeInTheDocument(); + + // Navigate to child screen. + await user.click( getNavigationButton( 'toChildScreen' ) ); + expect( getScreen( 'child' ) ).toBeInTheDocument(); + + // Navigate to nested screen. + await user.click( getNavigationButton( 'toNestedScreen' ) ); + expect( getScreen( 'nested' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'back' ) ).toBeInTheDocument(); + + // Navigate back to child screen using the back button. + await user.click( getNavigationButton( 'back' ) ); + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toNestedScreen' ) ).toHaveFocus(); + + // Re navigate to nested screen. + await user.click( getNavigationButton( 'toNestedScreen' ) ); + expect( getScreen( 'nested' ) ).toBeInTheDocument(); + expect( + getNavigationButton( 'backUsingGoTo' ) + ).toBeInTheDocument(); + + // Navigate back to child screen using the go to button. + await user.click( getNavigationButton( 'backUsingGoTo' ) ); + expect( getScreen( 'child' ) ).toBeInTheDocument(); + expect( getNavigationButton( 'toNestedScreen' ) ).toHaveFocus(); + + // Navigate back to home screen. + await user.click( getNavigationButton( 'back' ) ); + expect( getNavigationButton( 'toChildScreen' ) ).toHaveFocus(); + } ); } ); } ); diff --git a/packages/components/src/navigator/test/router.ts b/packages/components/src/navigator/test/router.ts index 7c60b846a4ab14..50e05fed47bb7c 100644 --- a/packages/components/src/navigator/test/router.ts +++ b/packages/components/src/navigator/test/router.ts @@ -1,7 +1,7 @@ /** * Internal dependencies */ -import { patternMatch } from '../utils/router'; +import { patternMatch, findParent } from '../utils/router'; describe( 'patternMatch', () => { it( 'should return undefined if not pattern is matched', () => { @@ -48,3 +48,75 @@ describe( 'patternMatch', () => { } ); } ); } ); + +describe( 'findParent', () => { + it( 'should return undefined if no parent is found', () => { + const result = findParent( '/test', [ + { id: 'route', path: '/test' }, + ] ); + expect( result ).toBeUndefined(); + } ); + + it( 'should return the parent path', () => { + const result = findParent( '/test', [ + { id: 'route1', path: '/test' }, + { id: 'route2', path: '/' }, + ] ); + expect( result ).toEqual( '/' ); + } ); + + it( 'should return to another parent path', () => { + const result = findParent( '/test/123', [ + { id: 'route1', path: '/test/:id' }, + { id: 'route2', path: '/test' }, + ] ); + expect( result ).toEqual( '/test' ); + } ); + + it( 'should return the parent path with params', () => { + const result = findParent( '/test/123/456', [ + { id: 'route1', path: '/test/:id/:subId' }, + { id: 'route2', path: '/test/:id' }, + ] ); + expect( result ).toEqual( '/test/123' ); + } ); + + it( 'should return the parent path with optional params', () => { + const result = findParent( '/test/123', [ + { id: 'route', path: '/test/:id?' }, + ] ); + expect( result ).toEqual( '/test' ); + } ); + + it( 'should return the grand parent if no parent found', () => { + const result = findParent( '/test/123/456', [ + { id: 'route1', path: '/test/:id/:subId' }, + { id: 'route2', path: '/test' }, + ] ); + expect( result ).toEqual( '/test' ); + } ); + + it( 'should return the root when no grand parent found', () => { + const result = findParent( '/test/nested/path', [ + { id: 'route1', path: '/other-path' }, + { id: 'route2', path: '/yet-another-path' }, + { id: 'root', path: '/' }, + ] ); + expect( result ).toEqual( '/' ); + } ); + + it( 'should return undefined when no potential parent found', () => { + const result = findParent( '/test/nested/path', [ + { id: 'route1', path: '/other-path' }, + { id: 'route2', path: '/yet-another-path' }, + ] ); + expect( result ).toBeUndefined(); + } ); + + it( 'should return undefined for non supported paths', () => { + const result = findParent( 'this-is-a-path', [ + { id: 'route', path: '/' }, + ] ); + expect( result ).toBeUndefined(); + } ); +} ); diff --git a/packages/components/src/navigator/types.ts b/packages/components/src/navigator/types.ts index 98494095e00387..f6d8f5c22b0a5f 100644 --- a/packages/components/src/navigator/types.ts +++ b/packages/components/src/navigator/types.ts @@ -12,11 +12,11 @@ export type MatchParams = Record< string, string | string[] >; type NavigateOptions = { focusTargetSelector?: string; + isBack?: boolean; }; export type NavigatorLocation = NavigateOptions & { isInitial?: boolean; - isBack?: boolean; path?: string; hasRestoredFocus?: boolean; }; @@ -27,6 +27,7 @@ export type Navigator = { params: MatchParams; goTo: ( path: string, options?: NavigateOptions ) => void; goBack: () => void; + goToParent: () => void; }; export type NavigatorContext = Navigator & { @@ -59,6 +60,17 @@ export type NavigatorScreenProps = { export type NavigatorBackButtonProps = ButtonAsButtonProps; +export type NavigatorBackButtonHookProps = NavigatorBackButtonProps & { + /** + * Whether we should navigate to the parent screen. + * + * @default 'false' + */ + goToParent?: boolean; +}; + +export type NavigatorToParentButtonProps = NavigatorBackButtonProps; + export type NavigatorButtonProps = NavigatorBackButtonProps & { /** * The path of the screen to navigate to. The value of this prop needs to be diff --git a/packages/components/src/navigator/use-navigator.ts b/packages/components/src/navigator/use-navigator.ts index bef5d37f5039ff..4d917649374d53 100644 --- a/packages/components/src/navigator/use-navigator.ts +++ b/packages/components/src/navigator/use-navigator.ts @@ -13,12 +13,14 @@ import type { Navigator } from './types'; * Retrieves a `navigator` instance. */ function useNavigator(): Navigator { - const { location, params, goTo, goBack } = useContext( NavigatorContext ); + const { location, params, goTo, goBack, goToParent } = + useContext( NavigatorContext ); return { location, goTo, goBack, + goToParent, params, }; } diff --git a/packages/components/src/navigator/utils/router.ts b/packages/components/src/navigator/utils/router.ts index 5675c415c200f2..6ff5be66661f98 100644 --- a/packages/components/src/navigator/utils/router.ts +++ b/packages/components/src/navigator/utils/router.ts @@ -8,12 +8,16 @@ import { match } from 'path-to-regexp'; */ import type { Screen, MatchParams } from '../types'; +function matchPath( path: string, pattern: string ) { + const matchingFunction = match< MatchParams >( pattern, { + decode: decodeURIComponent, + } ); + return matchingFunction( path ); +} + export function patternMatch( path: string, screens: Screen[] ) { for ( const screen of screens ) { - const matchingFunction = match< MatchParams >( screen.path, { - decode: decodeURIComponent, - } ); - const matched = matchingFunction( path ); + const matched = matchPath( path, screen.path ); if ( matched ) { return { params: matched.params, id: screen.id }; } @@ -21,3 +25,25 @@ export function patternMatch( path: string, screens: Screen[] ) { return undefined; } + +export function findParent( path: string, screens: Screen[] ) { + if ( ! path.startsWith( '/' ) ) { + return undefined; + } + const pathParts = path.split( '/' ); + let parentPath; + while ( pathParts.length > 1 && parentPath === undefined ) { + pathParts.pop(); + const potentialParentPath = + pathParts.join( '/' ) === '' ? '/' : pathParts.join( '/' ); + if ( + screens.find( ( screen ) => { + return matchPath( potentialParentPath, screen.path ) !== false; + } ) + ) { + parentPath = potentialParentPath; + } + } + + return parentPath; +} diff --git a/packages/edit-site/src/components/global-styles/header.js b/packages/edit-site/src/components/global-styles/header.js index 8167925098a68d..2cb1cc049a7908 100644 --- a/packages/edit-site/src/components/global-styles/header.js +++ b/packages/edit-site/src/components/global-styles/header.js @@ -7,7 +7,7 @@ import { __experimentalSpacer as Spacer, __experimentalHeading as Heading, __experimentalView as View, - __experimentalNavigatorBackButton as NavigatorBackButton, + __experimentalNavigatorToParentButton as NavigatorToParentButton, } from '@wordpress/components'; import { isRTL, __ } from '@wordpress/i18n'; import { chevronRight, chevronLeft } from '@wordpress/icons'; @@ -18,7 +18,7 @@ function ScreenHeader( { title, description } ) { - ; + return ( + + ); } export { NavigationButtonAsItem, NavigationBackButtonAsItem }; diff --git a/packages/edit-site/src/components/global-styles/ui.js b/packages/edit-site/src/components/global-styles/ui.js index 157ed92e2efabf..5372ce9e5fbd53 100644 --- a/packages/edit-site/src/components/global-styles/ui.js +++ b/packages/edit-site/src/components/global-styles/ui.js @@ -267,11 +267,6 @@ function GlobalStylesStyleBook( { onClose } ) { ) } onSelect={ ( blockName ) => { - // Clear navigator history by going back to the root. - const depth = path.match( /\//g ).length; - for ( let i = 0; i < depth; i++ ) { - navigator.goBack(); - } // Now go to the selected block. navigator.goTo( '/blocks/' + encodeURIComponent( blockName ) ); } } diff --git a/packages/edit-site/src/components/sidebar-navigation-screen/index.js b/packages/edit-site/src/components/sidebar-navigation-screen/index.js index a5f2eb2cc927b1..3f61a7c8208e84 100644 --- a/packages/edit-site/src/components/sidebar-navigation-screen/index.js +++ b/packages/edit-site/src/components/sidebar-navigation-screen/index.js @@ -4,7 +4,7 @@ import { __experimentalHStack as HStack, __experimentalVStack as VStack, - __experimentalNavigatorBackButton as NavigatorBackButton, + __experimentalNavigatorToParentButton as NavigatorToParentButton, __experimentalNavigatorScreen as NavigatorScreen, } from '@wordpress/components'; import { isRTL, __, sprintf } from '@wordpress/i18n'; @@ -29,7 +29,7 @@ export default function SidebarNavigationScreen( { className="edit-site-sidebar-navigation-screen__title-icon" > { parentTitle ? ( - { ).toBeVisible(); } ); - test( 'should clear Global Styles navigator history when example is clicked', async ( { + test( 'should allow to return Global Styles root when example is clicked', async ( { page, } ) => { await page.click( 'role=button[name="Blocks styles"]' ); @@ -109,11 +109,12 @@ test.describe( 'Style Book', () => { 'role=button[name="Open Quote styles in Styles panel"i]' ); + await page.click( 'role=button[name="Navigate to the previous view"]' ); await page.click( 'role=button[name="Navigate to the previous view"]' ); await expect( - page.locator( 'role=button[name="Navigate to the previous view"]' ) - ).not.toBeVisible(); + page.locator( 'role=button[name="Blocks styles"]' ) + ).toBeVisible(); } ); test( 'should disappear when closed', async ( { page } ) => {