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 }
-
- ) }
-
{ children }
-
+
+
+ { title && (
+
+ { title }
+
+ ) }
+
+ { children }
+
+
+
);
}
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 } ) => (
+