npm package: @geee-be/react-quick-menu
Description: Headless, accessible React hierarchical quick-menu component
Latest version: 0.1.5

# `@geee-be/react-quick-menu`

A headless, accessible, data-driven hierarchical popup menu for React. Items are navigated by typing their unique key sequences — no mouse required.

## Features

- **Keyboard-driven**: type a key sequence to filter and select items
- **Hierarchical**: items can have children; navigation stacks parent items into a breadcrumb
- **Accessible**: built on `@radix-ui/react-dialog` — modal dialog semantics, focus trap, ARIA attributes
- **Global hotkey**: open the menu with any hotkey via `@tanstack/react-hotkeys`
- **Headless**: zero styles — render items however you like via render-prop children
- **Fully typed**: strict TypeScript throughout

## Installation

```bash
pnpm add @geee-be/react-quick-menu
```

## Key Sequence Logic

Given items with these `keySequence` values:

| Item  | keySequence | Minimum unique prefix |
|-------|------------|----------------------|
| Hello | `hello`    | `h`                  |
| There | `there`    | `th`                 |
| Food  | `food`     | `f`                  |
| Truck | `truck`    | `tr`                 |

Typing `t` filters to _There_ and _Truck_. Typing `r` then selects _Truck_ (matching unique prefix `tr`).

If the typed characters match no items, an audible beep plays and the character is discarded. `Backspace` removes the last typed character. `Escape` closes the menu.

When a parent item (one with `children`) is selected, its children are shown and the parent appears in the breadcrumb. The selected item IDs from root to the chosen leaf are returned via `onSelect`.

## Usage

```tsx
import * as QuickMenu from '@geee-be/react-quick-menu';

const items: QuickMenu.QuickMenuItem[] = [
  { id: 'file', label: 'File', keySequence: 'file', children: [
    { id: 'file-save', label: 'Save', keySequence: 'save' },
    { id: 'file-open', label: 'Open', keySequence: 'open' },
  ]},
  { id: 'help', label: 'Help', keySequence: 'help' },
];

function App() {
  const handleSelect = (ids: string[]) => {
    console.log('Selected path:', ids);
    // e.g. ['file', 'file-save']
  };

  return (
    <QuickMenu.Root
      items={items}
      onSelect={handleSelect}
      hotkey="/"
      stackPosition="above"
    >
      <QuickMenu.Trigger>Open Menu</QuickMenu.Trigger>

      <QuickMenu.Content title="Quick Menu">
        {/* Breadcrumb of selected ancestors */}
        <QuickMenu.Breadcrumb
          render={(item, uniquePrefix) => (
            <span>
              <strong>{item.label}</strong>
              <kbd>{uniquePrefix}</kbd>
            </span>
          )}
        />

        {/* List of current items */}
        <QuickMenu.ItemList>
          {(item, uniquePrefix, typed) => (
            <div>
              {/* Highlight the typed portion of the prefix */}
              <span style={{ fontWeight: 'bold' }}>{uniquePrefix.slice(0, typed.length)}</span>
              <span>{uniquePrefix.slice(typed.length)}</span>
              <span>{item.label}</span>
              {item.children ? <span>▶</span> : null}
            </div>
          )}
        </QuickMenu.ItemList>
      </QuickMenu.Content>
    </QuickMenu.Root>
  );
}
```

## API

### `QuickMenuItem`

```ts
interface QuickMenuItem {
  id: string;
  label: React.ReactNode;
  keySequence: string;       // alphanumeric string used for keyboard navigation
  children?: QuickMenuItem[]; // nested items
  /**
   * Called when this item is selected. Optional for all items.
   * For leaf items, fires before the menu closes and before `onSelect`.
   * For parent items (with children), fires before navigating into the children.
   */
  onClick?: () => void;
}
```

### `<Root>`

| Prop            | Type                         | Default   | Description |
|-----------------|------------------------------|-----------|-------------|
| `items`         | `QuickMenuItem[]`            | —         | Top-level menu items |
| `onSelect`      | `(ids: string[]) => void`    | —         | Called with the full id path when a leaf is selected |
| `stackPosition` | `'above' \| 'below'`         | `'above'` | Where to render the breadcrumb relative to the item list |
| `hotkey`        | `string`                     | —         | Global hotkey to open the menu (e.g. `"/"` or `"Mod+K"`) |
| `closeOnRepeatHotkey` | `boolean`              | `false`   | When `true`, pressing `hotkey` while the menu is open closes it |
| `open`          | `boolean`                    | —         | Controlled open state |
| `onOpenChange`  | `(open: boolean) => void`    | —         | Called when open state changes |
| `defaultOpen`   | `boolean`                    | `false`   | Initial open state (uncontrolled) |

### `<Trigger>`

Renders a `<button>` by default. Supports `asChild` to merge props onto a child element.

| Prop      | Type      | Default | Description |
|-----------|-----------|---------|-------------|
| `asChild` | `boolean` | `false` | Merge props onto child element |
| `hotkey`  | `string`  | —       | Per-trigger hotkey (overrides Root's hotkey) |

### `<Content>`

Renders the modal panel via `@radix-ui/react-dialog`. Keyboard events are captured here.

| Prop           | Type        | Default       | Description |
|----------------|-------------|---------------|-------------|
| `title`        | `string`    | `'Quick Menu'`| Accessible title (visually hidden) |
| `description`  | `string`    | —             | Accessible description (visually hidden) |
| `contentProps` | `object`    | —             | Props forwarded to `Dialog.Content` |
| `overlayProps` | `object`    | —             | Props forwarded to `Dialog.Overlay` |

### `<ItemList>`

Renders the filtered list of items at the current navigation depth.

```tsx
<QuickMenu.ItemList>
  {(item: QuickMenuItem, uniquePrefix: string, typed: string) => (
    <YourItemComponent item={item} prefix={uniquePrefix} typed={typed} />
  )}
</QuickMenu.ItemList>
```

### `<Breadcrumb>`

Renders an `<ol>` of ancestor items in the current navigation stack.

| Prop     | Type | Description |
|----------|------|-------------|
| `render` | `(item: QuickMenuItem, uniquePrefix: string) => React.ReactNode` | Custom item renderer |

Returns `null` when at the root level (no ancestors).

### `useQuickMenuContext()`

Access the full menu context from any component inside `<QuickMenu.Root>`.

```ts
const {
  filteredItems,   // items matching the current typed sequence
  currentItems,    // all items at the current depth (unfiltered)
  uniquePrefixes,  // Map<id, minimumUniquePrefix>
  typed,           // characters typed so far
  stack,           // ancestor items navigated into
  open,            // current open state
  stackPosition,   // 'above' | 'below'
  onClose,         // close the menu
  handleKeyDown,   // bind to onKeyDown for custom content elements
} = useQuickMenuContext();
```

## Keyboard Shortcuts

| Key         | Action |
|-------------|--------|
| Alphanumeric | Append to filter / navigate |
| `Backspace` | Remove last typed character; if nothing is typed, pop back to the parent level |
| `Escape`    | Close menu |

## Accessibility

- Built on `@radix-ui/react-dialog` — modal dialog with focus trap, `role="dialog"`, `aria-modal="true"`
- Items rendered in a `role="list"` / `role="listitem"` structure
- Breadcrumb rendered as an `<ol>` with `aria-label="Navigation path"`
- Accessible title required via `<Content title="...">` (visually hidden by default)
- Invalid keystrokes produce an audible beep via the Web Audio API

## License

MIT