111 lines
3.4 KiB
Svelte
111 lines
3.4 KiB
Svelte
<script lang="ts">
|
|
export interface Command {
|
|
id: string;
|
|
label: string;
|
|
description?: string;
|
|
keywords?: string[];
|
|
action: () => void;
|
|
}
|
|
|
|
interface Props {
|
|
commands: Command[];
|
|
open: boolean;
|
|
onclose: () => void;
|
|
}
|
|
|
|
let { commands, open, onclose }: Props = $props();
|
|
|
|
let query = $state('');
|
|
let selectedIdx = $state(0);
|
|
let inputEl = $state<HTMLInputElement | null>(null);
|
|
|
|
const filtered = $derived(
|
|
query.trim()
|
|
? commands.filter(c =>
|
|
c.label.toLowerCase().includes(query.toLowerCase()) ||
|
|
c.keywords?.some(k => k.toLowerCase().includes(query.toLowerCase()))
|
|
)
|
|
: commands
|
|
);
|
|
|
|
$effect(() => {
|
|
if (open) {
|
|
query = '';
|
|
selectedIdx = 0;
|
|
setTimeout(() => inputEl?.focus(), 50);
|
|
}
|
|
});
|
|
|
|
// Reset selection when filter changes
|
|
$effect(() => { filtered; selectedIdx = 0; });
|
|
|
|
function execute(cmd: Command) {
|
|
onclose();
|
|
cmd.action();
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') { onclose(); return; }
|
|
if (e.key === 'ArrowDown') { e.preventDefault(); selectedIdx = Math.min(selectedIdx + 1, filtered.length - 1); }
|
|
if (e.key === 'ArrowUp') { e.preventDefault(); selectedIdx = Math.max(selectedIdx - 1, 0); }
|
|
if (e.key === 'Enter' && filtered[selectedIdx]) { e.preventDefault(); execute(filtered[selectedIdx]); }
|
|
}
|
|
</script>
|
|
|
|
{#if open}
|
|
<!-- Backdrop -->
|
|
<div
|
|
class="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm"
|
|
onclick={onclose}
|
|
></div>
|
|
|
|
<!-- Palette -->
|
|
<div class="fixed top-[20%] left-1/2 -translate-x-1/2 z-50 w-full max-w-md px-4 pointer-events-none">
|
|
<div class="bg-zinc-900 border border-zinc-700 rounded-2xl shadow-2xl overflow-hidden pointer-events-auto">
|
|
<!-- Input -->
|
|
<div class="flex items-center gap-2.5 px-4 py-3 border-b border-zinc-800">
|
|
<svg class="w-4 h-4 text-zinc-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
<input
|
|
bind:this={inputEl}
|
|
bind:value={query}
|
|
onkeydown={handleKeydown}
|
|
placeholder="Type a command…"
|
|
class="flex-1 bg-transparent text-zinc-100 placeholder-zinc-500 focus:outline-none text-sm"
|
|
/>
|
|
<kbd class="text-xs text-zinc-600 border border-zinc-700 rounded px-1.5 py-0.5">esc</kbd>
|
|
</div>
|
|
|
|
<!-- Commands -->
|
|
<ul class="py-1 max-h-72 overflow-y-auto">
|
|
{#each filtered as cmd, i (cmd.id)}
|
|
<li>
|
|
<button
|
|
onclick={() => execute(cmd)}
|
|
onmouseenter={() => selectedIdx = i}
|
|
class="w-full flex items-center gap-3 px-4 py-2.5 text-left transition-colors {i === selectedIdx ? 'bg-zinc-800' : 'hover:bg-zinc-800/50'}"
|
|
>
|
|
<div class="flex-1 min-w-0">
|
|
<p class="text-sm text-zinc-100">{cmd.label}</p>
|
|
{#if cmd.description}
|
|
<p class="text-xs text-zinc-500 mt-0.5 truncate">{cmd.description}</p>
|
|
{/if}
|
|
</div>
|
|
</button>
|
|
</li>
|
|
{/each}
|
|
|
|
{#if filtered.length === 0}
|
|
<li class="px-4 py-4 text-sm text-zinc-500 text-center">No commands found</li>
|
|
{/if}
|
|
</ul>
|
|
|
|
<!-- Footer hint -->
|
|
<div class="px-4 py-2 border-t border-zinc-800 flex gap-4 text-xs text-zinc-600">
|
|
<span><kbd class="border border-zinc-700 rounded px-1">↑↓</kbd> navigate</span>
|
|
<span><kbd class="border border-zinc-700 rounded px-1">↵</kbd> select</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|