From 6aaf8ddb9e1e9f2555e865e133967b5f5c886cb3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 4 Jun 2026 16:45:25 +0200 Subject: [PATCH] feat(transcribe): add ShortcutCheatsheet dialog overlay (#327) Native cheatsheet: showModal()/close() bridge, close button focused on open, eight grouped rows (nav/edit/utility), an autosave footer line, and a reduced-motion-guarded fade. Closes on Esc, backdrop click, and the close button; "?" while open is a no-op. Adds the shortcut_close_panel i18n key. 8 component tests. Co-Authored-By: Claude Sonnet 4.6 --- frontend/messages/de.json | 1 + frontend/messages/en.json | 1 + frontend/messages/es.json | 1 + .../transcription/ShortcutCheatsheet.svelte | 104 ++++++++++++++++++ .../ShortcutCheatsheet.svelte.spec.ts | 65 +++++++++++ 5 files changed, 172 insertions(+) create mode 100644 frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte create mode 100644 frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte.spec.ts diff --git a/frontend/messages/de.json b/frontend/messages/de.json index 602f11b4..8bfd4067 100644 --- a/frontend/messages/de.json +++ b/frontend/messages/de.json @@ -936,6 +936,7 @@ "shortcut_new_region": "Neuen Bereich zeichnen", "shortcut_toggle_training": "Für Training markieren", "shortcut_delete_region": "Aktuellen Bereich löschen", + "shortcut_close_panel": "Bereich schließen", "shortcut_help": "Tastaturkürzel anzeigen", "shortcut_draw_hint": "Ziehen Sie mit der Maus einen Bereich auf.", "cheatsheet_title": "Tastaturkürzel", diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 03c860d5..4ba5dcfc 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -936,6 +936,7 @@ "shortcut_new_region": "Draw a new region", "shortcut_toggle_training": "Mark for training", "shortcut_delete_region": "Delete current region", + "shortcut_close_panel": "Close panel", "shortcut_help": "Show keyboard shortcuts", "shortcut_draw_hint": "Drag a region with your mouse.", "cheatsheet_title": "Keyboard shortcuts", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index f76bffdd..954bcbbf 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -936,6 +936,7 @@ "shortcut_new_region": "Dibujar una nueva región", "shortcut_toggle_training": "Marcar para entrenamiento", "shortcut_delete_region": "Eliminar la región actual", + "shortcut_close_panel": "Cerrar panel", "shortcut_help": "Mostrar atajos de teclado", "shortcut_draw_hint": "Arrastre una región con el ratón.", "cheatsheet_title": "Atajos de teclado", diff --git a/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte b/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte new file mode 100644 index 00000000..199f85e5 --- /dev/null +++ b/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte @@ -0,0 +1,104 @@ + + + +
+

+ {m.cheatsheet_title()} +

+ +
+ +
+ {#each groups as group, i (i)} +
+ {#each group as shortcut (shortcut.cap)} +
+ {shortcut.cap} + {shortcut.label} +
+ {/each} +
+ {/each} +
+ +

+ {m.cheatsheet_autosave_hint()} +

+
+ + diff --git a/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte.spec.ts b/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte.spec.ts new file mode 100644 index 00000000..a5f90402 --- /dev/null +++ b/frontend/src/lib/document/transcription/ShortcutCheatsheet.svelte.spec.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import ShortcutCheatsheet from './ShortcutCheatsheet.svelte'; + +afterEach(cleanup); + +describe('ShortcutCheatsheet', () => { + it('is not in the accessibility tree when closed', async () => { + render(ShortcutCheatsheet, { open: false, onClose: vi.fn() }); + await expect.element(page.getByRole('dialog')).not.toBeInTheDocument(); + }); + + it('opens as a modal dialog with a labelled heading when open', async () => { + render(ShortcutCheatsheet, { open: true, onClose: vi.fn() }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await expect.element(page.getByRole('heading')).toBeInTheDocument(); + }); + + it('lists all eight shortcut rows', async () => { + render(ShortcutCheatsheet, { open: true, onClose: vi.fn() }); + const dialog = page.getByRole('dialog').element() as HTMLElement; + const keyCaps = dialog.querySelectorAll('kbd'); + expect(keyCaps.length).toBe(8); + }); + + it('shows the autosave footer line', async () => { + render(ShortcutCheatsheet, { open: true, onClose: vi.fn() }); + const dialog = page.getByRole('dialog').element() as HTMLElement; + expect(dialog.textContent).toContain('automatisch'); + }); + + it('calls onClose when Escape is pressed', async () => { + const onClose = vi.fn(); + render(ShortcutCheatsheet, { open: true, onClose }); + const dialog = page.getByRole('dialog').element() as HTMLDialogElement; + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + // native turns Esc into a 'cancel' + 'close'; assert close fired onClose + dialog.dispatchEvent(new Event('close')); + expect(onClose).toHaveBeenCalled(); + }); + + it('calls onClose when the backdrop is clicked', async () => { + const onClose = vi.fn(); + render(ShortcutCheatsheet, { open: true, onClose }); + const dialog = page.getByRole('dialog').element() as HTMLDialogElement; + // a click whose target is the dialog element itself is a backdrop click + dialog.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onClose).toHaveBeenCalled(); + }); + + it('does not close on "?" while open (open-only, not a toggle)', async () => { + const onClose = vi.fn(); + render(ShortcutCheatsheet, { open: true, onClose }); + const dialog = page.getByRole('dialog').element() as HTMLDialogElement; + dialog.dispatchEvent(new KeyboardEvent('keydown', { key: '?', bubbles: true })); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('focuses the close button on open', async () => { + render(ShortcutCheatsheet, { open: true, onClose: vi.fn() }); + const closeButton = page.getByRole('button', { name: /schließen/i }).element(); + expect(document.activeElement).toBe(closeButton); + }); +});