Skip to main content

Overlays

Command Palette code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/command-palette.blade.php

@props([
    'items' => [],
    'shortcut' => 'k',
    'placeholder' => 'Type a command or search...',
])

@php
    /*
    | Command palette (⌘K / Ctrl+K). A centered search overlay that filters a
    | flat or grouped list of commands, navigates with the arrow keys and runs
    | the highlighted item on Enter. Open/close it with the keyboard shortcut,
    | or from anywhere by dispatching a window event:
    |   $dispatch('open-command-palette')  /  $dispatch('close-command-palette')
    |
    | Pass `items` as an array of command shapes:
    |   [
    |     'label'    => 'New post',          // required
    |     'icon'     => 'plus',              // optional Phosphor icon
    |     'href'     => '/posts/create',     // optional link (navigates on select)
    |     'shortcut' => ['⌘', 'N'],          // optional kbd hint (string or array)
    |     'group'    => 'Actions',           // optional section heading
    |     'keywords' => 'create write',      // optional extra search terms
    |   ]
    |
    | Usage:
    |   <x-ui.command-palette :items="$commands" />
    |   <x-ui.button @click="$dispatch('open-command-palette')">Search</x-ui.button>
    */
    $commands = collect($items)->map(fn ($item, $i) => [
        'id' => $i,
        'label' => (string) ($item['label'] ?? ''),
        'icon' => $item['icon'] ?? null,
        'href' => $item['href'] ?? null,
        'group' => $item['group'] ?? null,
        'shortcut' => isset($item['shortcut']) ? (array) $item['shortcut'] : null,
        'keywords' => strtolower(trim(($item['label'] ?? '').' '.($item['keywords'] ?? '').' '.($item['group'] ?? ''))),
    ])->values()->all();
@endphp

<div
    x-data="{
        open: false,
        search: '',
        highlighted: 0,
        commands: @js($commands),
        shortcut: @js(strtolower($shortcut)),
        get filtered() {
            const q = this.search.toLowerCase().trim();
            if (q === '') { return this.commands; }
            return this.commands.filter(c => c.keywords.includes(q));
        },
        get grouped() {
            const groups = {};
            this.filtered.forEach((c) => {
                const key = c.group ?? '';
                (groups[key] = groups[key] ?? []).push(c);
            });
            return groups;
        },
        openPalette() {
            this.open = true;
            this.search = '';
            this.highlighted = 0;
            this.$nextTick(() => this.$refs.search.focus());
        },
        close() {
            this.open = false;
        },
        move(dir) {
            const count = this.filtered.length;
            if (count === 0) { return; }
            this.highlighted = (this.highlighted + dir + count) % count;
            this.$nextTick(() => {
                this.$refs.list?.querySelector('[data-active=\'true\']')?.scrollIntoView({ block: 'nearest' });
            });
        },
        select() {
            const item = this.filtered[this.highlighted];
            if (! item) { return; }
            this.run(item);
        },
        run(item) {
            this.close();
            if (item.href) {
                window.location.href = item.href;
            } else {
                this.$dispatch('command-selected', { id: item.id, label: item.label });
            }
        },
        indexOf(item) {
            return this.filtered.findIndex(c => c.id === item.id);
        },
    }"
    x-on:keydown.window="(($event.metaKey || $event.ctrlKey) && $event.key.toLowerCase() === shortcut) ? ($event.preventDefault(), openPalette()) : null"
    x-on:open-command-palette.window="openPalette()"
    x-on:close-command-palette.window="close()"
    x-on:keydown.escape.window="close()"
>
    <div
        x-show="open"
        x-cloak
        class="fixed inset-0 z-50 overflow-y-auto px-4 py-[12vh] sm:px-6"
        role="dialog"
        aria-modal="true"
        aria-label="Command palette"
    >
        {{-- Backdrop --}}
        <div
            x-show="open"
            x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0" x-transition:enter-end="opacity-100"
            x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0"
            @click="close()"
            class="fixed inset-0 bg-gray-950/60 backdrop-blur-sm"
        ></div>

        {{-- Panel --}}
        <div
            x-show="open"
            x-transition:enter="ease-out duration-200" x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
            x-transition:leave="ease-in duration-150" x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
            class="relative mx-auto max-w-xl overflow-hidden rounded-2xl bg-white dark:bg-gray-900 shadow-2xl ring-1 ring-black/5 dark:ring-white/10"
        >
            {{-- Search field --}}
            <div class="flex items-center gap-3 border-b border-gray-200 dark:border-gray-800 px-4">
                <x-ui.icon name="magnifying-glass" weight="bold" size="lg" class="text-gray-400 dark:text-gray-500" />
                <input
                    type="text"
                    x-ref="search"
                    x-model="search"
                    @input="highlighted = 0"
                    @keydown.arrow-down.prevent="move(1)"
                    @keydown.arrow-up.prevent="move(-1)"
                    @keydown.enter.prevent="select()"
                    placeholder="{{ $placeholder }}"
                    class="w-full border-0 bg-transparent py-4 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:ring-0 focus:outline-none"
                    autocomplete="off"
                >
                <x-ui.kbd>Esc</x-ui.kbd>
            </div>

            {{-- Results --}}
            <div x-ref="list" class="max-h-80 overflow-y-auto p-2">
                <template x-for="(items, group) in grouped" :key="group">
                    <div class="mb-1">
                        <p
                            x-show="group !== ''"
                            x-text="group"
                            class="px-2 pb-1 pt-2 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500"
                        ></p>

                        <template x-for="item in items" :key="item.id">
                            <button
                                type="button"
                                :data-active="indexOf(item) === highlighted"
                                @click="run(item)"
                                @mouseenter="highlighted = indexOf(item)"
                                :class="indexOf(item) === highlighted ? 'bg-brand-50 dark:bg-brand-950/50 text-brand-700 dark:text-brand-300' : 'text-gray-700 dark:text-gray-200'"
                                class="flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm transition cursor-pointer"
                            >
                                <template x-if="item.icon">
                                    <i :class="`ph-bold ph-${item.icon} text-base leading-none shrink-0`" aria-hidden="true"></i>
                                </template>
                                <span x-text="item.label" class="flex-1 truncate"></span>
                                <template x-if="item.shortcut">
                                    <span class="inline-flex items-center gap-1">
                                        <template x-for="(k, i) in item.shortcut" :key="i">
                                            <kbd class="inline-flex items-center justify-center min-w-[1.5rem] rounded-md border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-1.5 py-0.5 text-xs font-semibold text-gray-600 dark:text-gray-300 shadow-sm" x-text="k"></kbd>
                                        </template>
                                    </span>
                                </template>
                            </button>
                        </template>
                    </div>
                </template>

                <p x-show="filtered.length === 0" class="px-3 py-8 text-center text-sm text-gray-400 dark:text-gray-500">
                    No results found.
                </p>
            </div>
        </div>
    </div>
</div>