# SearchableMenu

A menu of actions you can type into.

- Group: Overlays
- Import: `import { SearchableMenu } from '@misoto22/folio'`
- Page: https://ui.misoto22.com/components/searchable-menu/
- Related: dropdown-menu, command, combobox

## When to reach for it

A DropdownMenu past about a dozen rows stops being scannable, and nesting submenus makes it worse. This is the same list with a filter over it. Not a Command palette: that is page-level and modal; this is anchored to a control.

## Anatomy

- **Trigger** (required) — A pill button built here rather than a Button: --control-h-sm tall, hairline bordered, with a chevron. Its accessible name is label, not the children you passed as its text.
- **Panel** (required) — A Popover with its padding removed and a flat 16rem width, so the filter and the list run edge to edge inside it. Not modal — this is a Popover, not a Dialog.
- **Filter** (required) — The Command input. Focus lands here on open and stays there; the highlight moves under it through aria-activedescendant.
- **Rows** — One per MenuAction, as listbox options. shortcut prints as a Kbd at the end of the row, destructive paints it --danger, and selecting one closes the menu before running onSelect.
- **Empty state** — emptyMessage, shown when the filter matches nothing. Say what would match rather than “no results”.

## Best practices

### Do

- Give an action keywords for the word a reader reaches for that the label does not print — “download” for Export, “bin” for Delete. The label’s own text is lifted into the filter for you; a label built only from elements prints none, and development warns SEARCHABLE_MENU_LABEL_UNREADABLE rather than shipping a row nothing matches.
- Make label the trigger’s visible text. It is set as aria-label on the trigger and overrides the children, so a button reading “Status” under a label of “Row actions” is announced as something the reader cannot say aloud (WCAG 2.5.3).
- Keep actions that share a group next to each other in the array: groups are built by walking the list and extending only the LAST one, so the same group name appearing again after other rows produces a second heading with identical text.

### Don’t

- It is a Popover underneath, so it is not modal: the page behind still scrolls while the filter is open and the panel is re-anchored as it does. A surface that should hold the page still is CommandDialog.
- className lands on the TRIGGER, not on the panel — the panel is a flat 16rem with no prop that widens it, so a long label wraps to a second line instead of the box growing to take it.
- The trigger is --control-h-sm — 36px comfortable, 30px under data-density="compact" — which is below the 44px pointer target (WCAG 2.5.5), and it is not the system Button, so variant and size do not reach it.

## Accessibility

- Inside a bounded frame — a device preview, an embedded console — wrap the subtree in `<OverlayContainer container={el}>`. The panel then renders into that element and collides with its edges instead of the viewport’s, and inherits the `dir` and `data-density` set there.
- The rows are options inside a listbox rather than menuitems, because filtering requires it — the highlight moves through aria-activedescendant while focus stays in the input, and a menu cannot do that.
- The trade is deliberate: a menu that cannot be filtered is worse for the reader than a listbox that runs actions.

## Keyboard

- Enter / Space — Opens the menu.
- ↑ / ↓ — Moves the highlight while focus stays in the filter.
- Enter — Runs the highlighted action.
- Escape — Closes without running anything.

## SearchableMenu

A menu of actions you can type into. The fourth corner of a square the system otherwise had three of, and the distinction is worth stating because reaching for the wrong one is easy: | | few options | many options | |---|---|---| | **sets a value** | `Select` | `Combobox` | | **runs an action** | `DropdownMenu` | `SearchableMenu` | A `DropdownMenu` past about a dozen rows stops being scannable, and the usual response — nesting submenus — makes it worse. This is the same list with a filter over it. Not the same thing as `Command`: that is a page-level palette, usually modal and usually bound to ⌘K. This is anchored to a control, like the menu it replaces. The rows are `option`s inside a listbox rather than `menuitem`s, because that is what the filtering pattern requires — the highlight moves through `aria-activedescendant` while focus stays in the input, and a menu cannot do that. The trade is deliberate: a menu that cannot be filtered is worse for the reader than a listbox that runs actions.

### Props

- `actions` (required) — `MenuAction[]`.
- `children` (required) — `ReactNode`. The trigger's text.
- `label` (required) — `string`. Names the menu for assistive tech.
- `searchPlaceholder` — `string` default `'Filter…'`.
- `emptyMessage` — `string` default `'Nothing matches.'`.
- `align` — `'start' | 'center' | 'end'` default `'start'`.
- `className` — `string`.

## Example — a filtered menu

```tsx
import { SearchableMenu, type MenuAction } from '@misoto22/folio'

<div className="flex flex-col items-center gap-4">
  <SearchableMenu label="Actions" actions={actions} searchPlaceholder="Filter actions…">
    Actions
  </SearchableMenu>
  <p className="m-0 mono-meta text-(--ink-3-aa)">{last ? `ran: ${last}` : 'nine actions, one filter'}</p>
</div>
```

## Example — searchable keywords

```tsx
import { SearchableMenu, Text, type MenuAction } from '@misoto22/folio'

<div className="flex flex-col items-center gap-4">
  <SearchableMenu label="Release actions" actions={actions} searchPlaceholder="Try spreadsheet…">
    Release actions
  </SearchableMenu>
  <Text size="sm" tone="muted">
    {last ? `ran: ${last}` : 'four opaque ids, matched by their keywords'}
  </Text>
</div>
```

## Example — groups in order

```tsx
import { SearchableMenu, type MenuAction } from '@misoto22/folio'

<div className="flex flex-col items-center gap-4">
  <SearchableMenu
    label="Collection actions"
    actions={actions}
    searchPlaceholder="Filter actions…"
    emptyMessage="Nothing here matches. Try share, or delete."
  >
    Collection actions
  </SearchableMenu>
  <p className="m-0 mono-meta text-(--ink-3-aa)">{last ? `ran: ${last}` : 'two groups, written adjacent'}</p>
</div>
```
