diff --git a/packages/components/src/navigation/README.md b/packages/components/src/navigation/README.md index 853881d9360fcf..78c9776ff8bf57 100644 --- a/packages/components/src/navigation/README.md +++ b/packages/components/src/navigation/README.md @@ -101,6 +101,13 @@ A callback to handle clicking on the back button. If this prop is provided then Optional className for the `NavigationMenu` component. +### hasSearch + +- Type: `boolean` +- Required: No + +Enable the search feature on the menu title. + ### `menu` - Type: `string` @@ -109,6 +116,13 @@ Optional className for the `NavigationMenu` component. The unique identifier of the menu. The root menu can omit this, and it will default to "root"; all other menus need to specify it. +### onSearch + +- Type: `function` +- Required: No + +When `hasSearch` is active, this function handles the search input's `onChange` event, making it controlled from the outside. It requires setting the `search` prop as well. + ### `parentMenu` - Type: `string` @@ -116,12 +130,19 @@ The unique identifier of the menu. The root menu can omit this, and it will defa The parent menu slug; used by nested menus to indicate their parent menu. +### search + +- Type: `string` +- Required: No + +When `hasSearch` is active and `onSearch` is provided, this controls the value of the search input. Required when the `onSearch` prop is provided. + ### `title` - Type: `string` - Required: No -The menu title. +The menu title. It's also the field used by the menu search function. ## Navigation Group Props @@ -169,7 +190,7 @@ If provided, renders `a` instead of `button`. ### `item` - Type: `string` -- Required: Yes +- Required: No The unique identifier of the item. diff --git a/packages/components/src/navigation/constants.js b/packages/components/src/navigation/constants.js index 753e5f51707922..b3b619e11a1591 100644 --- a/packages/components/src/navigation/constants.js +++ b/packages/components/src/navigation/constants.js @@ -1 +1,2 @@ export const ROOT_MENU = 'root'; +export const SEARCH_FOCUS_DELAY = 100; diff --git a/packages/components/src/navigation/group/context.js b/packages/components/src/navigation/group/context.js new file mode 100644 index 00000000000000..d6725504ba8f2c --- /dev/null +++ b/packages/components/src/navigation/group/context.js @@ -0,0 +1,9 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +export const NavigationGroupContext = createContext( { group: undefined } ); + +export const useNavigationGroupContext = () => + useContext( NavigationGroupContext ); diff --git a/packages/components/src/navigation/group/index.js b/packages/components/src/navigation/group/index.js index ba2b3a2ad13b91..bcea1b0a5894a4 100644 --- a/packages/components/src/navigation/group/index.js +++ b/packages/components/src/navigation/group/index.js @@ -2,35 +2,57 @@ * External dependencies */ import classnames from 'classnames'; +import { find, uniqueId } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; /** * Internal dependencies */ +import { NavigationGroupContext } from './context'; import { GroupTitleUI } from '../styles/navigation-styles'; -import { useNavigationMenuContext } from '../menu/context'; +import { useNavigationContext } from '../context'; export default function NavigationGroup( { children, className, title } ) { - const { isActive } = useNavigationMenuContext(); + const [ groupId ] = useState( uniqueId( 'group-' ) ); + const { + navigationTree: { items }, + } = useNavigationContext(); + + const context = { group: groupId }; - // Keep the children rendered to make sure inactive items are included in the navigation tree - if ( ! isActive ) { - return children; + // Keep the children rendered to make sure invisible items are included in the navigation tree. + if ( ! find( items, { group: groupId, _isVisible: true } ) ) { + return ( + + { children } + + ); } + const groupTitleId = `components-navigation__group-title-${ groupId }`; const classes = classnames( 'components-navigation__group', className ); return ( -
  • - { title && ( - - { title } - - ) } - -
  • + +
  • + { title && ( + + { title } + + ) } + +
  • +
    ); } diff --git a/packages/components/src/navigation/item/index.js b/packages/components/src/navigation/item/index.js index 7bedd7cd72f5db..ed02f092ea8891 100644 --- a/packages/components/src/navigation/item/index.js +++ b/packages/components/src/navigation/item/index.js @@ -2,11 +2,12 @@ * External dependencies */ import classnames from 'classnames'; -import { noop } from 'lodash'; +import { noop, uniqueId } from 'lodash'; /** * WordPress dependencies */ +import { useState } from '@wordpress/element'; import { Icon, chevronRight } from '@wordpress/icons'; /** @@ -14,9 +15,8 @@ import { Icon, chevronRight } from '@wordpress/icons'; */ import Button from '../../button'; import { useNavigationContext } from '../context'; -import { ItemBadgeUI, ItemTitleUI, ItemUI } from '../styles/navigation-styles'; import { useNavigationTreeItem } from './use-navigation-tree-item'; -import { useNavigationMenuContext } from '../menu/context'; +import { ItemBadgeUI, ItemTitleUI, ItemUI } from '../styles/navigation-styles'; export default function NavigationItem( props ) { const { @@ -30,14 +30,17 @@ export default function NavigationItem( props ) { title, ...restProps } = props; - useNavigationTreeItem( props ); - const { activeItem, setActiveMenu } = useNavigationContext(); - const { isActive } = useNavigationMenuContext(); - // If this item is in an inactive menu, then we skip rendering - // We need to make sure this component gets mounted though - // To make sure inactive items are included in the navigation tree - if ( ! isActive ) { + const [ itemId ] = useState( uniqueId( 'item-' ) ); + + useNavigationTreeItem( itemId, props ); + const { + activeItem, + navigationTree, + setActiveMenu, + } = useNavigationContext(); + + if ( ! navigationTree.getItem( itemId )?._isVisible ) { return null; } diff --git a/packages/components/src/navigation/item/use-navigation-tree-item.js b/packages/components/src/navigation/item/use-navigation-tree-item.js index b501da08d08b68..109c7a38bf9246 100644 --- a/packages/components/src/navigation/item/use-navigation-tree-item.js +++ b/packages/components/src/navigation/item/use-navigation-tree-item.js @@ -7,20 +7,32 @@ import { useEffect } from '@wordpress/element'; * Internal dependencies */ import { useNavigationContext } from '../context'; +import { useNavigationGroupContext } from '../group/context'; import { useNavigationMenuContext } from '../menu/context'; +import { normalizedSearch } from '../utils'; -export const useNavigationTreeItem = ( props ) => { +export const useNavigationTreeItem = ( itemId, props ) => { const { + activeMenu, navigationTree: { addItem, removeItem }, } = useNavigationContext(); - const { menu } = useNavigationMenuContext(); + const { group } = useNavigationGroupContext(); + const { menu, search } = useNavigationMenuContext(); - const key = props.item; useEffect( () => { - addItem( key, { ...props, menu } ); + const isMenuActive = activeMenu === menu; + const isItemVisible = + ! search || normalizedSearch( props.title, search ); + + addItem( itemId, { + ...props, + group, + menu, + _isVisible: isMenuActive && isItemVisible, + } ); return () => { - removeItem( key ); + removeItem( itemId ); }; - }, [] ); + }, [ activeMenu, search ] ); }; diff --git a/packages/components/src/navigation/menu/context.js b/packages/components/src/navigation/menu/context.js index 485ceec98c14ec..29b4814757c7cd 100644 --- a/packages/components/src/navigation/menu/context.js +++ b/packages/components/src/navigation/menu/context.js @@ -5,7 +5,7 @@ import { createContext, useContext } from '@wordpress/element'; export const NavigationMenuContext = createContext( { menu: undefined, - isActive: false, + search: '', } ); export const useNavigationMenuContext = () => useContext( NavigationMenuContext ); diff --git a/packages/components/src/navigation/menu/index.js b/packages/components/src/navigation/menu/index.js index 2e298eeddc9c9e..ef98f33b410520 100644 --- a/packages/components/src/navigation/menu/index.js +++ b/packages/components/src/navigation/menu/index.js @@ -3,39 +3,47 @@ */ import classnames from 'classnames'; +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + /** * Internal dependencies */ import { ROOT_MENU } from '../constants'; -import { useNavigationContext } from '../context'; -import { MenuTitleUI, MenuUI } from '../styles/navigation-styles'; -import NavigationBackButton from '../back-button'; import { NavigationMenuContext } from './context'; +import { useNavigationContext } from '../context'; import { useNavigationTreeMenu } from './use-navigation-tree-menu'; +import NavigationBackButton from '../back-button'; +import NavigationMenuTitle from './menu-title'; +import { NavigableMenu } from '../../navigable-container'; +import { MenuUI } from '../styles/navigation-styles'; export default function NavigationMenu( props ) { const { backButtonLabel, children, className, + hasSearch, menu = ROOT_MENU, + onSearch: setControlledSearch, parentMenu, + search: controlledSearch, title, onBackButtonClick, } = props; + const [ uncontrolledSearch, setUncontrolledSearch ] = useState( '' ); useNavigationTreeMenu( props ); const { activeMenu } = useNavigationContext(); - const isActive = activeMenu === menu; - - const classes = classnames( 'components-navigation__menu', className ); const context = { menu, - isActive, + search: uncontrolledSearch, }; - // Keep the children rendered to make sure inactive items are included in the navigation tree - if ( ! isActive ) { + // Keep the children rendered to make sure invisible items are included in the navigation tree + if ( activeMenu !== menu ) { return ( { children } @@ -43,6 +51,15 @@ export default function NavigationMenu( props ) { ); } + const isControlledSearch = !! setControlledSearch; + const search = isControlledSearch ? controlledSearch : uncontrolledSearch; + const onSearch = isControlledSearch + ? setControlledSearch + : setUncontrolledSearch; + + const menuTitleId = `components-navigation__menu-title-${ menu }`; + const classes = classnames( 'components-navigation__menu', className ); + return ( @@ -53,16 +70,17 @@ export default function NavigationMenu( props ) { onClick={ onBackButtonClick } /> ) } - { title && ( - - { title } - - ) } -
      { children }
    + + + + +
      { children }
    +
    ); diff --git a/packages/components/src/navigation/menu/menu-title-search.js b/packages/components/src/navigation/menu/menu-title-search.js new file mode 100644 index 00000000000000..02b87e8b089f14 --- /dev/null +++ b/packages/components/src/navigation/menu/menu-title-search.js @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import { filter } from 'lodash'; + +/** + * WordPress dependencies + */ +import { useEffect, useRef } from '@wordpress/element'; +import { Icon, closeSmall, search as searchIcon } from '@wordpress/icons'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { ESCAPE } from '@wordpress/keycodes'; + +/** + * Internal dependencies + */ +import Button from '../../button'; +import VisuallyHidden from '../../visually-hidden'; +import withSpokenMessages from '../../higher-order/with-spoken-messages'; +import { useNavigationMenuContext } from './context'; +import { useNavigationContext } from '../context'; +import { MenuTitleSearchUI } from '../styles/navigation-styles'; +import { SEARCH_FOCUS_DELAY } from '../constants'; + +function MenuTitleSearch( { + debouncedSpeak, + onCloseSearch, + onSearch, + search, + title, +} ) { + const { + navigationTree: { items }, + } = useNavigationContext(); + const { menu } = useNavigationMenuContext(); + const inputRef = useRef(); + + // Wait for the slide-in animation to complete before autofocusing the input. + // This prevents scrolling to the input during the animation. + useEffect( () => { + const delayedFocus = setTimeout( () => { + inputRef.current.focus(); + }, SEARCH_FOCUS_DELAY ); + + return () => { + clearTimeout( delayedFocus ); + }; + }, [] ); + + useEffect( () => { + if ( ! search ) { + return; + } + + const count = filter( items, '_isVisible' ).length; + const resultsFoundMessage = sprintf( + /* translators: %d: number of results. */ + _n( '%d result found.', '%d results found.', count ), + count + ); + debouncedSpeak( resultsFoundMessage ); + }, [ items, search ] ); + + const onClose = () => { + onSearch( '' ); + onCloseSearch(); + }; + + function onKeyDown( event ) { + if ( event.keyCode === ESCAPE ) { + event.stopPropagation(); + onClose(); + } + } + + const menuTitleId = `components-navigation__menu-title-${ menu }`; + const inputId = `components-navigation__menu-title-search-${ menu }`; + /* translators: placeholder for menu search box. %s: menu title */ + const placeholder = sprintf( __( 'Search in %s' ), title ); + + return ( + + + + + { placeholder } + + + onSearch( event.target.value ) } + onKeyDown={ onKeyDown } + placeholder={ placeholder } + ref={ inputRef } + type="search" + value={ search } + /> + + + + ); +} + +export default withSpokenMessages( MenuTitleSearch ); diff --git a/packages/components/src/navigation/menu/menu-title.js b/packages/components/src/navigation/menu/menu-title.js new file mode 100644 index 00000000000000..5e8ced22683b26 --- /dev/null +++ b/packages/components/src/navigation/menu/menu-title.js @@ -0,0 +1,86 @@ +/** + * WordPress dependencies + */ +import { useRef, useState } from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; +import { Icon, search as searchIcon } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import Animate from '../../animate'; +import Button from '../../button'; +import MenuTitleSearch from './menu-title-search'; +import { MenuTitleHeadingUI, MenuTitleUI } from '../styles/navigation-styles'; +import { useNavigationMenuContext } from './context'; +import { SEARCH_FOCUS_DELAY } from '../constants'; + +export default function NavigationMenuTitle( { + hasSearch, + onSearch, + search, + title, +} ) { + const [ isSearching, setIsSearching ] = useState( false ); + const { menu } = useNavigationMenuContext(); + const searchButtonRef = useRef(); + + if ( ! title ) { + return null; + } + + const onCloseSearch = () => { + setIsSearching( false ); + + // Wait for the slide-in animation to complete before focusing the search button. + // eslint-disable-next-line @wordpress/react-no-unsafe-timeout + setTimeout( () => { + searchButtonRef.current.focus(); + }, SEARCH_FOCUS_DELAY ); + }; + + const menuTitleId = `components-navigation__menu-title-${ menu }`; + /* translators: search button label for menu search box. %s: menu title */ + const searchButtonLabel = sprintf( __( 'Search in %s' ), title ); + + return ( + + { ! isSearching && ( + + { title } + + { hasSearch && ( + + ) } + + ) } + + { isSearching && ( + + { ( { className: animateClassName } ) => ( +
    + +
    + ) } +
    + ) } +
    + ); +} diff --git a/packages/components/src/navigation/stories/index.js b/packages/components/src/navigation/stories/index.js index 09094c914cc939..7617a0a65f6df6 100644 --- a/packages/components/src/navigation/stories/index.js +++ b/packages/components/src/navigation/stories/index.js @@ -8,6 +8,7 @@ import NavigationItem from '../item'; import NavigationMenu from '../menu'; import { DefaultStory } from './default'; import { ControlledStateStory } from './controlled-state'; +import { SearchStory } from './search'; import { MoreExamplesStory } from './more-examples'; import './style.css'; @@ -24,4 +25,5 @@ export default { export const _default = () => ; export const controlledState = () => ; +export const search = () => ; export const moreExamples = () => ; diff --git a/packages/components/src/navigation/stories/search.js b/packages/components/src/navigation/stories/search.js new file mode 100644 index 00000000000000..d9bcd405cbc349 --- /dev/null +++ b/packages/components/src/navigation/stories/search.js @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Navigation from '..'; +import NavigationGroup from '../group'; +import NavigationItem from '../item'; +import NavigationMenu from '../menu'; +import { normalizedSearch } from '../utils'; + +const searchItems = [ + { item: 'foo', title: 'Foo' }, + { item: 'bar', title: 'Bar' }, + { item: 'baz', title: 'Baz' }, + { item: 'qux', title: 'Qux' }, + { item: 'quux', title: 'Quux' }, + { item: 'corge', title: 'Corge' }, + { item: 'grault', title: 'Grault' }, + { item: 'garply', title: 'Garply' }, + { item: 'waldo', title: 'Waldo' }, +]; + +export function SearchStory() { + const [ activeItem, setActiveItem ] = useState( 'item-1' ); + const [ search, setSearch ] = useState( '' ); + + return ( + + + + { searchItems.map( ( { item, title } ) => ( + setActiveItem( `item-${ item }` ) } + title={ title } + /> + ) ) } + + + + + + + + setSearch( value ) } + parentMenu="root" + search={ search } + title="Controlled Search" + > + { searchItems + .filter( ( { title } ) => + normalizedSearch( title, search ) + ) + .map( ( { item, title } ) => ( + setActiveItem( `child-${ item }` ) } + title={ title } + /> + ) ) } + + + ); +} diff --git a/packages/components/src/navigation/styles/navigation-styles.js b/packages/components/src/navigation/styles/navigation-styles.js index 93c347a54ca250..43bc022454377e 100644 --- a/packages/components/src/navigation/styles/navigation-styles.js +++ b/packages/components/src/navigation/styles/navigation-styles.js @@ -9,7 +9,7 @@ import styled from '@emotion/styled'; import { G2, UI } from '../../utils/colors-values'; import Button from '../../button'; import Text from '../../text'; -import { reduceMotion } from '../../utils'; +import { reduceMotion, space } from '../../utils'; export const NavigationUI = styled.div` width: 100%; @@ -50,10 +50,73 @@ export const MenuBackButtonUI = styled( Button )` } `; -export const MenuTitleUI = styled( Text )` - padding: 4px 0 4px 16px; - margin-bottom: 8px; +export const MenuTitleUI = styled.div` + overflow: hidden; + width: 100%; +`; + +export const MenuTitleHeadingUI = styled( Text )` + align-items: center; color: ${ G2.gray[ 100 ] }; + display: flex; + justify-content: space-between; + margin-bottom: ${ space( 1 ) }; + padding: ${ space( 0.5 ) } 0 ${ space( 0.5 ) } ${ space( 2 ) }; + + .components-button.is-small { + color: ${ G2.lightGray.ui }; + margin-right: 2px; // Avoid hiding the focus outline + padding: 0; + + &:active:not( :disabled ) { + background: none; + color: ${ G2.gray[ 200 ] }; + } + &:hover:not( :disabled ) { + box-shadow: none; + color: ${ G2.gray[ 200 ] }; + } + } +`; + +export const MenuTitleSearchUI = styled.div` + padding: 0; + position: relative; + + input { + height: 36px; // Same height as MenuTitle + margin-bottom: ${ space( 1 ) }; + padding-left: 30px; // Leave room for the search icon + padding-right: 30px; // Leave room for the close search button + + &::-webkit-search-decoration, + &::-webkit-search-cancel-button, + &::-webkit-search-results-button, + &::-webkit-search-results-decoration { + -webkit-appearance: none; + } + } + + > svg { + left: ${ space( 0.5 ) }; + position: absolute; + top: 6px; + } + + .components-button.is-small { + height: 30px; + padding: 0; + position: absolute; + right: ${ space( 1 ) }; + top: 3px; + + &:active:not( :disabled ) { + background: none; + } + &:hover:not( :disabled ) { + box-shadow: none; + } + } `; export const GroupTitleUI = styled( Text )` diff --git a/packages/components/src/navigation/utils.js b/packages/components/src/navigation/utils.js new file mode 100644 index 00000000000000..251b90cfe44dfd --- /dev/null +++ b/packages/components/src/navigation/utils.js @@ -0,0 +1,11 @@ +/** + * External dependencies + */ +import { deburr } from 'lodash'; + +// @see packages/block-editor/src/components/inserter/search-items.js +export const normalizeInput = ( input ) => + deburr( input ).replace( /^\//, '' ).toLowerCase(); + +export const normalizedSearch = ( title, search ) => + -1 !== normalizeInput( title ).indexOf( normalizeInput( search ) );