Skip to content
Brok UI

Loading…

No results

Command

Open source Core

A filterable, keyboard-navigable command palette for server-authorized commands with stable action keys, disabled reasons, grouped links, shortcuts, and empty-state handling.

Version
v1.14.0
Stability
stable
License
MIT
Related
Combobox
Dialog
Dropdown Menu
Hotkeys
Kbd
Highlight

Preview

Disabled
previews.components.command.default.blade.php Blade
<x-ui.command class="w-full max-w-md">
    <x-ui.command.input :placeholder="__('Type a command or search…')" />
    <x-ui.command.list>
        <x-ui.command.empty>{{ __('No results found.') }}</x-ui.command.empty>
        <x-ui.command.item value="calendar">{{ __('Calendar') }}</x-ui.command.item>
        <x-ui.command.item value="search-emoji">{{ __('Search Emoji') }}</x-ui.command.item>
        <x-ui.command.item value="calculator">{{ __('Calculator') }}</x-ui.command.item>
        <x-ui.command.item value="profile">{{ __('Profile') }}</x-ui.command.item>
        <x-ui.command.item value="settings">{{ __('Settings') }}</x-ui.command.item>
    </x-ui.command.list>
</x-ui.command>

Installation

terminal
php artisan ui:add command

Note

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

resources/js/ui/index.js JS
import './command.js';

Registry contract

php artisan ui:add command 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/command.blade.php
  • blade resources/views/components/ui/command/input.blade.php
  • blade resources/views/components/ui/command/list.blade.php
  • blade resources/views/components/ui/command/item.blade.php
  • blade resources/views/components/ui/command/empty.blade.php
  • blade resources/views/components/ui/command/group.blade.php
  • blade resources/views/components/ui/command/shortcut.blade.php
  • blade resources/views/components/ui/command/chip.blade.php
  • blade resources/views/components/ui/command/footer.blade.php
  • js resources/js/ui/command.js
  • js resources/js/ui/command-rank.js
Registry dependencies
hotkeys
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.

command.md
# Brok UI: Command (`command`)

A filterable, keyboard-navigable command palette for server-authorized commands with stable action keys, disabled reasons, grouped links, shortcuts, and empty-state handling.

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

## Install

```bash
php artisan ui:add command
```

## Usage

```blade
<x-ui.command class="w-full max-w-md">
    <x-ui.command.input :placeholder="__('Type a command or search…')" />
    <x-ui.command.list>
        <x-ui.command.empty>{{ __('No results found.') }}</x-ui.command.empty>
        <x-ui.command.item value="calendar">{{ __('Calendar') }}</x-ui.command.item>
        <x-ui.command.item value="search-emoji">{{ __('Search Emoji') }}</x-ui.command.item>
        <x-ui.command.item value="calculator">{{ __('Calculator') }}</x-ui.command.item>
        <x-ui.command.item value="profile">{{ __('Profile') }}</x-ui.command.item>
        <x-ui.command.item value="settings">{{ __('Settings') }}</x-ui.command.item>
    </x-ui.command.list>
</x-ui.command>
```

## Props

- `resetOnSelect` (bool, default `true`) — Clear the typed query once an item is chosen. Set false for a list that stays open and keeps its filter.
- `filter` (bool, default `true`) — Filter items client-side against the typed query. Set false when the host already returns only matching hits (a debounced server search); the keyboard model still covers every item.
- `clearOnEscape` (bool, default `true`) — Escape clears a typed query first and closes a wrapping dialog on the next press. Set false to close on the first Escape even with text typed; the query clears as the dialog closes.
- `field` (bool, default `false`) — Field mode: no palette surface. The input renders as a standalone search field and the host places the list (a floating popover under the field), as record-search does.
- `hotkey` (string|null, default `null`) — Opt-in global shortcut registered with the hotkey registry, for example 'mod+k' (Cmd+K on macOS, Ctrl+K elsewhere). Inside a dialog it opens and closes that dialog; an inline palette focuses its input. Fires while typing; blocked by any other open modal.
- `hotkeyDescription` (string, default `Open command palette`) — Description of the shortcut in the hotkeys help list.
- `hotkeyGroup` (string, default `General`) — Group of the shortcut in the hotkeys help list.
- `match` (substring|fuzzy, default `substring`) — How the typed query matches an item: substring keeps items whose search text contains the query; fuzzy keeps items that contain every word of the query in order, with gaps allowed ("gs" finds "Go to settings").
- `rank` (bool, default `false`) — Opt-in ranking. While a query is typed, matching items show best first: the whole query as a phrase on word boundaries, then every word as a whole word, then word prefixes, then substrings, then scattered fuzzy matches (match="fuzzy"); ties keep document order. Items rank within their group and groups order by their best item; persistent rows and the actions footer stay last, server-mode sequenced results keep the host order, and an empty query shows document order. The reorder is visual (CSS order on a flex list and groups), so the DOM, Livewire morphs and x-for rows are untouched, and the arrow keys, Home/End and the first active option follow what is on screen. Give every group separator so the divider follows the visual order.
- `recent` (string|null, default `null`) — A localStorage key. When set, chosen items are remembered (newest first) and listed in a Recent group at the top of <x-ui.command.list> while nothing is typed. A recent row activates the original item; when it is no longer rendered, its remembered link is followed or command-select fires with its value.
- `recentMax` (int, default `5`) — How many recent items are kept.
- `recentHeading` (string, default `Recent`) — Heading of the Recent group (translated with __()).
- `server` (bool, default `false`) — Server mode: a query change dispatches command-search { query, sequence, done(), fail(message), isCurrent() } after debounce ms. Until done() the list is busy (aria-busy, a loading row, a polite status). Off: the palette filters in the browser as before.
- `debounce` (int, default `200`) — Server mode: milliseconds after the last query change before command-search is dispatched.
- `loadingText` (string|null, default `null`) — Server mode: loading row and status text; null uses the translated "Searching…".
- `errorText` (string|null, default `null`) — Server mode: text shown when the host calls fail() without a message; null uses the translated default.
- `disabled` (false|true, default `false`) — Documented catalog control used by the preview workbench.
- `placeholder` (mixed, default `__('Type a command or search…')`) — Declared by @props in the registry Blade source.
- `expanded` (string, default `true`) — Declared by @props in the registry Blade source.
- `actionsLabel` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `value` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `actionKey` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `keywords` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `href` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `disabledReason` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `valueExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `keywordsExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `selectedExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `persistent` (bool, default `false`) — Declared by @props in the registry Blade source.
- `heading` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `separator` (bool, default `false`) — Declared by @props in the registry Blade source.
- `keys` (array, default `[]`) — Declared by @props in the registry Blade source.
- `sequence` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `sequenceExpr` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `removable` (bool, default `true`) — Declared by @props in the registry Blade source.
- `hints` (array, default `[]`) — Declared by @props in the registry Blade source.

## Use when

- Use when the option set is long enough that search, suggestion, or filtering speeds selection.
- Users need to search and invoke actions from a keyboard-friendly palette.
- A long action or navigation list benefits from client-side filtering and grouping.
- A search asks the server (Livewire or fetch) for results as the user types; use server mode.

## Avoid when

- Do not use advanced selection UI when a simpler visible control would be clearer.
- The user is choosing a form value; use combobox or select-advanced.
- There are only a few always-visible actions; use buttons or a menu.

## Anti-patterns

- Using advanced search for a tiny list

## Rules

- Use the `<brok:command>` tag (or `<x-ui.command>`) 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/command
- Registry JSON (files, props, contract): https://brokui.dev/r/open/command.json

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

Examples

client-data.blade.php Blade
{{-- Items rendered from client data: `<template x-for>` over an Alpine array,
     with `value-expr` and `keywords-expr` as Alpine expressions instead of
     server strings. Filtering, arrow keys and Enter follow the rows on screen,
     also after the array changes: "Add issue" puts a row first, "Remove first"
     drops one, and the keyboard order follows. --}}
<div
    x-data="{
        issues: [
            { id: 'OPS-12', title: @js(__('Export times out on large workspaces')), team: @js(__('Platform')) },
            { id: 'OPS-15', title: @js(__('Invite email lands in spam')), team: @js(__('Growth')) },
            { id: 'OPS-21', title: @js(__('Audit log misses role changes')), team: @js(__('Security')) },
        ],
        next: 30,
        chosen: null,
        add() { this.issues.unshift({ id: 'OPS-' + this.next++, title: @js(__('Backfill missing invoice numbers')), team: @js(__('Billing')) }) },
        removeFirst() { this.issues.shift() },
    }"
    x-on:command-select="chosen = $event.detail.value"
    class="flex w-full max-w-md flex-col gap-4"
>
    <x-ui.command>
        <x-ui.command.input :placeholder="__('Search issues…')" />
        <x-ui.command.list>
            <x-ui.command.empty>{{ __('No issues found.') }}</x-ui.command.empty>
            <template x-for="issue in issues" :key="issue.id">
                <x-ui.command.item value-expr="issue.id" keywords-expr="issue.id + ' ' + issue.title + ' ' + issue.team">
                    <span class="w-16 shrink-0 font-mono text-xs text-muted-foreground" x-text="issue.id"></span>
                    <span class="min-w-0 truncate" x-text="issue.title"></span>
                    <span class="ms-auto shrink-0 text-xs text-muted-foreground" x-text="issue.team"></span>
                </x-ui.command.item>
            </template>
        </x-ui.command.list>
    </x-ui.command>

    <div class="flex flex-wrap items-center gap-2">
        <x-ui.button variant="outline" size="sm" x-on:click="add()">{{ __('Add issue') }}</x-ui.button>
        <x-ui.button variant="outline" size="sm" x-on:click="removeFirst()">{{ __('Remove first') }}</x-ui.button>
        <p class="text-sm text-muted-foreground">
            {{ __('Chosen:') }}
            <span data-slot="preview-event-log" class="font-mono text-foreground" x-text="chosen ?? '—'">—</span>
        </p>
    </div>
</div>
context.blade.php Blade
{{-- A palette scoped to a selection: the "2 selected" chip names what the
     actions apply to. Remove it with its button or Backspace in an empty
     query; the host listens for command-chip-remove and drops its state.
     Shortcut labels come from hotkeys.hint (keys), and the footer lists the
     palette keys. --}}
<div x-data="{ selected: 2 }" class="w-full max-w-md">
    <x-ui.command class="w-full" x-on:command-chip-remove="selected = 0">
        <x-ui.command.input :placeholder="__('Type a command or search…')">
            <x-ui.command.chip value="selection" x-show="selected > 0">
                {{ __(':count selected', ['count' => 2]) }}
            </x-ui.command.chip>
        </x-ui.command.input>
        <x-ui.command.list>
            <x-ui.command.empty>{{ __('No results found.') }}</x-ui.command.empty>

            <x-ui.command.group :heading="__('Tasks')" :keys="['status', 'assign', 'blocked']">
                <x-ui.command.item value="status" keywords="change status">
                    {{ __('Change status') }}
                    <x-ui.command.shortcut keys="s" />
                </x-ui.command.item>
                <x-ui.command.item value="assign" keywords="assign to">
                    {{ __('Assign to') }}
                    <x-ui.command.shortcut keys="a" />
                </x-ui.command.item>
                <x-ui.command.item value="blocked" keywords="mark as blocked">
                    {{ __('Mark as blocked') }}
                    <x-ui.command.shortcut keys="m b" />
                </x-ui.command.item>
            </x-ui.command.group>

            <x-ui.command.group :heading="__('Navigation')" :keys="['inbox', 'projects']" :separator="true">
                <x-ui.command.item value="inbox" keywords="go to inbox">
                    {{ __('Go to inbox') }}
                    <x-ui.command.shortcut keys="g i" />
                </x-ui.command.item>
                <x-ui.command.item value="projects" keywords="go to projects">
                    {{ __('Go to projects') }}
                    <x-ui.command.shortcut keys="g p" />
                </x-ui.command.item>
            </x-ui.command.group>
        </x-ui.command.list>
        <x-ui.command.footer :hints="[
            ['keys' => 'enter', 'label' => __('Open')],
            ['keys' => 'up', 'label' => __('Previous')],
            ['keys' => 'down', 'label' => __('Next')],
            ['keys' => 'esc', 'label' => __('Close')],
        ]" />
    </x-ui.command>
</div>
dialog.blade.php Blade
{{-- Command dialog: the palette lives inside a <x-ui.dialog> (preview-only
     dependency) so it gets focus-trap, Escape and overlay dismissal for free.
     `hotkey="mod+k"` registers Cmd/Ctrl+K with the hotkey registry: it opens
     the dialog from anywhere and closes it when pressed again, and another open
     modal blocks it. Choosing an item closes the dialog. --}}
<x-ui.dialog @command-select.window="hide()">
    <x-ui.dialog.trigger x-bind:aria-keyshortcuts="$hotkeys.aria('mod+k')" class="inline-flex h-10 items-center gap-2 rounded-md border border-border bg-background px-3 text-sm text-muted-foreground transition-colors hover:bg-muted">
        <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
            <circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
        </svg>
        {{ __('Search…') }}
        <x-ui.hotkeys.hint keys="mod+k" class="ms-auto" />
    </x-ui.dialog.trigger>

    <x-ui.dialog.content size="md" class="overflow-hidden p-0">
        <x-ui.dialog.title class="sr-only">{{ __('Command palette') }}</x-ui.dialog.title>
        <x-ui.dialog.description class="sr-only">{{ __('Search for a command to run.') }}</x-ui.dialog.description>

        <x-ui.command hotkey="mod+k" class="rounded-none border-0">
            <x-ui.command.input :placeholder="__('Type a command or search…')" />
            <x-ui.command.list>
                <x-ui.command.empty>{{ __('No results found.') }}</x-ui.command.empty>

                <x-ui.command.group :heading="__('Suggestions')" :keys="['dashboard', 'projects', 'team']">
                    <x-ui.command.item value="dashboard">{{ __('Go to Dashboard') }}</x-ui.command.item>
                    <x-ui.command.item value="projects">{{ __('Go to Projects') }}</x-ui.command.item>
                    <x-ui.command.item value="team">{{ __('Go to Team') }}</x-ui.command.item>
                </x-ui.command.group>

                <x-ui.command.group :heading="__('Actions')" :keys="['new file', 'invite', 'settings']" :separator="true">
                    <x-ui.command.item value="new file">
                        {{ __('New File') }}
                        <x-ui.command.shortcut>⌘N</x-ui.command.shortcut>
                    </x-ui.command.item>
                    <x-ui.command.item value="invite">
                        {{ __('Invite Member') }}
                        <x-ui.command.shortcut>⌘I</x-ui.command.shortcut>
                    </x-ui.command.item>
                    <x-ui.command.item value="settings">
                        {{ __('Open Settings') }}
                        <x-ui.command.shortcut>⌘,</x-ui.command.shortcut>
                    </x-ui.command.item>
                </x-ui.command.group>
            </x-ui.command.list>
        </x-ui.command>
    </x-ui.dialog.content>
</x-ui.dialog>
grouped.blade.php Blade
{{-- Grouped palette: labelled sections, leading icons and right-aligned
     keyboard shortcuts. Each group passes its item keys so the heading and its
     separator disappear once every child is filtered out. --}}
<x-ui.command class="w-full max-w-md">
    <x-ui.command.input :placeholder="__('Type a command or search…')" />
    <x-ui.command.list>
        <x-ui.command.empty>{{ __('No results found.') }}</x-ui.command.empty>

        <x-ui.command.group :heading="__('Suggestions')" :keys="['calendar', 'search emoji', 'calculator']">
            <x-ui.command.item value="calendar" keywords="calendar schedule">
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground">
                    <rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18M8 2v4M16 2v4" />
                </svg>
                {{ __('Calendar') }}
            </x-ui.command.item>
            <x-ui.command.item value="search emoji">
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground">
                    <circle cx="12" cy="12" r="10" /><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01" />
                </svg>
                {{ __('Search Emoji') }}
            </x-ui.command.item>
            <x-ui.command.item value="calculator">
                <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0 text-muted-foreground">
                    <rect width="16" height="20" x="4" y="2" rx="2" /><path d="M8 6h8M8 10h.01M12 10h.01M16 10h.01M8 14h.01M12 14h.01M16 14v4M8 18h4" />
                </svg>
                {{ __('Calculator') }}
            </x-ui.command.item>
        </x-ui.command.group>

        <x-ui.command.group :heading="__('Settings')" :keys="['profile', 'billing', 'settings']" :separator="true">
            <x-ui.command.item value="profile">
                {{ __('Profile') }}
                <x-ui.command.shortcut>⌘P</x-ui.command.shortcut>
            </x-ui.command.item>
            <x-ui.command.item value="billing">
                {{ __('Billing') }}
                <x-ui.command.shortcut>⌘B</x-ui.command.shortcut>
            </x-ui.command.item>
            <x-ui.command.item value="settings">
                {{ __('Settings') }}
                <x-ui.command.shortcut>⌘S</x-ui.command.shortcut>
            </x-ui.command.item>
        </x-ui.command.group>
    </x-ui.command.list>
</x-ui.command>
loading.blade.php Blade
{{-- Async loading state. A local `loading` flag (simulating a fetch) swaps a
     spinner skeleton for the results. The timer clears itself on teardown so
     unmounting mid-load can't fire a stray callback. Also shows a "Recent"
     group above the results. --}}
<div x-data="{ loading: false, timer: null, run() { this.loading = true; clearTimeout(this.timer); this.timer = setTimeout(() => { this.loading = false; }, 1200); } }" x-init="run()" @destroy="clearTimeout(timer)" class="w-full max-w-md">
    <x-ui.command>
        <x-ui.command.input :placeholder="__('Search the docs…')" />
        <x-ui.command.list>
            {{-- Loading skeleton --}}
            <div x-show="loading" role="status" aria-live="polite" class="space-y-1 p-1">
                <span class="sr-only">{{ __('Loading…') }}</span>
                @for ($i = 0; $i < 4; $i++)
                    <div aria-hidden="true" class="flex items-center gap-2 px-2 py-2">
                        <div class="size-4 shrink-0 animate-pulse rounded-sm bg-muted"></div>
                        <div class="h-3 w-2/3 animate-pulse rounded-sm bg-muted"></div>
                    </div>
                @endfor
            </div>

            {{-- Loaded results --}}
            <template x-if="!loading">
                <div>
                    <x-ui.command.empty>{{ __('No results found.') }}</x-ui.command.empty>

                    <x-ui.command.group :heading="__('Recent')" :keys="['installation', 'theming']">
                        <x-ui.command.item value="installation">{{ __('Installation') }}</x-ui.command.item>
                        <x-ui.command.item value="theming">{{ __('Theming') }}</x-ui.command.item>
                    </x-ui.command.group>

                    <x-ui.command.group :heading="__('Components')" :keys="['button', 'dialog', 'command']" :separator="true">
                        <x-ui.command.item value="button">{{ __('Button') }}</x-ui.command.item>
                        <x-ui.command.item value="dialog">{{ __('Dialog') }}</x-ui.command.item>
                        <x-ui.command.item value="command">{{ __('Command') }}</x-ui.command.item>
                    </x-ui.command.group>
                </div>
            </template>
        </x-ui.command.list>
    </x-ui.command>

    <button type="button" @click="run()" class="mt-3 inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-3 text-sm font-medium text-foreground transition-colors hover:bg-muted">
        {{ __('Reload') }}
    </button>
</div>
permissions.blade.php Blade
<x-ui.command class="max-w-md">
    <x-ui.command.input placeholder="Search authorized commands…" />
    <x-ui.command.list>
        <x-ui.command.item value="open-customer" action-key="customer.open" keywords="Open customer">{{ __('Open customer') }}</x-ui.command.item>
        <x-ui.command.item value="export-report" action-key="report.export" keywords="Export report">{{ __('Export report') }}</x-ui.command.item>
        <x-ui.command.item value="delete-record" action-key="record.delete" keywords="Delete record" :disabled="true" disabled-reason="{{ __('This record is locked.') }}">{{ __('Delete record') }}</x-ui.command.item>
    </x-ui.command.list>
</x-ui.command>
ranked.blade.php Blade
{{-- Ranked palette: with `rank`, a typed query shows the best matches first.
     "pin" lists "Pin a message" (a whole word) above "Pinned items" (a word
     prefix), and its group moves up; "in" puts "Sign in" first. Every group
     sets `separator`, so the divider follows the visual order. The persistent
     footer action stays last; clearing the query restores the order. --}}
<div
    x-data="{ chosen: '' }"
    x-on:command-select="chosen = $event.detail.value"
    class="w-full max-w-md space-y-2"
>
    <x-ui.command match="fuzzy" rank class="w-full">
        <x-ui.command.input :placeholder="__('Type a command…')" :aria-label="__('Commands')" />
        <x-ui.command.list :aria-label="__('Commands')" :actions-label="__('Command actions')">
            <x-ui.command.empty>{{ __('No matching command.') }}</x-ui.command.empty>
            <x-ui.command.group :heading="__('Navigation')" :keys="['pinned items', 'inbox', 'sign in account']" separator>
                <x-ui.command.item value="pinned" keywords="pinned items">{{ __('Pinned items') }}</x-ui.command.item>
                <x-ui.command.item value="inbox" keywords="inbox">{{ __('Inbox') }}</x-ui.command.item>
                <x-ui.command.item value="sign-in" keywords="sign in account">{{ __('Sign in') }}</x-ui.command.item>
            </x-ui.command.group>
            <x-ui.command.group :heading="__('Create')" :keys="['new invoice billing', 'in app message', 'pin a message']" separator>
                <x-ui.command.item value="invoice" keywords="new invoice billing">{{ __('New invoice') }}</x-ui.command.item>
                <x-ui.command.item value="in-app" keywords="in app message">{{ __('In-app message') }}</x-ui.command.item>
                <x-ui.command.item value="pin-message" keywords="pin a message">{{ __('Pin a message') }}</x-ui.command.item>
            </x-ui.command.group>
            <x-slot:actions>
                <x-ui.command.item value="ask" persistent>{{ __('Ask the assistant') }}</x-ui.command.item>
            </x-slot:actions>
        </x-ui.command.list>
    </x-ui.command>
    <p class="text-sm text-muted-foreground">{{ __('Chosen:') }} <span data-slot="preview-event-log" class="font-mono text-xs" x-text="chosen === '' ? @js(__('nothing yet')) : chosen">{{ __('nothing yet') }}</span></p>
</div>
recent.blade.php Blade
{{-- Recent items and fuzzy matching: choose an item, then clear the query to
     see it under Recent. "gs" finds "Go to settings". --}}
<x-ui.command class="w-full max-w-md" match="fuzzy" recent="docs-preview" :recent-max="3">
    <x-ui.command.input :placeholder="__('Type a command or search…')" />
    <x-ui.command.list>
        <x-ui.command.empty>{{ __('No results found.') }}</x-ui.command.empty>
        <x-ui.command.group :heading="__('Navigation')">
            <x-ui.command.item value="inbox" keywords="Go to inbox">{{ __('Go to inbox') }}</x-ui.command.item>
            <x-ui.command.item value="library" keywords="Go to library">{{ __('Go to library') }}</x-ui.command.item>
            <x-ui.command.item value="settings" keywords="Go to settings">{{ __('Go to settings') }}</x-ui.command.item>
        </x-ui.command.group>
        <x-ui.command.group :heading="__('Actions')" separator>
            <x-ui.command.item value="archive" keywords="Archive selected items">{{ __('Archive selected items') }}</x-ui.command.item>
            <x-ui.command.item value="theme-dark" keywords="Switch to dark theme">{{ __('Switch to dark theme') }}</x-ui.command.item>
        </x-ui.command.group>
    </x-ui.command.list>
</x-ui.command>
server.blade.php Blade
{{-- Server mode: the host supplies the results. The palette dispatches
     `command-search` 200 ms after the last change of the query; this preview
     answers from a fake server that takes 700 ms for a query of one or two
     characters and 150 ms for a longer one, so the answer for "at" arrives
     after the answer for "atl" and is ignored (isCurrent()). The results
     group carries the sequence number of its answer, so it shows only for the
     latest query. Type "fail" to see the error row. --}}
<div
    x-data="{
        tasks: [
            { id: 'OPS-12', title: @js(__('Atlas launch checklist')) },
            { id: 'OPS-15', title: @js(__('Atlas pricing page')) },
            { id: 'OPS-21', title: @js(__('Audit log misses role changes')) },
            { id: 'OPS-24', title: @js(__('Invite email lands in spam')) },
            { id: 'OPS-30', title: @js(__('Backfill invoice numbers')) },
        ],
        answer: { sequence: 0, query: '', hits: [] },
        chosen: null,
        timers: [],
        search(detail) {
            const query = detail.query.trim().toLowerCase();
            const delay = query.length <= 2 ? 700 : 150;
            this.timers.push(setTimeout(() => {
                if (query.includes('fail')) {
                    detail.fail();

                    return;
                }
                if (! detail.isCurrent()) return;
                const hits = query === '' ? [] : this.tasks.filter((task) => (task.id + ' ' + task.title).toLowerCase().includes(query));
                this.answer = { sequence: detail.sequence, query, hits };
                detail.done();
            }, delay));
        },
        destroy() { this.timers.forEach(clearTimeout) },
    }"
    x-on:command-search="search($event.detail)"
    x-on:command-select="chosen = $event.detail.value"
    class="flex w-full max-w-md flex-col gap-4"
>
    <x-ui.command server>
        <x-ui.command.input :placeholder="__('Search tasks…')" :aria-label="__('Search tasks')" />
        <x-ui.command.list>
            <x-ui.command.empty>{{ __('No tasks found.') }}</x-ui.command.empty>
            <x-ui.command.group :heading="__('Actions')" :keys="['create task', 'open inbox']">
                <x-ui.command.item value="create task">{{ __('Create task') }}</x-ui.command.item>
                <x-ui.command.item value="open inbox">{{ __('Open inbox') }}</x-ui.command.item>
            </x-ui.command.group>
            <x-ui.command.group :heading="__('Tasks')" sequence-expr="answer.sequence" separator>
                <template x-for="hit in answer.hits" :key="answer.sequence + '-' + hit.id">
                    <x-ui.command.item value-expr="'task-' + hit.id">
                        <span class="w-16 shrink-0 font-mono text-xs text-muted-foreground" x-text="hit.id"></span>
                        <span class="min-w-0 truncate" x-text="hit.title"></span>
                    </x-ui.command.item>
                </template>
            </x-ui.command.group>
        </x-ui.command.list>
    </x-ui.command>

    <p class="text-sm text-muted-foreground">
        {{ __('Chosen:') }}
        <span data-slot="preview-event-log" class="font-mono text-foreground" x-text="chosen ?? '—'">—</span>
    </p>
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
resetOnSelect bool true Clear the typed query once an item is chosen. Set false for a list that stays open and keeps its filter.
filter bool true Filter items client-side against the typed query. Set false when the host already returns only matching hits (a debounced server search); the keyboard model still covers every item.
clearOnEscape bool true Escape clears a typed query first and closes a wrapping dialog on the next press. Set false to close on the first Escape even with text typed; the query clears as the dialog closes.
field bool false Field mode: no palette surface. The input renders as a standalone search field and the host places the list (a floating popover under the field), as record-search does.
hotkey string | null null Opt-in global shortcut registered with the hotkey registry, for example 'mod+k' (Cmd+K on macOS, Ctrl+K elsewhere). Inside a dialog it opens and closes that dialog; an inline palette focuses its input. Fires while typing; blocked by any other open modal.
hotkeyDescription string Open command palette Description of the shortcut in the hotkeys help list.
hotkeyGroup string General Group of the shortcut in the hotkeys help list.
match substring | fuzzy substring How the typed query matches an item: substring keeps items whose search text contains the query; fuzzy keeps items that contain every word of the query in order, with gaps allowed ("gs" finds "Go to settings").
rank bool false Opt-in ranking. While a query is typed, matching items show best first: the whole query as a phrase on word boundaries, then every word as a whole word, then word prefixes, then substrings, then scattered fuzzy matches (match="fuzzy"); ties keep document order. Items rank within their group and groups order by their best item; persistent rows and the actions footer stay last, server-mode sequenced results keep the host order, and an empty query shows document order. The reorder is visual (CSS order on a flex list and groups), so the DOM, Livewire morphs and x-for rows are untouched, and the arrow keys, Home/End and the first active option follow what is on screen. Give every group separator so the divider follows the visual order.
recent string | null null A localStorage key. When set, chosen items are remembered (newest first) and listed in a Recent group at the top of <x-ui.command.list> while nothing is typed. A recent row activates the original item; when it is no longer rendered, its remembered link is followed or command-select fires with its value.
recentMax int 5 How many recent items are kept.
recentHeading string Recent Heading of the Recent group (translated with __()).
server bool false Server mode: a query change dispatches command-search { query, sequence, done(), fail(message), isCurrent() } after debounce ms. Until done() the list is busy (aria-busy, a loading row, a polite status). Off: the palette filters in the browser as before.
debounce int 200 Server mode: milliseconds after the last query change before command-search is dispatched.
loadingText string | null null Server mode: loading row and status text; null uses the translated "Searching…".
errorText string | null null Server mode: text shown when the host calls fail() without a message; null uses the translated default.
disabled false | true false Documented catalog control used by the preview workbench.
placeholder mixed __('Type a command or search…') Declared by @props in the registry Blade source.
expanded string true Declared by @props in the registry Blade source.
actionsLabel mixed | null null Declared by @props in the registry Blade source.
value mixed | null null Declared by @props in the registry Blade source.
actionKey mixed | null null Declared by @props in the registry Blade source.
keywords mixed | null null Declared by @props in the registry Blade source.
href mixed | null null Declared by @props in the registry Blade source.
disabledReason mixed | null null Declared by @props in the registry Blade source.
valueExpr mixed | null null Declared by @props in the registry Blade source.
keywordsExpr mixed | null null Declared by @props in the registry Blade source.
selectedExpr mixed | null null Declared by @props in the registry Blade source.
persistent bool false Declared by @props in the registry Blade source.
heading mixed | null null Declared by @props in the registry Blade source.
separator bool false Declared by @props in the registry Blade source.
keys array [] Declared by @props in the registry Blade source.
sequence mixed | null null Declared by @props in the registry Blade source.
sequenceExpr mixed | null null Declared by @props in the registry Blade source.
removable bool true Declared by @props in the registry Blade source.
hints array [] Declared by @props in the registry Blade source.

Slots

  • input.default — Context chips (command.chip) rendered before the text field, such as "2 selected" or the focused task.
  • list.actions — Footer actions of the list: command.item rows (usually persistent), in a named group that stays at the bottom while the list scrolls. They are options: the arrow keys and Enter reach them and a pick dispatches command-select. Key hints that are not actions go in command.footer, outside the listbox.
  • default — Compose command.input, command.list, command.item, and command.empty children.
  • x-ui.command.input — Installed subcomponent from the registry item.
  • x-ui.command.list — Installed subcomponent from the registry item.
  • x-ui.command.item — Installed subcomponent from the registry item.
  • x-ui.command.empty — Installed subcomponent from the registry item.
  • x-ui.command.group — Installed subcomponent from the registry item.
  • x-ui.command.shortcut — Installed subcomponent from the registry item.
  • x-ui.command.chip — Installed subcomponent from the registry item.
  • x-ui.command.footer — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

command command-actions command-chip command-chip-label command-chip-remove command-empty command-error command-footer command-footer-hint command-group command-group-heading command-input-wrapper command-item command-list command-loading command-separator command-sequence command-shortcut command-status {{ $slotName }}

Behavior

  • The server supplies only authorized commands; browser filtering never decides authorization.
  • Filters enabled items as the user types and maintains an active descendant for arrow-key navigation.
  • Enter activates the current enabled item and is left alone when nothing is active, so a host-rendered link inside the list keeps its native Enter; grouped headings remain non-interactive.
  • Selection dispatches command-select with stable key and compatibility value fields.
  • Items rendered from client data (<template x-for> with item valueExpr) filter, navigate and select like server items; an item unregisters when Alpine removes it, and arrow keys follow document order, so a reordered or replaced list never keeps a stale row.
  • With filter=false every item stays visible and only the keyboard model applies; with field=true the palette surface is dropped so the input is a standalone search field (record-search composes both).
  • With hotkey set, the palette registers a global shortcut with the hotkey registry: it toggles the enclosing dialog through the dialog's own show()/hide(), so focus trap, inert background and focus return stay with the dialog, and it dispatches command-hotkey. Another open modal blocks the shortcut.
  • A context chip (command.chip in command.input's slot) names what the palette acts on. Its remove button, or Backspace in an empty query, dispatches command-chip-remove { value } from the chip and keeps focus in the query field; the host drops its state and stops rendering the chip.
  • command.footer shows quiet key hints (Enter, arrows, Esc) under the list with platform labels.
  • Escape clears a typed query and keeps the palette open (clear-on-escape="false" skips that step); with the query empty it is not handled, so a wrapping dialog closes on that first press and an inline palette lets it bubble. Inside a dialog the query is cleared whenever the dialog closes (Escape, backdrop, the hotkey, a chosen link), so a reopened palette starts empty and one Escape closes it.
  • Fuzzy matching (match="fuzzy"): every word of the query must appear in the item's search text in order, with gaps allowed; items keep document order and the keyboard model is unchanged.
  • Recent items (recent="key"): each choice is stored under command-recent:<key> in localStorage (at most recentMax, newest first, a private-mode failure is ignored) with its label and link. The Recent group is the first group of the list while the query is empty; its rows are ordinary options for ArrowUp/ArrowDown/Enter. clearRecent() on the palette scope forgets them.
  • Server mode: every query change takes a new sequence number at once, so earlier results (a command.group with sequence) hide and leave the keyboard model before the new answer arrives; an older answer that arrives late stays hidden, and Enter never takes a stale or removed option.
  • Server mode: when done() arrives for the latest number the first result becomes active, unless the arrow keys moved meanwhile; fail(message) shows an error row and announces it; command.empty hides while the list is busy or failed.
  • Options whose element a render removed (a Livewire morph, an x-if) leave the keyboard model; a render that changes a kept group's sequence marker is checked again.
  • Footer row: command.list's actions slot holds actionable rows ("Create label …") as options in a sticky group at the end of the listbox; command.footer holds text only (key hints) outside the listbox, so it is never announced as an option.
  • With rank, a typed query orders matching items best first (phrase, whole words, word prefixes, substrings, fuzzy; ties in document order) inside each group, and groups by their best item. Only the CSS order changes: keyboard navigation and the first active option follow the visual order, aria-activedescendant names the active option, and clearing the query restores document order. The actions footer, persistent rows and server-mode sequenced regions keep their place.
  • Filters registered items as the user types.
  • Arrow keys move the active option, Home/End jump to boundaries, and Enter activates the selected item.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, behaviorTest.

Guidance

Searchable selection and filtering

Find and select within a long or uncertain option set.

Use when

  • Use when the option set is long enough that search, suggestion, or filtering speeds selection.
  • Users need to search and invoke actions from a keyboard-friendly palette.
  • A long action or navigation list benefits from client-side filtering and grouping.
  • A search asks the server (Livewire or fetch) for results as the user types; use server mode.

Avoid when

  • Do not use advanced selection UI when a simpler visible control would be clearer.
  • The user is choosing a form value; use combobox or select-advanced.
  • There are only a few always-visible actions; use buttons or a menu.

Use instead

  • Visible choices for small sets
  • Dedicated filter panel for complex refinement

Anti-patterns

  • Using advanced search for a tiny list
Anatomy
root input list group item shortcut empty chip actions footer
Theming hooks
command input command item active command item

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Enter Escape Home End
Focus
managed
  • Each action needs a unique accessible label and stable key.
  • Disabled items are skipped by keyboard activation and expose aria-disabled.
  • Keep keyboard shortcuts supplemental rather than the only visible label.
  • The chip's remove button is named "Remove <chip label>", and the chip sits before the combobox in reading order, so a screen reader hears the context before the query field.
  • Footer hints are read as words (Enter, then the label), not as glyphs.
  • Server mode: the listbox reports aria-busy while an answer is on its way, and a polite status outside the listbox announces Searching…, the result count or the failure.
  • Label the query field with aria-label (or an id with a label) on command.input.
  • 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="command-{{ $record->id }}">
    <x-ui.command class="w-full max-w-md">
        <x-ui.command.input :placeholder="__('Type a command or search…')" />
        <x-ui.command.list>
            <x-ui.command.empty>{{ __('No results found.') }}</x-ui.command.empty>
            <x-ui.command.item value="calendar">{{ __('Calendar') }}</x-ui.command.item>
            <x-ui.command.item value="search-emoji">{{ __('Search Emoji') }}</x-ui.command.item>
            <x-ui.command.item value="calculator">{{ __('Calculator') }}</x-ui.command.item>
            <x-ui.command.item value="profile">{{ __('Profile') }}</x-ui.command.item>
            <x-ui.command.item value="settings">{{ __('Settings') }}</x-ui.command.item>
        </x-ui.command.list>
    </x-ui.command>
</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:command
        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/command.blade.php Blade
{{--
    Command palette (spec §17 + §19). A filterable, keyboard-navigable list.
    Type to filter items, ArrowUp/Down to move the active option, Enter to
    activate it. Escape clears a typed query; with nothing typed (or with
    clear-on-escape off) it closes a wrapping <x-ui.dialog>. Inside a dialog the query is cleared whenever the
    dialog closes, so the palette reopens empty. Works standalone or inside a
    <x-ui.dialog>.
--}}
@props([
    // Clear the typed query once an item is chosen (a reopened palette starts
    // clean). Set false for a list that stays open and keeps its filter.
    'resetOnSelect' => true,
    // Filter the items client-side against the typed query. Set false when the
    // host already returns only matching hits (a debounced server search) and
    // the query is not expected to appear in an item's text — the keyboard
    // model (active option, Arrow/Home/End/Enter) still applies to every item.
    'filter' => true,
    // Escape clears a typed query first and closes a wrapping dialog on the
    // next press. Set false to close on the first Escape even with text typed
    // (the query clears as the dialog closes), for a palette whose search is
    // only a way to find the item.
    'clearOnEscape' => true,
    // Field mode: no palette surface. The input renders as a standalone
    // search field and the list is left for the host to place (a floating
    // popover under the field), so a search box gets the combobox keyboard
    // model without the palette chrome.
    'field' => false,
    // Global shortcut that opens the palette through the hotkey registry, for
    // example 'mod+k' (Cmd+K on macOS, Ctrl+K elsewhere). Inside a
    // dialog it opens (and, pressed again, closes) that dialog; an
    // inline palette focuses its input. Off by default. Another open modal
    // blocks it, so it never acts behind a focus trap.
    'hotkey' => null,
    // Help-list description and group for the shortcut.
    'hotkeyDescription' => 'Open command palette',
    'hotkeyGroup' => 'General',
    // How the typed query matches an item: 'substring' (default) or 'fuzzy'
    // (every word of the query in order, gaps allowed: "gp" finds "Go to
    // projects").
    'match' => 'substring',
    // Opt-in ranking: while a query is typed, matching items show best first
    // (the query as a phrase, then whole words, word prefixes, substrings and
    // scattered fuzzy matches), within each group, and groups order by their
    // best item. Ties, an empty query, persistent rows, the actions footer and
    // server-mode results keep document order. Visual only: the DOM stays.
    'rank' => false,
    // Remember chosen items under this localStorage key and list them in a
    // Recent group at the top of the command list while nothing is typed.
    // Off by default.
    'recent' => null,
    'recentMax' => 5,
    'recentHeading' => 'Recent',
    // Server mode: the host supplies the results. A query change dispatches
    // `command-search` { query, sequence, done(), fail(message), isCurrent() }
    // after `debounce` ms; until done() the list is busy (aria-busy, a
    // loading row and a polite status). Results rendered in a
    // command.group with `sequence` show only for the latest query and skip
    // the browser filter. Off by default: the palette filters in the browser.
    'server' => false,
    'debounce' => 200,
    // Loading row and status text while an answer is on its way.
    'loadingText' => null,
    // Row shown when the host calls fail() without a message.
    'errorText' => null,
])

@php
    $match = $match === 'fuzzy' ? 'fuzzy' : 'substring';
    $searchTexts = $server ? [
        'loading' => $loadingText ?? __('Searching…'),
        'error' => $errorText ?? __('The search failed. Try again.'),
        'empty' => __('No results found.'),
        'one' => __('1 result'),
        'many' => __(':count results'),
    ] : [];
    $surface = $field
        ? 'relative w-full text-foreground'
        : 'flex w-full flex-col overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground';
    $rank = filter_var($rank, FILTER_VALIDATE_BOOLEAN);
    // Ranking reorders rows with CSS `order`, so the list and its groups are
    // flex columns whose rows never shrink, and the actions footer stays last.
    if ($rank) {
        $surface .= ' [&_[data-slot=command-list]]:flex [&_[data-slot=command-list]]:flex-col [&_[data-slot=command-list]>*]:shrink-0 [&_[data-slot=command-group]]:flex [&_[data-slot=command-group]]:flex-col [&_[data-slot=command-group]>*]:shrink-0 [&_[data-slot=command-actions]]:order-last';
    }
@endphp

<div
    x-data="uiCommand({ resetOnSelect: {{ \Illuminate\Support\Js::from((bool) $resetOnSelect) }}, filter: {{ \Illuminate\Support\Js::from((bool) $filter) }}, clearOnEscape: {{ \Illuminate\Support\Js::from((bool) $clearOnEscape) }}, match: @js($match){{ $rank ? ', rank: true' : '' }}{{ filled($recent) ? ', recent: '.\Illuminate\Support\Js::from((string) $recent).', recentMax: '.max(1, (int) $recentMax) : '' }}@if (filled($hotkey)), hotkey: {{ \Illuminate\Support\Js::from((string) $hotkey) }}, hotkeyDescription: {{ \Illuminate\Support\Js::from(__($hotkeyDescription)) }}, hotkeyGroup: {{ \Illuminate\Support\Js::from(__($hotkeyGroup)) }}@endif @if ($server), server: true, debounce: {{ max(0, (int) $debounce) }}, texts: {{ \Illuminate\Support\Js::from($searchTexts) }}@endif })"
    x-id="['command-list']"
    data-slot="command"
    data-authorization="server-filtered"
    @if ($field) data-field @endif
    @if (filled($hotkey)) data-hotkey="{{ $hotkey }}" @endif
    data-match="{{ $match }}"
    @if ($rank) data-rank @endif
    @if (filled($recent)) data-recent="{{ $recent }}" @endif
    @if ($server) data-server x-bind:data-busy="busy ? 'true' : 'false'" @endif
    @keydown.escape="onEscape($event)"
    @keydown.down.prevent="move(1)"
    @keydown.up.prevent="move(-1)"
    @keydown.enter="choose($event)"
    @keydown.home.prevent="moveTo(0)"
    @keydown.end.prevent="moveTo(-1)"
    {{ $attributes->merge(['class' => $surface]) }}
>
    {{ $slot }}
    @if ($server)
        {{-- Outside the listbox: announces loading, the result count and a failure. --}}
        <div data-slot="command-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only" x-text="statusText"></div>
    @endif
</div>
resources/views/components/ui/command/input.blade.php Blade
@aware([
    'field' => false,
])

@props([
    'placeholder' => __('Type a command or search…'),
    // Alpine expression for aria-expanded. A palette's list is always shown;
    // a field-mode host whose list floats in a popover binds it to that
    // popover's open state so the combobox reports the truth.
    'expanded' => 'true',
])

@php
    // A host composing this input under its own name (record-search) passes
    // `data-slot`; the browser keeps only the first of two identical
    // attributes, so read the override out of the bag and print exactly one.
    // Same for `type`: a search field is `type="search"`.
    $slotName = (string) ($attributes->get('data-slot') ?: 'command-input');
    $type = (string) ($attributes->get('type') ?: 'text');

    $wrapperClass = $field
        ? 'relative'
        : 'flex items-center gap-2 border-b border-border px-3';
    $iconClass = $field
        ? 'pointer-events-none absolute inset-y-0 start-0 flex items-center ps-3 text-muted-foreground'
        : 'shrink-0 text-muted-foreground';
    $inputClass = $field
        ? 'h-10 w-full min-w-0 rounded-md border border-input bg-background ps-9 pe-9 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50'
        : 'h-control-h-md w-full min-w-0 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50';
@endphp

<div data-slot="command-input-wrapper" class="{{ $wrapperClass }}">
    <span class="{{ $iconClass }}" aria-hidden="true">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
            <circle cx="11" cy="11" r="8" />
            <path d="m21 21-4.3-4.3" />
        </svg>
    </span>
    {{-- Context chips (<x-ui.command.chip>) sit before the text field. --}}
    {{ $slot }}
    <input
        type="{{ $type }}"
        data-slot="{{ $slotName }}"
        role="combobox"
        aria-autocomplete="list"
        :aria-expanded="{{ $expanded }}"
        x-bind:aria-controls="$id('command-list')"
        x-bind:aria-activedescendant="({{ $expanded }}) ? activeId() : null"
        x-model="query"
        @input="reset()"
        @keydown.backspace="onBackspace($event)"
        placeholder="{{ $placeholder }}"
        autocomplete="off"
        {{ $attributes->except(['data-slot', 'type'])->merge(['class' => $inputClass]) }}
    />
</div>
resources/views/components/ui/command/list.blade.php Blade
@aware([
    'recent' => null,
    'recentHeading' => 'Recent',
    'server' => false,
])

@props([
    // Name of the `actions` footer group. Null: the translated "Actions".
    'actionsLabel' => null,
])

@php($recentId = 'command-recent-'.\Illuminate\Support\Str::random(6))

<div
    data-slot="command-list"
    role="listbox"
    x-ref="list"
    x-bind:id="$id('command-list')"
    @if ($server) x-bind:aria-busy="busy ? 'true' : 'false'" @endif
    {{ $attributes->merge(['class' => 'max-h-72 overflow-y-auto overflow-x-hidden p-1']) }}
>
    @if (filled($recent))
        {{-- Recent: the palette's remembered choices, newest first, shown
             only while nothing is typed. A row activates the original item. --}}
        <div data-slot="command-group" data-recent role="group" aria-labelledby="{{ $recentId }}" x-show="showRecent()" style="display: none" class="overflow-hidden">
            <div id="{{ $recentId }}" data-slot="command-group-heading" class="px-2 py-2 text-xs font-medium text-muted-foreground select-none">{{ __($recentHeading) }}</div>
            <template x-for="entry in recentEntries" :key="'recent:' + entry.value">
                <button
                    type="button"
                    role="option"
                    data-slot="command-item"
                    data-recent
                    x-bind:id="$id('command-list') + '-item-' + encodeId('recent:' + entry.value)"
                    x-bind:data-value="'recent:' + entry.value"
                    x-init="registerItem('recent:' + entry.value, '', true, $el)"
                    :aria-selected="active === 'recent:' + entry.value"
                    :data-active="active === 'recent:' + entry.value"
                    @click="chooseRecent(entry)"
                    @mousemove="active = 'recent:' + entry.value"
                    class="flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-start text-sm text-popover-foreground outline-none transition-colors data-[active=true]:bg-accent data-[active=true]:text-accent-foreground motion-reduce:transition-none"
                >
                    <svg aria-hidden="true" class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>
                    <span class="min-w-0 truncate" x-text="entry.label"></span>
                </button>
            </template>
        </div>
    @endif
    {{ $slot }}
    @if ($server)
        {{-- The status region on the command root announces these rows. --}}
        <div data-slot="command-loading" role="presentation" x-show="busy" x-cloak class="flex items-center gap-2 px-2 py-2 text-sm text-muted-foreground">
            <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" class="size-4 shrink-0 motion-safe:animate-spin">
                <path d="M21 12a9 9 0 1 1-6.2-8.56" />
            </svg>
            <span x-text="texts.loading"></span>
        </div>
        <div data-slot="command-error" role="presentation" x-show="!busy && error !== ''" x-cloak class="px-2 py-6 text-center text-sm text-muted-foreground" x-text="error"></div>
    @endif
    @isset($actions)
        {{-- Footer actions ("Create …"): command.item rows (usually persistent)
             in a named group that stays at the bottom while the list scrolls.
             They are options, so the arrow keys and Enter reach them. --}}
        <div data-slot="command-actions" role="group" aria-label="{{ filled($actionsLabel) ? $actionsLabel : __('Actions') }}" class="sticky bottom-0 mt-1 border-t border-border bg-popover pt-1">
            {{ $actions }}
        </div>
    @endisset
</div>
resources/views/components/ui/command/item.blade.php Blade
@aware([
    'rank' => false,
])

@props([
    'value' => null,
    'actionKey' => null,
    'keywords' => null,
    'href' => null,
    'disabled' => false,
    'disabledReason' => null,
    // Client data: Alpine expressions for the value and the search text, for
    // an item rendered inside `<template x-for>` (`value-expr="hit.id"`,
    // `keywords-expr="hit.title + ' ' + hit.team"`). They replace `value`,
    // `action-key` and `keywords`; the search text defaults to the value.
    'valueExpr' => null,
    'keywordsExpr' => null,
    // Alpine expression for aria-selected. Null: the active option is the
    // selected one (a palette). A picker passes its own selection
    // (`selected-expr="isPicked('high')"`), so aria-selected reports the
    // chosen values while data-active keeps showing the keyboard position.
    'selectedExpr' => null,
    // Always shown, whatever the query: an action row such as "Create …" in
    // the list's `actions` footer. It stays an option (arrow keys, Enter).
    'persistent' => false,
])

@php($dynamic = filled($valueExpr))
@php($search = $keywords ?? $value)
@php($key = filled($actionKey) ? (string) $actionKey : (string) $value)
@php($encoded = preg_replace('/[^a-zA-Z0-9_-]/', '-', $key))
@php($keyExpr = $dynamic ? '('.$valueExpr.')' : null)
@php($searchExpr = $dynamic ? (filled($keywordsExpr) ? '('.$keywordsExpr.')' : $keyExpr) : null)

@php($tag = filled($href) ? 'a' : 'button')
@php($ranked = filter_var($rank, FILTER_VALIDATE_BOOLEAN))

<{{ $tag }}
    @if ($tag === 'button') type="button" @disabled($disabled) @elseif (! $disabled) href="{{ $href }}" @else tabindex="-1" @endif
    data-slot="command-item"
    role="option"
    @if ($persistent) data-persistent @endif
@if ($dynamic)
    x-bind:id="$id('command-list') + '-item-' + encodeId({{ $keyExpr }})"
    x-bind:data-value="{{ $keyExpr }}"
    x-bind:data-action-key="{{ $keyExpr }}"
    x-init="registerItem({{ $keyExpr }}, {{ $searchExpr }}, @js(! $disabled), $el)"
    x-show="matches({{ $searchExpr }}, $el)"
    @if ($ranked) x-bind:style="rankStyle($el, {{ $searchExpr }})" @endif
    @if (filled($selectedExpr)) :aria-selected="({{ $selectedExpr }}) ? 'true' : 'false'" @else :aria-selected="active === {{ $keyExpr }}" @endif
    :data-active="active === {{ $keyExpr }}"
    @if ($disabled) aria-disabled="true" title="{{ $disabledReason }}" @else @click="select({{ $keyExpr }})" @mousemove="active = {{ $keyExpr }}" @endif
@else
    x-bind:id="$id('command-list') + '-item-' + @js($encoded)"
    data-value="{{ $key }}"
    data-action-key="{{ $key }}"
    x-init="registerItem(@js($key), @js($search), @js(! $disabled), $el)"
    x-show="matches(@js($search), $el)"
    @if ($ranked) x-bind:style="rankStyle($el, @js($search))" @endif
    @if (filled($selectedExpr)) :aria-selected="({{ $selectedExpr }}) ? 'true' : 'false'" @else :aria-selected="active === @js($key)" @endif
    :data-active="active === @js($key)"
    @if ($disabled) aria-disabled="true" title="{{ $disabledReason }}" @else @click="select(@js($key))" @mousemove="active = @js($key)" @endif
@endif
    {{ $attributes->merge(['class' => 'flex w-full cursor-pointer items-center gap-2 rounded-sm px-2 py-2 text-start text-sm text-popover-foreground outline-none transition-colors data-[active=true]:bg-accent data-[active=true]:text-accent-foreground disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50']) }}
>
    {{ $slot }}
</{{ $tag }}>
resources/views/components/ui/command/empty.blade.php Blade
<div
    data-slot="command-empty"
    role="presentation"
    x-show="!hasResults && !busy && error === ''"
    {{ $attributes->merge(['class' => 'py-6 text-center text-sm text-muted-foreground']) }}
>
    {{ $slot ?? __('No results found.') }}
</div>
resources/views/components/ui/command/group.blade.php Blade
@aware([
    'rank' => false,
])

@props([
    // Visible label for the group, rendered above its items.
    'heading' => null,
    // Render a top divider before this group (set on the 2nd+ group).
    'separator' => false,
    // The searchable strings of this group's items (their `value` or
    // `keywords`). Lets the whole group hide once every child is filtered out,
    // so a stale heading never floats above an empty region. Optional — when
    // omitted the group is always shown.
    'keys' => [],
    // Server mode: the sequence number of the answer this group belongs to
    // (from the command-search event). The group and its items show, take
    // keys and skip the browser filter only while it is the latest number,
    // so a late, older answer never shows. Key the group on it
    // (a wire:key that contains the number) so a render replaces its
    // options in one step.
    'sequence' => null,
    // Client data: an Alpine expression for the sequence number of the
    // answer on screen (sequence-expr="answer.sequence"), for results a
    // fetch put in Alpine state.
    'sequenceExpr' => null,
])

@php
    $labelId = 'command-group-'.\Illuminate\Support\Str::random(6);
    $keysCheck = empty($keys) ? null : 'groupMatches('.\Illuminate\Support\Js::from(array_values($keys)).')';
    $sequenced = $sequence !== null || filled($sequenceExpr);
    // A sequenced group shows for the latest answer, and only with results.
    $visibleWhen = implode(' && ', array_filter([$sequenced ? 'isCurrent($el) && hasItemsIn($el)' : null, $keysCheck]));
@endphp

<div
    data-slot="command-group"
    role="group"
    @if ($heading) aria-labelledby="{{ $labelId }}" @endif
    @if ($sequenced) data-sequenced @endif
    @if ($sequenced || ! empty($keys)) x-show="{{ $visibleWhen }}" @endif
    @if (! empty($keys)) data-keys="{{ json_encode(array_values($keys)) }}" @endif
    @if (filter_var($rank, FILTER_VALIDATE_BOOLEAN)) x-bind:style="groupRankStyle($el)" @endif
    {{ $attributes->merge(['class' => 'overflow-hidden']) }}
>
    @if ($sequenced)
        {{-- The answer's number, on a child without x-show, so a render updates it. --}}
        <span data-slot="command-sequence" hidden @if (filled($sequenceExpr)) x-bind:data-sequence="{{ $sequenceExpr }}" @else data-sequence="{{ (int) $sequence }}" @endif></span>
    @endif
    @if ($separator)
        {{-- Only between two visible groups: once the group above filters out,
             the line would otherwise sit directly under the input. --}}
        <div data-slot="command-separator" role="none" x-show="hasVisibleGroupBefore($el.parentElement)" class="-mx-1 my-1 h-px bg-border"></div>
    @endif

    @if ($heading)
        <div id="{{ $labelId }}" data-slot="command-group-heading" class="px-2 py-2 text-xs font-medium text-muted-foreground select-none">
            {{ $heading }}
        </div>
    @endif

    {{ $slot }}
</div>
resources/views/components/ui/command/shortcut.blade.php Blade
@props([
    // A registry key string ("mod+k", "g p"). When set, the hint renders
    // through the hotkeys hint: platform labels (⌘ on macOS, Ctrl
    // elsewhere) and the "then" step of a sequence. The slot stays the
    // free-text fallback.
    'keys' => null,
])

{{-- Decorative (aria-hidden): the item's own text names the action. --}}
@if (filled($keys))
    <span
        data-slot="command-shortcut"
        dir="ltr"
        data-keys="{{ $keys }}"
        aria-hidden="true"
        {{ $attributes->merge(['class' => 'ms-auto inline-flex shrink-0 items-center text-muted-foreground']) }}
    ><x-ui.hotkeys.hint :keys="(string) $keys" /></span>
@else
    <span
        data-slot="command-shortcut"
        dir="ltr"
        aria-hidden="true"
        {{ $attributes->merge(['class' => 'ms-auto text-xs tracking-widest text-muted-foreground']) }}
    >
        {{ $slot }}
    </span>
@endif
resources/views/components/ui/command/chip.blade.php Blade
@props([
    // Value sent with `command-chip-remove`, so a host with several chips
    // knows which one to drop.
    'value' => null,
    // Show the remove button. A chip the user cannot drop (a fixed scope)
    // passes false; Backspace then skips it.
    'removable' => true,
])

{{--
    Context chip in the palette input, such as "2 selected" or the focused
    task. Place it in <x-ui.command.input>'s slot, before the text field.
    Removing it (its button, or Backspace in an empty query) dispatches
    `command-chip-remove` { value } from the chip; the host drops its own
    state and stops rendering the chip (x-show, x-if or @if).
--}}
<span
    data-slot="command-chip"
    @if ($removable) data-removable @endif
    @if (filled($value)) data-value="{{ $value }}" @endif
    {{ $attributes->merge(['class' => 'inline-flex h-6 min-w-0 max-w-48 shrink-0 items-center gap-1 rounded-sm border border-border bg-muted ps-2 text-xs font-medium text-foreground '.($removable ? 'pe-0.5' : 'pe-2')]) }}
>
    <span data-slot="command-chip-label" class="min-w-0 truncate">{{ $slot }}</span>
    @if ($removable)
        <button
            type="button"
            data-slot="command-chip-remove"
            x-on:click="removeChip($el.closest('[data-slot=command-chip]'))"
            class="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
        >
            <span class="sr-only">{{ __('Remove :label', ['label' => trim(strip_tags((string) $slot))]) }}</span>
            <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3">
                <path d="M18 6 6 18M6 6l12 12" />
            </svg>
        </button>
    @endif
</span>
resources/views/components/ui/command/footer.blade.php Blade
@props([
    // Key hints as [['keys' => 'enter', 'label' => 'Open'], …]. Each renders a
    // hotkeys.hint (platform labels) followed by its label. The slot adds
    // anything else (a link to the full shortcut list).
    'hints' => [],
])

{{-- Palette footer: quiet key hints under the list (Enter to open, arrows to move). --}}
<div
    data-slot="command-footer"
    {{ $attributes->merge(['class' => 'flex min-w-0 flex-wrap items-center gap-x-4 gap-y-1 border-t border-border px-3 py-2 text-xs text-muted-foreground']) }}
>
    @foreach ($hints as $hint)
        <span data-slot="command-footer-hint" class="inline-flex min-w-0 items-center gap-2">
            <x-ui.hotkeys.hint :keys="(string) ($hint['keys'] ?? '')" :decorative="false" />
            <span class="truncate">{{ $hint['label'] ?? '' }}</span>
        </span>
    @endforeach
    {{ $slot }}
</div>
resources/js/ui/command.js JS
/**
 * Command palette behavior (spec §17 keyboard + §19 behavior layer).
 *
 * Items register themselves with the root so filtering and navigation are fully
 * reactive (no DOM polling): `query` drives a computed visible list, the active
 * item is tracked by value, and ArrowUp/Down/Home/End/Enter move over the
 * currently visible items only (so navigation skips filtered-out rows).
 *
 * Items rendered from client data (`<template x-for>` with the item's
 * `value-expr`) register the same way. An item unregisters when Alpine removes
 * its element, and navigation follows document order, so a list that is
 * filtered, reordered or replaced on the client never keeps a stale row.
 *
 * Matching (`match`): 'substring' (default) keeps an item when its search
 * text contains the query; 'fuzzy' keeps it when every word of the query
 * appears in order, with gaps allowed ("gp" finds "Go to projects").
 *
 * Ranking (`rank`, opt-in): while a query is typed, matching items show best
 * first instead of in document order. Tiers, best first: (0) the whole query
 * as a phrase on word boundaries; (1) every query word as a whole word;
 * (2) every query word as a word prefix; (3) the query or every word as a
 * substring; (4) a scattered fuzzy match. Ties keep document order. Items
 * rank within their group, and groups order by their best item. Persistent
 * rows, the `actions` footer group and server-mode sequenced regions keep
 * their place (the footer stays last; sequenced results stay in host order).
 * The reorder is visual only (CSS `order` on the flex list and groups), so
 * the DOM, Livewire morphs and `x-for` rows are untouched; the arrow keys,
 * Home/End and the first active option follow the visual order, and clearing
 * the query restores document order. aria-activedescendant names the active
 * option, so a screen reader hears the same item the eye sees.
 *
 * Recent (`recent`: a storage key): each chosen item is remembered in
 * localStorage (newest first, at most `recentMax`) and listed in a Recent
 * group at the top while the query is empty. Choosing a recent row activates
 * the original item (its link or its command-select); when the original is no
 * longer rendered, a remembered link is followed, else command-select fires
 * with the remembered value.
 *
 * Server mode (`server`): the host supplies the results. Every change of the
 * query takes a new sequence number at once and, after `debounce` ms,
 * dispatches `command-search` { query, sequence, done(), fail(message),
 * isCurrent() }. Until done() or fail() comes for the latest number the list
 * is busy (aria-busy, a loading row, a polite status). Groups and items the
 * host renders inside a sequenced region (command.group's
 * `sequence` prop) show, filter and take keys only while that number is the
 * latest, so an older answer that arrives late never shows and never takes
 * Enter; they also skip the browser filter, because the host has filtered.
 *
 * Footer actions: command.list's `actions` slot holds rows such as
 * "Create …" at the bottom of the list. A `persistent` item there matches
 * every query and stays an option for the arrow keys and Enter, but does not
 * count as a result, so the empty row still shows beside it.
 */
const RECENT_PREFIX = 'recent:';

function readRecent(key) {
    try {
        const value = JSON.parse(window.localStorage.getItem(key) || '[]');

        return Array.isArray(value) ? value.filter((entry) => entry && typeof entry.value === 'string') : [];
    } catch {
        return [];
    }
}

function writeRecent(key, entries) {
    try {
        window.localStorage.setItem(key, JSON.stringify(entries));
    } catch {
        // Private mode or a full quota: recent items are simply not kept.
    }
}

function safeHref(href) {
    return typeof href === 'string' && href !== '' && !/^\s*(javascript|data|vbscript):/i.test(href) ? href : null;
}
import './hotkeys.js';
import { documentOrder, fuzzyMatch, rankMethods } from './command-rank.js';

document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiCommand', (config = {}) => ({
        ...rankMethods,
        query: '',
        active: null,
        items: [],
        // Clear the query once an item is chosen, so a palette that is closed
        // and reopened (or reused after an action) starts clean.
        resetOnSelect: config.resetOnSelect ?? true,
        // Client-side filtering of items against `query`. Off when the host
        // already returns only matching hits (a server search): every item
        // then stays visible and the keyboard model covers all of them.
        filter: config.filter ?? true,
        // Escape clears a typed query before it closes. False: Escape is never
        // handled here, so a wrapping dialog closes on the first press and
        // clears the query as it closes (a search used only to find an item).
        clearOnEscape: config.clearOnEscape ?? true,
        matchMode: config.match === 'fuzzy' ? 'fuzzy' : 'substring',
        // Opt-in: order matching items best first while a query is typed.
        rank: config.rank ?? false,
        recentKey: config.recent ? `command-recent:${config.recent}` : null,
        recentMax: Math.max(1, Number(config.recentMax) || 5),
        recentEntries: [],
        hotkeyBinding: null,
        // Server mode: see the header. `sequence` is the number of the latest
        // query; `busy` holds until its answer is done; `error` is the text of
        // a failed answer.
        server: config.server ?? false,
        debounce: config.debounce ?? 200,
        sequence: 0,
        revision: 0,
        busy: false,
        error: '',
        searchTimer: null,
        sequenceObserver: null,
        // True once the arrow keys moved after the last query change, so an
        // arriving answer does not move the active option away from the user.
        movedSinceQuery: false,
        texts: config.texts ?? {},

        /**
         * `hotkey` (opt-in) registers a global shortcut with the hotkey
         * registry. The binding is registered from the palette root, so while
         * the palette's own dialog is on top it stays live (a second press
         * closes it), and any other open modal blocks it. It goes through the
         * store, not x-hotkey, on purpose: the palette is hidden while its
         * dialog is closed, and only store bindings may act on hidden UI.
         */
        init() {
            if (this.recentKey) this.recentEntries = readRecent(this.recentKey).slice(0, this.recentMax);
            this.watchDialog();
            if (this.server) {
                this.$watch('query', (query) => this.queueSearch(query));
                this.observeSequences();
            }
            if (!config.hotkey) return;
            this.hotkeyBinding = this.$store.hotkeys.register(config.hotkey, () => this.toggleFromHotkey(), {
                el: this.$el,
                scope: 'global',
                inputs: true,
                description: config.hotkeyDescription ?? '',
                group: config.hotkeyGroup ?? '',
            });
        },

        /**
         * Inside a dialog, clear the query whenever the dialog closes, however
         * it closes (Escape, backdrop, the hotkey, a link). The palette then
         * always reopens empty, so the first Escape closes it, as in Linear;
         * a typed query is only ever cleared by Escape while it is on screen.
         */
        watchDialog() {
            if (typeof this.show !== 'function' || typeof this.hide !== 'function' || !('open' in this)) return;
            this.$watch('open', (open) => {
                if (open || this.query === '') return;
                this.query = '';
                this.reset();
            });
        },

        destroy() {
            this.hotkeyBinding?.unregister();
            this.hotkeyBinding = null;
            clearTimeout(this.searchTimer);
            this.sequenceObserver?.disconnect();
        },

        /**
         * Server mode: a render can change the sequence marker of an element
         * it keeps (after done() ran), so check the rows again when one changes.
         */
        observeSequences() {
            if (typeof MutationObserver === 'undefined') return;
            this.sequenceObserver = new MutationObserver(() => { this.revision++; });
            this.sequenceObserver.observe(this.$el, { subtree: true, attributes: true, attributeFilter: ['data-sequence'] });
        },

        /**
         * Server mode: a query change makes every earlier answer stale at
         * once (its groups hide and leave the keyboard model) and asks the host
         * for the new one after the debounce.
         */
        queueSearch(query) {
            clearTimeout(this.searchTimer);
            const sequence = ++this.sequence;
            this.busy = true;
            this.error = '';
            this.movedSinceQuery = false;
            this.reset();
            this.searchTimer = setTimeout(() => {
                if (sequence !== this.sequence) return;
                this.$dispatch('command-search', {
                    query: String(query ?? ''),
                    sequence,
                    done: () => this.settle(sequence),
                    fail: (message) => this.settle(sequence, message ?? this.texts.error ?? ''),
                    isCurrent: () => sequence === this.sequence,
                });
            }, Math.max(0, Number(this.debounce) || 0));
        },

        /**
         * The answer for `sequence` arrived (or failed). An older one changes
         * nothing. The active option goes to the first result, unless the
         * arrow keys moved while the answer was on its way.
         */
        settle(sequence, error = null) {
            if (sequence !== this.sequence) return;
            this.busy = false;
            this.error = error === null ? '' : String(error || this.texts.error || '');
            this.$nextTick(() => {
                if (sequence !== this.sequence) return;
                this.revision++;
                // Forget entries whose element a render removed.
                this.items = this.items.filter((item) => !item.el || item.el.isConnected);
                if (!this.movedSinceQuery || !this.visibleValues().includes(this.active)) this.reset();
            });
        },

        /** True for an element outside any sequenced region, or in the latest one. */
        isCurrent(el) {
            // Read the revision too: settle() bumps it after the answer's
            // render, so rows whose data-sequence changed without a new query
            // number (client data, a reused element) are checked again.
            void this.sequence;
            void this.revision;
            const holder = el?.closest?.('[data-sequenced]');
            if (!holder) return true;
            // The number sits on a hidden marker child: Alpine's morph leaves
            // the attributes of an element alone when its x-show state
            // changes, so a number on the shown element itself would not update.
            const marker = holder.querySelector(':scope > [data-sequence]');

            return Number(marker?.dataset.sequence) === this.sequence;
        },

        /** True when a shown item (enabled or not) sits inside `el` (a results group). */
        hasItemsIn(el) {
            return this.items.some((item) => item.el && item.el.isConnected && el.contains(item.el) && this.matches(item.search, item.el));
        },

        /** Polite status text for screen readers in server mode. */
        get statusText() {
            if (!this.server) return '';
            if (this.busy) return this.texts.loading ?? '';
            if (this.error !== '') return this.error;
            if (this.query.trim() === '') return '';
            const count = this.visible().length;
            if (count === 0) return this.texts.empty ?? '';
            if (count === 1) return this.texts.one ?? '';

            return String(this.texts.many ?? '').replace(':count', String(count));
        },

        /**
         * Inside a dialog, Alpine's scope chain exposes the dialog's `open`,
         * `show()` and `hide()`: toggle it, so the dialog keeps owning focus
         * trap, inert background and focus return. An inline palette focuses
         * its input instead. Either way `command-hotkey` is dispatched.
         */
        toggleFromHotkey() {
            this.$dispatch('command-hotkey');
            if (typeof this.show === 'function' && typeof this.hide === 'function') {
                if (this.open) this.hide();
                else this.show();

                return;
            }
            this.$el.querySelector('[data-slot="command-input"]')?.focus();
        },

        registerItem(value, search, enabled = true, el = null) {
            // Items rendered inside a `<template x-if>` register again on every
            // re-render; replace the entry so navigation never steps twice.
            const recent = String(value).startsWith(RECENT_PREFIX) && el?.hasAttribute?.('data-recent');
            const entry = { value, search: String(search ?? '').toLowerCase(), enabled, el, recent };
            const index = this.items.findIndex((item) => item.value === value);
            if (index === -1) this.items.push(entry);
            else this.items.splice(index, 1, entry);
            // Alpine runs this when it removes the element (an x-for row whose
            // data went away, an x-if branch that closed).
            if (el && typeof window.Alpine?.onElRemoved === 'function') {
                window.Alpine.onElRemoved(el, () => this.unregisterItem(value, el));
            }
            if (this.active === null && enabled) {
                this.active = value;
            }
        },

        /**
         * Drop an item. With `el`, only when that element still owns the entry,
         * so a re-rendered row that registered first is kept.
         */
        unregisterItem(value, el = null) {
            const index = this.items.findIndex((item) => item.value === value && (!el || item.el === el));
            if (index === -1) return;
            this.items.splice(index, 1);
            if (this.active === value) this.reset();
        },

        /**
         * Backspace in an empty query removes the last removable context chip
         * (<x-ui.command.chip>), as in a token field.
         */
        onBackspace(event) {
            if (this.query !== '' || event.target.selectionStart !== event.target.selectionEnd) return;
            // $el is the element the handler runs on (the input); $root is the palette.
            const chips = Array.from(this.$root.querySelectorAll('[data-slot="command-chip"][data-removable]'))
                .filter((chip) => chip.getClientRects().length > 0);
            const last = chips[chips.length - 1];
            if (!last) return;
            event.preventDefault();
            this.removeChip(last);
        },

        /** Tell the host to drop a chip, and keep focus in the query field. */
        removeChip(chip) {
            if (!chip) return;
            chip.dispatchEvent(new CustomEvent('command-chip-remove', {
                bubbles: true,
                detail: { value: chip.dataset.value ?? null },
            }));
            this.$root.querySelector('[data-slot="command-input"]')?.focus();
        },

        /**
         * Escape clears a typed query first and keeps the palette open; with
         * nothing typed, or with `clearOnEscape` off, it bubbles on (to a
         * wrapping dialog, which closes on that first press).
         */
        onEscape(event) {
            if (this.query === '' || !this.clearOnEscape) return;
            event.stopPropagation();
            this.query = '';
            this.reset();
        },

        /**
         * With `el`, an item in a stale sequenced region never matches, and one
         * in the latest region skips the browser filter (the host filtered).
         */
        matches(search, el = null) {
            // A persistent row (a footer action) shows whatever the query.
            if (el?.hasAttribute?.('data-persistent')) return true;
            if (el && el.closest?.('[data-sequenced]')) return this.isCurrent(el);
            if (!this.filter) return true;
            const q = this.query.trim().toLowerCase();
            if (q === '') return true;
            const text = String(search).toLowerCase();

            return this.matchMode === 'fuzzy' ? fuzzyMatch(text, q) : text.includes(q);
        },

        /** The Recent group shows only while nothing is typed. */
        showRecent() {
            return this.recentEntries.length > 0 && this.query.trim() === '';
        },

        /** Enabled, matching items in visual order: document order, or ranked (see the header). */
        visible() {
            const shown = this.items
                .filter((item) => item.enabled && (!item.el || item.el.isConnected) && (item.recent ? this.showRecent() : this.matches(item.search, item.el)));
            if (!this.ranking()) {
                return shown.sort((a, b) => (!a.el || !b.el ? 0 : documentOrder(a.el, b.el)));
            }
            return this.sortRanked(shown);
        },

        visibleValues() {
            return this.visible().map((item) => item.value);
        },

        /** True with a matching row; a persistent footer action does not count. */
        get hasResults() {
            return this.visible().some((item) => !item.el?.hasAttribute?.('data-persistent'));
        },

        /** Reset active to the first visible item after the query changes. */
        reset() {
            const values = this.visibleValues();
            this.active = values[0] ?? null;
        },

        move(direction) {
            this.movedSinceQuery = true;
            const values = this.visibleValues();
            if (values.length === 0) {
                this.active = null;
                return;
            }
            const current = values.indexOf(this.active);
            const next = current === -1
                ? (direction > 0 ? 0 : values.length - 1)
                : (current + direction + values.length) % values.length;
            this.active = values[next];
            this.scrollActiveIntoView();
        },

        moveTo(index) {
            this.movedSinceQuery = true;
            const values = this.visibleValues();
            if (values.length === 0) {
                return;
            }
            this.active = index < 0 ? values[values.length - 1] : values[index];
            this.scrollActiveIntoView();
        },

        scrollActiveIntoView() {
            this.$nextTick(() => {
                this.$refs.list
                    ?.querySelector(`[data-slot="command-item"][data-value="${CSS.escape(this.active)}"]`)
                    ?.scrollIntoView({ block: 'nearest' });
            });
        },

        /**
         * Enter activates the active option. With nothing active the key is
         * left alone, so a focused link the host rendered inside the list (a
         * slot-driven row that never registered) keeps its native Enter.
         */
        choose(event) {
            if (this.active == null) {
                return;
            }
            event?.preventDefault();
            if (!this.visibleValues().includes(this.active)) {
                this.reset();

                return;
            }
            this.$refs.list
                ?.querySelector(`[data-slot="command-item"][data-value="${CSS.escape(this.active)}"]`)
                ?.click();
        },

        /** Remember a chosen item for the Recent group. */
        remember(value) {
            if (!this.recentKey || String(value).startsWith(RECENT_PREFIX)) return;
            const el = this.$refs.list?.querySelector(`[data-slot="command-item"][data-value="${CSS.escape(value)}"]:not([data-recent])`);
            let label = el?.dataset.recentLabel ?? '';
            if (!label && el) {
                const copy = el.cloneNode(true);
                copy.querySelectorAll('[data-slot="command-shortcut"], [aria-hidden="true"]').forEach((node) => node.remove());
                label = copy.textContent.replace(/\s+/g, ' ').trim();
            }
            const entry = { value: String(value), label: label || String(value), href: safeHref(el?.getAttribute('href')) };
            this.recentEntries = [entry, ...this.recentEntries.filter((item) => item.value !== entry.value)].slice(0, this.recentMax);
            writeRecent(this.recentKey, this.recentEntries);
        },

        /** Forget every recent item. */
        clearRecent() {
            this.recentEntries = [];
            if (this.recentKey) writeRecent(this.recentKey, []);
        },

        /** A recent row activates the original item, else its remembered link or value. */
        chooseRecent(entry) {
            const original = this.$refs.list?.querySelector(`[data-slot="command-item"][data-value="${CSS.escape(entry.value)}"]:not([data-recent])`);
            if (original && original.getAttribute('aria-disabled') !== 'true' && !original.disabled) {
                original.click();

                return;
            }
            this.select(entry.value);
            const href = safeHref(entry.href);
            if (href) window.location.assign(href);
        },

        select(value) {
            this.active = value;
            this.remember(value);
            this.$dispatch('command-select', { value, key: value });
            if (this.resetOnSelect && this.query !== '') {
                this.query = '';
                this.reset();
                // Once the Recent rows have rendered, the newest one leads.
                if (this.recentKey) this.$nextTick(() => this.reset());
            }
        },

        /**
         * Stable DOM id for the active option, so the combobox input can point
         * `aria-activedescendant` at it. Returns null when nothing is active.
         */
        activeId() {
            return this.active == null
                ? null
                : `${this.$id('command-list')}-item-${this.encodeId(this.active)}`;
        },

        /** Turn an arbitrary item value into an id-safe suffix. */
        encodeId(value) {
            return String(value).replace(/[^a-zA-Z0-9_-]/g, '-');
        },

        /**
         * True when a group region still has at least one matching item. Lets a
         * <x-ui.command.group> hide its heading/separator once every child is
         * filtered out. Reads `query` so it stays reactive to typing.
         */
        groupMatches(searches) {
            void this.query;
            return (searches || []).some((search) => this.matches(search));
        },

        /**
         * True when a group with matching items precedes `el` in the list, so a
         * group's top divider only shows between two visible groups (never as a
         * stale line under the input once the first group filtered out).
         */
        hasVisibleGroupBefore(el) {
            void this.query;
            const shows = (node) => {
                if (node.dataset.slot !== 'command-group') return false;
                if (node.hasAttribute('data-sequenced') && !this.isCurrent(node)) return false;
                const keys = node.dataset.keys ? JSON.parse(node.dataset.keys) : null;

                return keys === null || this.groupMatches(keys);
            };
            // Ranked: "before" is the visual order (group order, then document order).
            const mine = this.groupOrder(el);
            if (mine !== null) {
                for (const node of el.parentElement.children) {
                    if (node === el || node.hasAttribute('data-recent') || !shows(node)) continue;
                    const order = this.groupOrder(node) ?? 0;
                    if (order < mine || (order === mine && documentOrder(node, el) < 0)) return true;
                }

                return false;
            }
            for (let node = el.previousElementSibling; node; node = node.previousElementSibling) {
                if (shows(node)) return true;
            }
            return false;
        },
    }));
});
resources/js/ui/command-rank.js JS
/**
 * Command palette ranking (the `rank` prop; see command.js for the contract).
 *
 * rankTier(text, query) puts a matching item in a tier, best first:
 *   0  the whole query as a phrase on word boundaries ("sign in" in "Sign in")
 *   1  every query word as a whole word, in any order
 *   2  every query word as a word prefix ("inb" in "Inbox")
 *   3  the query, or every word, as a substring ("in" in "Pinned")
 *   4  a scattered fuzzy match ("csk" in "Create skill")
 * Unmatched rows get RANK_UNMATCHED. The palette shows items by tier within
 * their group and groups by their best tier, with CSS `order` on the flex list
 * and groups; ties keep document order. rankMethods are spread into the
 * uiCommand Alpine data, so `this` is the palette.
 */
export function fuzzyMatch(text, query) {
    return query.split(/\s+/).filter(Boolean).every((word) => {
        let from = 0;
        for (const char of word) {
            from = text.indexOf(char, from);
            if (from === -1) return false;
            from += 1;
        }

        return true;
    });
}

// Ranking (see the header). Unmatched rows rank after every tier; persistent
// rows and the actions footer keep their place at the end.
const RANK_UNMATCHED = 9;
const PERSISTENT_ORDER = 9000;
const ACTIONS_ORDER = 9999;
const WORD_START = '(?:^|[^\\p{L}\\p{N}])';
const WORD_END = '(?=$|[^\\p{L}\\p{N}])';
const tierCache = new Map();

function escapeRegExp(value) {
    return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** The ranking tier of `text` for `query`: 0 (a phrase match) to 4 (fuzzy), else RANK_UNMATCHED. */
export function rankTier(text, query) {
    const q = String(query ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
    if (q === '') return 0;
    const t = String(text ?? '').toLowerCase().replace(/\s+/g, ' ');
    const cacheKey = `${q}\u0000${t}`;
    if (tierCache.has(cacheKey)) return tierCache.get(cacheKey);
    const words = q.split(' ');
    const whole = (word) => new RegExp(WORD_START + escapeRegExp(word) + WORD_END, 'u').test(t);
    const prefix = (word) => new RegExp(WORD_START + escapeRegExp(word), 'u').test(t);
    let tier = RANK_UNMATCHED;
    if (whole(q)) tier = 0;
    else if (words.every(whole)) tier = 1;
    else if (words.every(prefix)) tier = 2;
    else if (t.includes(q) || words.every((word) => t.includes(word))) tier = 3;
    else if (fuzzyMatch(t, q)) tier = 4;
    if (tierCache.size > 4000) tierCache.clear();
    tierCache.set(cacheKey, tier);

    return tier;
}

export function documentOrder(a, b) {
    if (a === b) return 0;

    return a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
}

export const rankMethods = {
    /** Sorts shown items into the visual order the CSS `order` values draw. */
    sortRanked(shown) {
        const groupOrders = new Map();
        const keys = new Map(shown.map((item) => [item, this.visualKey(item, groupOrders)]));

        return shown.sort((a, b) => {
            if (!a.el || !b.el || a.el === b.el) return 0;
            const ka = keys.get(a);
            const kb = keys.get(b);
            if (ka.outer !== kb.outer) return ka.outer - kb.outer;
            if (ka.outerEl !== kb.outerEl) return documentOrder(ka.outerEl, kb.outerEl);
            if (ka.inner !== kb.inner) return ka.inner - kb.inner;

            return documentOrder(a.el, b.el);
        });
    },

    /** True while ranking applies: `rank` is on and a query is typed. */
    ranking() {
        return Boolean(this.rank) && this.query.trim() !== '';
    },

    /**
     * The CSS order of an item inside its list, group or actions footer;
     * null when it is not ranked (no query, a sequenced or Recent row, or
     * an item wrapped in some other element whose order cannot follow).
     */
    itemOrder(el, search) {
        if (!this.ranking() || !el) return null;
        const parent = el.parentElement;
        const slot = parent?.dataset?.slot;
        // The actions footer is not a flex container, so its rows keep document order.
        if (slot !== 'command-list' && slot !== 'command-group') return null;
        if (el.closest('[data-sequenced]') || parent.hasAttribute('data-recent')) return null;
        if (el.hasAttribute('data-persistent')) return PERSISTENT_ORDER;

        return rankTier(search, this.query);
    },

    /** The CSS order of a group in the list: its best matching item's tier. */
    groupOrder(group) {
        if (!this.ranking() || !group || group.parentElement?.dataset?.slot !== 'command-list') return null;
        if (group.hasAttribute('data-sequenced') || group.hasAttribute('data-recent')) return null;
        let best = PERSISTENT_ORDER;
        for (const item of this.items) {
            if (!item.el || item.recent || !item.el.isConnected || !group.contains(item.el)) continue;
            if (item.el.hasAttribute('data-persistent') || !this.matches(item.search, item.el)) continue;
            best = Math.min(best, rankTier(item.search, this.query));
        }

        return best;
    },

    /** Sort key that mirrors the CSS order: the list-level child first, then the row inside it. */
    visualKey(item, groupOrders) {
        const el = item.el;
        const parent = el?.parentElement;
        const own = this.itemOrder(el, item.search);
        if (own !== null && parent.dataset.slot === 'command-group') {
            if (!groupOrders.has(parent)) groupOrders.set(parent, this.groupOrder(parent) ?? 0);

            return { outer: groupOrders.get(parent), outerEl: parent, inner: own };
        }
        if (own !== null) return { outer: own, outerEl: el, inner: 0 };
        // Not ranked: it moves with the list-level element that holds it.
        let top = el;
        while (top?.parentElement && top.parentElement.dataset.slot !== 'command-list') top = top.parentElement;
        const holder = top?.parentElement ? top : el;
        const outer = holder.dataset?.slot === 'command-actions'
            ? ACTIONS_ORDER
            : (holder.dataset?.slot === 'command-group' ? (this.groupOrder(holder) ?? 0) : 0);

        return { outer, outerEl: holder, inner: 0 };
    },

    /** x-bind:style for an item: its rank order while ranking, else nothing. */
    rankStyle(el, search) {
        const order = this.itemOrder(el, search);

        return order === null ? {} : { order: String(order) };
    },

    /** x-bind:style for a group: its best item's order while ranking, else nothing. */
    groupRankStyle(el) {
        const order = this.groupOrder(el);

        return order === null ? {} : { order: String(order) };
    },
};

Ownership & lifecycle

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