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

Navigation Component: Merge feature branch #24920

Closed
wants to merge 13 commits into from
Closed
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,12 @@
"markdown_source": "../packages/components/src/navigable-container/README.md",
"parent": "components"
},
{
"title": "Navigation",
"slug": "navigation",
"markdown_source": "../packages/components/src/navigation/README.md",
"parent": "components"
},
{
"title": "Notice",
"slug": "notice",
Expand Down
2 changes: 2 additions & 0 deletions packages/components/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## Unreleased

- Introduce `Navigation` component as `__experimentalNavigation` for displaying a heirarchy of items.

## 10.0.0 (2020-07-07)

### Breaking Change
Expand Down
5 changes: 5 additions & 0 deletions packages/components/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ export { default as MenuItemsChoice } from './menu-items-choice';
export { default as Modal } from './modal';
export { default as ScrollLock } from './scroll-lock';
export { NavigableMenu, TabbableContainer } from './navigable-container';
export {
default as __experimentalNavigation,
NavigationMenu as __experimentalNavigationMenu,
NavigationMenuItem as __experimentalNavigationMenuItem,
} from './navigation';
export { default as Notice } from './notice';
export { default as __experimentalNumberControl } from './number-control';
export { default as NoticeList } from './notice/list';
Expand Down
155 changes: 155 additions & 0 deletions packages/components/src/navigation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Navigation

Render a flat array of menu items into a waterfall style hierarchy navigation.

## Usage

```jsx
import {
__experimentalNavigation as Navigation,
__experimentalNavigationMenu as NavigationMenu,
__experimentalNavigationMenuItem as NavigationMenuItem,
} from '@wordpress/components';
import { useState } from '@wordpress/compose';

const data = [
{
title: 'Item 1',
id: 'item-1',
},
{
title: 'Item 2',
id: 'item-2',
},
{
title: 'Category',
id: 'item-3',
badge: '2',
},
{
title: 'Child 1',
id: 'child-1',
parent: 'item-3',
badge: '1',
},
{
title: 'Child 2',
id: 'child-2',
parent: 'item-3',
},
];

const MyNavigation = () => {
const [ active, setActive ] = useState( 'item-1' );

return (
<Navigation activeItemId={ active } data={ data } rootTitle="Home">
{ ( { level, parentLevel, NavigationBackButton } ) => {
return (
<>
{ parentLevel && (
<NavigationBackButton>
<Icon icon={ arrowLeft } />
{ parentLevel.title }
</NavigationBackButton>
) }
<h1>{ level.title }</h1>
<NavigationMenu>
{ level.children.map( ( item ) => {
return (
<NavigationMenuItem
{ ...item }
key={ item.id }
onClick={ ( selected ) =>
setActive( selected.id )
}
/>
);
} ) }
</NavigationMenu>
</>
);
} }
</Navigation>
};
```

## Navigation Props

Navigation supports the following props.

### `data`

- Type: `array`
- Required: Yes

An array of config objects for each menu item.

Config objects can be represented

#### `config.title`
psealock marked this conversation as resolved.
Show resolved Hide resolved

- Type: `string`
- Required: Yes

A menu item's title.

#### `config.id`

- Type: `string|Number`
- Required: Yes

A menu item's id.

#### `config.parent`

- Type: `string|Number`
- Required: No

Specify a menu item's parent id. Defaults to the menu item's parent if none is provided.

#### `config.href`

- Type: `string`
- Required: No

Turn a menu item into a link by supplying a url.

#### `config.linkProps`

- Type: `object`
- Required: No

Supply properties passed to the menu-item.

#### `config.LinkComponent`

- Type: `Node`
- Required: No

Supply a React component to render as the menu item. This is useful for router link components for internal navigation.

### `activeItemId`

- Type: `string`
- Required: Yes

The active screen id.

### `rootTitle`

- Type: `string`
- Required: No

A top level title.

## NavigationMenuItem Props

NavigationMenuItem supports the following props.

### `onClick`

- Type: `function`
- Required: No

A callback to handle selection of a menu item.
116 changes: 116 additions & 0 deletions packages/components/src/navigation/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { useEffect, useMemo, useState } from '@wordpress/element';
import { usePrevious } from '@wordpress/compose';

/**
* Internal dependencies
*/
import Animate from '../animate';
import { Root } from './styles/navigation-styles';
import Button from '../button';

const Navigation = ( { activeItemId, children, data, rootTitle } ) => {
const [ activeLevelId, setActiveLevelId ] = useState( 'root' );

const appendItemData = ( item ) => {
return {
...item,
children: [],
parent: item.id === 'root' ? null : item.parent || 'root',
isActive: item.id === activeItemId,
setActiveLevelId,
};
};

const mapItems = ( itemData ) => {
const items = new Map(
[
{ id: 'root', parent: null, title: rootTitle },
...itemData,
].map( ( item ) => [ item.id, appendItemData( item ) ] )
);

items.forEach( ( item ) => {
const parentItem = items.get( item.parent );
if ( parentItem ) {
parentItem.children.push( item );
parentItem.hasChildren = true;
}
} );

return items;
};

const items = useMemo( () => mapItems( data ), [
data,
activeItemId,
rootTitle,
] );
const activeItem = items.get( activeItemId );
const previousActiveLevelId = usePrevious( activeLevelId );
const level = items.get( activeLevelId );
const parentLevel = level && items.get( level.parent );
const isNavigatingBack =
previousActiveLevelId &&
items.get( previousActiveLevelId ).parent === activeLevelId;

useEffect( () => {
if ( activeItem ) {
setActiveLevelId( activeItem.parent );
}
}, [] );

const NavigationBackButton = ( { children: backButtonChildren } ) => {
if ( ! parentLevel ) {
return null;
}

return (
<Button
className="components-navigation__back-button"
isPrimary
onClick={ () => setActiveLevelId( parentLevel.id ) }
>
{ backButtonChildren }
</Button>
);
};

return (
<Root className="components-navigation">
<Animate
key={ level.id }
type="slide-in"
options={ {
origin: isNavigatingBack ? 'right' : 'left',
} }
>
{ ( { className: animateClassName } ) => (
<div
className={ classnames(
'components-navigation__level',
animateClassName
) }
>
{ children( {
level,
NavigationBackButton,
parentLevel,
} ) }
</div>
) }
</Animate>
</Root>
);
};

export default Navigation;
export { default as NavigationMenu } from './menu';
export { default as NavigationMenuItem } from './menu-item';
Copy link
Contributor

Choose a reason for hiding this comment

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

Not a super fan of having default and named exports together, but I'd argue this is fine since we would end up importing from the package index anyway. 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Agree. I'm following a pattern already in place though:

export { default as TreeGridRow } from './row';
export { default as TreeGridCell } from './cell';
export { default as TreeGridItem } from './item';

Copy link
Contributor

Choose a reason for hiding this comment

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

Ha! I've tried opening a bunch of random index.js files and never found the same pattern, but I suspected it was there somewhere. 😄

75 changes: 75 additions & 0 deletions packages/components/src/navigation/menu-item.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* External dependencies
*/
import classnames from 'classnames';

/**
* WordPress dependencies
*/
import { Icon, chevronRight } from '@wordpress/icons';

/**
* Internal dependencies
*/
import Button from '../button';
import { MenuItemUI, BadgeUI } from './styles/navigation-styles';
import Text from '../text';

const NavigationMenuItem = ( props ) => {
const {
badge,
children,
hasChildren,
href,
id,
isActive,
LinkComponent,
linkProps,
onClick,
setActiveLevelId,
title,
} = props;
const classes = classnames( 'components-navigation__menu-item', {
'is-active': isActive,
} );

const handleClick = () => {
if ( children.length ) {
setActiveLevelId( id );
return;
}
if ( ! onClick ) {
return;
}
onClick( props );
};

const LinkComponentTag = LinkComponent ? LinkComponent : Button;

return (
<MenuItemUI className={ classes }>
<LinkComponentTag
className={ classes }
href={ ! children.length ? href : undefined }
onClick={ handleClick }
{ ...linkProps }
>
<Text
className="components-navigation__menu-item-title"
variant="body.small"
as="span"
>
{ title }
</Text>
{ badge && (
<BadgeUI className="components-navigation__menu-item-badge">
{ badge }
</BadgeUI>
) }
{ hasChildren ? <Icon icon={ chevronRight } /> : null }
</LinkComponentTag>
</MenuItemUI>
);
};

export default NavigationMenuItem;
12 changes: 12 additions & 0 deletions packages/components/src/navigation/menu.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Internal dependencies
*/
import { MenuUI } from './styles/navigation-styles';

const NavigationMenu = ( { children } ) => {
return (
<MenuUI className="components-navigation__menu">{ children }</MenuUI>
);
};

export default NavigationMenu;
Loading