Submenu with Combobox
Nesting Notion-style dropdown menus with search & autocomplete features by combining Menu with Combobox.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
import { Fragment, startTransition, useMemo, useState } from "react";import { actions, defaultValues } from "./actions.ts";import type { Action } from "./actions.ts";import { filterActions } from "./utils.ts";import "./style.css";function renderItems(items: Action[], group?: string) {return items.map((item, index) => {const value = item.value || item.label;const separator = !!items[index - 1]?.items || (item.items && index > 0);const element = item.items ? ({renderItems(item.items, item.label)}) : ({item.label});return (<Fragment key={value}>{element}</Fragment>);});}function renderMatches(matches: Action[] | null) {if (!matches) return null;if (!matches.length) {return <div className="no-results">No results</div>;}return renderItems(matches);}export default function Example() {const [values, setValues] = useState(defaultValues);const [searchValue, setSearchValue] = useState("");const [pageSearchValue, setPageSearchValue] = useState("");const matches = useMemo(() => filterActions(Object.values(actions), searchValue),[searchValue],);const pageMatches = useMemo(() => filterActions(actions.turnIntoPageIn.items, pageSearchValue),[pageSearchValue],);const defaultItems = (<>{renderItems(actions.turnInto.items, actions.turnInto.label)}label={actions.turnIntoPageIn.label}onSearch={(value) => startTransition(() => setPageSearchValue(value))}combobox={<input placeholder="Search pages to add in..." />}>{renderMatches(pageMatches) ||renderItems(actions.turnIntoPageIn.items,actions.turnIntoPageIn.label,)}{renderItems(actions.color.items, actions.color.label)}</>);const trigger = (<button className="menu-button secondary" aria-label="Actions"><svg viewBox="0 0 15 15" fill="currentColor" width={20} height={20}><pathfillRule="evenodd"clipRule="evenodd"d="M5.5 4.625C6.12132 4.625 6.625 4.12132 6.625 3.5C6.625 2.87868 6.12132 2.375 5.5 2.375C4.87868 2.375 4.375 2.87868 4.375 3.5C4.375 4.12132 4.87868 4.625 5.5 4.625ZM9.5 4.625C10.1213 4.625 10.625 4.12132 10.625 3.5C10.625 2.87868 10.1213 2.375 9.5 2.375C8.87868 2.375 8.375 2.87868 8.375 3.5C8.375 4.12132 8.87868 4.625 9.5 4.625ZM10.625 7.5C10.625 8.12132 10.1213 8.625 9.5 8.625C8.87868 8.625 8.375 8.12132 8.375 7.5C8.375 6.87868 8.87868 6.375 9.5 6.375C10.1213 6.375 10.625 6.87868 10.625 7.5ZM5.5 8.625C6.12132 8.625 6.625 8.12132 6.625 7.5C6.625 6.87868 6.12132 6.375 5.5 6.375C4.87868 6.375 4.375 6.87868 4.375 7.5C4.375 8.12132 4.87868 8.625 5.5 8.625ZM10.625 11.5C10.625 12.1213 10.1213 12.625 9.5 12.625C8.87868 12.625 8.375 12.1213 8.375 11.5C8.375 10.8787 8.87868 10.375 9.5 10.375C10.1213 10.375 10.625 10.8787 10.625 11.5ZM5.5 12.625C6.12132 12.625 6.625 12.1213 6.625 11.5C6.625 10.8787 6.12132 10.375 5.5 10.375C4.87868 10.375 4.375 10.8787 4.375 11.5C4.375 12.1213 4.87868 12.625 5.5 12.625Z"/></svg></button>);return (<div className="wrapper">values={values}onValuesChange={(values: typeof defaultValues) => setValues(values)}onSearch={(value) => startTransition(() => setSearchValue(value))}combobox={<input placeholder="Search actions..." />}trigger={trigger}>{renderMatches(matches) || defaultItems}<div style={{ color: values.Color, backgroundColor: values.Background }}>{values["Turn into"]}</div></div>);}import { Fragment, startTransition, useMemo, useState } from "react";import { actions, defaultValues } from "./actions.ts";import type { Action } from "./actions.ts";import { filterActions } from "./utils.ts";import "./style.css";function renderItems(items: Action[], group?: string) {return items.map((item, index) => {const value = item.value || item.label;const separator = !!items[index - 1]?.items || (item.items && index > 0);const element = item.items ? ({renderItems(item.items, item.label)}) : ({item.label});return (<Fragment key={value}>{element}</Fragment>);});}function renderMatches(matches: Action[] | null) {if (!matches) return null;if (!matches.length) {return <div className="no-results">No results</div>;}return renderItems(matches);}export default function Example() {const [values, setValues] = useState(defaultValues);const [searchValue, setSearchValue] = useState("");const [pageSearchValue, setPageSearchValue] = useState("");const matches = useMemo(() => filterActions(Object.values(actions), searchValue),[searchValue],);const pageMatches = useMemo(() => filterActions(actions.turnIntoPageIn.items, pageSearchValue),[pageSearchValue],);const defaultItems = (<>{renderItems(actions.turnInto.items, actions.turnInto.label)}label={actions.turnIntoPageIn.label}onSearch={(value) => startTransition(() => setPageSearchValue(value))}combobox={<input placeholder="Search pages to add in..." />}>{renderMatches(pageMatches) ||renderItems(actions.turnIntoPageIn.items,actions.turnIntoPageIn.label,)}{renderItems(actions.color.items, actions.color.label)}</>);const trigger = (<button className="menu-button secondary" aria-label="Actions"><svg viewBox="0 0 15 15" fill="currentColor" width={20} height={20}><pathfillRule="evenodd"clipRule="evenodd"d="M5.5 4.625C6.12132 4.625 6.625 4.12132 6.625 3.5C6.625 2.87868 6.12132 2.375 5.5 2.375C4.87868 2.375 4.375 2.87868 4.375 3.5C4.375 4.12132 4.87868 4.625 5.5 4.625ZM9.5 4.625C10.1213 4.625 10.625 4.12132 10.625 3.5C10.625 2.87868 10.1213 2.375 9.5 2.375C8.87868 2.375 8.375 2.87868 8.375 3.5C8.375 4.12132 8.87868 4.625 9.5 4.625ZM10.625 7.5C10.625 8.12132 10.1213 8.625 9.5 8.625C8.87868 8.625 8.375 8.12132 8.375 7.5C8.375 6.87868 8.87868 6.375 9.5 6.375C10.1213 6.375 10.625 6.87868 10.625 7.5ZM5.5 8.625C6.12132 8.625 6.625 8.12132 6.625 7.5C6.625 6.87868 6.12132 6.375 5.5 6.375C4.87868 6.375 4.375 6.87868 4.375 7.5C4.375 8.12132 4.87868 8.625 5.5 8.625ZM10.625 11.5C10.625 12.1213 10.1213 12.625 9.5 12.625C8.87868 12.625 8.375 12.1213 8.375 11.5C8.375 10.8787 8.87868 10.375 9.5 10.375C10.1213 10.375 10.625 10.8787 10.625 11.5ZM5.5 12.625C6.12132 12.625 6.625 12.1213 6.625 11.5C6.625 10.8787 6.12132 10.375 5.5 10.375C4.87868 10.375 4.375 10.8787 4.375 11.5C4.375 12.1213 4.87868 12.625 5.5 12.625Z"/></svg></button>);return (<div className="wrapper">values={values}onValuesChange={(values: typeof defaultValues) => setValues(values)}onSearch={(value) => startTransition(() => setSearchValue(value))}combobox={<input placeholder="Search actions..." />}trigger={trigger}>{renderMatches(matches) || defaultItems}<div style={{ color: values.Color, backgroundColor: values.Background }}>{values["Turn into"]}</div></div>);}
Related examples
SubmenuRendering nested Menu components to create a dropdown menu with submenus that open when hovering over the parent menu item.
Menu with ComboboxCombining Menu and Combobox to create a dropdown menu with a search field that can be used to filter menu items.
Select with ComboboxCombining Select and Combobox to create a dropdown with a search field that can be used to filter items.
Multi-selectable ComboboxAllowing Combobox to select multiple options by passing an array value to the selectedValue prop.
Combobox filteringListing suggestions in a Combobox component based on the input value using React.startTransition to ensure the UI remains responsive during typing.
Combobox with integrated filterFiltering options in a Combobox component through an abstracted implementation using React.useDeferredValue, resulting in a simple higher-level API.
MenuItemRadioRendering Menu using MenuItemRadio as children and the values prop from MenuProvider to control the checked item.
Menu with TooltipRendering Menu with a Tooltip that appears when hovering over the MenuButton component by combining it with the TooltipAnchor component.