Suggestion Menu
A list of suggestions that follows an input which keeps focus, like a / command menu or @ mentions.
Type to filter, then use ↑ ↓ and Enter. The caret never leaves the field.
Show codeHide code
Installation
import {
SuggestionMenu,
type SuggestionMenuActions,
SuggestionMenuGroup,
SuggestionMenuItem,
} from "@makeplane/propel/components/suggestion-menu";Usage
import { Icon } from "@makeplane/propel/components/icon";
import { Input } from "@makeplane/propel/components/input";
import {
SuggestionMenu,
type SuggestionMenuActions,
SuggestionMenuGroup,
SuggestionMenuItem,
} from "@makeplane/propel/components/suggestion-menu";
import {
CodeOutline,
H1Outline,
H2Outline,
ListOutline,
NumberedListOutline,
ToDoOutline,
QuoteOutline,
TitleOutline,
} from "@makeplane/propel/icons";
import * as React from "react";
const SECTIONS = [
{
label: "Basics",
items: [
{ title: "Text", icon: TitleOutline },
{ title: "Heading 1", icon: H1Outline },
{ title: "Heading 2", icon: H2Outline },
{ title: "Quote", icon: QuoteOutline },
{ title: "Code", icon: CodeOutline },
],
},
{
label: "Lists",
items: [
{ title: "Numbered list", icon: NumberedListOutline },
{ title: "Bulleted list", icon: ListOutline },
{ title: "To-do list", icon: ToDoOutline },
],
},
];
export default function BasicDemo() {
const actionsRef = React.useRef<SuggestionMenuActions>(null);
const [query, setQuery] = React.useState("");
const [picked, setPicked] = React.useState<string | null>(null);
const [highlightedId, setHighlightedId] = React.useState<string | null>(null);
const pickHandlers = React.useMemo(() => {
const handlers = new Map<string, () => void>();
for (const section of SECTIONS) {
for (const item of section.items) {
handlers.set(item.title, () => setPicked(item.title));
}
}
return handlers;
}, []);
const sections = React.useMemo(
() =>
SECTIONS.map((section) => ({
...section,
items: section.items.filter((item) =>
item.title.toLowerCase().includes(query.toLowerCase()),
),
})).filter((section) => section.items.length > 0),
[query],
);
// Gated so no ARIA reference dangles once the menu unmounts when empty.
const menuOpen = sections.length > 0;
const rows = React.useMemo(
() =>
sections.map((section) => (
<SuggestionMenuGroup key={section.label} label={section.label}>
{section.items.map((item) => (
<SuggestionMenuItem
key={item.title}
icon={<Icon icon={item.icon} />}
label={item.title}
onClick={pickHandlers.get(item.title)}
/>
))}
</SuggestionMenuGroup>
)),
[sections, pickHandlers],
);
return (
<div className="flex w-80 flex-col gap-2">
<Input
size="md"
aria-label="Command"
placeholder="Type to filter, ↑ ↓ to move, Enter to pick"
value={query}
role="combobox"
aria-expanded={menuOpen}
aria-controls={menuOpen ? "blocks-menu" : undefined}
aria-activedescendant={menuOpen ? (highlightedId ?? undefined) : undefined}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (actionsRef.current?.handleKeyDown(event)) event.preventDefault();
}}
/>
{menuOpen ? (
<SuggestionMenu
id="blocks-menu"
actionsRef={actionsRef}
aria-label="Blocks"
onHighlightChange={setHighlightedId}
>
{rows}
</SuggestionMenu>
) : null}
<output className="text-body-xs-regular text-tertiary">Picked: {picked ?? "none"}</output>
</div>
);
}Menu or Suggestion Menu?
Use Menu for a list someone opens from a button. It moves focus into the popup, so the keyboard works inside the list.
Use Suggestion Menu when the list completes something being typed. Focus stays in the input, so typing keeps filtering the list. The input forwards its arrow keys to the menu, and the menu decides which row is highlighted.
Connecting the input
Pass a ref to actionsRef and forward the input’s key presses to handleKeyDown. It returns
true when the menu used the key; call preventDefault() then, so the input does not act on it
too (an editor would otherwise move the caret or start a new line).
Wire the input as a combobox so the highlight is announced: give the menu an id, point the
input’s aria-controls at it, and mirror onHighlightChange into the input’s
aria-activedescendant.
Memoize the row list (or individual rows) against the filtered query. Mirroring the highlight
re-renders the host; without a stable row tree, every arrow key recommits every
SuggestionMenuItem instead of just the two whose highlight state changed.
const actionsRef = React.useRef<SuggestionMenuActions>(null);
const [highlightedId, setHighlightedId] = React.useState<string | null>(null);
const sections = React.useMemo(() => filterSections(query), [query]);
// Only reference the menu while it is rendered: a dangling `aria-controls` or
// `aria-activedescendant` fails axe's `aria-valid-attr-value` rule.
const menuOpen = open && sections.length > 0;
const rows = React.useMemo(
() =>
sections.map((section) => (
<SuggestionMenuGroup key={section.label} label={section.label}>
{section.items.map((item) => (
<SuggestionMenuItem key={item.key} label={item.title} onClick={item.onSelect} />
))}
</SuggestionMenuGroup>
)),
[sections],
);
<input
role="combobox"
aria-expanded={menuOpen}
aria-controls={menuOpen ? "commands-menu" : undefined}
aria-activedescendant={menuOpen ? (highlightedId ?? undefined) : undefined}
onKeyDown={(event) => {
if (actionsRef.current?.handleKeyDown(event)) event.preventDefault();
}}
/>;
{
menuOpen ? (
<SuggestionMenu id="commands-menu" actionsRef={actionsRef} onHighlightChange={setHighlightedId}>
{rows}
</SuggestionMenu>
) : null;
}| Key | What happens |
|---|---|
ArrowDown |
Highlights the next row. Wraps to the first row unless loopFocus={false}. |
ArrowUp |
Highlights the previous row. Wraps to the last row unless loopFocus={false}. |
PageDown/PageUp |
Highlights the row a page (10 rows) below/above, wrapping like the arrows. |
Home/End |
Highlights the first/last row. |
Enter/Tab |
Chooses the highlighted row: runs its onClick. |
| Anything else | Returns false. The input handles it, so typing keeps filtering. |
Escape is left to the host: closing the menu is a decision about the input, not the list.
The highlight starts on the first row. When filtering removes the highlighted row, it moves back to the first row that is left. Rows are counted in the order they appear, so sections need no index of their own.
Positioning is the host’s job. The menu renders where you put it; an editor places it at the caret. Render it only while it has rows: an empty menu draws an empty panel.
Examples
Opening with /
The editor use case: the menu renders only while the input starts with /. Typing narrows the
rows, Enter picks one, and Escape dismisses. The host owns the open state; the menu only owns
the highlight.
Show codeHide code
Trailing content
trailing pins content to the end of a row, such as a plan badge on a feature the workspace does
not have yet. Keep it non-interactive: a press anywhere in the menu keeps focus in the input, so
a button or link placed here would never take focus.
Show codeHide code
Highlighting matches
label takes a node, so the part of a row that matches the query can be emphasized.
Show codeHide code
Fitting the widest row
The menu is a 320px column by default. width="fit" sizes it to its widest row instead, so a list
of short rows leaves no empty space. It follows the same width rule as Menu:
never narrower than 204px, never wider than 400px, where long labels truncate.
While the menu stays open, its width can only grow: filtering out the widest rows does not narrow it, so it does not jump as someone types. It measures again the next time it opens.
The 204px floor and 400px ceiling are bounded by the viewport, not the menu’s container, so a
fit menu inside a container narrower than 204px overflows it. Use the default fixed width
there: it never grows past its container.
Show codeHide code
Accessibility
The menu is a role="listbox". Name it with aria-label. Each row is a role="option", and the
highlighted row carries aria-selected="true". A section with a label is a role="group" named
by that label.
The input is the combobox: it keeps focus and points at the menu with aria-controls, and at the
highlighted row with aria-activedescendant (mirrored from the menu’s onHighlightChange). That
is how the highlight is announced without moving focus.
Rows never take focus, and pressing a row or a heading keeps focus in the input. That is the point of the component, and it is why the list is not focusable even when it scrolls: a focusable list would pull the caret out of the input on a click. Presses on the list itself (the scrollbar) are left alone so the thumb stays draggable; they take no focus either.
API Reference
SuggestionMenu
A list of suggestions that follows an input which keeps focus — a / command menu in an editor,
@ mentions, query completions. Unlike Menu it never moves focus: the input forwards its key
presses through actionsRef, and the menu owns which row is highlighted. The highlight starts on
the first row and falls back to it whenever the highlighted row is filtered out.
Renders the listbox; compose SuggestionMenuGroup sections of SuggestionMenuItem rows inside
it. Positioning is the host's job — the menu renders in place.
Wire the input as a combobox: give the menu an id, point the input's aria-controls at it, and
mirror onHighlightChange into the input's aria-activedescendant, so the highlight is
announced while focus stays in the input. Memoize the row list: otherwise the host re-render from
that mirror re-renders every SuggestionMenuItem on each arrow key.
SuggestionMenuGroup
A section of SuggestionMenuItem rows, with an optional heading. Sections are divided by a
hairline; the last draws none.
SuggestionMenuItem
A suggestion row. onClick runs when the row is chosen — by a click, or by Enter/Tab
forwarded through SuggestionMenu's actionsRef. Pointer movement highlights the row, and a
press never takes focus from the input.