# 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.

## Installation

```tsx
import {
  SuggestionMenu,
  type SuggestionMenuActions,
  SuggestionMenuGroup,
  SuggestionMenuItem,
} from "@makeplane/propel/components/suggestion-menu";
```
## Usage

```tsx
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](/components/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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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](/components/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.

```tsx
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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `actionsRef` | `Ref<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. |
| `loopFocus` | `boolean` | `true` | Whether 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `ReactNode` | — | 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.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` (required) | `ReactNode` | — | Row label. A node, so the matched part of a query can be emphasized. |
| `icon` | `ReactNode` | — | Leading glyph, e.g. `<Heading1 />`. Sized to 16px and tinted `icon-secondary`. |
| `trailing` | `ReactNode` | — | 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. |
