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>