diff --git a/docs/manifest.json b/docs/manifest.json
index 5ffb21b8e7c86f..2df0d018e20f7f 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -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",
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 693c21dc1a37f9..8027daa71459f7 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -2,6 +2,8 @@
## Unreleased
+- Introduce `Navigation` component as `__experimentalNavigation` for displaying a hierarchy of items.
+
## 10.0.0 (2020-07-07)
### Breaking Change
diff --git a/packages/components/src/index.js b/packages/components/src/index.js
index cc2bc1deb3ace8..177bfc406480a4 100644
--- a/packages/components/src/index.js
+++ b/packages/components/src/index.js
@@ -67,6 +67,10 @@ 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 } from './navigation';
+export { default as __experimentalNavigationGroup } from './navigation/group';
+export { default as __experimentalNavigationItem } from './navigation/item';
+export { default as __experimentalNavigationMenu } from './navigation/menu';
export { default as Notice } from './notice';
export { default as __experimentalNumberControl } from './number-control';
export { default as NoticeList } from './notice/list';
diff --git a/packages/components/src/navigation/README.md b/packages/components/src/navigation/README.md
new file mode 100644
index 00000000000000..52412144161c13
--- /dev/null
+++ b/packages/components/src/navigation/README.md
@@ -0,0 +1,193 @@
+# Navigation
+
+Render a navigation list with optional groupings and hierarchy.
+
+## Usage
+
+```jsx
+import {
+ __experimentalNavigation as Navigation,
+ __experimentalNavigationGroup as NavigationGroup,
+ __experimentalNavigationItem as NavigationItem,
+ __experimentalNavigationMenu as NavigationMenu,
+} from '@wordpress/components';
+
+const MyNavigation = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+);
+```
+
+## Navigation Props
+
+`Navigation` supports the following props.
+
+### `activeItem`
+
+- Type: `string`
+- Required: No
+
+The active item slug.
+
+### `activeMenu`
+
+- Type: `string`
+- Required: No
+- Default: "root"
+
+The active menu slug.
+
+### className
+
+- Type: `string`
+- Required: No
+
+Optional className for the `Navigation` component.
+
+### `onActivateItem`
+
+- Type: `function`
+- Required: No
+
+Sync the active item between the external state and the Navigation's internal state.
+
+### `onActivateMenu`
+
+- Type: `function`
+- Required: No
+
+Sync the active menu between the external state and the Navigation's internal state.
+
+## Navigation Menu Props
+
+`NavigationMenu` supports the following props.
+
+### `backButtonLabel`
+
+- Type: `string`
+- Required: No
+- Default: "Back"
+
+The back button label used in nested menus.
+
+### className
+
+- Type: `string`
+- Required: No
+
+Optional className for the `NavigationMenu` component.
+
+### `menu`
+
+- Type: `string`
+- Required: No
+- Default: "root"
+
+The menu slug.
+
+### `parentMenu`
+
+- Type: `string`
+- Required: No
+
+The parent menu slug; used by nested menus to indicate their parent menu.
+
+### `title`
+
+- Type: `string`
+- Required: No
+
+The menu title.
+
+## Navigation Group Props
+
+`NavigationGroup` supports the following props.
+
+### className
+
+- Type: `string`
+- Required: No
+
+Optional className for the `NavigationGroup` component.
+
+### `title`
+
+- Type: `string`
+- Required: No
+
+The group title.
+
+## Navigation Item Props
+
+`NavigationItem` supports the following props.
+
+### `badge`
+
+- Type: `string|Number`
+- Required: No
+
+The item badge content.
+
+### className
+
+- Type: `string`
+- Required: No
+
+Optional className for the `NavigationItem` component.
+
+### `href`
+
+- Type: `string`
+- Required: No
+
+If provided, renders `a` instead of `button`.
+
+### `navigateToMenu`
+
+- Type: `string`
+- Required: No
+
+The child menu slug. If provided, clicking on the item will navigate to the target menu.
+
+### `onClick`
+
+- Type: `function`
+- Required: No
+
+A callback to handle clicking on a menu item.
+
+### `title`
+
+- Type: `string`
+- Required: No
+
+The item title.
diff --git a/packages/components/src/navigation/constants.js b/packages/components/src/navigation/constants.js
new file mode 100644
index 00000000000000..753e5f51707922
--- /dev/null
+++ b/packages/components/src/navigation/constants.js
@@ -0,0 +1 @@
+export const ROOT_MENU = 'root';
diff --git a/packages/components/src/navigation/context.js b/packages/components/src/navigation/context.js
new file mode 100644
index 00000000000000..ab02e4efe56364
--- /dev/null
+++ b/packages/components/src/navigation/context.js
@@ -0,0 +1,22 @@
+/**
+ * External dependencies
+ */
+import { noop } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { createContext, useContext } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { ROOT_MENU } from './constants';
+
+export const NavigationContext = createContext( {
+ activeItem: undefined,
+ activeMenu: ROOT_MENU,
+ setActiveItem: noop,
+ setActiveMenu: noop,
+} );
+export const useNavigationContext = () => useContext( NavigationContext );
diff --git a/packages/components/src/navigation/group.js b/packages/components/src/navigation/group.js
new file mode 100644
index 00000000000000..46702c8fc9880b
--- /dev/null
+++ b/packages/components/src/navigation/group.js
@@ -0,0 +1,28 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * Internal dependencies
+ */
+import { GroupTitleUI } from './styles/navigation-styles';
+
+export default function NavigationGroup( { children, className, title } ) {
+ const classes = classnames( 'components-navigation__group', className );
+
+ return (
+
+ { title && (
+
+ { title }
+
+ ) }
+
+
+ );
+}
diff --git a/packages/components/src/navigation/index.js b/packages/components/src/navigation/index.js
new file mode 100644
index 00000000000000..577ac165b82ae7
--- /dev/null
+++ b/packages/components/src/navigation/index.js
@@ -0,0 +1,91 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { noop } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { useEffect, useRef, useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import Animate from '../animate';
+import { ROOT_MENU } from './constants';
+import { NavigationContext } from './context';
+import { NavigationUI } from './styles/navigation-styles';
+
+export default function Navigation( {
+ activeItem,
+ activeMenu = ROOT_MENU,
+ children,
+ className,
+ onActivateItem = noop,
+ onActivateMenu = noop,
+} ) {
+ const [ item, setItem ] = useState( activeItem );
+ const [ menu, setMenu ] = useState( activeMenu );
+ const [ slideOrigin, setSlideOrigin ] = useState();
+
+ const setActiveItem = ( itemId ) => {
+ setItem( itemId );
+ onActivateItem( itemId );
+ };
+
+ const setActiveMenu = ( menuId, slideInOrigin = 'left' ) => {
+ setSlideOrigin( slideInOrigin );
+ setMenu( menuId );
+ onActivateMenu( menuId );
+ };
+
+ // Used to prevent the sliding animation on mount
+ const isMounted = useRef( false );
+ useEffect( () => {
+ if ( ! isMounted.current ) {
+ isMounted.current = true;
+ }
+ }, [] );
+
+ useEffect( () => {
+ if ( activeItem !== item ) {
+ setActiveItem( activeItem );
+ }
+ if ( activeMenu !== menu ) {
+ setActiveMenu( activeMenu );
+ }
+ }, [ activeItem, activeMenu ] );
+
+ const context = {
+ activeItem: item,
+ activeMenu: menu,
+ setActiveItem,
+ setActiveMenu,
+ };
+
+ const classes = classnames( 'components-navigation', className );
+
+ return (
+
+
+ { ( { className: animateClassName } ) => (
+
+
+ { children }
+
+
+ ) }
+
+
+ );
+}
diff --git a/packages/components/src/navigation/item.js b/packages/components/src/navigation/item.js
new file mode 100644
index 00000000000000..1f4564ea9abf96
--- /dev/null
+++ b/packages/components/src/navigation/item.js
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { noop } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { Icon, chevronRight } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import Button from '../button';
+import { useNavigationContext } from './context';
+import { ItemBadgeUI, ItemTitleUI, ItemUI } from './styles/navigation-styles';
+
+export default function NavigationItem( {
+ badge,
+ children,
+ className,
+ href,
+ item,
+ navigateToMenu,
+ onClick = noop,
+ title,
+ ...props
+} ) {
+ const { activeItem, setActiveItem, setActiveMenu } = useNavigationContext();
+
+ const classes = classnames( 'components-navigation__item', className, {
+ 'is-active': item && activeItem === item,
+ } );
+
+ const onItemClick = () => {
+ if ( navigateToMenu ) {
+ setActiveMenu( navigateToMenu );
+ } else if ( ! href ) {
+ setActiveItem( item );
+ }
+ onClick();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/components/src/navigation/menu.js b/packages/components/src/navigation/menu.js
new file mode 100644
index 00000000000000..eeb6fdcb7f0578
--- /dev/null
+++ b/packages/components/src/navigation/menu.js
@@ -0,0 +1,65 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Icon, chevronLeft } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import { ROOT_MENU } from './constants';
+import { useNavigationContext } from './context';
+import {
+ MenuBackButtonUI,
+ MenuTitleUI,
+ MenuUI,
+} from './styles/navigation-styles';
+
+export default function NavigationMenu( {
+ backButtonLabel,
+ children,
+ className,
+ menu = ROOT_MENU,
+ parentMenu,
+ title,
+} ) {
+ const { activeMenu, setActiveMenu } = useNavigationContext();
+
+ if ( activeMenu !== menu ) {
+ return null;
+ }
+
+ const classes = classnames( 'components-navigation__menu', className );
+
+ return (
+
+ { parentMenu && (
+ setActiveMenu( parentMenu, 'right' ) }
+ >
+
+ { backButtonLabel || __( 'Back' ) }
+
+ ) }
+
+ { title && (
+
+ { title }
+
+ ) }
+ { children }
+
+
+ );
+}
diff --git a/packages/components/src/navigation/stories/index.js b/packages/components/src/navigation/stories/index.js
new file mode 100644
index 00000000000000..95e85033d1b7d6
--- /dev/null
+++ b/packages/components/src/navigation/stories/index.js
@@ -0,0 +1,129 @@
+/**
+ * External dependencies
+ */
+import styled from '@emotion/styled';
+
+/**
+ * WordPress dependencies
+ */
+import { Button } from '@wordpress/components';
+import { useState } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import Navigation from '..';
+import NavigationGroup from '../group';
+import NavigationItem from '../item';
+import NavigationMenu from '../menu';
+
+export default {
+ title: 'Components/Navigation',
+ component: Navigation,
+};
+
+const Container = styled.div`
+ max-width: 246px;
+`;
+
+function Example() {
+ const [ activeItem, setActiveItem ] = useState( 'item-1' );
+ const [ activeMenu, setActiveMenu ] = useState( 'root' );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Item { activeItem }
is active.
+
+
+
+
+
+ );
+}
+
+export const _default = () => ;
diff --git a/packages/components/src/navigation/styles/navigation-styles.js b/packages/components/src/navigation/styles/navigation-styles.js
new file mode 100644
index 00000000000000..800fddf788ae9c
--- /dev/null
+++ b/packages/components/src/navigation/styles/navigation-styles.js
@@ -0,0 +1,102 @@
+/**
+ * External dependencies
+ */
+import styled from '@emotion/styled';
+
+/**
+ * Internal dependencies
+ */
+import { G2, UI } from '../../utils/colors-values';
+import Button from '../../button';
+import Text from '../../text';
+
+export const NavigationUI = styled.div`
+ width: 100%;
+ background-color: ${ G2.darkGray.primary };
+ color: #f0f0f0;
+ padding: 8px;
+ overflow: hidden;
+`;
+
+export const MenuUI = styled.div`
+ margin-top: 24px;
+ margin-bottom: 24px;
+ display: flex;
+ flex-direction: column;
+ ul {
+ padding: 0;
+ margin: 0;
+ list-style: none;
+ }
+`;
+
+export const MenuBackButtonUI = styled( Button )`
+ &.is-tertiary {
+ color: ${ G2.lightGray.ui };
+
+ &:hover:not( :disabled ) {
+ color: #ddd;
+ box-shadow: none;
+ }
+
+ &:active:not( :disabled ) {
+ background: transparent;
+ color: #ddd;
+ }
+ }
+`;
+
+export const MenuTitleUI = styled( Text )`
+ padding: 4px 0 4px 16px;
+ margin-bottom: 8px;
+`;
+
+export const GroupTitleUI = styled( Text )`
+ margin-top: 8px;
+ padding: 4px 0 4px 16px;
+ text-transform: uppercase;
+`;
+
+export const ItemUI = styled.li`
+ border-radius: 2px;
+ color: ${ G2.lightGray.ui };
+
+ button,
+ a {
+ padding-left: 16px;
+ padding-right: 16px;
+ width: 100%;
+ color: ${ G2.lightGray.ui };
+
+ &:hover,
+ &:focus:not( [aria-disabled='true'] ):active,
+ &:active:not( [aria-disabled='true'] ):active {
+ color: #ddd;
+ }
+ }
+
+ &.is-active {
+ background-color: ${ UI.theme };
+ color: ${ UI.textDark };
+
+ button,
+ a {
+ color: ${ UI.textDark };
+ }
+ }
+
+ svg path {
+ color: ${ G2.lightGray.ui };
+ }
+`;
+
+export const ItemBadgeUI = styled.span`
+ margin-left: 8px;
+ display: inline-flex;
+ padding: 4px 12px;
+ border-radius: 2px;
+`;
+
+export const ItemTitleUI = styled( Text )`
+ margin-right: auto;
+`;
diff --git a/packages/components/src/navigation/test/index.js b/packages/components/src/navigation/test/index.js
new file mode 100644
index 00000000000000..cadf6e47216215
--- /dev/null
+++ b/packages/components/src/navigation/test/index.js
@@ -0,0 +1,142 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import Navigation from '..';
+import NavigationItem from '../item';
+import NavigationMenu from '../menu';
+
+const testNavigation = ( { activeItem, rootTitle, showBadge } = {} ) => (
+
+
+
+
+
+
+
+ customize me
+
+
+
+
+
+
+
+);
+
+describe( 'Navigation', () => {
+ it( 'should render the panes and active item', async () => {
+ render( testNavigation( { activeItem: 'item-2' } ) );
+
+ const menuItems = screen.getAllByRole( 'listitem' );
+
+ expect( menuItems.length ).toBe( 4 );
+ expect( menuItems[ 0 ].textContent ).toBe( 'Item 1' );
+ expect( menuItems[ 1 ].textContent ).toBe( 'Item 2' );
+ expect( menuItems[ 2 ].textContent ).toBe( 'Category' );
+ expect( menuItems[ 3 ].textContent ).toBe( 'customize me' );
+ expect( menuItems[ 0 ].classList.contains( 'is-active' ) ).toBe(
+ false
+ );
+ expect( menuItems[ 1 ].classList.contains( 'is-active' ) ).toBe( true );
+ } );
+
+ it( 'should render anchor links when menu item supplies an href', async () => {
+ render( testNavigation() );
+
+ const linkItem = screen.getByRole( 'link', { name: 'Item 2' } );
+
+ expect( linkItem ).toBeDefined();
+ expect( linkItem.target ).toBe( '_blank' );
+ } );
+
+ it( 'should render a custom component when menu item supplies one', async () => {
+ render( testNavigation() );
+
+ const customItem = screen.getByRole( 'button', {
+ name: 'customize me',
+ } );
+
+ expect( customItem ).toBeDefined();
+ } );
+
+ it( 'should set an active category on click', async () => {
+ render( testNavigation() );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Category' } ) );
+ const categoryTitle = screen.getByRole( 'heading' );
+ const menuItems = screen.getAllByRole( 'listitem' );
+
+ expect( categoryTitle.textContent ).toBe( 'Category' );
+ expect( menuItems.length ).toBe( 2 );
+ expect( menuItems[ 0 ].textContent ).toBe( 'Child 1' );
+ expect( menuItems[ 1 ].textContent ).toBe( 'Child 2' );
+ } );
+
+ it( 'should render the root title', async () => {
+ const { rerender } = render( testNavigation() );
+
+ const emptyTitle = screen.queryByRole( 'heading' );
+ expect( emptyTitle ).toBeNull();
+
+ rerender( testNavigation( { rootTitle: 'Home' } ) );
+
+ const rootTitle = screen.getByRole( 'heading' );
+ expect( rootTitle.textContent ).toBe( 'Home' );
+ } );
+
+ it( 'should render badges', async () => {
+ render( testNavigation( { showBadge: true } ) );
+
+ const menuItem = screen.getAllByRole( 'listitem' );
+ expect( menuItem[ 0 ].textContent ).toBe( 'Item 1' + '21' );
+ } );
+
+ it( 'should render menu titles when items exist', async () => {
+ const { rerender } = render( );
+
+ const emptyMenu = screen.queryByText( 'Menu title' );
+ expect( emptyMenu ).toBeNull();
+
+ rerender( testNavigation( { rootTitle: 'Menu title' } ) );
+
+ const menuTitle = screen.queryByText( 'Menu title' );
+ expect( menuTitle ).not.toBeNull();
+ } );
+
+ it( 'should navigate up a level when clicking the back button', async () => {
+ render( testNavigation( { rootTitle: 'Home' } ) );
+
+ fireEvent.click( screen.getByRole( 'button', { name: 'Category' } ) );
+ let menuTitle = screen.getByRole( 'heading' );
+ expect( menuTitle.textContent ).toBe( 'Category' );
+ fireEvent.click( screen.getByRole( 'button', { name: 'Home' } ) );
+ menuTitle = screen.getByRole( 'heading' );
+ expect( menuTitle.textContent ).toBe( 'Home' );
+ } );
+} );