Replace the modal command palette with a nonmodal terminal dock.
Ctrl+K now opens a corner session with a parsed navigate grammar, persistent history, and no page lock. Unknown targets stay on a closed alias table and never become URLs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { pagePath } from './helpers';
|
||||
|
||||
test.describe('keyboard and palette', () => {
|
||||
test.describe('keyboard and terminal dock', () => {
|
||||
test('skip link is first and moves focus to main', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.keyboard.press('Tab');
|
||||
@@ -87,37 +87,32 @@ test.describe('keyboard and palette', () => {
|
||||
const headerBox = await page.locator('.site-header').boundingBox();
|
||||
expect(headerBox, 'header while submenu is open').toBeTruthy();
|
||||
expect(headerBox!.height, 'header height while submenu is open').toBeLessThanOrEqual(200);
|
||||
|
||||
const background = await firstChild.evaluate((element) => {
|
||||
const submenu = element.closest('.submenu');
|
||||
return submenu ? getComputedStyle(submenu).backgroundColor : '';
|
||||
});
|
||||
expect(background, 'submenu background must be fully opaque').toMatch(
|
||||
/^rgb\(\d+,\s*\d+,\s*\d+\)$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('palette opens with Control+K, traps focus, locks scroll and restores on Escape', async ({
|
||||
test('terminal dock opens with Control+K without locking the page and restores on Escape', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
const trigger = page.locator('.command-palette-trigger');
|
||||
const trigger = page.locator('.terminal-dock-trigger');
|
||||
await trigger.focus();
|
||||
await page.keyboard.press('Control+k');
|
||||
|
||||
const dialog = page.locator('[role="dialog"]');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(page.locator('.site')).toHaveAttribute('inert', '');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');
|
||||
|
||||
const focusable = dialog.locator(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])',
|
||||
);
|
||||
const count = await focusable.count();
|
||||
const first = focusable.first();
|
||||
const last = focusable.nth(count - 1);
|
||||
await last.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(first).toBeFocused();
|
||||
await page.keyboard.press('Shift+Tab');
|
||||
await expect(last).toBeFocused();
|
||||
const panel = page.locator('.terminal-dock-panel');
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.locator('.site')).not.toHaveAttribute('inert');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden');
|
||||
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.locator('.site')).not.toHaveAttribute('inert');
|
||||
expect(await page.evaluate(() => document.body.style.overflow)).toBe('');
|
||||
await expect(panel).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
});
|
||||
|
||||
67
e2e/terminal.e2e.ts
Normal file
67
e2e/terminal.e2e.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { SIGNATURE_COPY } from '../src/app/core/content/signature-copy';
|
||||
|
||||
test.describe('terminal dock', () => {
|
||||
test('walks collapsed, expanded and maximized and runs the grammar', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const copy = SIGNATURE_COPY.de.terminal;
|
||||
await page.goto('/');
|
||||
|
||||
const trigger = page.locator('.terminal-dock-trigger');
|
||||
const panel = page.locator('.terminal-dock-panel');
|
||||
const input = page.locator('#terminal-dock-input');
|
||||
const log = page.locator('.terminal-dock-log');
|
||||
const maximize = page.locator('.terminal-dock-control[aria-pressed]');
|
||||
|
||||
await expect(trigger).toBeVisible();
|
||||
await expect(panel).toHaveCount(0);
|
||||
|
||||
await trigger.click();
|
||||
await expect(panel).toBeVisible();
|
||||
await expect(page.locator('.terminal-dock-prompt')).toContainText(copy.prompt);
|
||||
|
||||
if (testInfo.project.name === 'mobile') {
|
||||
const box = await panel.boundingBox();
|
||||
expect(box, 'bottom-sheet panel').toBeTruthy();
|
||||
expect(box!.width).toBeGreaterThan(300);
|
||||
} else {
|
||||
await maximize.click();
|
||||
await expect(maximize).toHaveAttribute('aria-pressed', 'true');
|
||||
await maximize.click();
|
||||
await expect(maximize).toHaveAttribute('aria-pressed', 'false');
|
||||
}
|
||||
|
||||
await input.fill('navigate pitch');
|
||||
await input.press('Enter');
|
||||
await page.waitForURL('**/pitch');
|
||||
await expect(page).toHaveURL(/\/pitch$/);
|
||||
await expect(log).toContainText(`${copy.prompt} navigate pitch`);
|
||||
|
||||
await input.press('ArrowUp');
|
||||
await expect(input).toHaveValue('navigate pitch');
|
||||
|
||||
await input.fill('history');
|
||||
await input.press('Enter');
|
||||
await expect(log).toContainText(copy.historyIntro);
|
||||
await expect(log).toContainText('navigate pitch');
|
||||
|
||||
await input.fill('clear');
|
||||
await input.press('Enter');
|
||||
await expect(log).toHaveText(copy.clearedMessage);
|
||||
|
||||
await input.fill('navigate p');
|
||||
await input.press('Tab');
|
||||
await expect(log).toContainText('pitch');
|
||||
await expect(log).toContainText('projects');
|
||||
|
||||
await input.fill('navigate nowhere');
|
||||
await input.press('Enter');
|
||||
await expect(log).toContainText(copy.validTargetsLabel);
|
||||
await expect(log).toContainText('pitch');
|
||||
|
||||
await input.fill('nav');
|
||||
await input.press('Tab');
|
||||
await expect(input).toHaveValue('navigate');
|
||||
});
|
||||
});
|
||||
@@ -1,24 +1,10 @@
|
||||
export type CommandId =
|
||||
| 'help'
|
||||
| 'projects'
|
||||
| 'servicesAi'
|
||||
| 'cv'
|
||||
| 'contact'
|
||||
| 'brew'
|
||||
| 'ignite'
|
||||
| 'rev'
|
||||
| 'clear'
|
||||
| 'close';
|
||||
export type CommandId = 'help' | 'history' | 'clear' | 'brew' | 'rev' | 'navigate';
|
||||
|
||||
export const COMMAND_IDS: readonly CommandId[] = [
|
||||
'help',
|
||||
'projects',
|
||||
'servicesAi',
|
||||
'cv',
|
||||
'contact',
|
||||
'brew',
|
||||
'ignite',
|
||||
'rev',
|
||||
'history',
|
||||
'clear',
|
||||
'close',
|
||||
'brew',
|
||||
'rev',
|
||||
'navigate',
|
||||
];
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('SIGNATURE_COPY', () => {
|
||||
|
||||
it('describes every CommandId in both locales', () => {
|
||||
for (const locale of APP_LOCALES) {
|
||||
const descriptions = SIGNATURE_COPY[locale].palette.commandDescriptions;
|
||||
const descriptions = SIGNATURE_COPY[locale].terminal.commandDescriptions;
|
||||
|
||||
expect(Object.keys(descriptions).sort()).toEqual([...COMMAND_IDS].sort());
|
||||
|
||||
|
||||
@@ -2,27 +2,34 @@ import { type CommandId } from '../commands/command-ids';
|
||||
import { type AppLocale } from '../i18n/locale';
|
||||
|
||||
export interface SignatureCopy {
|
||||
readonly palette: {
|
||||
readonly terminal: {
|
||||
readonly triggerLabel: string;
|
||||
readonly shortcutHint: string;
|
||||
readonly shortcutHintApple: string;
|
||||
readonly dialogTitle: string;
|
||||
readonly dialogDescription: string;
|
||||
readonly prompt: string;
|
||||
readonly panelLabel: string;
|
||||
readonly inputLabel: string;
|
||||
readonly inputPlaceholder: string;
|
||||
readonly closeLabel: string;
|
||||
readonly suggestionsLabel: string;
|
||||
readonly collapseLabel: string;
|
||||
readonly expandLabel: string;
|
||||
readonly restoreLabel: string;
|
||||
readonly maximizeLabel: string;
|
||||
readonly outputLabel: string;
|
||||
readonly emptySuggestions: string;
|
||||
readonly unknownCommand: string;
|
||||
readonly unknownTarget: string;
|
||||
readonly validTargetsLabel: string;
|
||||
readonly helpIntro: string;
|
||||
readonly helpGrammar: string;
|
||||
readonly historyEmpty: string;
|
||||
readonly historyIntro: string;
|
||||
readonly clearedMessage: string;
|
||||
readonly cvOpened: string;
|
||||
readonly navigating: string;
|
||||
readonly incompleteHint: string;
|
||||
readonly commandDescriptions: Record<CommandId, string>;
|
||||
readonly responses: {
|
||||
readonly brew: string;
|
||||
readonly ignite: string;
|
||||
readonly rev: string;
|
||||
};
|
||||
};
|
||||
@@ -45,39 +52,41 @@ export interface SignatureCopy {
|
||||
|
||||
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||
de: {
|
||||
palette: {
|
||||
triggerLabel: 'Befehle öffnen',
|
||||
terminal: {
|
||||
triggerLabel: 'Terminal',
|
||||
shortcutHint: 'Strg+K',
|
||||
shortcutHintApple: '⌘K',
|
||||
dialogTitle: 'Befehle',
|
||||
dialogDescription:
|
||||
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
|
||||
prompt: 'visitor@antoniolede:~$',
|
||||
panelLabel: 'Terminal',
|
||||
inputLabel: 'Befehl',
|
||||
inputPlaceholder: 'Befehl eingeben',
|
||||
closeLabel: 'Schließen',
|
||||
suggestionsLabel: 'Vorschläge',
|
||||
collapseLabel: 'Terminal einklappen',
|
||||
expandLabel: 'Terminal öffnen',
|
||||
restoreLabel: 'Terminal verkleinern',
|
||||
maximizeLabel: 'Terminal vergrößern',
|
||||
outputLabel: 'Ausgabe',
|
||||
emptySuggestions: 'Keine passenden Befehle.',
|
||||
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||
unknownTarget: 'Unbekanntes Ziel: {target}',
|
||||
validTargetsLabel: 'Gültige Ziele:',
|
||||
helpIntro: 'Verfügbare Befehle:',
|
||||
helpGrammar: 'Grammatik: help | history | clear | brew | rev | navigate <ziel> [<unterziel>]',
|
||||
historyEmpty: 'In dieser Sitzung wurde noch kein Befehl eingegeben.',
|
||||
historyIntro: 'Eingegebene Befehle:',
|
||||
clearedMessage: 'Ausgabe geleert.',
|
||||
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
||||
navigating: 'Wechsel zu {target}.',
|
||||
incompleteHint: 'Unvollständiger Befehl. Mögliche Ziele:',
|
||||
commandDescriptions: {
|
||||
help: 'Listet die verfügbaren Befehle.',
|
||||
projects: 'Öffnet die Projektübersicht.',
|
||||
servicesAi: 'Öffnet die Seite zur KI-Integration.',
|
||||
cv: 'Öffnet den Lebenslauf als PDF.',
|
||||
contact: 'Öffnet die Kontaktseite.',
|
||||
help: 'Listet die Grammatik und jeden Befehl.',
|
||||
history: 'Listet die in dieser Sitzung eingegebenen Befehle.',
|
||||
clear: 'Leert das Ausgabebuch.',
|
||||
brew: 'Eine kurze, spielerische Rückmeldung.',
|
||||
ignite: 'Eine kurze, spielerische Rückmeldung.',
|
||||
rev: 'Eine kurze, spielerische Rückmeldung.',
|
||||
clear: 'Leert die Ausgabe.',
|
||||
close: 'Schließt die Befehlsübersicht.',
|
||||
navigate: 'Wechselt zu einer bekannten Seite oder öffnet den Lebenslauf.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
|
||||
ignite: 'Zündung frei. Die Systeme laufen warm.',
|
||||
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
|
||||
},
|
||||
},
|
||||
@@ -99,38 +108,41 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||
},
|
||||
},
|
||||
en: {
|
||||
palette: {
|
||||
triggerLabel: 'Open commands',
|
||||
terminal: {
|
||||
triggerLabel: 'Terminal',
|
||||
shortcutHint: 'Ctrl+K',
|
||||
shortcutHintApple: '⌘K',
|
||||
dialogTitle: 'Commands',
|
||||
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
|
||||
prompt: 'visitor@antoniolede:~$',
|
||||
panelLabel: 'Terminal',
|
||||
inputLabel: 'Command',
|
||||
inputPlaceholder: 'Type a command',
|
||||
closeLabel: 'Close',
|
||||
suggestionsLabel: 'Suggestions',
|
||||
collapseLabel: 'Collapse the terminal',
|
||||
expandLabel: 'Open the terminal',
|
||||
restoreLabel: 'Restore the terminal',
|
||||
maximizeLabel: 'Maximise the terminal',
|
||||
outputLabel: 'Output',
|
||||
emptySuggestions: 'No matching commands.',
|
||||
unknownCommand: 'Unknown command: {command}',
|
||||
unknownTarget: 'Unknown target: {target}',
|
||||
validTargetsLabel: 'Valid targets:',
|
||||
helpIntro: 'Available commands:',
|
||||
helpGrammar: 'Grammar: help | history | clear | brew | rev | navigate <target> [<sub>]',
|
||||
historyEmpty: 'No commands have been entered in this session.',
|
||||
historyIntro: 'Entered commands:',
|
||||
clearedMessage: 'Output cleared.',
|
||||
cvOpened: 'Opened the CV in a new tab.',
|
||||
navigating: 'Going to {target}.',
|
||||
incompleteHint: 'Incomplete command. Possible targets:',
|
||||
commandDescriptions: {
|
||||
help: 'Lists the available commands.',
|
||||
projects: 'Opens the projects overview.',
|
||||
servicesAi: 'Opens the AI integration page.',
|
||||
cv: 'Opens the CV as a PDF.',
|
||||
contact: 'Opens the contact page.',
|
||||
help: 'Lists the grammar and every command.',
|
||||
history: 'Lists the commands entered in this session.',
|
||||
clear: 'Clears the output log.',
|
||||
brew: 'A short playful acknowledgement.',
|
||||
ignite: 'A short playful acknowledgement.',
|
||||
rev: 'A short playful acknowledgement.',
|
||||
clear: 'Clears the output.',
|
||||
close: 'Closes the command palette.',
|
||||
navigate: 'Goes to a known page or opens the curriculum vitae.',
|
||||
},
|
||||
responses: {
|
||||
brew: 'Freshly brewed. Automation that runs before the first coffee.',
|
||||
ignite: 'Ignition on. Systems are warming up.',
|
||||
rev: 'Revs climbing, the content stays calm.',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ export function viewportMatches(query: string): boolean {
|
||||
|
||||
/**
|
||||
* Keyboard-labelling exception: there is no CSS media query for the Command key.
|
||||
* Used only to swap the palette shortcut hint after hydration.
|
||||
* Used only to swap the terminal shortcut hint after hydration.
|
||||
*/
|
||||
export function isApplePlatform(): boolean {
|
||||
if (!isBrowserPlatform()) {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="command-palette-trigger"
|
||||
aria-haspopup="dialog"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-controls]="open() ? dialogId : null"
|
||||
[attr.aria-label]="copy().triggerLabel"
|
||||
(click)="onTriggerClick()"
|
||||
>
|
||||
<span>{{ copy().triggerLabel }}</span>
|
||||
<kbd>{{ shortcutHint() }}</kbd>
|
||||
</button>
|
||||
@@ -1,32 +0,0 @@
|
||||
:host {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.command-palette-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-trigger kbd {
|
||||
padding: 0.1rem var(--space-2);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.command-palette-trigger:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import {
|
||||
afterNextRender,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
Injector,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { SIGNATURE_COPY } from '../../../core/content/signature-copy';
|
||||
import { LocaleService } from '../../../core/i18n/locale.service';
|
||||
import { isApplePlatform, isBrowserPlatform } from '../../../core/platform/browser';
|
||||
import { CommandPaletteService } from '../command-palette.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-command-palette-trigger',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './command-palette-trigger.html',
|
||||
styleUrl: './command-palette-trigger.scss',
|
||||
})
|
||||
export class CommandPaletteTrigger {
|
||||
private readonly palette = inject(CommandPaletteService);
|
||||
private readonly localeService = inject(LocaleService);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly applePlatform = isApplePlatform();
|
||||
|
||||
protected readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||
protected readonly useAppleHint = signal(false);
|
||||
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].palette);
|
||||
protected readonly shortcutHint = computed(() =>
|
||||
this.useAppleHint() ? this.copy().shortcutHintApple : this.copy().shortcutHint,
|
||||
);
|
||||
protected readonly open = this.palette.open;
|
||||
protected readonly dialogId = this.palette.dialogId;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (this.isBrowser) {
|
||||
this.useAppleHint.set(this.applePlatform);
|
||||
}
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
protected onTriggerClick(): void {
|
||||
const trigger = this.triggerRef()?.nativeElement;
|
||||
trigger?.focus();
|
||||
this.palette.openPalette(trigger ?? null);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
@if (open()) {
|
||||
<div class="command-palette-overlay">
|
||||
<div class="command-palette-scrim" aria-hidden="true"></div>
|
||||
<div
|
||||
#dialog
|
||||
class="command-palette-dialog glass-surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[id]="dialogId"
|
||||
[attr.aria-labelledby]="titleId"
|
||||
[attr.aria-describedby]="descriptionId"
|
||||
(keydown)="onDialogKeydown($event, dialog)"
|
||||
>
|
||||
<div class="command-palette-header">
|
||||
<div>
|
||||
<h2 [id]="titleId" class="command-palette-title">{{ copy().dialogTitle }}</h2>
|
||||
<p [id]="descriptionId" class="command-palette-description">
|
||||
{{ copy().dialogDescription }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="command-palette-close" (click)="closePalette()">
|
||||
{{ copy().closeLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="command-palette-form" (submit)="onSubmit($event)">
|
||||
<label [attr.for]="inputId">{{ copy().inputLabel }}</label>
|
||||
<input
|
||||
#commandInput
|
||||
[id]="inputId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
[attr.placeholder]="copy().inputPlaceholder"
|
||||
[value]="query()"
|
||||
(input)="onQueryInput($event)"
|
||||
/>
|
||||
|
||||
<p class="command-palette-kicker" [id]="suggestionsId">{{ copy().suggestionsLabel }}</p>
|
||||
@if (suggestions().length === 0) {
|
||||
<p class="command-palette-empty">{{ copy().emptySuggestions }}</p>
|
||||
} @else {
|
||||
<ul class="command-palette-suggestions" [attr.aria-labelledby]="suggestionsId">
|
||||
@for (command of suggestions(); track command.id) {
|
||||
<li>
|
||||
<button type="button" (click)="runDefinition(command)">
|
||||
<span>{{ command.input }}</span>
|
||||
<span>{{ copy().commandDescriptions[command.id] }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</form>
|
||||
|
||||
<div
|
||||
class="command-palette-output"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
[attr.aria-label]="copy().outputLabel"
|
||||
>
|
||||
<ul>
|
||||
@for (line of output(); track line.id) {
|
||||
<li>{{ line.text }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.command-palette-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.command-palette-scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-surface);
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.command-palette-dialog {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
width: min(100%, 40rem);
|
||||
max-height: min(36rem, 90vh);
|
||||
overflow: auto;
|
||||
padding: var(--space-5);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.command-palette-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.command-palette-title,
|
||||
.command-palette-description,
|
||||
.command-palette-kicker,
|
||||
.command-palette-empty,
|
||||
.command-palette-output ul,
|
||||
.command-palette-suggestions {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.command-palette-title {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--leading-tight);
|
||||
}
|
||||
|
||||
.command-palette-description,
|
||||
.command-palette-empty,
|
||||
.command-palette-output {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.command-palette-close {
|
||||
min-height: 2.75rem;
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-form {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.command-palette-form input {
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.command-palette-kicker {
|
||||
font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.command-palette-suggestions,
|
||||
.command-palette-output ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.command-palette-suggestions {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.command-palette-suggestions button {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.command-palette-suggestions button span:first-child {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.command-palette-suggestions button span:last-child,
|
||||
.command-palette-output {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.command-palette-output {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.command-palette-close:hover,
|
||||
.command-palette-suggestions button:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.command-palette-overlay,
|
||||
.command-palette-dialog {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
Injector,
|
||||
PLATFORM_ID,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CommandPaletteService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
|
||||
readonly open = signal(false);
|
||||
readonly dialogId = 'command-palette-dialog';
|
||||
readonly titleId = 'command-palette-title';
|
||||
readonly descriptionId = 'command-palette-description';
|
||||
readonly inputId = 'command-palette-input';
|
||||
readonly suggestionsId = 'command-palette-suggestions';
|
||||
|
||||
private opener: HTMLElement | null = null;
|
||||
private previousOverflow = '';
|
||||
private scrollLocked = false;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
this.destroyRef.onDestroy(() => this.teardown());
|
||||
}
|
||||
|
||||
openPalette(opener?: HTMLElement | null): void {
|
||||
if (this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const active = opener ?? this.document.activeElement;
|
||||
this.opener = active instanceof HTMLElement ? active : null;
|
||||
this.lockScroll();
|
||||
this.open.set(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.open.set(false);
|
||||
this.unlockScroll();
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
private restoreFocus(): void {
|
||||
const opener = this.opener;
|
||||
this.opener = null;
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (opener instanceof HTMLElement && opener.isConnected) {
|
||||
opener.focus();
|
||||
}
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private lockScroll(): void {
|
||||
if (!this.isBrowser || this.scrollLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.previousOverflow = this.document.body.style.overflow;
|
||||
this.document.body.style.overflow = 'hidden';
|
||||
this.scrollLocked = true;
|
||||
}
|
||||
|
||||
private unlockScroll(): void {
|
||||
if (!this.isBrowser || !this.scrollLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.body.style.overflow = this.previousOverflow;
|
||||
this.previousOverflow = '';
|
||||
this.scrollLocked = false;
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
this.unlockScroll();
|
||||
this.open.set(false);
|
||||
this.opener = null;
|
||||
}
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.openPalette();
|
||||
};
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ApplicationRef, Component, inject, PLATFORM_ID } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { CommandPalette } from './command-palette';
|
||||
import { CommandPaletteTrigger } from './command-palette-trigger/command-palette-trigger';
|
||||
import { CommandPaletteService } from './command-palette.service';
|
||||
import { COMMAND_IDS, COMMANDS } from './commands';
|
||||
|
||||
@Component({
|
||||
selector: 'app-palette-host',
|
||||
imports: [CommandPaletteTrigger, CommandPalette],
|
||||
template: `
|
||||
<div class="site" [attr.inert]="palette.open() ? '' : null">
|
||||
<app-command-palette-trigger></app-command-palette-trigger>
|
||||
</div>
|
||||
<app-command-palette></app-command-palette>
|
||||
`,
|
||||
})
|
||||
class PaletteHost {
|
||||
readonly palette = inject(CommandPaletteService);
|
||||
}
|
||||
|
||||
describe('CommandPalette', () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function createFixture(
|
||||
locale: AppLocale = 'de',
|
||||
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
||||
): Promise<ComponentFixture<PaletteHost>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PaletteHost],
|
||||
providers: [provideRouter([]), ...extraProviders],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(LocaleService).setLocale(locale);
|
||||
const fixture = TestBed.createComponent(PaletteHost);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function flush(fixture: ComponentFixture<PaletteHost>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function trigger(fixture: ComponentFixture<PaletteHost>): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.command-palette-trigger');
|
||||
}
|
||||
|
||||
function dialog(fixture: ComponentFixture<PaletteHost>): HTMLElement | null {
|
||||
return fixture.nativeElement.querySelector('[role="dialog"]');
|
||||
}
|
||||
|
||||
function site(fixture: ComponentFixture<PaletteHost>): HTMLElement {
|
||||
return fixture.nativeElement.querySelector('.site');
|
||||
}
|
||||
|
||||
async function openViaTrigger(fixture: ComponentFixture<PaletteHost>): Promise<void> {
|
||||
trigger(fixture).click();
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
async function submitQuery(fixture: ComponentFixture<PaletteHost>, value: string): Promise<void> {
|
||||
const input = fixture.nativeElement.querySelector('input');
|
||||
expect(input).toBeTruthy();
|
||||
input.value = value;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
fixture.nativeElement
|
||||
.querySelector('form')
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
function outputText(fixture: ComponentFixture<PaletteHost>): string {
|
||||
return fixture.nativeElement.querySelector('.command-palette-output')?.textContent ?? '';
|
||||
}
|
||||
|
||||
function focusable(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.tabIndex >= 0);
|
||||
}
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'renders the trigger and not the dialog on the server (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(trigger(fixture)).toBeTruthy();
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const input = fixture.nativeElement.querySelector('input');
|
||||
expect(dialog(fixture)).toBeTruthy();
|
||||
expect(document.activeElement).toBe(input);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'opens from Ctrl+K and Meta+K and prevents the default (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await flush(fixture);
|
||||
|
||||
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
|
||||
const openDialog = dialog(fixture);
|
||||
if (openDialog) {
|
||||
openDialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifier,
|
||||
});
|
||||
const prevent = vi.spyOn(event, 'preventDefault');
|
||||
document.dispatchEvent(event);
|
||||
await flush(fixture);
|
||||
|
||||
expect(prevent).toHaveBeenCalled();
|
||||
expect(dialog(fixture)).toBeTruthy();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const openDialog = dialog(fixture);
|
||||
expect(openDialog).toBeTruthy();
|
||||
|
||||
openDialog?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await flush(fixture);
|
||||
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger(fixture));
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('closes from the close button and restores focus (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const closeButton = fixture.nativeElement.querySelector('.command-palette-close');
|
||||
closeButton.click();
|
||||
await flush(fixture);
|
||||
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger(fixture));
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('wraps Tab and Shift+Tab inside the dialog (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const openDialog = dialog(fixture);
|
||||
expect(openDialog).toBeTruthy();
|
||||
|
||||
const items = focusable(openDialog as HTMLElement);
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
|
||||
last.focus();
|
||||
openDialog?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
first.focus();
|
||||
openDialog?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }),
|
||||
);
|
||||
expect(document.activeElement).toBe(last);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'marks .site inert while open and removes it on close (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(false);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(true);
|
||||
|
||||
fixture.nativeElement.querySelector('.command-palette-close').click();
|
||||
await flush(fixture);
|
||||
expect(site(fixture).hasAttribute('inert')).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'locks body overflow while open and restores it on close (%s)',
|
||||
async (locale) => {
|
||||
document.body.style.overflow = 'auto';
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
fixture.nativeElement.querySelector('.command-palette-close').click();
|
||||
await flush(fixture);
|
||||
expect(document.body.style.overflow).toBe('auto');
|
||||
document.body.style.overflow = '';
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('writes localized help, playful replies and clear (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const copy = SIGNATURE_COPY[locale].palette;
|
||||
|
||||
await submitQuery(fixture, 'help');
|
||||
const helpText = outputText(fixture);
|
||||
expect(helpText).toContain(copy.helpIntro);
|
||||
for (const command of COMMANDS) {
|
||||
expect(helpText).toContain(command.input);
|
||||
expect(helpText).toContain(copy.commandDescriptions[command.id]);
|
||||
}
|
||||
expect(COMMAND_IDS.every((id) => helpText.includes(copy.commandDescriptions[id]))).toBe(true);
|
||||
|
||||
await submitQuery(fixture, 'brew');
|
||||
expect(outputText(fixture)).toContain(copy.responses.brew);
|
||||
await submitQuery(fixture, 'ignite');
|
||||
expect(outputText(fixture)).toContain(copy.responses.ignite);
|
||||
await submitQuery(fixture, 'rev');
|
||||
expect(outputText(fixture)).toContain(copy.responses.rev);
|
||||
|
||||
await submitQuery(fixture, 'clear');
|
||||
expect(outputText(fixture).trim()).toBe(copy.clearedMessage);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'navigates projects, services ai and contact through NavigationService (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigation = TestBed.inject(NavigationService);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'projects');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('projects'));
|
||||
expect(dialog(fixture)).toBeNull();
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'services ai');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('servicesAi'));
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'contact');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('contact'));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('opens the CV in a new tab and never navigates (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'cv');
|
||||
|
||||
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(outputText(fixture)).toContain(SIGNATURE_COPY[locale].palette.cvOpened);
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'reports unknown input without navigating or opening a window (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||
const raw = 'rm -rf /';
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, raw);
|
||||
|
||||
expect(outputText(fixture)).toContain(
|
||||
SIGNATURE_COPY[locale].palette.unknownCommand.replace('{command}', raw),
|
||||
);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('removes the document keydown listener on destroy', async () => {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
const fixture = await createFixture();
|
||||
await flush(fixture);
|
||||
|
||||
const added = add.mock.calls.find((call) => call[0] === 'keydown');
|
||||
expect(added).toBeTruthy();
|
||||
|
||||
const remove = vi.spyOn(document, 'removeEventListener');
|
||||
fixture.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
|
||||
});
|
||||
|
||||
it('registers no document listener on the server', async () => {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the trigger aria-label stable and shows the Ctrl hint before hydration', async () => {
|
||||
const fixture = await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
const button = trigger(fixture);
|
||||
expect(button.getAttribute('aria-label')).toBe(SIGNATURE_COPY.de.palette.triggerLabel);
|
||||
expect(button.querySelector('kbd')?.textContent).toBe(SIGNATURE_COPY.de.palette.shortcutHint);
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import { APP_LOCALES } from '../../core/i18n/locale';
|
||||
import {
|
||||
COMMAND_IDS,
|
||||
COMMANDS,
|
||||
normalizeCommandInput,
|
||||
parseCommand,
|
||||
suggestCommands,
|
||||
type CommandId,
|
||||
} from './commands';
|
||||
|
||||
describe('command parsing', () => {
|
||||
it('collapses whitespace and lowercases input', () => {
|
||||
expect(normalizeCommandInput(' Services AI ')).toBe('services ai');
|
||||
expect(normalizeCommandInput('HELP')).toBe('help');
|
||||
});
|
||||
|
||||
it('resolves every CommandId from its canonical input and aliases in both locales', () => {
|
||||
for (const command of COMMANDS) {
|
||||
const canonical = parseCommand(command.input);
|
||||
expect(canonical).toEqual({ kind: 'command', definition: command });
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const alias of command.aliases[locale]) {
|
||||
expect(parseCommand(alias)).toEqual({ kind: 'command', definition: command });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(COMMANDS.map((command) => command.id)).toEqual([...COMMAND_IDS]);
|
||||
});
|
||||
|
||||
it('treats hostile and prototype inputs as unknown without side effects', () => {
|
||||
const prototypeNames = Object.getOwnPropertyNames(Object.prototype);
|
||||
const hostile = [
|
||||
'rm -rf /',
|
||||
'eval(1+1)',
|
||||
'new Function()',
|
||||
'<script>alert(1)</script>',
|
||||
'__proto__',
|
||||
'constructor',
|
||||
'toString',
|
||||
'hasOwnProperty',
|
||||
];
|
||||
|
||||
for (const input of hostile) {
|
||||
expect(parseCommand(input)).toEqual({ kind: 'unknown', input });
|
||||
}
|
||||
|
||||
expect(parseCommand('')).toEqual({ kind: 'empty' });
|
||||
expect(parseCommand(' ')).toEqual({ kind: 'empty' });
|
||||
expect(Object.getOwnPropertyNames(Object.prototype)).toEqual(prototypeNames);
|
||||
expect(Object.prototype).not.toHaveProperty('polluted');
|
||||
});
|
||||
|
||||
it('returns an equal result for the same input across repeated calls', () => {
|
||||
const samples = ['help', 'services ai', ' ', 'unknown-token', '__proto__'];
|
||||
|
||||
for (const sample of samples) {
|
||||
expect(parseCommand(sample)).toEqual(parseCommand(sample));
|
||||
}
|
||||
});
|
||||
|
||||
it('suggests commands in a stable COMMAND_IDS order and filters by prefix', () => {
|
||||
const emptyDe = suggestCommands('', 'de').map((command) => command.id);
|
||||
const emptyEn = suggestCommands('', 'en').map((command) => command.id);
|
||||
const expectedIds: CommandId[] = [...COMMAND_IDS];
|
||||
|
||||
expect(emptyDe).toEqual(expectedIds);
|
||||
expect(emptyEn).toEqual(expectedIds);
|
||||
expect(suggestCommands('c', 'en').map((command) => command.id)).toEqual([
|
||||
'cv',
|
||||
'contact',
|
||||
'clear',
|
||||
'close',
|
||||
]);
|
||||
expect(suggestCommands('lei', 'de').map((command) => command.id)).toEqual(['servicesAi']);
|
||||
expect(suggestCommands('c', 'de')).toEqual(suggestCommands('c', 'de'));
|
||||
});
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
import { type CommandId } from '../../core/commands/command-ids';
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { type RouteId } from '../../core/routing/route-ids';
|
||||
|
||||
export { COMMAND_IDS, type CommandId } from '../../core/commands/command-ids';
|
||||
|
||||
export type CommandAction =
|
||||
| { readonly kind: 'navigate'; readonly routeId: RouteId }
|
||||
| { readonly kind: 'openCv' }
|
||||
| { readonly kind: 'message'; readonly messageKey: 'help' | 'brew' | 'ignite' | 'rev' }
|
||||
| { readonly kind: 'clear' }
|
||||
| { readonly kind: 'close' };
|
||||
|
||||
export interface CommandDefinition {
|
||||
readonly id: CommandId;
|
||||
readonly input: string;
|
||||
readonly aliases: Record<AppLocale, readonly string[]>;
|
||||
readonly action: CommandAction;
|
||||
}
|
||||
|
||||
export type CommandMatch =
|
||||
| { readonly kind: 'command'; readonly definition: CommandDefinition }
|
||||
| { readonly kind: 'empty' }
|
||||
| { readonly kind: 'unknown'; readonly input: string };
|
||||
|
||||
export const COMMANDS: readonly CommandDefinition[] = [
|
||||
{
|
||||
id: 'help',
|
||||
input: 'help',
|
||||
aliases: { de: ['hilfe'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'help' },
|
||||
},
|
||||
{
|
||||
id: 'projects',
|
||||
input: 'projects',
|
||||
aliases: { de: ['projekte'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'projects' },
|
||||
},
|
||||
{
|
||||
id: 'servicesAi',
|
||||
input: 'services ai',
|
||||
aliases: { de: ['leistungen ai'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'servicesAi' },
|
||||
},
|
||||
{
|
||||
id: 'cv',
|
||||
input: 'cv',
|
||||
aliases: { de: ['lebenslauf'], en: ['resume'] },
|
||||
action: { kind: 'openCv' },
|
||||
},
|
||||
{
|
||||
id: 'contact',
|
||||
input: 'contact',
|
||||
aliases: { de: ['kontakt'], en: [] },
|
||||
action: { kind: 'navigate', routeId: 'contact' },
|
||||
},
|
||||
{
|
||||
id: 'brew',
|
||||
input: 'brew',
|
||||
aliases: { de: ['brauen'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'brew' },
|
||||
},
|
||||
{
|
||||
id: 'ignite',
|
||||
input: 'ignite',
|
||||
aliases: { de: ['zuenden', 'zünden'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'ignite' },
|
||||
},
|
||||
{
|
||||
id: 'rev',
|
||||
input: 'rev',
|
||||
aliases: { de: ['drehzahl'], en: [] },
|
||||
action: { kind: 'message', messageKey: 'rev' },
|
||||
},
|
||||
{
|
||||
id: 'clear',
|
||||
input: 'clear',
|
||||
aliases: { de: ['leeren'], en: [] },
|
||||
action: { kind: 'clear' },
|
||||
},
|
||||
{
|
||||
id: 'close',
|
||||
input: 'close',
|
||||
aliases: { de: ['schliessen', 'schließen'], en: [] },
|
||||
action: { kind: 'close' },
|
||||
},
|
||||
];
|
||||
|
||||
const COMMAND_LOOKUP = buildCommandLookup(COMMANDS);
|
||||
|
||||
function buildCommandLookup(
|
||||
commands: readonly CommandDefinition[],
|
||||
): ReadonlyMap<string, CommandDefinition> {
|
||||
const lookup = new Map<string, CommandDefinition>();
|
||||
|
||||
for (const command of commands) {
|
||||
lookup.set(normalizeCommandInput(command.input), command);
|
||||
|
||||
for (const locale of APP_LOCALES) {
|
||||
for (const alias of command.aliases[locale]) {
|
||||
lookup.set(normalizeCommandInput(alias), command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
export function normalizeCommandInput(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
export function parseCommand(raw: string): CommandMatch {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
const definition = COMMAND_LOOKUP.get(normalized);
|
||||
|
||||
if (!definition) {
|
||||
return { kind: 'unknown', input: raw };
|
||||
}
|
||||
|
||||
return { kind: 'command', definition };
|
||||
}
|
||||
|
||||
export function suggestCommands(raw: string, locale: AppLocale): readonly CommandDefinition[] {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
|
||||
return COMMANDS.filter((command) => {
|
||||
if (normalized.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (command.input.startsWith(normalized)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return command.aliases[locale].some((alias) =>
|
||||
normalizeCommandInput(alias).startsWith(normalized),
|
||||
);
|
||||
});
|
||||
}
|
||||
91
src/app/shared/terminal/terminal-commands.spec.ts
Normal file
91
src/app/shared/terminal/terminal-commands.spec.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
applyCompletion,
|
||||
isUnsafeNavigateTarget,
|
||||
navigateTargetKeys,
|
||||
parseCommand,
|
||||
suggestCompletions,
|
||||
} from './terminal-commands';
|
||||
|
||||
describe('terminal command grammar', () => {
|
||||
it('parses help, history, clear, brew and rev', () => {
|
||||
expect(parseCommand('help')).toEqual({ kind: 'help' });
|
||||
expect(parseCommand('history')).toEqual({ kind: 'history' });
|
||||
expect(parseCommand('clear')).toEqual({ kind: 'clear' });
|
||||
expect(parseCommand('brew')).toEqual({ kind: 'brew' });
|
||||
expect(parseCommand('rev')).toEqual({ kind: 'rev' });
|
||||
expect(parseCommand(' Hilfe ')).toEqual({ kind: 'help' });
|
||||
expect(parseCommand('')).toEqual({ kind: 'empty' });
|
||||
});
|
||||
|
||||
it('resolves navigate targets including the services ai sub-target', () => {
|
||||
expect(parseCommand('navigate home')).toEqual({
|
||||
kind: 'navigate',
|
||||
resolution: { kind: 'route', routeId: 'home' },
|
||||
targetKey: 'home',
|
||||
});
|
||||
expect(parseCommand('navigate pitch')).toEqual({
|
||||
kind: 'navigate',
|
||||
resolution: { kind: 'route', routeId: 'pitch' },
|
||||
targetKey: 'pitch',
|
||||
});
|
||||
expect(parseCommand('navigate services ai')).toEqual({
|
||||
kind: 'navigate',
|
||||
resolution: { kind: 'route', routeId: 'servicesAi' },
|
||||
targetKey: 'services ai',
|
||||
});
|
||||
expect(parseCommand('navigate leistungen software')).toEqual({
|
||||
kind: 'navigate',
|
||||
resolution: { kind: 'route', routeId: 'servicesSoftware' },
|
||||
targetKey: 'services software',
|
||||
});
|
||||
expect(parseCommand('navigate cv')).toEqual({
|
||||
kind: 'navigate',
|
||||
resolution: { kind: 'cv' },
|
||||
targetKey: 'cv',
|
||||
});
|
||||
});
|
||||
|
||||
it('suggests valid targets for incomplete navigate input', () => {
|
||||
const parsed = parseCommand('navigate');
|
||||
expect(parsed.kind).toBe('incomplete');
|
||||
if (parsed.kind === 'incomplete') {
|
||||
expect(parsed.suggestions).toEqual([...navigateTargetKeys()]);
|
||||
}
|
||||
|
||||
const unknown = parseCommand('navigate nowhere');
|
||||
expect(unknown.kind).toBe('unknown-target');
|
||||
if (unknown.kind === 'unknown-target') {
|
||||
expect(unknown.target).toBe('nowhere');
|
||||
}
|
||||
});
|
||||
|
||||
it('completes the current token against commands and navigate targets', () => {
|
||||
expect(suggestCompletions('he')).toEqual(['help']);
|
||||
expect(applyCompletion('he')).toEqual({ value: 'help', candidates: ['help'] });
|
||||
expect(suggestCompletions('navigate p')).toEqual(['pitch', 'projects', 'projekte']);
|
||||
expect(applyCompletion('navigate pit')).toEqual({
|
||||
value: 'navigate pitch',
|
||||
candidates: ['pitch'],
|
||||
});
|
||||
expect(suggestCompletions('navigate services a')).toEqual(['services ai']);
|
||||
});
|
||||
|
||||
it('rejects unsafe navigate targets without treating them as routes', () => {
|
||||
const hostile = [
|
||||
'navigate https://example.com',
|
||||
'navigate ../secret',
|
||||
'navigate /etc/passwd',
|
||||
'navigate javascript:alert(1)',
|
||||
];
|
||||
|
||||
for (const input of hostile) {
|
||||
const target = input.slice('navigate '.length);
|
||||
expect(isUnsafeNavigateTarget(target), input).toBe(true);
|
||||
expect(parseCommand(input)).toEqual({
|
||||
kind: 'unknown-target',
|
||||
input,
|
||||
target,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
234
src/app/shared/terminal/terminal-commands.ts
Normal file
234
src/app/shared/terminal/terminal-commands.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { COMMAND_IDS, type CommandId } from '../../core/commands/command-ids';
|
||||
import { type RouteId } from '../../core/routing/route-ids';
|
||||
|
||||
export { COMMAND_IDS, type CommandId };
|
||||
|
||||
export type NavigateResolution =
|
||||
{ readonly kind: 'route'; readonly routeId: RouteId } | { readonly kind: 'cv' };
|
||||
|
||||
export interface NavigateTarget {
|
||||
readonly key: string;
|
||||
readonly aliases: readonly string[];
|
||||
readonly resolution: NavigateResolution;
|
||||
}
|
||||
|
||||
export type ParsedCommand =
|
||||
| { readonly kind: 'empty' }
|
||||
| { readonly kind: 'help' }
|
||||
| { readonly kind: 'history' }
|
||||
| { readonly kind: 'clear' }
|
||||
| { readonly kind: 'brew' }
|
||||
| { readonly kind: 'rev' }
|
||||
| {
|
||||
readonly kind: 'navigate';
|
||||
readonly resolution: NavigateResolution;
|
||||
readonly targetKey: string;
|
||||
}
|
||||
| { readonly kind: 'unknown-command'; readonly input: string }
|
||||
| { readonly kind: 'unknown-target'; readonly input: string; readonly target: string }
|
||||
| {
|
||||
readonly kind: 'incomplete';
|
||||
readonly input: string;
|
||||
readonly suggestions: readonly string[];
|
||||
};
|
||||
|
||||
export const NAVIGATE_TARGETS: readonly NavigateTarget[] = [
|
||||
{ key: 'home', aliases: ['start', 'startseite'], resolution: { kind: 'route', routeId: 'home' } },
|
||||
{ key: 'pitch', aliases: [], resolution: { kind: 'route', routeId: 'pitch' } },
|
||||
{ key: 'services', aliases: ['leistungen'], resolution: { kind: 'route', routeId: 'services' } },
|
||||
{
|
||||
key: 'services ai',
|
||||
aliases: ['leistungen ai'],
|
||||
resolution: { kind: 'route', routeId: 'servicesAi' },
|
||||
},
|
||||
{
|
||||
key: 'services software',
|
||||
aliases: ['leistungen software'],
|
||||
resolution: { kind: 'route', routeId: 'servicesSoftware' },
|
||||
},
|
||||
{
|
||||
key: 'services network',
|
||||
aliases: ['leistungen network', 'leistungen netzwerk', 'services hardware'],
|
||||
resolution: { kind: 'route', routeId: 'servicesHardwareNetwork' },
|
||||
},
|
||||
{
|
||||
key: 'services clusters',
|
||||
aliases: ['leistungen clusters', 'leistungen cluster'],
|
||||
resolution: { kind: 'route', routeId: 'servicesClusters' },
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
aliases: ['projekte'],
|
||||
resolution: { kind: 'route', routeId: 'projects' },
|
||||
},
|
||||
{ key: 'stack', aliases: [], resolution: { kind: 'route', routeId: 'stack' } },
|
||||
{
|
||||
key: 'about',
|
||||
aliases: ['ueber-mich', 'über-mich'],
|
||||
resolution: { kind: 'route', routeId: 'about' },
|
||||
},
|
||||
{
|
||||
key: 'contact',
|
||||
aliases: ['kontakt'],
|
||||
resolution: { kind: 'route', routeId: 'contact' },
|
||||
},
|
||||
{ key: 'cv', aliases: ['lebenslauf'], resolution: { kind: 'cv' } },
|
||||
];
|
||||
|
||||
const COMMAND_TOKENS: readonly string[] = [
|
||||
'help',
|
||||
'hilfe',
|
||||
'history',
|
||||
'verlauf',
|
||||
'clear',
|
||||
'leeren',
|
||||
'brew',
|
||||
'brauen',
|
||||
'rev',
|
||||
'drehzahl',
|
||||
'navigate',
|
||||
'gehe',
|
||||
];
|
||||
|
||||
const COMMAND_BY_TOKEN: ReadonlyMap<string, Exclude<CommandId, 'navigate'>> = new Map([
|
||||
['help', 'help'],
|
||||
['hilfe', 'help'],
|
||||
['history', 'history'],
|
||||
['verlauf', 'history'],
|
||||
['clear', 'clear'],
|
||||
['leeren', 'clear'],
|
||||
['brew', 'brew'],
|
||||
['brauen', 'brew'],
|
||||
['rev', 'rev'],
|
||||
['drehzahl', 'rev'],
|
||||
]);
|
||||
|
||||
const UNSAFE_TARGET = /[:/\\]|\.\.|^\.|javascript\s*:|^https?$|^file$|^data$/i;
|
||||
|
||||
export function normalizeCommandInput(raw: string): string {
|
||||
return raw.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
}
|
||||
|
||||
export function navigateTargetKeys(): readonly string[] {
|
||||
return NAVIGATE_TARGETS.map((target) => target.key);
|
||||
}
|
||||
|
||||
export function allNavigateTokens(): readonly string[] {
|
||||
return NAVIGATE_TARGETS.flatMap((target) => [target.key, ...target.aliases]);
|
||||
}
|
||||
|
||||
function resolveNavigateTarget(raw: string): NavigateTarget | undefined {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
return NAVIGATE_TARGETS.find(
|
||||
(target) => target.key === normalized || target.aliases.includes(normalized),
|
||||
);
|
||||
}
|
||||
|
||||
export function isUnsafeNavigateTarget(raw: string): boolean {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
if (normalized.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
UNSAFE_TARGET.test(normalized) ||
|
||||
normalized.split(/\s+/).some((token) => UNSAFE_TARGET.test(token))
|
||||
);
|
||||
}
|
||||
|
||||
export function parseCommand(raw: string): ParsedCommand {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return { kind: 'empty' };
|
||||
}
|
||||
|
||||
const [verb, ...rest] = normalized.split(' ');
|
||||
const remainder = rest.join(' ');
|
||||
|
||||
if (verb === 'navigate' || verb === 'gehe') {
|
||||
if (isUnsafeNavigateTarget(remainder)) {
|
||||
return { kind: 'unknown-target', input: raw, target: remainder };
|
||||
}
|
||||
|
||||
if (remainder.length === 0) {
|
||||
return { kind: 'incomplete', input: raw, suggestions: [...navigateTargetKeys()] };
|
||||
}
|
||||
|
||||
const target = resolveNavigateTarget(remainder);
|
||||
if (!target) {
|
||||
return { kind: 'unknown-target', input: raw, target: remainder };
|
||||
}
|
||||
|
||||
return { kind: 'navigate', resolution: target.resolution, targetKey: target.key };
|
||||
}
|
||||
|
||||
if (rest.length === 0) {
|
||||
const command = COMMAND_BY_TOKEN.get(verb);
|
||||
if (command) {
|
||||
return { kind: command };
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'unknown-command', input: raw };
|
||||
}
|
||||
|
||||
export function suggestCompletions(raw: string): readonly string[] {
|
||||
const trimmedEnd = raw.replace(/\s+$/, '');
|
||||
const normalized = normalizeCommandInput(trimmedEnd);
|
||||
const trailingSpace = raw.length > 0 && /\s$/.test(raw);
|
||||
|
||||
if (normalized.length === 0) {
|
||||
return [...COMMAND_IDS];
|
||||
}
|
||||
|
||||
const tokens = normalized.split(' ');
|
||||
const verb = tokens[0] ?? '';
|
||||
|
||||
if (verb === 'navigate' || verb === 'gehe') {
|
||||
const targetSoFar = tokens.slice(1).join(' ');
|
||||
const prefix = trailingSpace ? `${targetSoFar} `.trimStart() : targetSoFar;
|
||||
const candidates = allNavigateTokens().filter((token) =>
|
||||
prefix.length === 0 ? true : token.startsWith(prefix),
|
||||
);
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
if (tokens.length === 1 && !trailingSpace) {
|
||||
const commandHits = COMMAND_TOKENS.filter((token) => token.startsWith(verb));
|
||||
const unique = new Set<string>();
|
||||
for (const token of commandHits) {
|
||||
if (token === 'navigate' || token === 'gehe') {
|
||||
unique.add('navigate');
|
||||
} else {
|
||||
const id = COMMAND_BY_TOKEN.get(token);
|
||||
if (id) {
|
||||
unique.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function applyCompletion(raw: string): {
|
||||
readonly value: string;
|
||||
readonly candidates: readonly string[];
|
||||
} {
|
||||
const candidates = suggestCompletions(raw);
|
||||
if (candidates.length === 1) {
|
||||
const normalized = normalizeCommandInput(raw);
|
||||
const tokens = normalized.split(' ').filter((token) => token.length > 0);
|
||||
const verb = tokens[0] ?? '';
|
||||
|
||||
if (verb === 'navigate' || verb === 'gehe') {
|
||||
return { value: `navigate ${candidates[0]}`, candidates };
|
||||
}
|
||||
|
||||
return { value: candidates[0], candidates };
|
||||
}
|
||||
|
||||
return { value: raw, candidates };
|
||||
}
|
||||
71
src/app/shared/terminal/terminal-dock.html
Normal file
71
src/app/shared/terminal/terminal-dock.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<div class="terminal-dock" [class.is-open]="open()" [class.is-maximized]="maximized()">
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="terminal-dock-trigger"
|
||||
[attr.aria-expanded]="open()"
|
||||
[attr.aria-controls]="open() ? panelId : null"
|
||||
[attr.aria-label]="copy().triggerLabel"
|
||||
(click)="onTriggerClick()"
|
||||
>
|
||||
<span>{{ copy().triggerLabel }}</span>
|
||||
<kbd>{{ shortcutHint() }}</kbd>
|
||||
</button>
|
||||
|
||||
@if (open()) {
|
||||
<section class="terminal-dock-panel" [id]="panelId" [attr.aria-label]="copy().panelLabel">
|
||||
<div class="terminal-dock-toolbar cluster">
|
||||
<p class="terminal-dock-title">{{ copy().panelLabel }}</p>
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-dock-control"
|
||||
[attr.aria-label]="maximizeLabel()"
|
||||
[attr.aria-pressed]="maximized()"
|
||||
(click)="toggleMaximized()"
|
||||
>
|
||||
{{ maximizeLabel() }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="terminal-dock-control"
|
||||
[attr.aria-label]="copy().collapseLabel"
|
||||
(click)="collapse()"
|
||||
>
|
||||
{{ copy().collapseLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="terminal-dock-log"
|
||||
[id]="logId"
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
[attr.aria-label]="copy().outputLabel"
|
||||
>
|
||||
@for (line of log(); track line.id) {
|
||||
<p [class.echo]="line.kind === 'echo'">{{ line.text }}</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
<form class="terminal-dock-form" (submit)="onSubmit($event)">
|
||||
<label class="terminal-dock-prompt" [attr.for]="inputId">
|
||||
<span aria-hidden="true">{{ copy().prompt }}</span>
|
||||
<span class="visually-hidden">{{ copy().inputLabel }}</span>
|
||||
</label>
|
||||
<input
|
||||
#commandInput
|
||||
[id]="inputId"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
[attr.placeholder]="copy().inputPlaceholder"
|
||||
[value]="query()"
|
||||
(input)="onQueryInput($event)"
|
||||
(keydown)="onInputKeydown($event)"
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
161
src/app/shared/terminal/terminal-dock.scss
Normal file
161
src/app/shared/terminal/terminal-dock.scss
Normal file
@@ -0,0 +1,161 @@
|
||||
@use 'breakpoints' as bp;
|
||||
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.terminal-dock {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
inset-inline-end: var(--space-4);
|
||||
inset-block-end: var(--space-4);
|
||||
display: grid;
|
||||
justify-items: end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.terminal-dock-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.terminal-dock-trigger kbd {
|
||||
padding: 0.1rem var(--space-2);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.terminal-dock-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
max-height: min(70vh, 32rem);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-raised);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.terminal-dock.is-open {
|
||||
inset-inline: 0;
|
||||
inset-block-end: 0;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.terminal-dock.is-open .terminal-dock-trigger {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.is-maximized .terminal-dock-panel {
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.terminal-dock-title,
|
||||
.terminal-dock-log,
|
||||
.terminal-dock-log p,
|
||||
.terminal-dock-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.terminal-dock-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.terminal-dock-control {
|
||||
min-height: 2.75rem;
|
||||
padding-inline: var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.terminal-dock-log {
|
||||
overflow: auto;
|
||||
min-height: 6rem;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.terminal-dock-log .echo {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.terminal-dock-form {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.terminal-dock-prompt {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-accent-cool);
|
||||
}
|
||||
|
||||
.terminal-dock-form input {
|
||||
min-height: 2.75rem;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--surface-glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.terminal-dock-trigger:hover,
|
||||
.terminal-dock-control:hover {
|
||||
border-color: var(--surface-glass-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.terminal-dock,
|
||||
.terminal-dock-panel {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include bp.respond-to(md) {
|
||||
.terminal-dock.is-open {
|
||||
inset-inline: auto var(--space-4);
|
||||
inset-block-end: var(--space-4);
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.terminal-dock.is-open .terminal-dock-trigger {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.terminal-dock-panel {
|
||||
width: min(28rem, calc(100vw - var(--space-8)));
|
||||
max-height: min(22rem, 60vh);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.is-maximized .terminal-dock-panel {
|
||||
width: min(48rem, calc(100vw - var(--space-8)));
|
||||
max-height: min(36rem, 80vh);
|
||||
}
|
||||
}
|
||||
114
src/app/shared/terminal/terminal-dock.service.ts
Normal file
114
src/app/shared/terminal/terminal-dock.service.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
DestroyRef,
|
||||
inject,
|
||||
Injectable,
|
||||
Injector,
|
||||
PLATFORM_ID,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
|
||||
export type TerminalDockState = 'collapsed' | 'expanded' | 'maximized';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class TerminalDockService {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
|
||||
readonly state = signal<TerminalDockState>('collapsed');
|
||||
readonly panelId = 'terminal-dock-panel';
|
||||
readonly inputId = 'terminal-dock-input';
|
||||
readonly logId = 'terminal-dock-log';
|
||||
|
||||
private trigger: HTMLElement | null = null;
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.document.addEventListener('keydown', this.onDocumentKeydown);
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
this.destroyRef.onDestroy(() => this.teardown());
|
||||
}
|
||||
|
||||
registerTrigger(element: HTMLElement | null): void {
|
||||
this.trigger = element;
|
||||
}
|
||||
|
||||
open(opener?: HTMLElement | null): void {
|
||||
if (this.state() !== 'collapsed') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (opener instanceof HTMLElement) {
|
||||
this.trigger = opener;
|
||||
}
|
||||
|
||||
this.state.set('expanded');
|
||||
}
|
||||
|
||||
collapse(): void {
|
||||
if (this.state() === 'collapsed') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.set('collapsed');
|
||||
this.restoreFocus();
|
||||
}
|
||||
|
||||
toggle(opener?: HTMLElement | null): void {
|
||||
if (this.state() === 'collapsed') {
|
||||
this.open(opener);
|
||||
return;
|
||||
}
|
||||
|
||||
this.collapse();
|
||||
}
|
||||
|
||||
toggleMaximized(): void {
|
||||
if (this.state() === 'collapsed') {
|
||||
this.open();
|
||||
this.state.set('maximized');
|
||||
return;
|
||||
}
|
||||
|
||||
this.state.update((current) => (current === 'maximized' ? 'expanded' : 'maximized'));
|
||||
}
|
||||
|
||||
private restoreFocus(): void {
|
||||
const trigger = this.trigger;
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (trigger instanceof HTMLElement && trigger.isConnected) {
|
||||
trigger.focus();
|
||||
}
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
}
|
||||
|
||||
private teardown(): void {
|
||||
this.document.removeEventListener('keydown', this.onDocumentKeydown);
|
||||
this.state.set('collapsed');
|
||||
this.trigger = null;
|
||||
}
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent): void => {
|
||||
if (event.key.toLowerCase() !== 'k' || !(event.ctrlKey || event.metaKey) || event.altKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
this.toggle();
|
||||
};
|
||||
}
|
||||
232
src/app/shared/terminal/terminal-dock.spec.ts
Normal file
232
src/app/shared/terminal/terminal-dock.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import { ApplicationRef, PLATFORM_ID } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { SIGNATURE_COPY } from '../../core/content/signature-copy';
|
||||
import { SITE_CONFIG } from '../../core/content/site-config';
|
||||
import { APP_LOCALES, type AppLocale } from '../../core/i18n/locale';
|
||||
import { LocaleService } from '../../core/i18n/locale.service';
|
||||
import { NavigationService } from '../../core/navigation/navigation.service';
|
||||
import { TerminalDock } from './terminal-dock';
|
||||
import { TerminalDockService } from './terminal-dock.service';
|
||||
|
||||
describe('TerminalDock', () => {
|
||||
afterEach(() => {
|
||||
document.body.style.overflow = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function createFixture(
|
||||
locale: AppLocale = 'de',
|
||||
extraProviders: { provide: unknown; useValue: unknown }[] = [],
|
||||
): Promise<ComponentFixture<TerminalDock>> {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TerminalDock],
|
||||
providers: [provideRouter([]), ...extraProviders],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.inject(LocaleService).setLocale(locale);
|
||||
const fixture = TestBed.createComponent(TerminalDock);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
return fixture;
|
||||
}
|
||||
|
||||
async function flush(fixture: ComponentFixture<TerminalDock>): Promise<void> {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
TestBed.inject(ApplicationRef).tick();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
}
|
||||
|
||||
function trigger(fixture: ComponentFixture<TerminalDock>): HTMLButtonElement {
|
||||
return fixture.nativeElement.querySelector('.terminal-dock-trigger');
|
||||
}
|
||||
|
||||
function panel(fixture: ComponentFixture<TerminalDock>): HTMLElement | null {
|
||||
return fixture.nativeElement.querySelector('.terminal-dock-panel');
|
||||
}
|
||||
|
||||
function input(fixture: ComponentFixture<TerminalDock>): HTMLInputElement | null {
|
||||
return fixture.nativeElement.querySelector('input');
|
||||
}
|
||||
|
||||
async function openViaTrigger(fixture: ComponentFixture<TerminalDock>): Promise<void> {
|
||||
trigger(fixture).click();
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
async function submitQuery(
|
||||
fixture: ComponentFixture<TerminalDock>,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const field = input(fixture);
|
||||
expect(field).toBeTruthy();
|
||||
field!.value = value;
|
||||
field!.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
fixture.nativeElement
|
||||
.querySelector('form')
|
||||
?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
function logText(fixture: ComponentFixture<TerminalDock>): string {
|
||||
return fixture.nativeElement.querySelector('.terminal-dock-log')?.textContent ?? '';
|
||||
}
|
||||
|
||||
it.each(APP_LOCALES)('renders the collapsed trigger on the server (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale, [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(trigger(fixture)).toBeTruthy();
|
||||
expect(panel(fixture)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('opens from the trigger and focuses the input (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
expect(panel(fixture)).toBeTruthy();
|
||||
expect(document.activeElement).toBe(input(fixture));
|
||||
expect(fixture.nativeElement.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(fixture.nativeElement.querySelector('.command-palette-scrim')).toBeNull();
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('moves through collapsed, expanded and maximized (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const dock = TestBed.inject(TerminalDockService);
|
||||
expect(dock.state()).toBe('collapsed');
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
expect(dock.state()).toBe('expanded');
|
||||
|
||||
fixture.nativeElement.querySelector('[aria-pressed]')?.click();
|
||||
await flush(fixture);
|
||||
expect(dock.state()).toBe('maximized');
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[aria-pressed]')?.getAttribute('aria-pressed'),
|
||||
).toBe('true');
|
||||
|
||||
fixture.nativeElement.querySelector('[aria-pressed]')?.click();
|
||||
await flush(fixture);
|
||||
expect(dock.state()).toBe('expanded');
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('opens from Ctrl+K and Meta+K (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await flush(fixture);
|
||||
|
||||
for (const modifier of [{ ctrlKey: true }, { metaKey: true }] as const) {
|
||||
if (panel(fixture)) {
|
||||
input(fixture)?.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }),
|
||||
);
|
||||
await flush(fixture);
|
||||
}
|
||||
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'k',
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
...modifier,
|
||||
});
|
||||
const prevent = vi.spyOn(event, 'preventDefault');
|
||||
document.dispatchEvent(event);
|
||||
await flush(fixture);
|
||||
|
||||
expect(prevent).toHaveBeenCalled();
|
||||
expect(panel(fixture)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('closes on Escape and returns focus to the trigger (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
input(fixture)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
||||
await flush(fixture);
|
||||
|
||||
expect(panel(fixture)).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger(fixture));
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)('does not lock scroll or mark the page inert (%s)', async (locale) => {
|
||||
document.body.style.overflow = 'auto';
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
|
||||
expect(document.body.style.overflow).toBe('auto');
|
||||
expect(document.querySelector('.site')?.hasAttribute('inert')).toBeFalsy();
|
||||
expect(fixture.nativeElement.querySelector('[aria-modal]')).toBeNull();
|
||||
document.body.style.overflow = '';
|
||||
});
|
||||
|
||||
it.each(APP_LOCALES)(
|
||||
'echoes the prompt, keeps the log and walks command history (%s)',
|
||||
async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
await openViaTrigger(fixture);
|
||||
const copy = SIGNATURE_COPY[locale].terminal;
|
||||
|
||||
await submitQuery(fixture, 'help');
|
||||
const helpText = logText(fixture);
|
||||
expect(helpText).toContain(`${copy.prompt} help`);
|
||||
expect(helpText).toContain(copy.helpIntro);
|
||||
expect(helpText).toContain(copy.helpGrammar);
|
||||
|
||||
await submitQuery(fixture, 'brew');
|
||||
expect(logText(fixture)).toContain(copy.responses.brew);
|
||||
expect(logText(fixture)).toContain(`${copy.prompt} help`);
|
||||
|
||||
await submitQuery(fixture, 'rev');
|
||||
expect(logText(fixture)).toContain(copy.responses.rev);
|
||||
|
||||
await submitQuery(fixture, 'history');
|
||||
expect(logText(fixture)).toContain(copy.historyIntro);
|
||||
expect(logText(fixture)).toContain('help');
|
||||
|
||||
const field = input(fixture);
|
||||
field?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
|
||||
await flush(fixture);
|
||||
expect(field?.value).toBe('history');
|
||||
|
||||
await submitQuery(fixture, 'clear');
|
||||
expect(logText(fixture).trim()).toBe(copy.clearedMessage);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(APP_LOCALES)('navigates a known target and opens the CV asset (%s)', async (locale) => {
|
||||
const fixture = await createFixture(locale);
|
||||
const router = TestBed.inject(Router);
|
||||
const navigation = TestBed.inject(NavigationService);
|
||||
const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
|
||||
const view = TestBed.inject(DOCUMENT).defaultView;
|
||||
const open = vi.spyOn(view as Window, 'open').mockReturnValue(null);
|
||||
|
||||
await openViaTrigger(fixture);
|
||||
await submitQuery(fixture, 'navigate pitch');
|
||||
expect(navigate).toHaveBeenCalledWith(navigation.link('pitch'));
|
||||
|
||||
await submitQuery(fixture, 'navigate cv');
|
||||
expect(open).toHaveBeenCalledWith(SITE_CONFIG.cvAssetPath, '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('registers no document listener on the server', async () => {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
await createFixture('de', [{ provide: PLATFORM_ID, useValue: 'server' }]);
|
||||
expect(add.mock.calls.some((call) => call[0] === 'keydown')).toBe(false);
|
||||
});
|
||||
|
||||
it('removes the document keydown listener on destroy', async () => {
|
||||
const add = vi.spyOn(document, 'addEventListener');
|
||||
const fixture = await createFixture();
|
||||
await flush(fixture);
|
||||
|
||||
const added = add.mock.calls.find((call) => call[0] === 'keydown');
|
||||
expect(added).toBeTruthy();
|
||||
|
||||
const remove = vi.spyOn(document, 'removeEventListener');
|
||||
fixture.destroy();
|
||||
TestBed.resetTestingModule();
|
||||
|
||||
expect(remove).toHaveBeenCalledWith('keydown', added?.[1]);
|
||||
});
|
||||
});
|
||||
279
src/app/shared/terminal/terminal-dock.ts
Normal file
279
src/app/shared/terminal/terminal-dock.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
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 { isApplePlatform, isBrowserPlatform } from '../../core/platform/browser';
|
||||
import {
|
||||
applyCompletion,
|
||||
COMMAND_IDS,
|
||||
navigateTargetKeys,
|
||||
parseCommand,
|
||||
suggestCompletions,
|
||||
} from './terminal-commands';
|
||||
import { TerminalDockService } from './terminal-dock.service';
|
||||
|
||||
interface LogLine {
|
||||
readonly id: number;
|
||||
readonly kind: 'echo' | 'output';
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
let terminalLogId = 0;
|
||||
|
||||
@Component({
|
||||
selector: 'app-terminal-dock',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './terminal-dock.html',
|
||||
styleUrl: './terminal-dock.scss',
|
||||
})
|
||||
export class TerminalDock {
|
||||
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 dock = inject(TerminalDockService);
|
||||
private readonly isBrowser = isBrowserPlatform();
|
||||
private readonly applePlatform = isApplePlatform();
|
||||
|
||||
private readonly inputRef = viewChild<ElementRef<HTMLInputElement>>('commandInput');
|
||||
private readonly triggerRef = viewChild<ElementRef<HTMLButtonElement>>('trigger');
|
||||
|
||||
protected readonly panelId = this.dock.panelId;
|
||||
protected readonly inputId = this.dock.inputId;
|
||||
protected readonly logId = this.dock.logId;
|
||||
protected readonly state = this.dock.state;
|
||||
protected readonly useAppleHint = signal(false);
|
||||
protected readonly query = signal('');
|
||||
protected readonly log = signal<readonly LogLine[]>([]);
|
||||
protected readonly entered = signal<readonly string[]>([]);
|
||||
protected readonly historyIndex = signal<number | null>(null);
|
||||
|
||||
protected readonly copy = computed(() => SIGNATURE_COPY[this.localeService.locale()].terminal);
|
||||
protected readonly shortcutHint = computed(() =>
|
||||
this.useAppleHint() ? this.copy().shortcutHintApple : this.copy().shortcutHint,
|
||||
);
|
||||
protected readonly open = computed(() => this.state() !== 'collapsed');
|
||||
protected readonly maximized = computed(() => this.state() === 'maximized');
|
||||
protected readonly suggestions = computed(() => suggestCompletions(this.query()));
|
||||
protected readonly maximizeLabel = computed(() =>
|
||||
this.maximized() ? this.copy().restoreLabel : this.copy().maximizeLabel,
|
||||
);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(
|
||||
() => {
|
||||
if (this.isBrowser) {
|
||||
this.useAppleHint.set(this.applePlatform);
|
||||
}
|
||||
|
||||
this.dock.registerTrigger(this.triggerRef()?.nativeElement ?? null);
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
|
||||
effect(() => {
|
||||
if (!this.open()) {
|
||||
return;
|
||||
}
|
||||
|
||||
afterNextRender(
|
||||
() => {
|
||||
this.inputRef()?.nativeElement.focus();
|
||||
},
|
||||
{ injector: this.injector },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
protected onTriggerClick(): void {
|
||||
const trigger = this.triggerRef()?.nativeElement;
|
||||
trigger?.focus();
|
||||
this.dock.open(trigger ?? null);
|
||||
}
|
||||
|
||||
protected onQueryInput(event: Event): void {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLInputElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.query.set(target.value);
|
||||
this.historyIndex.set(null);
|
||||
}
|
||||
|
||||
protected onSubmit(event: Event): void {
|
||||
event.preventDefault();
|
||||
this.runRaw(this.query());
|
||||
}
|
||||
|
||||
protected onInputKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.dock.collapse();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
this.stepHistory(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
this.stepHistory(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault();
|
||||
this.completeCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
protected toggleMaximized(): void {
|
||||
this.dock.toggleMaximized();
|
||||
}
|
||||
|
||||
protected collapse(): void {
|
||||
this.dock.collapse();
|
||||
}
|
||||
|
||||
private runRaw(raw: string): void {
|
||||
const parsed = parseCommand(raw);
|
||||
if (parsed.kind === 'empty') {
|
||||
return;
|
||||
}
|
||||
|
||||
const copy = this.copy();
|
||||
this.echo(raw);
|
||||
this.entered.update((items) => [...items, raw]);
|
||||
this.historyIndex.set(null);
|
||||
this.query.set('');
|
||||
|
||||
switch (parsed.kind) {
|
||||
case 'help': {
|
||||
this.append([
|
||||
copy.helpIntro,
|
||||
copy.helpGrammar,
|
||||
...COMMAND_IDS.map((id) => `${id} — ${copy.commandDescriptions[id]}`),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
case 'history': {
|
||||
const commands = this.entered();
|
||||
if (commands.length === 0) {
|
||||
this.append([copy.historyEmpty]);
|
||||
return;
|
||||
}
|
||||
this.append([copy.historyIntro, ...commands]);
|
||||
return;
|
||||
}
|
||||
case 'clear': {
|
||||
this.log.set([]);
|
||||
this.append([copy.clearedMessage]);
|
||||
return;
|
||||
}
|
||||
case 'brew': {
|
||||
this.append([copy.responses.brew]);
|
||||
return;
|
||||
}
|
||||
case 'rev': {
|
||||
this.append([copy.responses.rev]);
|
||||
return;
|
||||
}
|
||||
case 'navigate': {
|
||||
if (parsed.resolution.kind === 'cv') {
|
||||
if (this.isBrowser) {
|
||||
this.document.defaultView?.open(
|
||||
SITE_CONFIG.cvAssetPath,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}
|
||||
this.append([copy.cvOpened]);
|
||||
return;
|
||||
}
|
||||
|
||||
void this.router.navigate(this.navigation.link(parsed.resolution.routeId));
|
||||
this.append([copy.navigating.replace('{target}', parsed.targetKey)]);
|
||||
return;
|
||||
}
|
||||
case 'unknown-command': {
|
||||
this.append([copy.unknownCommand.replace('{command}', parsed.input)]);
|
||||
return;
|
||||
}
|
||||
case 'unknown-target': {
|
||||
this.append([
|
||||
copy.unknownTarget.replace('{target}', parsed.target),
|
||||
copy.validTargetsLabel,
|
||||
...navigateTargetKeys(),
|
||||
]);
|
||||
return;
|
||||
}
|
||||
case 'incomplete': {
|
||||
this.append([copy.incompleteHint, ...parsed.suggestions]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private completeCurrent(): void {
|
||||
const result = applyCompletion(this.query());
|
||||
if (result.candidates.length === 1) {
|
||||
this.query.set(result.value);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.candidates.length > 1) {
|
||||
this.append(result.candidates);
|
||||
}
|
||||
}
|
||||
|
||||
private stepHistory(direction: -1 | 1): void {
|
||||
const commands = this.entered();
|
||||
if (commands.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.historyIndex();
|
||||
const next =
|
||||
current === null
|
||||
? direction < 0
|
||||
? commands.length - 1
|
||||
: 0
|
||||
: Math.min(commands.length - 1, Math.max(0, current + direction));
|
||||
|
||||
this.historyIndex.set(next);
|
||||
this.query.set(commands[next] ?? '');
|
||||
}
|
||||
|
||||
private echo(input: string): void {
|
||||
this.log.update((lines) => [
|
||||
...lines,
|
||||
{ id: terminalLogId++, kind: 'echo', text: `${this.copy().prompt} ${input}` },
|
||||
]);
|
||||
}
|
||||
|
||||
private append(texts: readonly string[]): void {
|
||||
this.log.update((lines) => [
|
||||
...lines,
|
||||
...texts.map((text) => ({ id: terminalLogId++, kind: 'output' as const, text })),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user