signature: add systems map, command palette and motion primitives
Ship the Signature UX as standalone, SSR-safe pieces: bilingual copy, a dual SVG/list systems map, a Map-backed command palette in the shell, opt-in reveal and metric primitives, and a leak-free decorative canvas. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
272
src/app/shared/command-palette/command-palette.ts
Normal file
272
src/app/shared/command-palette/command-palette.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
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<ElementRef<HTMLButtonElement>>('trigger');
|
||||
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('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<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()),
|
||||
);
|
||||
|
||||
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<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);
|
||||
}
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openPalette();
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user