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 { expect, test } from '@playwright/test';
|
||||||
import { pagePath } from './helpers';
|
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 }) => {
|
test('skip link is first and moves focus to main', async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
await page.keyboard.press('Tab');
|
await page.keyboard.press('Tab');
|
||||||
@@ -87,37 +87,32 @@ test.describe('keyboard and palette', () => {
|
|||||||
const headerBox = await page.locator('.site-header').boundingBox();
|
const headerBox = await page.locator('.site-header').boundingBox();
|
||||||
expect(headerBox, 'header while submenu is open').toBeTruthy();
|
expect(headerBox, 'header while submenu is open').toBeTruthy();
|
||||||
expect(headerBox!.height, 'header height while submenu is open').toBeLessThanOrEqual(200);
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.goto('/');
|
await page.goto('/');
|
||||||
const trigger = page.locator('.command-palette-trigger');
|
const trigger = page.locator('.terminal-dock-trigger');
|
||||||
await trigger.focus();
|
await trigger.focus();
|
||||||
await page.keyboard.press('Control+k');
|
await page.keyboard.press('Control+k');
|
||||||
|
|
||||||
const dialog = page.locator('[role="dialog"]');
|
const panel = page.locator('.terminal-dock-panel');
|
||||||
await expect(dialog).toBeVisible();
|
await expect(panel).toBeVisible();
|
||||||
await expect(page.locator('.site')).toHaveAttribute('inert', '');
|
await expect(page.locator('.site')).not.toHaveAttribute('inert');
|
||||||
expect(await page.evaluate(() => document.body.style.overflow)).toBe('hidden');
|
expect(await page.evaluate(() => document.body.style.overflow)).not.toBe('hidden');
|
||||||
|
await expect(page.locator('[role="dialog"]')).toHaveCount(0);
|
||||||
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();
|
|
||||||
|
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
await expect(dialog).toHaveCount(0);
|
await expect(panel).toHaveCount(0);
|
||||||
await expect(page.locator('.site')).not.toHaveAttribute('inert');
|
|
||||||
expect(await page.evaluate(() => document.body.style.overflow)).toBe('');
|
|
||||||
await expect(trigger).toBeFocused();
|
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 =
|
export type CommandId = 'help' | 'history' | 'clear' | 'brew' | 'rev' | 'navigate';
|
||||||
| 'help'
|
|
||||||
| 'projects'
|
|
||||||
| 'servicesAi'
|
|
||||||
| 'cv'
|
|
||||||
| 'contact'
|
|
||||||
| 'brew'
|
|
||||||
| 'ignite'
|
|
||||||
| 'rev'
|
|
||||||
| 'clear'
|
|
||||||
| 'close';
|
|
||||||
|
|
||||||
export const COMMAND_IDS: readonly CommandId[] = [
|
export const COMMAND_IDS: readonly CommandId[] = [
|
||||||
'help',
|
'help',
|
||||||
'projects',
|
'history',
|
||||||
'servicesAi',
|
|
||||||
'cv',
|
|
||||||
'contact',
|
|
||||||
'brew',
|
|
||||||
'ignite',
|
|
||||||
'rev',
|
|
||||||
'clear',
|
'clear',
|
||||||
'close',
|
'brew',
|
||||||
|
'rev',
|
||||||
|
'navigate',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ describe('SIGNATURE_COPY', () => {
|
|||||||
|
|
||||||
it('describes every CommandId in both locales', () => {
|
it('describes every CommandId in both locales', () => {
|
||||||
for (const locale of APP_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());
|
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';
|
import { type AppLocale } from '../i18n/locale';
|
||||||
|
|
||||||
export interface SignatureCopy {
|
export interface SignatureCopy {
|
||||||
readonly palette: {
|
readonly terminal: {
|
||||||
readonly triggerLabel: string;
|
readonly triggerLabel: string;
|
||||||
readonly shortcutHint: string;
|
readonly shortcutHint: string;
|
||||||
readonly shortcutHintApple: string;
|
readonly shortcutHintApple: string;
|
||||||
readonly dialogTitle: string;
|
readonly prompt: string;
|
||||||
readonly dialogDescription: string;
|
readonly panelLabel: string;
|
||||||
readonly inputLabel: string;
|
readonly inputLabel: string;
|
||||||
readonly inputPlaceholder: string;
|
readonly inputPlaceholder: string;
|
||||||
readonly closeLabel: string;
|
readonly collapseLabel: string;
|
||||||
readonly suggestionsLabel: string;
|
readonly expandLabel: string;
|
||||||
|
readonly restoreLabel: string;
|
||||||
|
readonly maximizeLabel: string;
|
||||||
readonly outputLabel: string;
|
readonly outputLabel: string;
|
||||||
readonly emptySuggestions: string;
|
readonly emptySuggestions: string;
|
||||||
readonly unknownCommand: string;
|
readonly unknownCommand: string;
|
||||||
|
readonly unknownTarget: string;
|
||||||
|
readonly validTargetsLabel: string;
|
||||||
readonly helpIntro: string;
|
readonly helpIntro: string;
|
||||||
|
readonly helpGrammar: string;
|
||||||
|
readonly historyEmpty: string;
|
||||||
|
readonly historyIntro: string;
|
||||||
readonly clearedMessage: string;
|
readonly clearedMessage: string;
|
||||||
readonly cvOpened: string;
|
readonly cvOpened: string;
|
||||||
readonly navigating: string;
|
readonly navigating: string;
|
||||||
|
readonly incompleteHint: string;
|
||||||
readonly commandDescriptions: Record<CommandId, string>;
|
readonly commandDescriptions: Record<CommandId, string>;
|
||||||
readonly responses: {
|
readonly responses: {
|
||||||
readonly brew: string;
|
readonly brew: string;
|
||||||
readonly ignite: string;
|
|
||||||
readonly rev: string;
|
readonly rev: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -45,39 +52,41 @@ export interface SignatureCopy {
|
|||||||
|
|
||||||
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
||||||
de: {
|
de: {
|
||||||
palette: {
|
terminal: {
|
||||||
triggerLabel: 'Befehle öffnen',
|
triggerLabel: 'Terminal',
|
||||||
shortcutHint: 'Strg+K',
|
shortcutHint: 'Strg+K',
|
||||||
shortcutHintApple: '⌘K',
|
shortcutHintApple: '⌘K',
|
||||||
dialogTitle: 'Befehle',
|
prompt: 'visitor@antoniolede:~$',
|
||||||
dialogDescription:
|
panelLabel: 'Terminal',
|
||||||
'Zur Navigation oder zu einer kurzen Rückmeldung. Es wird kein Code ausgeführt.',
|
|
||||||
inputLabel: 'Befehl',
|
inputLabel: 'Befehl',
|
||||||
inputPlaceholder: 'Befehl eingeben',
|
inputPlaceholder: 'Befehl eingeben',
|
||||||
closeLabel: 'Schließen',
|
collapseLabel: 'Terminal einklappen',
|
||||||
suggestionsLabel: 'Vorschläge',
|
expandLabel: 'Terminal öffnen',
|
||||||
|
restoreLabel: 'Terminal verkleinern',
|
||||||
|
maximizeLabel: 'Terminal vergrößern',
|
||||||
outputLabel: 'Ausgabe',
|
outputLabel: 'Ausgabe',
|
||||||
emptySuggestions: 'Keine passenden Befehle.',
|
emptySuggestions: 'Keine passenden Befehle.',
|
||||||
unknownCommand: 'Unbekannter Befehl: {command}',
|
unknownCommand: 'Unbekannter Befehl: {command}',
|
||||||
|
unknownTarget: 'Unbekanntes Ziel: {target}',
|
||||||
|
validTargetsLabel: 'Gültige Ziele:',
|
||||||
helpIntro: 'Verfügbare Befehle:',
|
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.',
|
clearedMessage: 'Ausgabe geleert.',
|
||||||
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
cvOpened: 'Lebenslauf in einem neuen Tab geöffnet.',
|
||||||
navigating: 'Wechsel zu {target}.',
|
navigating: 'Wechsel zu {target}.',
|
||||||
|
incompleteHint: 'Unvollständiger Befehl. Mögliche Ziele:',
|
||||||
commandDescriptions: {
|
commandDescriptions: {
|
||||||
help: 'Listet die verfügbaren Befehle.',
|
help: 'Listet die Grammatik und jeden Befehl.',
|
||||||
projects: 'Öffnet die Projektübersicht.',
|
history: 'Listet die in dieser Sitzung eingegebenen Befehle.',
|
||||||
servicesAi: 'Öffnet die Seite zur KI-Integration.',
|
clear: 'Leert das Ausgabebuch.',
|
||||||
cv: 'Öffnet den Lebenslauf als PDF.',
|
|
||||||
contact: 'Öffnet die Kontaktseite.',
|
|
||||||
brew: 'Eine kurze, spielerische Rückmeldung.',
|
brew: 'Eine kurze, spielerische Rückmeldung.',
|
||||||
ignite: 'Eine kurze, spielerische Rückmeldung.',
|
|
||||||
rev: 'Eine kurze, spielerische Rückmeldung.',
|
rev: 'Eine kurze, spielerische Rückmeldung.',
|
||||||
clear: 'Leert die Ausgabe.',
|
navigate: 'Wechselt zu einer bekannten Seite oder öffnet den Lebenslauf.',
|
||||||
close: 'Schließt die Befehlsübersicht.',
|
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
brew: 'Frisch aufgebrüht. Automatisierung, die auch vor dem ersten Kaffee läuft.',
|
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.',
|
rev: 'Drehzahl steigt, der Content bleibt trotzdem ruhig.',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -99,38 +108,41 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
palette: {
|
terminal: {
|
||||||
triggerLabel: 'Open commands',
|
triggerLabel: 'Terminal',
|
||||||
shortcutHint: 'Ctrl+K',
|
shortcutHint: 'Ctrl+K',
|
||||||
shortcutHintApple: '⌘K',
|
shortcutHintApple: '⌘K',
|
||||||
dialogTitle: 'Commands',
|
prompt: 'visitor@antoniolede:~$',
|
||||||
dialogDescription: 'Navigate or get a short acknowledgement. No code is executed.',
|
panelLabel: 'Terminal',
|
||||||
inputLabel: 'Command',
|
inputLabel: 'Command',
|
||||||
inputPlaceholder: 'Type a command',
|
inputPlaceholder: 'Type a command',
|
||||||
closeLabel: 'Close',
|
collapseLabel: 'Collapse the terminal',
|
||||||
suggestionsLabel: 'Suggestions',
|
expandLabel: 'Open the terminal',
|
||||||
|
restoreLabel: 'Restore the terminal',
|
||||||
|
maximizeLabel: 'Maximise the terminal',
|
||||||
outputLabel: 'Output',
|
outputLabel: 'Output',
|
||||||
emptySuggestions: 'No matching commands.',
|
emptySuggestions: 'No matching commands.',
|
||||||
unknownCommand: 'Unknown command: {command}',
|
unknownCommand: 'Unknown command: {command}',
|
||||||
|
unknownTarget: 'Unknown target: {target}',
|
||||||
|
validTargetsLabel: 'Valid targets:',
|
||||||
helpIntro: 'Available commands:',
|
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.',
|
clearedMessage: 'Output cleared.',
|
||||||
cvOpened: 'Opened the CV in a new tab.',
|
cvOpened: 'Opened the CV in a new tab.',
|
||||||
navigating: 'Going to {target}.',
|
navigating: 'Going to {target}.',
|
||||||
|
incompleteHint: 'Incomplete command. Possible targets:',
|
||||||
commandDescriptions: {
|
commandDescriptions: {
|
||||||
help: 'Lists the available commands.',
|
help: 'Lists the grammar and every command.',
|
||||||
projects: 'Opens the projects overview.',
|
history: 'Lists the commands entered in this session.',
|
||||||
servicesAi: 'Opens the AI integration page.',
|
clear: 'Clears the output log.',
|
||||||
cv: 'Opens the CV as a PDF.',
|
|
||||||
contact: 'Opens the contact page.',
|
|
||||||
brew: 'A short playful acknowledgement.',
|
brew: 'A short playful acknowledgement.',
|
||||||
ignite: 'A short playful acknowledgement.',
|
|
||||||
rev: 'A short playful acknowledgement.',
|
rev: 'A short playful acknowledgement.',
|
||||||
clear: 'Clears the output.',
|
navigate: 'Goes to a known page or opens the curriculum vitae.',
|
||||||
close: 'Closes the command palette.',
|
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
brew: 'Freshly brewed. Automation that runs before the first coffee.',
|
brew: 'Freshly brewed. Automation that runs before the first coffee.',
|
||||||
ignite: 'Ignition on. Systems are warming up.',
|
|
||||||
rev: 'Revs climbing, the content stays calm.',
|
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.
|
* 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 {
|
export function isApplePlatform(): boolean {
|
||||||
if (!isBrowserPlatform()) {
|
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