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.
Preview
<div class="w-full flex justify-center p-6">
<x-ui.editor.mention-menu class="w-72" />
</div>
Installation
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:
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.
-
resources/views/components/ui/editor/mention-menu.blade.php -
resources/views/components/ui/mention-menu.blade.php -
resources/js/ui/mention-menu.js
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.
# 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
{{-- 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
{{-- 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add wire:ignore to the component root because its behavior owns rendered DOM.
<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.
<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.
@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>
{{-- <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 }} />
/**
* 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} ` });
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