Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Components: Add support for named arguments in the navigator components #47827

Merged
merged 12 commits into from
Feb 9, 2023
8 changes: 7 additions & 1 deletion 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( () => {
ciampo marked this conversation as resolved.
Show resolved Hide resolved
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 ] );
youknowriad marked this conversation as resolved.
Show resolved Hide resolved

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 );
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
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">
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Noticed that alignment="left" doesn't work as expected in VStack. It seems useFlex forces alignItems to be "normal" regardless of the value provided by the useHStack hook (for the VStack case). I think that's probably a bug in useFlex that I'll explore separately.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you for spotting. Let us know if you have any further findings!

<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>
);
}
Loading