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 ( + + + + + + + + + + + + WordPress Logo + + + + + +
    + + + +
+
+ + +
    + + +
+
+
+ +
+

+ 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' ); + } ); +} );