import { Component, ElementRef, EventEmitter, HostListener, Input, OnChanges, Output, SimpleChanges, ViewChild, } from '@angular/core'; export interface CommandItem { label: string; meta?: string; keywords?: string; /** Optional leading icon as an SVG path `d` string. */ icon?: string; onRun?: () => void; } export interface CommandGroup { label: string; items: CommandItem[]; } type FlatCmd = CommandItem & { group: string; idx: number }; const haystack = (it: CommandItem): string => [it.label, it.keywords ?? '', it.meta ?? ''].join(' ').toLowerCase(); /** * A ⌘K-style command palette. Renders in place — `.ui-overlay` is * `position: fixed`, so as long as the component lives inside `.vsp-root` it * covers the viewport and inherits the theme tokens (no portal needed). */ @Component({ selector: 'vsp-command-palette', template: `@if (open) { }`, }) export class VspCommandPalette implements OnChanges { @Input() open = false; @Input() groups: CommandGroup[] = []; @Output() close = new EventEmitter(); @ViewChild('input') input?: ElementRef; q = ''; active = 0; ngOnChanges(changes: SimpleChanges): void { if (changes['open'] && this.open) { this.q = ''; this.active = 0; setTimeout(() => this.input?.nativeElement.focus(), 30); } } get flat(): FlatCmd[] { const query = this.q.toLowerCase(); const out: FlatCmd[] = []; for (const g of this.groups) { for (const it of g.items) { if (!query || haystack(it).includes(query)) { out.push({ ...it, group: g.label, idx: out.length }); } } } return out; } get groupOrder(): string[] { const order: string[] = []; const seen = new Set(); for (const it of this.flat) { if (!seen.has(it.group)) { seen.add(it.group); order.push(it.group); } } return order; } itemsOf(g: string): FlatCmd[] { return this.flat.filter((it) => it.group === g); } onInput(e: Event): void { this.q = (e.target as HTMLInputElement).value; this.active = 0; } run(it: FlatCmd | undefined): void { it?.onRun?.(); this.close.emit(); } @HostListener('document:keydown.escape') onEsc(): void { if (this.open) this.close.emit(); } onBackdrop(e: MouseEvent): void { if (e.target === e.currentTarget) this.close.emit(); } onKey(e: KeyboardEvent): void { const flat = this.flat; if (e.key === 'ArrowDown') { e.preventDefault(); this.active = Math.min(flat.length - 1, this.active + 1); } else if (e.key === 'ArrowUp') { e.preventDefault(); this.active = Math.max(0, this.active - 1); } else if (e.key === 'Enter') { e.preventDefault(); this.run(flat[this.active]); } } }