import { DOCUMENT } from '@angular/common'; import { afterNextRender, ChangeDetectionStrategy, Component, computed, effect, 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 { CommandPaletteService } from './command-palette.service'; import { COMMAND_IDS, COMMANDS, parseCommand, suggestCommands, type CommandDefinition, } from './commands'; 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 router = inject(Router); private readonly navigation = inject(NavigationService); private readonly localeService = inject(LocaleService); private readonly palette = inject(CommandPaletteService); private readonly isBrowser = isBrowserPlatform(); private readonly inputRef = viewChild>('commandInput'); protected readonly dialogId = this.palette.dialogId; protected readonly titleId = this.palette.titleId; protected readonly descriptionId = this.palette.descriptionId; protected readonly inputId = this.palette.inputId; protected readonly suggestionsId = this.palette.suggestionsId; protected readonly open = this.palette.open; 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()), ); constructor() { effect(() => { if (!this.open()) { return; } afterNextRender( () => { this.inputRef()?.nativeElement.focus(); }, { injector: this.injector }, ); }); } 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; } } } protected closePalette(): void { this.palette.close(); } 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 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); } }