import { DOCUMENT } from '@angular/common'; import { afterNextRender, ChangeDetectionStrategy, Component, computed, DestroyRef, ElementRef, inject, Injector, signal, viewChild, } from '@angular/core'; import { Router } from '@angular/router'; import { SIGNATURE_COPY } from '../../core/content/signature-copy'; import { SITE_CONFIG } from '../../core/content/site-config'; import { LocaleService } from '../../core/i18n/locale.service'; import { NavigationService } from '../../core/navigation/navigation.service'; import { isBrowserPlatform } from '../../core/platform/browser'; import { COMMAND_IDS, COMMANDS, parseCommand, suggestCommands, type CommandDefinition, } from './commands'; let commandPaletteInstanceId = 0; let commandPaletteOutputId = 0; @Component({ selector: 'app-command-palette', changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './command-palette.html', styleUrl: './command-palette.scss', }) export class CommandPalette { private readonly document = inject(DOCUMENT); private readonly injector = inject(Injector); private readonly destroyRef = inject(DestroyRef); private readonly router = inject(Router); private readonly navigation = inject(NavigationService); private readonly localeService = inject(LocaleService); private readonly isBrowser = isBrowserPlatform(); private readonly instanceId = commandPaletteInstanceId++; protected readonly triggerRef = viewChild>('trigger'); private readonly inputRef = viewChild>('commandInput'); protected readonly dialogId = `command-palette-dialog-${this.instanceId}`; protected readonly titleId = `command-palette-title-${this.instanceId}`; protected readonly descriptionId = `command-palette-description-${this.instanceId}`; protected readonly inputId = `command-palette-input-${this.instanceId}`; protected readonly suggestionsId = `command-palette-suggestions-${this.instanceId}`; protected readonly open = signal(false); protected readonly query = signal(''); protected readonly output = signal([]); protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette); protected readonly suggestions = computed(() => suggestCommands(this.query(), this.localeService.locale()), ); private opener: HTMLElement | null = null; constructor() { afterNextRender( () => { if (!this.isBrowser) { return; } this.document.addEventListener('keydown', this.onDocumentKeydown); }, { injector: this.injector }, ); this.destroyRef.onDestroy(() => { this.document.removeEventListener('keydown', this.onDocumentKeydown); }); } protected onTriggerClick(): void { this.triggerRef()?.nativeElement.focus(); this.openPalette(); } protected onQueryInput(event: Event): void { const target = event.target; if (!(target instanceof HTMLInputElement)) { return; } this.query.set(target.value); } protected onSubmit(event: Event): void { event.preventDefault(); this.runRaw(this.query()); } protected onDialogKeydown(event: KeyboardEvent, dialog: HTMLElement): void { if (event.key === 'Escape') { event.preventDefault(); this.closePalette(); return; } if (event.key !== 'Tab') { return; } const focusable = this.focusableElements(dialog); if (focusable.length === 0) { return; } const first = focusable[0]; const last = focusable[focusable.length - 1]; const active = this.document.activeElement; if (event.shiftKey && active === first) { event.preventDefault(); last.focus(); return; } if (!event.shiftKey && active === last) { event.preventDefault(); first.focus(); } } protected runDefinition(definition: CommandDefinition): void { const copy = this.copy(); switch (definition.action.kind) { case 'navigate': { void this.router.navigate(this.navigation.link(definition.action.routeId)); this.append(copy.navigating.replace('{target}', definition.input)); this.closePalette(); return; } case 'openCv': { if (this.isBrowser) { this.document.defaultView?.open(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer'); } this.append(copy.cvOpened); return; } case 'message': { this.appendMessage(definition.action.messageKey); return; } case 'clear': { this.output.set([]); this.append(copy.clearedMessage); return; } case 'close': { this.closePalette(); return; } } } private runRaw(raw: string): void { const match = parseCommand(raw); if (match.kind === 'empty') { return; } if (match.kind === 'unknown') { this.append(this.copy().unknownCommand.replace('{command}', match.input)); return; } this.runDefinition(match.definition); } private appendMessage(key: 'help' | 'brew' | 'ignite' | 'rev'): void { const copy = this.copy(); if (key === 'help') { const lines = [ copy.helpIntro, ...COMMAND_IDS.map((id) => { const command = COMMANDS.find((entry) => entry.id === id); const input = command?.input ?? id; return `${input} — ${copy.commandDescriptions[id]}`; }), ]; this.append(lines.join('\n')); return; } this.append(copy.responses[key]); } private append(text: string): void { this.output.update((lines) => [...lines, { id: commandPaletteOutputId++, text }]); } private openPalette(): void { if (this.open()) { this.focusInput(); return; } const active = this.document.activeElement; this.opener = active instanceof HTMLElement ? active : null; this.open.set(true); this.focusInput(); } protected closePalette(): void { if (!this.open()) { return; } this.open.set(false); this.restoreFocus(); } private focusInput(): void { afterNextRender( () => { this.inputRef()?.nativeElement.focus(); }, { injector: this.injector }, ); } private restoreFocus(): void { const opener = this.opener; this.opener = null; afterNextRender( () => { if (opener instanceof HTMLElement && opener.isConnected) { opener.focus(); return; } this.triggerRef()?.nativeElement.focus(); }, { injector: this.injector }, ); } private focusableElements(container: HTMLElement): HTMLElement[] { return Array.from( container.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ).filter((element) => !element.hasAttribute('disabled') && element.tabIndex >= 0); } private readonly onDocumentKeydown = (event: KeyboardEvent): void => { if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) { return; } event.preventDefault(); this.openPalette(); }; }