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>
210 lines
5.6 KiB
TypeScript
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);
|
|
}
|
|
}
|