feat(transcribe): add ShortcutCheatsheet dialog overlay (#327)
Native <dialog aria-modal> cheatsheet: showModal()/close() bridge, close button focused on open, eight grouped <kbd> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
let { open = false, onClose }: { open?: boolean; onClose: () => void } = $props();
|
||||
|
||||
let dialogEl = $state<HTMLDialogElement>();
|
||||
let closeButton = $state<HTMLButtonElement>();
|
||||
|
||||
// Grouped navigation / editing / utility — whitespace dividers, no headers.
|
||||
const groups = [
|
||||
[
|
||||
{ cap: 'j', label: m.shortcut_next_region() },
|
||||
{ cap: 'k', label: m.shortcut_prev_region() }
|
||||
],
|
||||
[
|
||||
{ cap: 'e', label: m.shortcut_toggle_mode() },
|
||||
{ cap: 'n', label: m.shortcut_new_region() },
|
||||
{ cap: 't', label: m.shortcut_toggle_training() },
|
||||
{ cap: 'Entf', label: m.shortcut_delete_region() }
|
||||
],
|
||||
[
|
||||
{ cap: 'Esc', label: m.shortcut_close_panel() },
|
||||
{ cap: '?', label: m.shortcut_help() }
|
||||
]
|
||||
];
|
||||
|
||||
$effect(() => {
|
||||
const el = dialogEl;
|
||||
if (!el) return;
|
||||
if (open && !el.open) {
|
||||
el.showModal();
|
||||
closeButton?.focus();
|
||||
} else if (!open && el.open) {
|
||||
el.close();
|
||||
}
|
||||
});
|
||||
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === dialogEl) onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
bind:this={dialogEl}
|
||||
aria-modal="true"
|
||||
aria-labelledby="cheatsheet-title"
|
||||
class="w-[calc(100%-2rem)] max-w-md rounded-sm border border-line bg-surface p-6 shadow-lg backdrop:bg-black/40"
|
||||
onclose={onClose}
|
||||
onclick={handleBackdropClick}
|
||||
>
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h2 id="cheatsheet-title" class="font-serif text-lg font-bold text-ink">
|
||||
{m.cheatsheet_title()}
|
||||
</h2>
|
||||
<button
|
||||
bind:this={closeButton}
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
aria-label={m.cheatsheet_close()}
|
||||
class="flex h-11 w-11 items-center justify-center rounded-sm text-ink-2 hover:bg-muted"
|
||||
>
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-width="2" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="divide-y divide-line">
|
||||
{#each groups as group, i (i)}
|
||||
<div class="flex flex-col gap-2 py-3 first:pt-0 last:pb-0">
|
||||
{#each group as shortcut (shortcut.cap)}
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<kbd
|
||||
class="rounded border border-line bg-muted px-1.5 py-0.5 font-mono text-xs text-ink shadow-sm"
|
||||
>{shortcut.cap}</kbd
|
||||
>
|
||||
<span class="flex-1 text-right font-serif text-sm text-ink">{shortcut.label}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="mt-5 border-t border-line pt-3.5 font-sans text-xs text-ink-3">
|
||||
{m.cheatsheet_autosave_hint()}
|
||||
</p>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
dialog[open] {
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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 <dialog> 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user