Source: https://monarchui.dev/components/mention-picker/

Components · Collaboration

# MentionPicker

@mention suggestions anchored to the caret, with async search, people and channel groups and listbox keys.

@rookcom/monarch-react/collaboration

Component

## Comment field with people and channels

## Usage examples

### With a rich-text editor

## Purpose

Suggests people and channels while someone types `@` or `#` in a text field the product owns, and writes a stable mention token (`@[Ana Lopez](u_ana)`) when one is chosen, so the server can notify the right ids.

### Use when

- Comments, replies and descriptions in a plain textarea (`useMentionTextarea`).
- A rich-text editor that reports its caret (`useMentionPicker`, which the wave 9 editor reuses).

### Avoid when

- Picking an assignee or owner from a field: use AssigneePicker or EntityPicker.
- Choosing from a fixed list in a form: use Combobox.

## Design principles

- Focus never leaves the text field; the field points at the highlighted option, as in the APG combobox pattern.
- Searching is the product’s: Monarch debounces, aborts stale requests and shows loading, empty and error states.
- The token format is a contract: `formatMentionToken` and `parseMentionTokens` round-trip, and `serialize` lets a product keep its own format.
- Typing prose is never hijacked: the list only opens at a word start and closes on a two-word query with no matches.

## API reference

```tsx
import { MentionPicker, findMentionQuery, formatMentionToken, getCaretRect, parseMentionTokens, useMentionPicker, useMentionTextarea } from '@rookcom/monarch-react/collaboration';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `MentionPicker` | component | client | `.mn-mention-` |
| `findMentionQuery` | utility | client | – |
| `formatMentionToken` | utility | client | – |
| `getCaretRect` | utility | client | – |
| `parseMentionTokens` | utility | client | – |
| `useMentionPicker` | hook | client | – |
| `useMentionTextarea` | hook | client | – |

`useMentionTextarea({ value, onChange, search, triggers?, serialize?, onMention?, debounceMs?, onKeyDown? })` returns `textareaProps` (spread on the `<textarea>`, including `value` and `onChange`) and `pickerProps` (spread on `<MentionPicker>`).

`useMentionPicker({ search, onSelect, debounceMs? })` is the editor-agnostic core: call `update(query, caretRect)` as the caret moves, and `handleKeyDown(event)` first in your key handler; spread `fieldProps` on the editable element.

`search(text, { trigger, signal })` returns items or a promise of items; abort on `signal`.

Helpers: `findMentionQuery(value, caret, triggers)`, `getCaretRect(field, index)`, `formatMentionToken(item, trigger)`, `parseMentionTokens(text, triggers)`.

Generated from the TypeScript declarations of `packages/react/src/components/collaboration/mention-picker`.

## Properties

### `MentionPicker`

The

| Prop | Type | Description |
| --- | --- | --- |
| `activeId` required | `string \| null` |  |
| `anchor` required | `MentionAnchorRect \| null` | The caret rectangle in viewport coordinates. The list opens below it, or above when there is no room. |
| `id` required | `string` | Base id; the listbox is `${id}-listbox`. From the hook. |
| `items` required | `readonly MentionItem[]` |  |
| `onActiveChange` required | `(id: string) => void` |  |
| `onSelect` required | `(item: MentionItem) => void` |  |
| `open` required | `boolean` |  |
| `status` required | `MentionPickerStatus` |  |
| `children` | `ReactNode` |  |
| `className` | `string` |  |
| `container` | `HTMLElement \| null` | Where to portal the list. Default `document.body`. |
| `messages` | `Partial<MentionPickerMessages>` |  |
| `onRetry` | `(() => void)` | Repeats a failed search. |
| `query` | `MentionQuery \| null` |  |
| `renderIcon` | `((item: MentionItem) => ReactNode)` | Overrides the default avatar or `#` glyph before each option. |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `triggers` | `'['@']'``'['@', '#']'` | Characters that open the list. `search` receives the trigger, so `#` can search channels only. |
| `MentionItem.kind` | `'person'``'channel'` | People show an avatar, channels a `#` tile. Mixed results are split into labelled groups. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Searching | `data-status="loading"` | Spinner and “Searching…”. |
| Results | `data-status="ready"` | Listbox with the first enabled option highlighted. |
| No matches | – | “No matches” for a one-word query; a two-word query with none closes the list. |
| Error | `data-status="error"` | “Couldn’t load suggestions.” with Retry. |
| Disabled option | `data-disabled` | Shown, skipped by arrow keys, cannot be chosen. |

## Interaction behaviour

- Typing the trigger at the start of the text, after a space or after `(` opens the list; `ana@example.com` does not.
- Each change searches after `debounceMs` (150 ms) and aborts the previous request.
- Choosing replaces the query with the token and a space, then puts the caret after it.
- The list opens below the caret and flips above it near the bottom of the viewport; it is kept inside the viewport horizontally.
- Blurring the field or pressing Escape closes the list.

## Keyboard support

| Keys | Action |
| --- | --- |
| Arrow Down / Arrow Up | Highlight the next or previous enabled suggestion (wraps). |
| Enter / Tab | Insert the highlighted suggestion. |
| Escape | Close the list without leaving the field (and without closing a surrounding dialog). |
| Home / End, Arrow Left / Right | Move the caret, as normal; moving it out of the query closes the list. |

Keys reach your own `onKeyDown` whenever the list did not use them.

## Accessibility

Role

listbox with option children; groups for people and channels

Accessible name

“Suggestions” (

messages.label

); groups are labelled “People” and “Channels”.

- The field gets `aria-autocomplete="list"`, `aria-controls` while the listbox is shown and `aria-activedescendant` for the highlighted option, so screen readers follow the highlight while focus stays in the field.
- A polite status announces “Searching…”, “5 suggestions”, “No matches” or the error.
- The textarea keeps its native textbox role: ARIA does not allow `combobox` on `<textarea>`.
- Options use `aria-selected` for the highlight and `aria-disabled` for unavailable people.

## Responsive behaviour

- The list is at most 18rem wide and never wider than the viewport minus 1rem; it scrolls past 22rem or half the viewport height.
- Long names and descriptions are truncated with an ellipsis.

## Theme support

- A raised surface (`bg-raised`, `border-subtle`, `shadow-lg`) like Monarch menus; the highlight is `bg-muted`.
- Forced colours outline the highlighted option.

## Do and don't

### Do

- Abort your request when `signal` fires.
- Return people the viewer may mention; mark the ones without access `disabled` with a reason in `description`.
- Resolve tokens to ids on the server too; never trust the label.

### Don't

- Do not move focus into the list.
- Do not change the token format without updating every reader of stored text.
- Do not return hundreds of results; cap the search at about 8–10.

## Related components

- [MessageComposer](https://monarchui.dev/components/message-composer/): The chat composer that will host it (planned).
- [RichTextEditor](https://monarchui.dev/components/rich-text-editor/): Reuses `useMentionPicker` (planned, wave 9).
- [AssigneePicker](https://monarchui.dev/components/assignee-picker/): Choosing people for a field, not inline text.
- [Combobox](https://monarchui.dev/components/combobox/): Choosing from a list in a form.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `react``react-dom` |
| Monarch components | [Avatar](https://monarchui.dev/components/avatar/), [Spinner](https://monarchui.dev/components/spinner/) |

## Testing coverage

- Opens a listbox for @ and wires `aria-autocomplete`, `aria-controls` and `aria-activedescendant`; announces the count.
- Arrow keys wrap and skip disabled options; Enter and Tab insert a token and leave the caret after it; clicks keep focus.
- Escape closes without reaching outer handlers; other keys pass to the product.
- People and channel groups; the # trigger.
- Aborts the previous search; loading, error with Retry, no matches, two-word close.
- Does not open inside an email address; closes on blur.
- `findMentionQuery`, `formatMentionToken` and `parseMentionTokens`.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/mention-picker; Cross-browser axe: /components/mention-picker |

Tests: `packages/react/src/components/collaboration/mention-picker`

## All example code

### Comment field with people and channels

```tsx
import { useState } from 'react';
import { Field, FieldDescription, FieldLabel } from '@rookcom/monarch-react';
import { MentionPicker, parseMentionTokens, useMentionTextarea, type MentionItem, type MentionSearch } from '@rookcom/monarch-react/collaboration';

const DIRECTORY: MentionItem[] = [
  { id: 'u_ana', label: 'Ana Lopez', description: 'Design' },
  { id: 'u_ben', label: 'Ben Okafor', description: 'Engineering' },
  { id: 'u_chloe', label: 'Chloé Martin', description: 'Support' },
  { id: 'u_dev', label: 'Dev Patel', description: 'Billing' },
  { id: 'u_eli', label: 'Eli Novak', description: 'No access to this project', disabled: true },
  { id: 'c_launch', label: 'launch', kind: 'channel', description: '12 members' },
  { id: 'c_design', label: 'design-review', kind: 'channel', description: '8 members' },
];

/** A fake directory search with a short delay, honouring the abort signal. */
const demoSearch: MentionSearch = (text, { trigger, signal }) =>
  new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      const q = text.toLowerCase();
      resolve(
        DIRECTORY.filter(
          (d) =>
            (trigger === '#' ? d.kind === 'channel' : true) && d.label.toLowerCase().includes(q),
        ).slice(0, 6),
      );
    }, 250);
    signal.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(new DOMException('Aborted', 'AbortError'));
    });
  });

const textareaStyle = {
  width: '100%',
  minHeight: 96,
  padding: '8px 10px',
  border: '1px solid var(--mn-color-border-input)',
  borderRadius: 'var(--mn-radius-md)',
  background: 'var(--mn-color-bg-surface)',
  color: 'var(--mn-color-text-default)',
  font: 'inherit',
  resize: 'vertical' as const,
};

function CommentDemo() {
  const [value, setValue] = useState('Thanks @[Ana Lopez](u_ana), looping in ');
  const mention = useMentionTextarea({
    value,
    onChange: setValue,
    search: demoSearch,
    triggers: ['@', '#'],
  });
  const mentioned = parseMentionTokens(value).filter((s) => s.type === 'mention');
  return (
    <div style={{ width: '100%', maxWidth: 480 }}>
      <Field>
        <FieldLabel htmlFor="mention-demo">Comment</FieldLabel>
        <textarea id="mention-demo" style={textareaStyle} {...mention.textareaProps} />
        <FieldDescription>Type @ for people or # for channels.</FieldDescription>
      </Field>
      <MentionPicker {...mention.pickerProps} />
      <p style={{ margin: '8px 0 0', fontSize: 12, color: 'var(--mn-color-text-muted)' }}>
        Will notify:{' '}
        {mentioned.map((s) => (s.type === 'mention' ? s.label : '')).join(', ') || 'nobody'}
      </p>
    </div>
  );
}

<CommentDemo />
```

### With a rich-text editor

```tsx
<p style={{ margin: 0, fontSize: 13 }}>
  Editors call the core hook with their own caret position.
</p>
```
