From 8406acff9d554a4c319298cb8bee935e838f4426 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Thu, 9 Feb 2023 09:33:31 +0100 Subject: [PATCH] Components: Add support for named arguments in the navigator components (#47827) * Components: Add support for named arguments in the navigator components * Refactor to use useMemo * Improve the types * Switch to React's useId * Refactor how we store the focus target * Add unit tests * Add changelog entry * Rename the Screen type * Improve the types of the params argument * Add unit test * Early return * Add documentation --- package-lock.json | 6 + packages/components/CHANGELOG.md | 1 + packages/components/package.json | 1 + packages/components/src/navigator/context.ts | 3 + .../navigator/navigator-provider/README.md | 4 + .../navigator-provider/component.tsx | 112 ++++++++++++++++-- .../navigator/navigator-screen/component.tsx | 33 ++++-- .../src/navigator/stories/index.tsx | 30 ++++- .../components/src/navigator/test/router.ts | 50 ++++++++ packages/components/src/navigator/types.ts | 18 ++- .../components/src/navigator/use-navigator.ts | 3 +- .../components/src/navigator/utils/router.ts | 23 ++++ 12 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 packages/components/src/navigator/test/router.ts create mode 100644 packages/components/src/navigator/utils/router.ts diff --git a/package-lock.json b/package-lock.json index 5ef93bb067ded..dfdb2d4c0b7be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17466,6 +17466,7 @@ "highlight-words-core": "^1.2.2", "lodash": "^4.17.21", "memize": "^1.1.0", + "path-to-regexp": "^6.2.1", "re-resizable": "^6.4.0", "react-colorful": "^5.3.1", "reakit": "^1.3.8", @@ -17479,6 +17480,11 @@ "version": "2.29.3", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==" + }, + "path-to-regexp": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.2.1.tgz", + "integrity": "sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==" } } }, diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 05548a4cdee41..b8b5b957ed815 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -6,6 +6,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)). ### Internal diff --git a/packages/components/package.json b/packages/components/package.json index f31816feecb48..35acb65b66f9f 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -67,6 +67,7 @@ "highlight-words-core": "^1.2.2", "lodash": "^4.17.21", "memize": "^1.1.0", + "path-to-regexp": "^6.2.1", "re-resizable": "^6.4.0", "react-colorful": "^5.3.1", "reakit": "^1.3.8", diff --git a/packages/components/src/navigator/context.ts b/packages/components/src/navigator/context.ts index 13863aacc5c91..fd069b616a7f8 100644 --- a/packages/components/src/navigator/context.ts +++ b/packages/components/src/navigator/context.ts @@ -12,5 +12,8 @@ const initialContextValue: NavigatorContextType = { location: {}, goTo: () => {}, goBack: () => {}, + addScreen: () => {}, + removeScreen: () => {}, + params: {}, }; export const NavigatorContext = createContext( initialContextValue ); diff --git a/packages/components/src/navigator/navigator-provider/README.md b/packages/components/src/navigator/navigator-provider/README.md index d552d2bb20634..6f90cf31198e9 100644 --- a/packages/components/src/navigator/navigator-provider/README.md +++ b/packages/components/src/navigator/navigator-provider/README.md @@ -70,3 +70,7 @@ The `location` object represent the current location, and has a few properties: - `path`: `string`. The path associated to the location. - `isBack`: `boolean`. A flag that is `true` when the current location was reached by navigating backwards in the location stack. - `isInitial`: `boolean`. A flag that is `true` only for the first (root) location in the location stack. + +### `params`: `Record< string, string | string[] >` + +The parsed record of parameters from the current location. For example if the current screen path is `/product/:productId` and the location is `/product/123`, then `params` will be `{ productId: '123' }`. diff --git a/packages/components/src/navigator/navigator-provider/component.tsx b/packages/components/src/navigator/navigator-provider/component.tsx index e80beaec9d14c..77447d6e97fca 100644 --- a/packages/components/src/navigator/navigator-provider/component.tsx +++ b/packages/components/src/navigator/navigator-provider/component.tsx @@ -7,7 +7,14 @@ import { css } from '@emotion/react'; /** * WordPress dependencies */ -import { useMemo, useState, useCallback } from '@wordpress/element'; +import { + useMemo, + useState, + useCallback, + useReducer, + useRef, +} from '@wordpress/element'; +import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -24,7 +31,26 @@ import type { NavigatorProviderProps, NavigatorLocation, NavigatorContext as NavigatorContextType, + Screen, } from '../types'; +import { patternMatch } from '../utils/router'; + +type MatchedPath = ReturnType< typeof patternMatch >; +type ScreenAction = { type: string; screen: Screen }; + +function screensReducer( + state: Screen[] = [], + action: ScreenAction +): Screen[] { + switch ( action.type ) { + case 'add': + return [ ...state, action.screen ]; + case 'remove': + return state.filter( ( s: Screen ) => s.id !== action.screen.id ); + } + + return state; +} function UnconnectedNavigatorProvider( props: WordPressComponentProps< NavigatorProviderProps, 'div' >, @@ -40,18 +66,84 @@ function UnconnectedNavigatorProvider( path: initialPath, }, ] ); + const [ screens, dispatch ] = useReducer( screensReducer, [] ); + const currentMatch = useRef< MatchedPath >(); + const matchedPath = useMemo( () => { + let currentPath: string | undefined; + if ( + locationHistory.length === 0 || + ( currentPath = + locationHistory[ locationHistory.length - 1 ].path ) === + undefined + ) { + currentMatch.current = undefined; + return undefined; + } + + const resolvePath = ( path: string ) => { + const newMatch = patternMatch( path, screens ); + + // If the new match is the same as the current match, + // return the previous one for performance reasons. + if ( + currentMatch.current && + newMatch && + isShallowEqual( + newMatch.params, + currentMatch.current.params + ) && + newMatch.id === currentMatch.current.id + ) { + return currentMatch.current; + } + + return newMatch; + }; + + const newMatch = resolvePath( currentPath ); + currentMatch.current = newMatch; + return newMatch; + }, [ screens, locationHistory ] ); + + const addScreen = useCallback( + ( screen: Screen ) => dispatch( { type: 'add', screen } ), + [] + ); + + const removeScreen = useCallback( + ( screen: Screen ) => dispatch( { type: 'remove', screen } ), + [] + ); const goTo: NavigatorContextType[ 'goTo' ] = useCallback( ( path, options = {} ) => { - setLocationHistory( ( prevLocationHistory ) => [ - ...prevLocationHistory, - { - ...options, + setLocationHistory( ( prevLocationHistory ) => { + const { focusTargetSelector, ...restOptions } = options; + + const newLocation = { + ...restOptions, path, isBack: false, hasRestoredFocus: false, - }, - ] ); + }; + + if ( prevLocationHistory.length < 1 ) { + return [ newLocation ]; + } + + return [ + ...prevLocationHistory.slice( 0, -1 ), + // Assign `focusTargetSelector` to the previous location in history + // (the one we just navigated from). + { + ...prevLocationHistory[ + prevLocationHistory.length - 1 + ], + focusTargetSelector, + }, + newLocation, + ]; + } ); }, [] ); @@ -78,10 +170,14 @@ function UnconnectedNavigatorProvider( ...locationHistory[ locationHistory.length - 1 ], isInitial: locationHistory.length === 1, }, + params: matchedPath ? matchedPath.params : {}, + match: matchedPath ? matchedPath.id : undefined, goTo, goBack, + addScreen, + removeScreen, } ), - [ locationHistory, goTo, goBack ] + [ locationHistory, matchedPath, goTo, goBack, addScreen, removeScreen ] ); const cx = useCx(); diff --git a/packages/components/src/navigator/navigator-screen/component.tsx b/packages/components/src/navigator/navigator-screen/component.tsx index 266bd553e0a8d..15cec834d8b8a 100644 --- a/packages/components/src/navigator/navigator-screen/component.tsx +++ b/packages/components/src/navigator/navigator-screen/component.tsx @@ -10,12 +10,14 @@ import { css } from '@emotion/react'; * WordPress dependencies */ import { focus } from '@wordpress/dom'; -import { useContext, useEffect, useMemo, useRef } from '@wordpress/element'; import { - useReducedMotion, - useMergeRefs, - usePrevious, -} from '@wordpress/compose'; + useContext, + useEffect, + useMemo, + useRef, + useId, +} from '@wordpress/element'; +import { useReducedMotion, useMergeRefs } from '@wordpress/compose'; import { isRTL } from '@wordpress/i18n'; import { escapeAttribute } from '@wordpress/escape-html'; @@ -48,17 +50,26 @@ function UnconnectedNavigatorScreen( props: Props, forwardedRef: ForwardedRef< any > ) { + const screenId = useId(); const { children, className, path, ...otherProps } = useContextSystem( props, 'NavigatorScreen' ); const prefersReducedMotion = useReducedMotion(); - const { location } = useContext( NavigatorContext ); - const isMatch = location.path === escapeAttribute( path ); + const { location, match, addScreen, removeScreen } = + useContext( NavigatorContext ); + const isMatch = match === screenId; const wrapperRef = useRef< HTMLDivElement >( null ); - const previousLocation = usePrevious( location ); + useEffect( () => { + const screen = { + id: screenId, + path: escapeAttribute( path ), + }; + addScreen( screen ); + return () => removeScreen( screen ); + }, [ screenId, path, addScreen, removeScreen ] ); const cx = useCx(); const classes = useMemo( @@ -110,9 +121,9 @@ function UnconnectedNavigatorScreen( // 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 ) { + if ( location.isBack && location?.focusTargetSelector ) { elementToFocus = wrapperRef.current.querySelector( - previousLocation.focusTargetSelector + location.focusTargetSelector ); } @@ -131,7 +142,7 @@ function UnconnectedNavigatorScreen( isInitialLocation, isMatch, location.isBack, - previousLocation?.focusTargetSelector, + location.focusTargetSelector, ] ); const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] ); diff --git a/packages/components/src/navigator/stories/index.tsx b/packages/components/src/navigator/stories/index.tsx index a1f6e2fbf0c9a..ffc7a87ae1cc1 100644 --- a/packages/components/src/navigator/stories/index.tsx +++ b/packages/components/src/navigator/stories/index.tsx @@ -8,13 +8,14 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; */ import Button from '../../button'; import { Card, CardBody, CardFooter, CardHeader } from '../../card'; -import { HStack } from '../../h-stack'; +import { VStack } from '../../v-stack'; import Dropdown from '../../dropdown'; import { NavigatorProvider, NavigatorScreen, NavigatorButton, NavigatorBackButton, + useNavigator, } from '..'; const meta: ComponentMeta< typeof NavigatorProvider > = { @@ -46,7 +47,7 @@ const Template: ComponentStory< typeof NavigatorProvider > = ( {

This is the home screen.

- + Navigate to child screen. @@ -62,6 +63,10 @@ const Template: ComponentStory< typeof NavigatorProvider > = ( { Navigate to screen with sticky content. + + Navigate to product screen with id 1. + + = ( { ) } /> - +
@@ -166,6 +171,10 @@ const Template: ComponentStory< typeof NavigatorProvider > = ( { + + + + ); @@ -208,3 +217,18 @@ function MetaphorIpsum( { quantity }: { quantity: number } ) { ); } + +function ProductDetails() { + const { params } = useNavigator(); + + return ( + + + + Go back + +

This is the screen for the product with id: { params.id }

+
+
+ ); +} diff --git a/packages/components/src/navigator/test/router.ts b/packages/components/src/navigator/test/router.ts new file mode 100644 index 0000000000000..7c60b846a4ab1 --- /dev/null +++ b/packages/components/src/navigator/test/router.ts @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ +import { patternMatch } from '../utils/router'; + +describe( 'patternMatch', () => { + it( 'should return undefined if not pattern is matched', () => { + const result = patternMatch( '/test', [ { id: 'route', path: '/' } ] ); + expect( result ).toBeUndefined(); + } ); + + it( 'should match a pattern with no params', () => { + const result = patternMatch( '/test', [ + { id: 'route', path: '/test' }, + ] ); + expect( result ).toEqual( { id: 'route', params: {} } ); + } ); + + it( 'should match a pattern with params', () => { + const result = patternMatch( '/test/123', [ + { id: 'route', path: '/test/:id' }, + ] ); + expect( result ).toEqual( { id: 'route', params: { id: '123' } } ); + } ); + + it( 'should match the first pattern in case of ambiguity', () => { + const result = patternMatch( '/test/123', [ + { id: 'route1', path: '/test/:id' }, + { id: 'route2', path: '/test/123' }, + ] ); + expect( result ).toEqual( { id: 'route1', params: { id: '123' } } ); + } ); + + it( 'should match a pattern with optional params', () => { + const result = patternMatch( '/test', [ + { id: 'route', path: '/test/:id?' }, + ] ); + expect( result ).toEqual( { id: 'route', params: {} } ); + } ); + + it( 'should return an array of matches for the same param', () => { + const result = patternMatch( '/some/basic/route', [ + { id: 'route', path: '/:test+' }, + ] ); + expect( result ).toEqual( { + id: 'route', + params: { test: [ 'some', 'basic', 'route' ] }, + } ); + } ); +} ); diff --git a/packages/components/src/navigator/types.ts b/packages/components/src/navigator/types.ts index 62477db27a595..98494095e0038 100644 --- a/packages/components/src/navigator/types.ts +++ b/packages/components/src/navigator/types.ts @@ -8,6 +8,8 @@ import type { ReactNode } from 'react'; */ import type { ButtonAsButtonProps } from '../button/types'; +export type MatchParams = Record< string, string | string[] >; + type NavigateOptions = { focusTargetSelector?: string; }; @@ -19,14 +21,19 @@ export type NavigatorLocation = NavigateOptions & { hasRestoredFocus?: boolean; }; -export type NavigatorContext = { +// Returned by the `useNavigator` hook. +export type Navigator = { location: NavigatorLocation; + params: MatchParams; goTo: ( path: string, options?: NavigateOptions ) => void; goBack: () => void; }; -// Returned by the `useNavigator` hook. -export type Navigator = NavigatorContext; +export type NavigatorContext = Navigator & { + addScreen: ( screen: Screen ) => void; + removeScreen: ( screen: Screen ) => void; + match?: string; +}; export type NavigatorProviderProps = { /** @@ -66,3 +73,8 @@ export type NavigatorButtonProps = NavigatorBackButtonProps & { */ attributeName?: string; }; + +export type Screen = { + id: string; + path: string; +}; diff --git a/packages/components/src/navigator/use-navigator.ts b/packages/components/src/navigator/use-navigator.ts index 052be3d0be498..bef5d37f5039f 100644 --- a/packages/components/src/navigator/use-navigator.ts +++ b/packages/components/src/navigator/use-navigator.ts @@ -13,12 +13,13 @@ import type { Navigator } from './types'; * Retrieves a `navigator` instance. */ function useNavigator(): Navigator { - const { location, goTo, goBack } = useContext( NavigatorContext ); + const { location, params, goTo, goBack } = useContext( NavigatorContext ); return { location, goTo, goBack, + params, }; } diff --git a/packages/components/src/navigator/utils/router.ts b/packages/components/src/navigator/utils/router.ts new file mode 100644 index 0000000000000..5675c415c200f --- /dev/null +++ b/packages/components/src/navigator/utils/router.ts @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { match } from 'path-to-regexp'; + +/** + * Internal dependencies + */ +import type { Screen, MatchParams } from '../types'; + +export function patternMatch( path: string, screens: Screen[] ) { + for ( const screen of screens ) { + const matchingFunction = match< MatchParams >( screen.path, { + decode: decodeURIComponent, + } ); + const matched = matchingFunction( path ); + if ( matched ) { + return { params: matched.params, id: screen.id }; + } + } + + return undefined; +}