Skip to content

Commit

Permalink
Components: Add support for named arguments in the navigator componen…
Browse files Browse the repository at this point in the history
…ts (#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
  • Loading branch information
youknowriad authored Feb 9, 2023
1 parent 79b9fde commit 8406acf
Show file tree
Hide file tree
Showing 12 changed files with 258 additions and 26 deletions.
6 changes: 6 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/components/src/navigator/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,8 @@ const initialContextValue: NavigatorContextType = {
location: {},
goTo: () => {},
goBack: () => {},
addScreen: () => {},
removeScreen: () => {},
params: {},
};
export const NavigatorContext = createContext( initialContextValue );
Original file line number Diff line number Diff line change
Expand Up @@ -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' }`.
112 changes: 104 additions & 8 deletions packages/components/src/navigator/navigator-provider/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' >,
Expand All @@ -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,
];
} );
},
[]
);
Expand All @@ -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();
Expand Down
33 changes: 22 additions & 11 deletions packages/components/src/navigator/navigator-screen/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
);
}

Expand All @@ -131,7 +142,7 @@ function UnconnectedNavigatorScreen(
isInitialLocation,
isMatch,
location.isBack,
previousLocation?.focusTargetSelector,
location.focusTargetSelector,
] );

const mergedWrapperRef = useMergeRefs( [ forwardedRef, wrapperRef ] );
Expand Down
30 changes: 27 additions & 3 deletions packages/components/src/navigator/stories/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 > = {
Expand Down Expand Up @@ -46,7 +47,7 @@ const Template: ComponentStory< typeof NavigatorProvider > = ( {
<CardBody>
<p>This is the home screen.</p>

<HStack justify="flex-start" wrap>
<VStack alignment="left">
<NavigatorButton variant="secondary" path="/child">
Navigate to child screen.
</NavigatorButton>
Expand All @@ -62,6 +63,10 @@ const Template: ComponentStory< typeof NavigatorProvider > = ( {
Navigate to screen with sticky content.
</NavigatorButton>

<NavigatorButton variant="secondary" path="/product/1">
Navigate to product screen with id 1.
</NavigatorButton>

<Dropdown
renderToggle={ ( {
isOpen,
Expand All @@ -86,7 +91,7 @@ const Template: ComponentStory< typeof NavigatorProvider > = ( {
</Card>
) }
/>
</HStack>
</VStack>
</CardBody>
</Card>
</NavigatorScreen>
Expand Down Expand Up @@ -166,6 +171,10 @@ const Template: ComponentStory< typeof NavigatorProvider > = ( {
</CardFooter>
</Card>
</NavigatorScreen>

<NavigatorScreen path="/product/:id">
<ProductDetails />
</NavigatorScreen>
</NavigatorProvider>
);

Expand Down Expand Up @@ -208,3 +217,18 @@ function MetaphorIpsum( { quantity }: { quantity: number } ) {
</>
);
}

function ProductDetails() {
const { params } = useNavigator();

return (
<Card>
<CardBody>
<NavigatorBackButton variant="secondary">
Go back
</NavigatorBackButton>
<p>This is the screen for the product with id: { params.id }</p>
</CardBody>
</Card>
);
}
50 changes: 50 additions & 0 deletions packages/components/src/navigator/test/router.ts
Original file line number Diff line number Diff line change
@@ -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' ] },
} );
} );
} );
Loading

1 comment on commit 8406acf

@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 8406acf.
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/4132377688
📝 Reported issues:

Please sign in to comment.