Propel

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.

Basics
Text
Heading 1
Heading 2
Quote
Code
Lists
Numbered list
Bulleted list
To-do list
Picked: none
Show code
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>
  );
}

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

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.

Picked: none
Show code
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 {
  H1Outline,
  H2Outline,
  NumberedListOutline,
  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 },
    ],
  },
  {
    label: "Lists",
    items: [{ title: "Numbered list", icon: NumberedListOutline }],
  },
];

export default function SlashTriggerDemo() {
  const actionsRef = React.useRef<SuggestionMenuActions>(null);
  const [value, setValue] = React.useState("");
  const [picked, setPicked] = React.useState<string | null>(null);
  const [highlightedId, setHighlightedId] = React.useState<string | null>(null);
  const open = value.startsWith("/");
  const query = open ? value.slice(1).toLowerCase() : "";
  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);
          setValue("");
        });
      }
    }
    return handlers;
  }, []);
  const sections = React.useMemo(
    () =>
      SECTIONS.map((section) => ({
        ...section,
        items: section.items.filter((item) => item.title.toLowerCase().includes(query)),
      })).filter((section) => section.items.length > 0),
    [query],
  );
  // Gated so no ARIA reference dangles once the menu unmounts on close.
  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.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="Editor"
        placeholder="Type / for commands"
        value={value}
        role="combobox"
        aria-expanded={menuOpen}
        aria-controls={menuOpen ? "commands-menu" : undefined}
        aria-activedescendant={menuOpen ? (highlightedId ?? undefined) : undefined}
        onChange={(event) => setValue(event.target.value)}
        onKeyDown={(event) => {
          if (event.key === "Escape") {
            setValue("");
            return;
          }
          if (actionsRef.current?.handleKeyDown(event)) event.preventDefault();
        }}
      />
      {menuOpen ? (
        <SuggestionMenu
          id="commands-menu"
          actionsRef={actionsRef}
          aria-label="Commands"
          onHighlightChange={setHighlightedId}
        >
          {rows}
        </SuggestionMenu>
      ) : null}
      <output className="text-body-xs-regular text-tertiary">Picked: {picked ?? "none"}</output>
    </div>
  );
}

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.

Media
Image
VideoPro
Block equationPro
Show code
import { Badge } from "@makeplane/propel/components/badge";
import { Icon } from "@makeplane/propel/components/icon";
import {
  SuggestionMenu,
  SuggestionMenuGroup,
  SuggestionMenuItem,
} from "@makeplane/propel/components/suggestion-menu";
import { ImageOutline, FormulaOutline, VideoOutline } from "@makeplane/propel/icons";

export default function TrailingDemo() {
  return (
    <SuggestionMenu aria-label="Blocks">
      <SuggestionMenuGroup label="Media">
        <SuggestionMenuItem icon={<Icon icon={ImageOutline} />} label="Image" />
        <SuggestionMenuItem
          icon={<Icon icon={VideoOutline} />}
          label="Video"
          trailing={<Badge size="xs" variant="brand" label="Pro" />}
        />
        <SuggestionMenuItem
          icon={<Icon icon={FormulaOutline} />}
          label="Block equation"
          trailing={<Badge size="xs" variant="brand" label="Pro" />}
        />
      </SuggestionMenuGroup>
    </SuggestionMenu>
  );
}

Highlighting matches

label takes a node, so the part of a row that matches the query can be emphasized.

People
Aria Chen
Arjun Mehta
Carlos Ruiz
Maria Santos
Show code
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 { AtOutline } from "@makeplane/propel/icons";
import * as React from "react";

const PEOPLE = ["Aria Chen", "Arjun Mehta", "Bea Lindqvist", "Carlos Ruiz", "Maria Santos"];

// `label` takes a node, so the part of each name that matches the query can be emphasized.
function withMatch(text: string, query: string) {
  const index = text.toLowerCase().indexOf(query.toLowerCase());
  if (!query || index === -1) return text;
  return (
    <>
      {text.slice(0, index)}
      <strong className="font-semibold">{text.slice(index, index + query.length)}</strong>
      {text.slice(index + query.length)}
    </>
  );
}

export default function HighlightMatchDemo() {
  const actionsRef = React.useRef<SuggestionMenuActions>(null);
  const [query, setQuery] = React.useState("ar");
  const [highlightedId, setHighlightedId] = React.useState<string | null>(null);
  const people = React.useMemo(
    () => PEOPLE.filter((name) => name.toLowerCase().includes(query.toLowerCase())),
    [query],
  );
  // Gated so no ARIA reference dangles once the menu unmounts when empty.
  const menuOpen = people.length > 0;
  // Labels depend on the query (match emphasis), so the row tree invalidates with `people`/`query`.
  const rows = React.useMemo(
    () => (
      <SuggestionMenuGroup label="People">
        {people.map((name) => (
          <SuggestionMenuItem
            key={name}
            icon={<Icon icon={AtOutline} />}
            label={withMatch(name, query)}
          />
        ))}
      </SuggestionMenuGroup>
    ),
    [people, query],
  );

  return (
    <div className="flex w-80 flex-col gap-2">
      <Input
        size="md"
        aria-label="Mention"
        value={query}
        role="combobox"
        aria-expanded={menuOpen}
        aria-controls={menuOpen ? "people-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="people-menu"
          actionsRef={actionsRef}
          aria-label="People"
          onHighlightChange={setHighlightedId}
        >
          {rows}
        </SuggestionMenu>
      ) : null}
    </div>
  );
}

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.

Basics
Text
Heading 1
Heading 2
Show code
import { Icon } from "@makeplane/propel/components/icon";
import {
  SuggestionMenu,
  SuggestionMenuGroup,
  SuggestionMenuItem,
} from "@makeplane/propel/components/suggestion-menu";
import { H1Outline, H2Outline, TitleOutline } from "@makeplane/propel/icons";

export default function FitWidthDemo() {
  return (
    <SuggestionMenu aria-label="Blocks" width="fit">
      <SuggestionMenuGroup label="Basics">
        <SuggestionMenuItem icon={<Icon icon={TitleOutline} />} label="Text" />
        <SuggestionMenuItem icon={<Icon icon={H1Outline} />} label="Heading 1" />
        <SuggestionMenuItem icon={<Icon icon={H2Outline} />} label="Heading 2" />
      </SuggestionMenuGroup>
    </SuggestionMenu>
  );
}

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.

PropTypeDefaultDescription
actionsRefRef<SuggestionMenuActions>—Receives the menu's SuggestionMenuActions. Forward the input's key presses to actionsRef.current.handleKeyDown — the menu never takes focus, so it never sees them itself.
loopFocusbooleantrueWhether the highlight wraps from the last row to the first (and back) at the ends of the list.
onHighlightChange((id: string | null) => void)—Runs whenever the highlighted row changes, with its id (or null when the menu has no rows). Point the input's aria-activedescendant at it, so assistive tech announces the highlight while focus stays in the input.
width"fixed" | "fit""fixed"fixed is the 320px design column. fit sizes the menu to its widest row, between 204px and 400px like Menu, so short lists leave no empty space. While mounted, a fit menu only grows: filtering out wide rows never narrows it, so the width does not jump as someone types. fit is bounded by the viewport, not its container: in a container narrower than 204px it overflows, so use fixed there.

SuggestionMenuGroup

A section of SuggestionMenuItem rows, with an optional heading. Sections are divided by a hairline; the last draws none.

PropTypeDefaultDescription
labelReactNode—Section heading shown above the rows. Also names the group for assistive tech.

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.

PropTypeDefaultDescription
label(required)ReactNode—Row label. A node, so the matched part of a query can be emphasized.
iconReactNode—Leading glyph, e.g. <Heading1 />. Sized to 16px and tinted icon-secondary.
trailingReactNode—Content pinned at the row's inline end, e.g. a plan badge. Non-interactive only: a press anywhere in the menu keeps focus in the input, so a button or link here would never take focus.