Skip to content
Brok UI

Loading…

No results

Slash Menu

Open source

Command palette for inserting and transforming editor blocks. Typing '/' in the editor opens it at the caret (`floating`) or drives the inline list, with live filtering, keyboard navigation and grouped item categories. Composed as <x-ui.editor.slash-menu>; <x-ui.slash-menu> stays as the compatibility tag.

Version
v1.6.2
Stability
stable
License
MIT
Related
Editor Toolbar
Turn Into Menu
Mention Menu

Preview

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

Installation

terminal
php artisan ui:add slash-menu

Note

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

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

Registry contract

php artisan ui:add slash-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/slash-menu.blade.php
  • blade resources/views/components/ui/slash-menu.blade.php
  • js resources/js/ui/slash-menu.js
Registry dependencies
input kbd 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.

slash-menu.md
# Brok UI: Slash Menu (`slash-menu`)

Command palette for inserting and transforming editor blocks. Typing '/' in the editor opens it at the caret (`floating`) or drives the inline list, with live filtering, keyboard navigation and grouped item categories. Composed as <x-ui.editor.slash-menu>; <x-ui.slash-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 slash-menu
```

## Usage

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

## Props

- `placeholder` (string, default `Filter…`) — Placeholder text for the command filter input.
- `groups` (array|null, default `null`) — Override the grouped command list: [{label, items: [{label, description, iconKey, action: 'block'|'insert'|'image'|'page', value, shortcut}]}]. Omitted, the built-in list (Text, Heading 1-3, Bulleted, Numbered, To-do and Toggle list, Quote, Code, Divider, Callout, Image, Table) is used, built in Blade with __() so every label and description is translatable through the host app's lang files with no source edit. A 'block' value is any editor block type (paragraph, h1-h3, bulleted, numbered, todo, toggle, blockquote, code, callout). `iconKey` must resolve in slash-menu.js's getIcon() map; add a key there for a new icon. action 'image' opens the editor's image form (editor-image). action 'page' with value 'sub' or 'link' asks the host for a sub-page or a page link (editor-page).
- `floating` (boolean, default `false`) — A caret popover: hidden until / is typed in the surrounding <x-ui.editor>, then placed at the caret (flips at viewport edges, mirrors in RTL) and driven from the editor, with no search field. Without it the menu is an inline list with its own filter field, and a typed / still drives it.
- `pages` (bool, default `false`) — Adds Page (a sub-page) and Link to page to the built-in Insert group; choosing one dispatches editor-page { kind: 'sub' | 'link' } and the editor asks the host for the page.

## Use when

- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Offering a floating command palette for inserting or transforming editor blocks, triggered by a slash command.

## Avoid when

- Do not use rich editor affordances for simple one-line or plain-text tasks.
- The set of insertable blocks is meant to be permanent chrome rather than a transient palette; use editor-toolbar's block-type dropdown instead.

## Anti-patterns

- Rich editors for one-line or easily validated input

## Rules

- Use the `<brok:slash-menu>` tag (or `<x-ui.slash-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/slash-menu
- Registry JSON (files, props, contract): https://brokui.dev/r/open/slash-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 slash menu opens at the caret: type / at the start of a line,
     or use the Insert block button, which types it for you. --}}
<x-ui.editor class="mx-auto max-w-2xl" placeholder="{{ __('Type / for blocks…') }}">
    <x-slot:toolbar>
        <div class="flex items-center p-2">
            <x-ui.button variant="ghost" size="sm" data-trigger-button="slash" x-on:mousedown.prevent="" x-on:click="$dispatch('editor-trigger', { char: '/' })">{{ __('Insert block') }}</x-ui.button>
        </div>
    </x-slot:toolbar>

    <x-slot:tools>
        <x-ui.editor.slash-menu floating />
    </x-slot:tools>

    <p>{{ __('Plan the launch') }}</p>
    <p><br></p>
</x-ui.editor>
in-editor.blade.php Blade
{{-- Inside an editor: put the caret in a line, then choose a block to turn it into. --}}
<x-ui.editor class="mx-auto max-w-2xl" placeholder="{{ __('Write something…') }}">
    <p>{{ __('Plan the launch') }}</p>

    <x-slot:tools>
        <x-ui.editor.slash-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
placeholder string Filter… Placeholder text for the command filter input.
groups array | null null Override the grouped command list: [{label, items: [{label, description, iconKey, action: 'block'|'insert'|'image'|'page', value, shortcut}]}]. Omitted, the built-in list (Text, Heading 1-3, Bulleted, Numbered, To-do and Toggle list, Quote, Code, Divider, Callout, Image, Table) is used, built in Blade with __() so every label and description is translatable through the host app's lang files with no source edit. A 'block' value is any editor block type (paragraph, h1-h3, bulleted, numbered, todo, toggle, blockquote, code, callout). `iconKey` must resolve in slash-menu.js's getIcon() map; add a key there for a new icon. action 'image' opens the editor's image form (editor-image). action 'page' with value 'sub' or 'link' asks the host for a sub-page or a page link (editor-page).
floating boolean false A caret popover: hidden until / is typed in the surrounding <x-ui.editor>, then placed at the caret (flips at viewport edges, mirrors in RTL) and driven from the editor, with no search field. Without it the menu is an inline list with its own filter field, and a typed / still drives it.
pages bool false Adds Page (a sub-page) and Link to page to the built-in Insert group; choosing one dispatches editor-page { kind: 'sub' | 'link' } and the editor asks the host for the page.

Slots

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

Data slots

Stable hooks for CSS overrides and browser tests.

slash-menu slash-menu-group slash-menu-item slash-menu-shortcut

Behavior

  • Choosing a command dispatches editor-block with the target type, bubbling to the editor to convert the current block, editor-insert for raw HTML, or editor-image for the Image command (the editor's image form: URL, alt text, upload), plus slash-select for standalone listeners.
  • Inside <x-ui.editor>, typing / at the start of a block or after a space opens the menu from the caret: the text after / filters the list live, ArrowUp/ArrowDown move the active option, Enter or Tab choose it and remove the /query text, Escape closes and keeps the text. With no results the translated empty state shows and Enter does nothing. The editor's slash-trigger="false" turns this off.
  • In the inline list, ArrowDown/Up navigate the filtered results, Enter chooses the active command, and Escape clears the current query.
  • The JS carries no hard-coded label or description text: `groups` always arrives from Blade (the built-in list translated with __(), or a consumer override), mirroring <x-ui.editor.turn-into-menu>'s $defaultTypes mechanism.
  • 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 a floating command palette for inserting or transforming editor blocks, triggered by a slash command.

Avoid when

  • Do not use rich editor affordances for simple one-line or plain-text tasks.
  • The set of insertable blocks is meant to be permanent chrome rather than a transient palette; use editor-toolbar's block-type dropdown 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
slash-menu slash-menu-group slash-menu-item slash-menu-shortcut

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape ArrowUp ArrowDown
Focus
managed
  • The filter input carries role="combobox" with aria-controls pointing at the results listbox, and every command row uses @mousedown.prevent to preserve the editor selection.
  • A markdown shortcut hint renders as a <kbd> inside a wrapper with a screen-reader "Shortcut" label, so a lone "-" is announced and laid out as a key, not a separator.
  • Opened from the caret, focus stays in the editor, which points aria-activedescendant at the active option; the result count is announced once through a polite status region when typing settles. Tab is taken by the menu while it is open, so the options never take focus.
  • 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:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="slash-menu-{{ $record->id }}">
    <div class="w-full flex justify-center p-6">
        <x-ui.editor.slash-menu />
    </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:slash-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/slash-menu.blade.php Blade
@props([
    'placeholder' => 'Filter…',
    // Override the grouped command list: [['label' => 'Basic blocks', 'items' => [
    //   ['label' => 'Text', 'description' => 'Start writing with plain text',
    //    'iconKey' => 'text', 'action' => 'block'|'insert', 'value' => 'paragraph'|'<hr>',
    //    'shortcut' => null], …]], …]. Every entry needs `iconKey` to resolve an
    // icon from slash-menu.js's getIcon() (add a new key there for a custom block).
    // Omit to use the built-in list, translated with __() below; pass your own
    // array to add or drop blocks or to translate through your own dictionary
    // instead of Laravel's translator. 'block' values: paragraph, h1-h3,
    // bulleted, numbered, todo, toggle, blockquote, code, callout; 'image'
    // opens the editor's image form; 'page' with 'sub' or 'link' asks the
    // host for a sub-page or a page link.
    'groups' => null,
    // A caret popover: hidden until `/` is typed in the editor around it,
    // then shown at the caret and driven from the editor (no search field).
    'floating' => false,
    // Adds Page and Link to page to the built-in list: the editor asks the
    // host for them (editor-page-create / editor-page-link).
    'pages' => false,
])

@php
    // role=combobox requires aria-controls pointing at its listbox (a11y).
    $listId = 'slash-menu-list-'.\Illuminate\Support\Str::random(6);

    // The built-in command list, translated with __() so a consumer's lang
    // files cover it with no source edit. Mirrors <x-ui.editor.turn-into-menu>'s
    // $defaultTypes mechanism: labels/descriptions live in Blade, not JS.
    $defaultGroups = [
        [
            'label' => __('Basic blocks'),
            'items' => [
                ['label' => __('Text'), 'description' => __('Start writing with plain text'), 'iconKey' => 'text', 'action' => 'block', 'value' => 'paragraph', 'shortcut' => null],
                ['label' => __('Heading 1'), 'description' => __('Large section heading'), 'iconKey' => 'h1', 'action' => 'block', 'value' => 'h1', 'shortcut' => '#'],
                ['label' => __('Heading 2'), 'description' => __('Medium section heading'), 'iconKey' => 'h2', 'action' => 'block', 'value' => 'h2', 'shortcut' => '##'],
                ['label' => __('Heading 3'), 'description' => __('Small section heading'), 'iconKey' => 'h3', 'action' => 'block', 'value' => 'h3', 'shortcut' => '###'],
                ['label' => __('Bulleted list'), 'description' => __('Create a simple bulleted list'), 'iconKey' => 'ul', 'action' => 'block', 'value' => 'bulleted', 'shortcut' => '-'],
                ['label' => __('Numbered list'), 'description' => __('Create a list with numbering'), 'iconKey' => 'ol', 'action' => 'block', 'value' => 'numbered', 'shortcut' => '1.'],
                ['label' => __('To-do list'), 'description' => __('Track tasks with a checklist'), 'iconKey' => 'todo', 'action' => 'block', 'value' => 'todo', 'shortcut' => '[]'],
                ['label' => __('Toggle list'), 'description' => __('Hide details inside a toggle'), 'iconKey' => 'toggle', 'action' => 'block', 'value' => 'toggle', 'shortcut' => null],
                ['label' => __('Quote'), 'description' => __('Capture a quote'), 'iconKey' => 'quote', 'action' => 'block', 'value' => 'blockquote', 'shortcut' => '>'],
                ['label' => __('Code'), 'description' => __('Capture a code snippet'), 'iconKey' => 'code', 'action' => 'block', 'value' => 'code', 'shortcut' => '```'],
            ],
        ],
        [
            'label' => __('Insert'),
            'items' => [
                ['label' => __('Divider'), 'description' => __('Visually divide blocks'), 'iconKey' => 'divider', 'action' => 'insert', 'value' => '<hr>', 'shortcut' => '---'],
                ['label' => __('Callout'), 'description' => __('Make writing stand out'), 'iconKey' => 'callout', 'action' => 'block', 'value' => 'callout', 'shortcut' => null],
                ['label' => __('Image'), 'description' => __('Upload or embed an image'), 'iconKey' => 'image', 'action' => 'image', 'value' => 'image', 'shortcut' => null],
                ['label' => __('Table'), 'description' => __('Insert a simple table'), 'iconKey' => 'table', 'action' => 'insert', 'value' => '<table><thead><tr><th></th><th></th></tr></thead><tbody><tr><td></td><td></td></tr></tbody></table>', 'shortcut' => null],
            ],
        ],
    ];

    if (filter_var($pages, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false) {
        array_push(
            $defaultGroups[1]['items'],
            ['label' => __('Page'), 'description' => __('Add a page inside this one'), 'iconKey' => 'page', 'action' => 'page', 'value' => 'sub', 'shortcut' => null],
            ['label' => __('Link to page'), 'description' => __('Link to another page'), 'iconKey' => 'page-link', 'action' => 'page', 'value' => 'link', 'shortcut' => null],
        );
    }

    $groups = is_array($groups) && $groups !== [] ? array_values($groups) : $defaultGroups;
    $floating = filter_var($floating, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
    $menuConfig = [
        'listId' => $listId,
        'groups' => $groups,
        'floating' => $floating,
        'strings' => ['resultsOne' => __('1 result'), 'resultsMany' => __(':count results'), 'noResults' => __('No results found.')],
    ];
@endphp

{{--
    Slash Menu — floating command palette for inserting/transforming editor blocks.

    Drop this anywhere inside or adjacent to <x-ui.editor>. It dispatches:
        editor-block  { type }   — bubble to editor to convert current block
        editor-insert { html }   — bubble to editor to insert raw HTML
        slash-select  { value }  — also fired standalone (no-ops without an editor parent)

    Keyboard: ArrowDown/Up navigate, Enter chooses, Escape clears the query.
    @mousedown.prevent on every interactive row preserves the editor selection.

    Inside <x-ui.editor>, typing `/` at a word start drives this menu from the
    caret (editor-triggers.js): the typed text filters it, the keys stay in
    the editor, and the chosen command replaces the `/query` text. With
    `floating` the menu is a popover at the caret, hidden until then; without
    it the inline list follows the query.

    `groups` — override the command list; see the prop doc above. Omitted, the
    built-in list renders translated through __().
--}}
<div
    x-data="uiSlashMenu(@js($menuConfig))"
    data-slot="slash-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="{{ __($placeholder) }}"
            role="combobox"
            aria-label="{{ __('Search commands') }}"
            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="{{ __('Commands') }}"
        class="max-h-80 overflow-y-auto p-1"
    >
        {{-- Group sections --}}
        <template x-for="(group, gi) in visibleGroups" :key="gi">
            <div data-slot="slash-menu-group" role="group" :aria-label="group.label">
                {{-- Group heading --}}
                <div
                    x-text="group.label"
                    class="px-2 py-2 text-xs font-medium text-muted-foreground"
                ></div>

                {{-- Items --}}
                <template x-for="(item, ii) in group.items" :key="item.value">
                    <button
                        type="button"
                        role="option"
                        data-slot="slash-menu-item"
                        :id="`{{ $listId }}-option-${flatIndex(gi, ii)}`"
                        :data-index="flatIndex(gi, ii)"
                        :aria-selected="flatIndex(gi, ii) === activeIndex"
                        @mousedown.prevent
                        @click="choose(flatIndex(gi, ii))"
                        @mouseenter="activeIndex = flatIndex(gi, ii)"
                        :class="{
                            'bg-accent text-accent-foreground': flatIndex(gi, ii) === activeIndex,
                            'text-popover-foreground': flatIndex(gi, ii) !== activeIndex
                        }"
                        class="flex w-full items-center gap-3 rounded-sm px-2 py-2 text-start text-sm outline-none transition-colors"
                    >
                        {{-- Icon tile --}}
                        <span
                            class="flex h-8 w-8 shrink-0 items-center justify-center rounded-sm border border-border bg-card text-muted-foreground"
                            aria-hidden="true"
                            x-html="getIcon(item.iconKey)"
                        ></span>

                        {{-- Label + description --}}
                        <span class="flex min-w-0 flex-col">
                            <span class="truncate font-medium leading-tight" x-text="item.label"></span>
                            <span class="truncate text-xs text-muted-foreground" x-text="item.description"></span>
                        </span>

                        {{-- Optional markdown shortcut: a <kbd> inside a labelled
                             wrapper, so a lone "-" reads as a key, not a separator;
                             dir="ltr" keeps "1." in typing order under RTL. --}}
                        <template x-if="item.shortcut">
                            <span data-slot="slash-menu-shortcut" class="ms-auto shrink-0">
                                <span class="sr-only">{{ __('Shortcut') }}</span>
                                <x-ui.kbd dir="ltr" x-text="item.shortcut" />
                            </span>
                        </template>
                    </button>
                </template>
            </div>
        </template>

        {{-- Empty state --}}
        <template x-if="isEmpty">
            <div
                role="option"
                aria-disabled="true"
                class="px-2 py-6 text-center text-sm text-muted-foreground"
            >{{ __('No results found.') }}</div>
        </template>
    </div>

    {{-- Result count for screen readers, once typing settles. --}}
    <span role="status" class="sr-only" x-text="announcement"></span>
</div>

{{-- Icons are served by the getIcon() method defined in slash-menu.js. --}}
resources/views/components/ui/slash-menu.blade.php Blade
{{-- <x-ui.slash-menu> is the install slug's compatibility tag: the part lives at
     <x-ui.editor.slash-menu> (ui/editor/slash-menu) with every other editor part.
     Compose the namespaced tag in new code; this file only forwards to it. --}}
@props([
    'placeholder' => 'Filter…',
    'groups' => null,
    'floating' => false,
    'pages' => false,
])

<x-ui.editor.slash-menu :placeholder="$placeholder" :groups="$groups" :floating="$floating" :pages="$pages" {{ $attributes }} />
resources/js/ui/slash-menu.js JS
/**
 * Slash menu — floating command palette for block insertion and type conversion.
 *
 * Dispatches two event types that bubble to <x-ui.editor>:
 *   editor-block  { type }  — changes the current block's type (paragraph,
 *                             h1-h3, bulleted, numbered, todo, toggle,
 *                             blockquote, code, callout)
 *   editor-insert { html }  — inserts arbitrary HTML at the caret
 *
 *   editor-image            — opens the editor's image form (the Image command)
 *   editor-page { kind }    — asks the host for a sub-page or a page link (`pages`)
 *
 * Also dispatches `slash-select { value }` for standalone use.
 *
 * Inside <x-ui.editor> typing `/` drives the menu from the caret through the
 * trigger methods (editor-menu-trigger.js); a `floating` menu is a popover
 * placed at the caret with the dropdown's overlay engine.
 *
 * Usage: @mousedown.prevent @click on menu items preserves the editor selection.
 */
import { menuTriggerMethods } from './editor-menu-trigger.js';
import { placeOverlay, trackOverlayPosition } from './overlay-position.js';

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiSlashMenu', (config = {}) => ({
        ...menuTriggerMethods({ placeOverlay, trackOverlayPosition }),
        query: '',
        activeIndex: 0,
        listId: config.listId || null,
        floating: !!config.floating,
        menuStrings: config.strings || {},

        // Labels and descriptions are never hard-coded here: the Blade template
        // builds the default list with __() (see slash-menu.blade.php's
        // $defaultGroups) and always passes it as `groups`, so every installed
        // copy is translatable through the host app's lang files with no JS
        // edit. A consumer overrides the whole list via the `groups` prop.
        groups: Array.isArray(config.groups) ? config.groups : [],

        /**
         * Does an item match the query? Case, spaces and punctuation are
         * ignored, so "tod" finds "To-do list"; a block value counts too.
         */
        matches(item, query) {
            const fold = (text) => String(text ?? '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
            const q = fold(query) || query.trim().toLowerCase();
            const words = [item.label, item.description, item.action === 'insert' ? '' : item.value];
            return words.some((text) => fold(text).includes(q) || String(text ?? '').toLowerCase().includes(q));
        },

        /** Flat list of items matching the current query (across all groups). */
        get visible() {
            return this.visibleGroups.flatMap((g) => g.items);
        },

        /** Groups that have at least one visible item (for rendering section headers). */
        get visibleGroups() {
            if (this.query.trim() === '') {
                return this.groups;
            }
            return this.groups
                .map((g) => ({ ...g, items: g.items.filter((item) => this.matches(item, this.query)) }))
                .filter((g) => g.items.length > 0);
        },

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

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

        init() {
            // Reset active index whenever the query changes.
            this.$watch('query', () => {
                this.activeIndex = 0;
                this.announceResults();
            });
            this.$watch('activeIndex', () => this.$nextTick(() => this.syncTriggerHost()));
        },

        destroy() {
            this.teardownTrigger();
        },

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

        /** 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="slash-menu-item"][data-index="${this.activeIndex}"]`)
                    ?.scrollIntoView({ block: 'nearest' });
            });
        },

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

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

            if (item.action === 'block') {
                this.$dispatch('editor-block', { type: item.value });
            } else if (item.action === 'image') {
                this.$dispatch('editor-image');
            } else if (item.action === 'page') {
                this.$dispatch('editor-page', { kind: item.value });
            } else {
                this.$dispatch('editor-insert', { html: item.value });
            }

            this.$dispatch('slash-select', { value: item.value });
            this.query = '';
        },

        /** Flat index of an item within the current visible list, relative to a group render. */
        flatIndex(groupIndex, itemIndex) {
            let count = 0;
            for (let g = 0; g < this.visibleGroups.length; g++) {
                if (g === groupIndex) return count + itemIndex;
                count += this.visibleGroups[g].items.length;
            }
            return count + itemIndex;
        },

        /**
         * Return SVG markup for a given icon key (used via x-html in the Blade template).
         * All icons use stroke="currentColor" — no hardcoded colours.
         */
        getIcon(key) {
            const icons = {
                text: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/></svg>',
                h1: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6v12M4 12h8m0-6v12M16 8l2-2v10"/></svg>',
                h2: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6v12M4 12h8m0-6v12M15 10c0-1.1.9-2 2-2s2 .9 2 2c0 1.5-2 3-2 3h4"/></svg>',
                h3: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6v12M4 12h8m0-6v12"/></svg>',
                ul: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>',
                ol: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M10 6h11M10 12h11M10 18h11M4 6h-.5v3M4 15c.5-.5 1-1 1-1.5S5 12 4 12H3m.5 6H3m0 0h1.5"/></svg>',
                todo: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M9 11l3 3L22 4M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>',
                toggle: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6l4 4-4 4M12 8h8M12 14h8M8 20h12"/></svg>',
                quote: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1zm12 0c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V20c0 1 0 1 1 1z"/></svg>',
                code: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/></svg>',
                divider: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M5 12h14"/></svg>',
                callout: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>',
                image: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>',
                page: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8zM14 3v5h5M12 11v6M9 14h6"/></svg>',
                'page-link': '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M14 3H7a2 2 0 00-2 2v14a2 2 0 002 2h10a2 2 0 002-2V8zM14 3v5h5M10 17l5-5M11 12h4v4"/></svg>',
                table: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 10h18M3 14h18M10 3v18M3 6a3 3 0 013-3h12a3 3 0 013 3v12a3 3 0 01-3 3H6a3 3 0 01-3-3V6z"/></svg>',
            };
            return icons[key] ?? '';
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.6.2
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