Collapse the terminal dock from Escape anywhere in the panel.

Drop unused suggestion state and copy, and give the panel a reduced-motion-aware open transition.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-26 13:13:43 +02:00
parent 35a575dba9
commit 19d00da9bb
6 changed files with 70 additions and 14 deletions

View File

@@ -64,4 +64,25 @@ test.describe('terminal dock', () => {
await input.press('Tab'); await input.press('Tab');
await expect(input).toHaveValue('navigate'); await expect(input).toHaveValue('navigate');
}); });
test('Escape from a panel control collapses the dock and returns focus to the trigger', async ({
page,
}) => {
const copy = SIGNATURE_COPY.de.terminal;
await page.goto('/');
const trigger = page.locator('.terminal-dock-trigger');
const panel = page.locator('.terminal-dock-panel');
await trigger.click();
await expect(panel).toBeVisible();
const collapse = page.getByRole('button', { name: copy.collapseLabel });
await collapse.focus();
await expect(collapse).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel).toHaveCount(0);
await expect(trigger).toBeFocused();
});
}); });

View File

@@ -11,11 +11,9 @@ export interface SignatureCopy {
readonly inputLabel: string; readonly inputLabel: string;
readonly inputPlaceholder: string; readonly inputPlaceholder: string;
readonly collapseLabel: string; readonly collapseLabel: string;
readonly expandLabel: string;
readonly restoreLabel: string; readonly restoreLabel: string;
readonly maximizeLabel: string; readonly maximizeLabel: string;
readonly outputLabel: string; readonly outputLabel: string;
readonly emptySuggestions: string;
readonly unknownCommand: string; readonly unknownCommand: string;
readonly unknownTarget: string; readonly unknownTarget: string;
readonly validTargetsLabel: string; readonly validTargetsLabel: string;
@@ -61,11 +59,9 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
inputLabel: 'Befehl', inputLabel: 'Befehl',
inputPlaceholder: 'Befehl eingeben', inputPlaceholder: 'Befehl eingeben',
collapseLabel: 'Terminal einklappen', collapseLabel: 'Terminal einklappen',
expandLabel: 'Terminal öffnen',
restoreLabel: 'Terminal verkleinern', restoreLabel: 'Terminal verkleinern',
maximizeLabel: 'Terminal vergrößern', maximizeLabel: 'Terminal vergrößern',
outputLabel: 'Ausgabe', outputLabel: 'Ausgabe',
emptySuggestions: 'Keine passenden Befehle.',
unknownCommand: 'Unbekannter Befehl: {command}', unknownCommand: 'Unbekannter Befehl: {command}',
unknownTarget: 'Unbekanntes Ziel: {target}', unknownTarget: 'Unbekanntes Ziel: {target}',
validTargetsLabel: 'Gültige Ziele:', validTargetsLabel: 'Gültige Ziele:',
@@ -117,11 +113,9 @@ export const SIGNATURE_COPY: Record<AppLocale, SignatureCopy> = {
inputLabel: 'Command', inputLabel: 'Command',
inputPlaceholder: 'Type a command', inputPlaceholder: 'Type a command',
collapseLabel: 'Collapse the terminal', collapseLabel: 'Collapse the terminal',
expandLabel: 'Open the terminal',
restoreLabel: 'Restore the terminal', restoreLabel: 'Restore the terminal',
maximizeLabel: 'Maximise the terminal', maximizeLabel: 'Maximise the terminal',
outputLabel: 'Output', outputLabel: 'Output',
emptySuggestions: 'No matching commands.',
unknownCommand: 'Unknown command: {command}', unknownCommand: 'Unknown command: {command}',
unknownTarget: 'Unknown target: {target}', unknownTarget: 'Unknown target: {target}',
validTargetsLabel: 'Valid targets:', validTargetsLabel: 'Valid targets:',

View File

@@ -13,7 +13,13 @@
</button> </button>
@if (open()) { @if (open()) {
<section class="terminal-dock-panel" [id]="panelId" [attr.aria-label]="copy().panelLabel"> <section
class="terminal-dock-panel"
[id]="panelId"
tabindex="-1"
[attr.aria-label]="copy().panelLabel"
(keydown)="onPanelKeydown($event)"
>
<div class="terminal-dock-toolbar cluster"> <div class="terminal-dock-toolbar cluster">
<p class="terminal-dock-title">{{ copy().panelLabel }}</p> <p class="terminal-dock-title">{{ copy().panelLabel }}</p>
<button <button

View File

@@ -48,6 +48,10 @@
background: var(--color-surface-raised); background: var(--color-surface-raised);
box-shadow: var(--shadow-raised); box-shadow: var(--shadow-raised);
color: var(--color-text); color: var(--color-text);
animation: terminal-dock-in var(--duration-base) var(--ease-standard);
transition:
width var(--duration-base) var(--ease-standard),
max-height var(--duration-base) var(--ease-standard);
} }
.terminal-dock.is-open { .terminal-dock.is-open {
@@ -130,9 +134,21 @@
} }
} }
@keyframes terminal-dock-in {
from {
opacity: 0;
transform: translateY(0.4rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.terminal-dock,
.terminal-dock-panel { .terminal-dock-panel {
animation: none;
transition: none; transition: none;
} }
} }

View File

@@ -148,6 +148,24 @@ describe('TerminalDock', () => {
expect(document.activeElement).toBe(trigger(fixture)); expect(document.activeElement).toBe(trigger(fixture));
}); });
it.each(APP_LOCALES)(
'closes on Escape from a panel control and returns focus to the trigger (%s)',
async (locale) => {
const fixture = await createFixture(locale);
await openViaTrigger(fixture);
const maximize = fixture.nativeElement.querySelector('[aria-pressed]') as HTMLButtonElement;
maximize.focus();
expect(document.activeElement).toBe(maximize);
maximize.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) => { it.each(APP_LOCALES)('does not lock scroll or mark the page inert (%s)', async (locale) => {
document.body.style.overflow = 'auto'; document.body.style.overflow = 'auto';
const fixture = await createFixture(locale); const fixture = await createFixture(locale);

View File

@@ -22,7 +22,6 @@ import {
COMMAND_IDS, COMMAND_IDS,
navigateTargetKeys, navigateTargetKeys,
parseCommand, parseCommand,
suggestCompletions,
} from './terminal-commands'; } from './terminal-commands';
import { TerminalDockService } from './terminal-dock.service'; import { TerminalDockService } from './terminal-dock.service';
@@ -69,7 +68,6 @@ export class TerminalDock {
); );
protected readonly open = computed(() => this.state() !== 'collapsed'); protected readonly open = computed(() => this.state() !== 'collapsed');
protected readonly maximized = computed(() => this.state() === 'maximized'); protected readonly maximized = computed(() => this.state() === 'maximized');
protected readonly suggestions = computed(() => suggestCompletions(this.query()));
protected readonly maximizeLabel = computed(() => protected readonly maximizeLabel = computed(() =>
this.maximized() ? this.copy().restoreLabel : this.copy().maximizeLabel, this.maximized() ? this.copy().restoreLabel : this.copy().maximizeLabel,
); );
@@ -121,13 +119,16 @@ export class TerminalDock {
this.runRaw(this.query()); this.runRaw(this.query());
} }
protected onInputKeydown(event: KeyboardEvent): void { protected onPanelKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') { if (event.key !== 'Escape') {
event.preventDefault();
this.dock.collapse();
return; return;
} }
event.preventDefault();
this.dock.collapse();
}
protected onInputKeydown(event: KeyboardEvent): void {
if (event.key === 'ArrowUp') { if (event.key === 'ArrowUp') {
event.preventDefault(); event.preventDefault();
this.stepHistory(-1); this.stepHistory(-1);