Skip to content
Brok UI

Loading…

No results

Mention Menu

Open source

An @/# mention autocomplete that opens at the caret when its trigger is typed in the editor (`floating`), filters people or typed items (pages, tasks, projects) grouped by type, supports a host search, and inserts a linked mention into the editor. Composed as <x-ui.editor.mention-menu>; <x-ui.mention-menu> stays as the compatibility tag.

Version
v1.3.3
Stability
stable
License
MIT
Related
Mention
Slash Menu
Turn Into Menu

Preview

previews.components.mention-menu.default.blade.php Blade
<div class="w-full flex justify-center p-6">
    <x-ui.editor.mention-menu class="w-72" />
</div>

Installation

terminal
php artisan ui:add mention-menu

Note

This component ships an Alpine behavior module at resources/js/ui/mention-menu.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './mention-menu.js';

Registry contract

php artisan ui:add mention-menu writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/editor/mention-menu.blade.php
  • blade resources/views/components/ui/mention-menu.blade.php
  • js resources/js/ui/mention-menu.js
Registry dependencies
input avatar editor dropdown
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

mention-menu.md
# Brok UI: Mention Menu (`mention-menu`)

An @/# mention autocomplete that opens at the caret when its trigger is typed in the editor (`floating`), filters people or typed items (pages, tasks, projects) grouped by type, supports a host search, and inserts a linked mention into the editor. Composed as <x-ui.editor.mention-menu>; <x-ui.mention-menu> stays as the compatibility tag.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add mention-menu
```

## Usage

```blade
<div class="w-full flex justify-center p-6">
    <x-ui.editor.mention-menu class="w-72" />
</div>
```

## Props

- `trigger` (string, default `@`) — Character that opens the menu from the editor, such as @ or #, and prefixes the inserted mention.
- `placeholder` (string|null, default `null`) — Placeholder of the search field. Null: "Search people…" for a people list, "Search…" for typed items.
- `items` (array, default `[]`) — Mentionable entries: people {id, label, handle, avatar}, typed items {id, label, type: person|page|task|project|client|contact|<string>, typeLabel, description, href, avatar, initials}, or plain strings. Empty falls back to a demo people list (none with a host search).
- `floating` (boolean, default `false`) — A caret popover: hidden until the trigger is typed in the surrounding <x-ui.editor>, then placed at the caret and driven from the editor, with no search field.
- `searchMethod` (string|null, default `null`) — Livewire method called with the query (debounced) that returns the items to show; a loading state shows while it runs. Implies remote.
- `remote` (boolean, default `false`) — The host filters: items show as given, and a loading state shows from each mention-search event until the items change (x-model / wire:model on the menu).

## Use when

- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Offering an @ or # autocomplete of people, pages, tasks or other records while typing inside <x-ui.editor>.

## Avoid when

- Do not use rich editor affordances for simple one-line or plain-text tasks.
- Mentions inside a plain textarea or chat composer; use mention instead.

## Anti-patterns

- Rich editors for one-line or easily validated input

## Rules

- Use the `<brok:mention-menu>` tag (or `<x-ui.mention-menu>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/mention-menu
- Registry JSON (files, props, contract): https://brokui.dev/r/open/mention-menu.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

Examples

caret.blade.php Blade
{{-- The floating mention menu opens at the caret: type @ after a space, or
     use the Mention button, which types it for you. Items of more than one
     type are grouped under a heading per type. --}}
@php
    $mentionItems = [
        ['id' => 'ada', 'label' => 'Ada Lovelace', 'type' => 'person', 'description' => __('Engineering')],
        ['id' => 'alan', 'label' => 'Alan Turing', 'type' => 'person', 'description' => __('Research')],
        ['id' => 'roadmap', 'label' => __('Roadmap'), 'type' => 'page', 'href' => '/docs/components/mention-menu'],
        ['id' => '42', 'label' => __('Write the release notes'), 'type' => 'task', 'description' => __('Due Friday')],
    ];
@endphp
<x-ui.editor class="mx-auto max-w-2xl" placeholder="{{ __('Type @ to mention…') }}">
    <x-slot:toolbar>
        <div class="flex items-center p-2">
            <x-ui.button variant="ghost" size="sm" data-trigger-button="mention" x-on:mousedown.prevent="" x-on:click="$dispatch('editor-trigger', { char: '@' })">{{ __('Mention') }}</x-ui.button>
        </div>
    </x-slot:toolbar>

    <x-slot:tools>
        <x-ui.editor.mention-menu floating :items="$mentionItems" />
    </x-slot:tools>

    <p>{{ __('Ask for a review from') }}</p>
</x-ui.editor>
in-editor.blade.php Blade
{{-- Inside an editor: put the caret in the text, then choose a person to insert the mention there. --}}
<x-ui.editor class="mx-auto max-w-2xl" placeholder="{{ __('Write something…') }}">
    <p>{{ __('Ask for a review from ') }}</p>

    <x-slot:tools>
        <x-ui.editor.mention-menu class="mx-4 mb-4" />
    </x-slot:tools>
</x-ui.editor>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
trigger string @ Character that opens the menu from the editor, such as @ or #, and prefixes the inserted mention.
placeholder string | null null Placeholder of the search field. Null: "Search people…" for a people list, "Search…" for typed items.
items array [] Mentionable entries: people {id, label, handle, avatar}, typed items {id, label, type: person|page|task|project|client|contact|<string>, typeLabel, description, href, avatar, initials}, or plain strings. Empty falls back to a demo people list (none with a host search).
floating boolean false A caret popover: hidden until the trigger is typed in the surrounding <x-ui.editor>, then placed at the caret and driven from the editor, with no search field.
searchMethod string | null null Livewire method called with the query (debounced) that returns the items to show; a loading state shows while it runs. Implies remote.
remote boolean false The host filters: items show as given, and a loading state shows from each mention-search event until the items change (x-model / wire:model on the menu).

Slots

  • default —
  • x-ui.editor.mention-menu — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

avatar mention-menu mention-menu-empty mention-menu-group mention-menu-item mention-menu-loading mention-menu-type mention-menu-type-icon

Behavior

  • Choosing an item inserts <a data-type="mention" data-mention-type="<type>" data-id="<id>" href="<href>">@Label</a> (a <span> without an href) through editor-insert, and dispatches editor-mention { id, type, label, href } and mention-select { id, label, handle, type, href }. The node is built with createElement and textContent, so the label, id and URL are never markup; an unsafe href or an id outside [\w.:@-] is left off.
  • Typed items show a type icon (person, page, task, project, client, contact, generic fallback) and their type label (translated per known type, typeLabel overrides), and group under translated headings when more than one type is present. A people-only list keeps the People copy; typed items use Mention, Search and No results.
  • Every query change dispatches mention-search { query } after 200 ms. Answer it by setting the items: search-method="searchMentions" calls $wire.searchMentions(query) and uses the returned array; or listen to mention-search and set the items through x-model / wire:model on the menu (x-modelable="mentionItems") with remote set. A translated Searching state shows until the items arrive.
  • Inside <x-ui.editor>, typing the trigger at a word start opens the menu from the caret: the text after it filters live, ArrowUp/ArrowDown move, Enter or Tab insert and remove the @query text, Escape closes and keeps the text. In the inline list ArrowDown/Up navigate, Enter chooses and Escape clears the query.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Rich authoring and editing

Create or revise structured rich content.

Use when

  • Use when users need rich structured authoring, annotation, or collaborative editing controls.
  • Offering an @ or # autocomplete of people, pages, tasks or other records while typing inside <x-ui.editor>.

Avoid when

  • Do not use rich editor affordances for simple one-line or plain-text tasks.
  • Mentions inside a plain textarea or chat composer; use mention instead.

Use instead

  • Textarea or plain field for simple content

Anti-patterns

  • Rich editors for one-line or easily validated input
Anatomy
root
Theming hooks
avatar mention-menu mention-menu-empty mention-menu-group mention-menu-item mention-menu-loading mention-menu-type mention-menu-type-icon

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape ArrowUp ArrowDown
Focus
managed
  • The search input carries role="combobox" with aria-controls pointing at the listbox, matching the ARIA combobox pattern. Opened from the caret, focus stays in the editor, which points aria-activedescendant at the active option; Tab is taken by the menu while it is open, so the options never take focus.
  • Type icons are aria-hidden; the type label is text. The result count is announced once through a polite status region when typing settles; loading and empty states are disabled options.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:ignore

Add wire:ignore to the component root because its behavior owns rendered DOM.

livewire-component.blade.php Blade
<div wire:ignore>
    <div class="w-full flex justify-center p-6">
        <x-ui.editor.mention-menu class="w-72" />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:mention-menu
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/editor/mention-menu.blade.php Blade
@props([
    'trigger' => '@',
    // Null: "Search people…" for a people list, "Search…" for typed items.
    'placeholder' => null,
    // [{ id, label, type?: person|page|task|project|client|contact|<string>,
    //    typeLabel?, description?, href?, avatar?, initials?, handle? }] or strings.
    'items' => [],
    // A caret popover: hidden until the trigger is typed in the editor around
    // it, then shown at the caret and driven from the editor (no search field).
    'floating' => false,
    // Livewire: a component method called with the query, returning items.
    'searchMethod' => null,
    // The host filters: items show as given, with a loading state from each
    // mention-search event until the items change.
    'remote' => false,
])

@php
    // role=combobox requires aria-controls pointing at its listbox (a11y).
    $listId = 'mention-menu-list-'.\Illuminate\Support\Str::random(6);
    $flag = fn ($value) => filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
    $floating = $flag($floating);
    $remote = $flag($remote) || filled($searchMethod);

    // Known mention types: translated item labels and group headings. An
    // item's own typeLabel wins; an unknown type shows its typeLabel or name.
    $typeLabels = [
        'person' => __('Person'), 'page' => __('Page'), 'task' => __('Task'),
        'project' => __('Project'), 'client' => __('Client'), 'contact' => __('Contact'),
    ];
    $groupLabels = [
        'person' => __('People'), 'page' => __('Pages'), 'task' => __('Tasks'),
        'project' => __('Projects'), 'client' => __('Clients'), 'contact' => __('Contacts'),
    ];

    // Normalise items to { id, label, handle, avatar, initials, type, typeLabel,
    // description, href }. Plain strings become untyped people.
    $normalisedItems = collect($items)->map(function ($item) use ($typeLabels) {
        if (! is_array($item)) {
            $item = ['id' => (string) $item, 'label' => (string) $item];
        }
        $label = (string) ($item['label'] ?? ($item['id'] ?? ''));
        $type = filled($item['type'] ?? null) ? (string) $item['type'] : null;

        return [
            'id' => $item['id'] ?? $label,
            'label' => $label,
            'handle' => (string) ($item['handle'] ?? \Illuminate\Support\Str::slug($label)),
            'avatar' => $item['avatar'] ?? null,
            'initials' => $item['initials'] ?? null,
            'type' => $type,
            'typeLabel' => $item['typeLabel'] ?? ($type ? ($typeLabels[$type] ?? $type) : null),
            'description' => (string) ($item['description'] ?? ''),
            'href' => $item['href'] ?? null,
        ];
    })->values()->all();

    // People copy for an untyped list, neutral copy once items are typed.
    $typed = $remote || collect($normalisedItems)->contains(fn ($item) => $item['type'] !== null);
    $copy = [
        'people' => ['placeholder' => __('Search people…'), 'search' => __('Search people'), 'list' => __('People'), 'empty' => __('No people found.')],
        'typed' => ['placeholder' => __('Search…'), 'search' => __('Search'), 'list' => __('Mention'), 'empty' => __('No results.')],
    ];
    if (filled($placeholder)) {
        $copy['people']['placeholder'] = __($placeholder);
        $copy['typed']['placeholder'] = __($placeholder);
    }
    $initialCopy = $copy[$typed ? 'typed' : 'people'];

    $menuConfig = [
        'items' => $normalisedItems,
        'trigger' => $trigger,
        'listId' => $listId,
        'floating' => $floating,
        'remote' => $remote,
        'searchMethod' => filled($searchMethod) ? (string) $searchMethod : null,
        'strings' => [
            'copy' => $copy,
            'typeLabels' => $typeLabels,
            'groupLabels' => $groupLabels,
            'other' => __('Other'),
            'resultsOne' => __('1 result'),
            'resultsMany' => __(':count results'),
        ],
    ];
@endphp

{{--
    Mention Menu — @/# autocomplete for the contenteditable <x-ui.editor> host.

    Drop this anywhere inside or adjacent to <x-ui.editor>. It dispatches:
        editor-insert  { html }                          — bubble to editor to insert the mention
        editor-mention { id, type, label, href }         — the inserted mention
        mention-select { id, label, handle, type, href } — also fired standalone
        mention-search { query }                         — debounced, for a host search

    The mention is <a data-type="mention" data-mention-type data-id href>@Label</a>
    (a <span> without an href), built with createElement / textContent.

    Items: people ({ id, label, handle, avatar }) or typed items ({ id, label,
    type, typeLabel, description, href, avatar, initials }). Typed lists show a
    type icon and label, and group by type when more than one type is present.
    Host search: `search-method` (Livewire, returns items), or listen to
    mention-search and set the items through x-model / wire:model (the root is
    x-modelable). `remote` turns off local filtering for the second way.
        public function searchMentions(string $query): array {
            return Task::where('title', 'like', "%{$query}%")->limit(8)->get()
                ->map(fn ($task) => ['id' => $task->id, 'label' => $task->title, 'type' => 'task', 'href' => route('tasks.show', $task)])
                ->all();
        }
        <x-ui.editor.mention-menu floating search-method="searchMentions" />
    Plain Alpine:
        <div x-data="{ found: [] }" x-on:mention-search="found = await lookup($event.detail.query)">
            … <x-ui.editor.mention-menu floating remote x-model="found" /> …
        </div>

    Inside <x-ui.editor>, typing the trigger at a word start drives the menu
    from the caret; with `floating` it is a popover at the caret.

    Keyboard: ArrowDown/Up navigate, Enter chooses, Escape clears the query.
    @mousedown.prevent on every interactive row preserves the editor selection.
--}}
<div
    x-data="uiMentionMenu(@js($menuConfig))"
    x-modelable="mentionItems"
    data-slot="mention-menu"
    @if ($floating) data-floating x-show="triggerActive" x-cloak @endif
    @keydown.arrow-down.prevent="move(1)"
    @keydown.arrow-up.prevent="move(-1)"
    @keydown.enter.prevent="choose()"
    @keydown.escape.prevent="query = ''"
    {{ $attributes->merge(['class' => ($floating ? 'fixed z-popover ' : '').'w-72 rounded-lg border border-border bg-popover text-popover-foreground shadow-md']) }}
>
    {{-- Search input (the floating popover takes its query from the editor) --}}
    @unless ($floating)
    <div class="border-b border-border p-2">
        {{-- :name="null" stops @aware from giving this search box the name of a
             surrounding named editor or field, which would post the query. --}}
        <x-ui.input
            :name="null"
            type="search"
            x-model="query"
            placeholder="{{ $initialCopy['placeholder'] }}"
            x-bind:placeholder="copy.placeholder"
            role="combobox"
            aria-label="{{ $initialCopy['search'] }}"
            x-bind:aria-label="copy.search"
            x-bind:aria-expanded="!isEmpty"
            aria-controls="{{ $listId }}"
            aria-haspopup="listbox"
            x-bind:aria-activedescendant="activeId()"
            autocomplete="off"
            class="h-8 text-sm"
        />
    </div>
    @endunless

    {{-- Results list --}}
    <div
        id="{{ $listId }}"
        role="listbox"
        aria-label="{{ $initialCopy['list'] }}"
        x-bind:aria-label="copy.list"
        class="max-h-80 overflow-y-auto p-1"
    >
        <template x-for="group in groups" :key="group.key">
            <div data-slot="mention-menu-group" x-bind:role="group.label ? 'group' : null" x-bind:aria-label="group.label">
                <template x-if="group.label">
                    <div class="px-2 py-2 text-xs font-medium text-muted-foreground" aria-hidden="true" x-text="group.label"></div>
                </template>

                <template x-for="entry in group.entries" :key="entry.index + ':' + entry.item.id">
                    <button
                        type="button"
                        role="option"
                        data-slot="mention-menu-item"
                        :id="`{{ $listId }}-option-${entry.index}`"
                        :data-index="entry.index"
                        :data-type="entry.item.type"
                        :aria-selected="entry.index === activeIndex"
                        @mousedown.prevent
                        @click="choose(entry.index)"
                        @mouseenter="activeIndex = entry.index"
                        :class="{
                            'bg-accent text-accent-foreground': entry.index === activeIndex,
                            'text-popover-foreground': entry.index !== activeIndex
                        }"
                        class="flex w-full items-center gap-3 rounded-sm px-2 py-2 text-start text-sm outline-none transition-colors"
                    >
                        {{-- A person shows an avatar (mirrors <x-ui.avatar> markup so it works
                             inside x-for); any other type shows its icon. --}}
                        <template x-if="showsAvatar(entry.item)">
                            <span
                                data-slot="avatar"
                                data-size="sm"
                                data-shape="circle"
                                class="relative inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-xs text-muted-foreground"
                            >
                                <template x-if="entry.item.avatar">
                                    <img :src="entry.item.avatar" alt="" class="size-full object-cover" />
                                </template>
                                <template x-if="!entry.item.avatar">
                                    <span class="font-medium" aria-hidden="true" x-text="entry.item.initials || initials(entry.item.label)"></span>
                                </template>
                            </span>
                        </template>
                        <template x-if="!showsAvatar(entry.item)">
                            <span
                                data-slot="mention-menu-type-icon"
                                class="flex size-8 shrink-0 items-center justify-center rounded-sm border border-border bg-card text-muted-foreground"
                                aria-hidden="true"
                                x-html="typeIcon(entry.item.type)"
                            ></span>
                        </template>

                        {{-- Label + handle (people) or description (typed items) --}}
                        <span class="flex min-w-0 flex-1 flex-col">
                            <span class="truncate font-medium leading-tight" x-text="entry.item.label"></span>
                            <template x-if="secondary(entry.item)">
                                <span class="truncate text-xs text-muted-foreground" x-text="secondary(entry.item)"></span>
                            </template>
                        </span>

                        <template x-if="entry.item.typeLabel">
                            <span data-slot="mention-menu-type" class="ms-auto shrink-0 text-xs text-muted-foreground" x-text="entry.item.typeLabel"></span>
                        </template>
                    </button>
                </template>
            </div>
        </template>

        {{-- Loading state (host search in flight) --}}
        <template x-if="loading">
            <div
                role="option"
                aria-disabled="true"
                aria-selected="false"
                data-slot="mention-menu-loading"
                class="px-2 py-6 text-center text-sm text-muted-foreground"
            >{{ __('Searching…') }}</div>
        </template>

        {{-- Empty state --}}
        <template x-if="!loading && isEmpty">
            <div
                role="option"
                aria-disabled="true"
                aria-selected="false"
                data-slot="mention-menu-empty"
                class="px-2 py-6 text-center text-sm text-muted-foreground"
                x-text="copy.empty"
            >{{ $initialCopy['empty'] }}</div>
        </template>
    </div>

    {{-- Result count for screen readers, once typing settles. --}}
    <span role="status" class="sr-only" x-text="announcement"></span>
</div>
resources/views/components/ui/mention-menu.blade.php Blade
{{-- <x-ui.mention-menu> is the install slug's compatibility tag: the part lives at
     <x-ui.editor.mention-menu> (ui/editor/mention-menu) with every other editor part.
     Compose the namespaced tag in new code; this file only forwards to it. --}}
@props([
    'trigger' => '@',
    'placeholder' => null,
    'items' => [],
    'floating' => false,
    'searchMethod' => null,
    'remote' => false,
])

<x-ui.editor.mention-menu :trigger="$trigger" :placeholder="$placeholder" :items="$items" :floating="$floating" :search-method="$searchMethod" :remote="$remote" {{ $attributes }} />
resources/js/ui/mention-menu.js JS
/**
 * Mention menu — @/# autocomplete that inserts a mention into the editor.
 *
 * Dispatches:
 *   editor-insert  { html }                           — bubbles to <x-ui.editor>, inserts at the caret
 *   editor-mention { id, type, label, href }          — the mention that was inserted
 *   mention-select { id, label, handle, type, href }  — also fired standalone
 *   mention-search { query }                          — debounced (200 ms) on every query change
 *
 * The mention is <a data-type="mention" data-mention-type="…" data-id="…"
 * href="…">@Label</a> (a <span> without an href), built by editor-embeds.js
 * with createElement / textContent, so a label, id or URL is never markup.
 *
 * Items are people ({ id, label, handle, avatar }) or typed items ({ id,
 * label, type, typeLabel, description, href, avatar, initials }). Typed lists
 * show a type icon and label, and group by type when more than one type is
 * present. A host search answers mention-search by setting the items: the
 * root is x-modelable="mentionItems" (x-model / wire:model), or
 * `searchMethod` calls a Livewire method with the query and uses what it
 * returns. With `remote` the items are shown as given (no local filter).
 *
 * Inside <x-ui.editor> typing the trigger drives the menu from the caret
 * (editor-menu-trigger.js); a `floating` menu is a popover at the caret.
 * @mousedown.prevent @click on rows preserves the editor selection.
 */
import { createMention, safeHref } from './editor-embeds.js';
import { menuTriggerMethods } from './editor-menu-trigger.js';
import { placeOverlay, trackOverlayPosition } from './overlay-position.js';

// Small inline icons by mention type (constants, never host data); the
// generic tag icon covers any other type.
const ICON = (path) => `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${path}</svg>`;
const TYPE_ICONS = {
    person: ICON('<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>'),
    page: ICON('<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><path d="M14 3v6h6M8 13h8M8 17h5"/>'),
    task: ICON('<rect x="3" y="3" width="18" height="18" rx="2"/><path d="m8 12 3 3 5-6"/>'),
    project: ICON('<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>'),
    client: ICON('<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18"/>'),
    contact: ICON('<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="10" cy="11" r="2.5"/><path d="M6 17a4 4 0 0 1 8 0M16 9h2M16 13h2"/>'),
};
const GENERIC_ICON = ICON('<path d="M20.6 13.4 13.4 20.6a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8z"/><circle cx="7.5" cy="7.5" r="1.5"/>');

const DEMO_PEOPLE = [
    { id: 'ada', label: 'Ada Lovelace', handle: 'ada', avatar: null },
    { id: 'alan', label: 'Alan Turing', handle: 'alan', avatar: null },
    { id: 'grace', label: 'Grace Hopper', handle: 'grace', avatar: null },
    { id: 'katherine', label: 'Katherine Johnson', handle: 'katherine', avatar: null },
    { id: 'linus', label: 'Linus Torvalds', handle: 'linus', avatar: null },
    { id: 'margaret', label: 'Margaret Hamilton', handle: 'margaret', avatar: null },
];

const slug = (value) => String(value).toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_]+/g, '-');

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiMentionMenu', (config = {}) => ({
        ...menuTriggerMethods({ placeOverlay, trackOverlayPosition }),
        query: '',
        activeIndex: 0,
        trigger: config.trigger || '@',
        listId: config.listId || null,
        floating: !!config.floating,
        remote: !!config.remote,
        searchMethod: config.searchMethod || null,
        menuStrings: config.strings || {},
        loading: false,
        _searchTimer: 0,
        _loadingTimer: 0,
        _searchTicket: 0,

        // The raw item list: passed-in items win; a local list with none
        // falls back to a demo list, a host-searched one starts empty.
        people: Array.isArray(config.items) && config.items.length > 0
            ? config.items
            : config.remote || config.searchMethod ? [] : DEMO_PEOPLE,

        /** Modelable item list (`x-modelable="mentionItems"`) for x-model / wire:model. */
        get mentionItems() {
            return this.people;
        },
        set mentionItems(value) {
            this.people = Array.isArray(value) ? value : [];
            this.loading = false;
            clearTimeout(this._loadingTimer);
        },

        /** Items normalised to { id, label, handle, avatar, initials, type, typeLabel, description, href }. */
        get entries() {
            const labels = this.menuStrings.typeLabels || {};
            return this.people.map((raw) => {
                const item = raw !== null && typeof raw === 'object' ? raw : { id: String(raw), label: String(raw) };
                const label = String(item.label ?? item.id ?? '');
                const type = item.type ? String(item.type) : null;
                return {
                    id: item.id ?? label,
                    label,
                    handle: String(item.handle ?? slug(label)),
                    avatar: item.avatar || null,
                    initials: item.initials || null,
                    type,
                    typeLabel: item.typeLabel || (type ? labels[type] || type : null),
                    description: String(item.description ?? ''),
                    href: item.href || null,
                };
            });
        },

        /** Typed items (or a host search) use the neutral copy. */
        get typed() {
            return this.remote || this.entries.some((item) => item.type);
        },

        get copy() {
            const copy = this.menuStrings.copy || {};
            return (this.typed ? copy.typed : copy.people) || {};
        },

        /** Items matching the query (label, handle, description or type). */
        get visible() {
            const q = this.query.trim().toLowerCase();
            if (q === '' || this.remote) {
                return this.entries;
            }
            return this.entries.filter((item) =>
                [item.label, item.handle, item.description, item.typeLabel].some((text) => (text || '').toLowerCase().includes(q)),
            );
        },

        /**
         * Visible items in render groups, each entry with its flat index. One
         * unlabelled group, or one group per type when several types show.
         */
        get groups() {
            const entries = this.visible.map((item, index) => ({ item, index }));
            const types = [...new Set(entries.map((entry) => entry.item.type || ''))];
            if (types.length < 2) return entries.length ? [{ key: 'all', label: null, entries }] : [];
            const headings = this.menuStrings.groupLabels || {};
            const grouped = types.map((type) => ({
                key: `type:${type}`,
                label: type === '' ? this.menuStrings.other : headings[type] || entries.find((entry) => entry.item.type === type)?.item.typeLabel || type,
                entries: [],
            }));
            entries.forEach((entry) => grouped[types.indexOf(entry.item.type || '')].entries.push(entry));
            // Flat indexes follow the rendered order, so arrows move top to bottom.
            let index = 0;
            grouped.forEach((group) => group.entries.forEach((entry) => { entry.index = index++; }));
            return grouped;
        },

        /** The visible items in rendered (grouped) order. */
        get ordered() {
            return this.groups.flatMap((group) => group.entries.map((entry) => entry.item));
        },

        /** True when the query matches nothing. */
        get isEmpty() {
            return this.visible.length === 0;
        },

        /**
         * Stable DOM id for the highlighted row, so the search input (or the
         * editor) can point `aria-activedescendant` at it.
         */
        activeId() {
            return this.isEmpty || this.loading || this.listId == null
                ? null
                : `${this.listId}-option-${this.activeIndex}`;
        },

        init() {
            // Reset the highlight, ask the host, and announce on every query change.
            this.$watch('query', (query) => {
                this.activeIndex = 0;
                this.scheduleSearch(query);
                this.announceResults();
            });
            this.$watch('activeIndex', () => this.$nextTick(() => this.syncTriggerHost()));
        },

        destroy() {
            this.teardownTrigger();
            clearTimeout(this._searchTimer);
            clearTimeout(this._loadingTimer);
            this._searchTicket++;
        },

        /** The character that opens this menu from the editor. */
        triggerChar() {
            return this.trigger;
        },

        scheduleSearch(query) {
            clearTimeout(this._searchTimer);
            this._searchTimer = setTimeout(() => this.runSearch(query), 200);
        },

        // mention-search for any listener; a host search shows the loading
        // state until its items arrive (or 10 s pass).
        async runSearch(query) {
            const ticket = ++this._searchTicket;
            if (this.remote) {
                this.loading = true;
                clearTimeout(this._loadingTimer);
                this._loadingTimer = setTimeout(() => { this.loading = false; }, 10000);
            }
            this.$dispatch('mention-search', { query });
            if (!this.searchMethod) return;
            try {
                const found = await this.$wire[this.searchMethod](query);
                if (ticket === this._searchTicket) this.mentionItems = Array.isArray(found) ? found : [];
            } catch (error) {
                if (ticket === this._searchTicket) this.loading = false;
            }
        },

        showsAvatar(item) {
            return !!(item.avatar || item.initials || !item.type);
        },

        typeIcon(type) {
            return TYPE_ICONS[type] || GENERIC_ICON;
        },

        secondary(item) {
            return item.type ? item.description : `${this.trigger}${item.handle}`;
        },

        /** Build initials (max two) from a display name for the avatar fallback. */
        initials(name) {
            return (name || '')
                .split(/\s+/)
                .filter(Boolean)
                .slice(0, 2)
                .map((part) => part.charAt(0).toUpperCase())
                .join('');
        },

        /** Move active highlight by direction (1 = down, -1 = up), clamped. */
        move(dir) {
            const len = this.visible.length;
            if (len === 0) return;
            this.activeIndex = Math.max(0, Math.min(len - 1, this.activeIndex + dir));
            this.$nextTick(() => {
                this.$el
                    .querySelector(`[data-slot="mention-menu-item"][data-index="${this.activeIndex}"]`)
                    ?.scrollIntoView({ block: 'nearest' });
            });
        },

        /** Choose the item at the given index (defaults to activeIndex). */
        choose(index) {
            const idx = index ?? this.activeIndex;
            const item = this.ordered[idx];
            if (!item || this.loading) return;

            // Opened from the caret: the editor removes the `@query` text first.
            if (this.triggerActive) this.$dispatch('editor-trigger-commit');

            const type = item.type || 'person';
            const href = safeHref(item.href);
            const mention = createMention({ id: item.id, type, label: item.label, href }, this.trigger);

            this.$dispatch('editor-insert', { html: `${mention.outerHTML}&nbsp;` });
            this.$dispatch('editor-mention', { id: item.id, type, label: item.label, href });
            this.$dispatch('mention-select', { id: item.id, label: item.label, handle: item.handle, type, href });

            this.query = '';
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.3.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0