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]);
}
}
}