Files
Portfolio/src/app/shared/command-palette/command-palette.ts
Antonio Ledebuhr 46c86447d1 integration: add route metadata, JSON-LD and harden the public shell
Ship prerendered SEO, crawl files and palette/map a11y so every locale route is indexable, honest and keyboard-usable without inventing claims.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 18:28:22 +02:00

210 lines
5.6 KiB
TypeScript

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<ElementRef<HTMLInputElement>>('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<readonly { id: number; text: string }[]>([]);
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<HTMLElement>(
'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);
}
}