feat(discovery): keyboard shortcuts for queue review

Ship 2 MR 8. Operator-productivity layer on top of the detail drawer:
j/k to walk rows, Enter to open, a/r to accept-reject the selection,
e/s to jump into the drawer with AI enrich / Similar already visible,
? for a help modal listing everything. Escape closes the drawer (or
the help modal if it's open).

Implementation
- selectedId $state drives a subtle indigo ring on the highlighted
  row. Follows drawerId when the drawer opens so Esc → j leaves you
  on the same row. Auto-resets to queue[0] if the selected row
  scrolls off the page (pagination / refresh).
- Global <svelte:window onkeydown> listener. isTypingTarget() bails
  out when focus is inside an input/textarea/select/contenteditable
  so typing in the drawer's edit form doesn't trigger shortcuts.
  Cmd/Ctrl/Alt combos also skipped so browser shortcuts stay intact.
- selectRelative() updates selectedId + scrolls the row into view
  (block: 'nearest') so keyboard-driven scanning through a long
  queue keeps the highlight visible.
- submitRowAction() builds + submits a hidden <form> for a/r so the
  SvelteKit action pipeline (invalidations, form result propagation)
  runs the same way a button click would.

Decisions baked in
- 'e' (AI enrich) and 's' (Similar) open the drawer rather than
  firing the LLM call directly. LLM calls cost money; keeping the
  UI explicit avoids hidden side effects from a misclick.
- Persistent '?' button bottom-right for discoverability — operators
  shouldn't have to read docs to find the help.
- Modal uses click-outside-to-dismiss + Esc + ✕ button, all three.

No backend changes. Frontend-only.
This commit is contained in:
2026-04-24 12:40:12 +02:00
parent 3516999345
commit ef6e1def3d

View File

@@ -85,6 +85,131 @@
goto(qs ? `?${qs}` : '?', { replaceState: false, noScroll: true });
}
// --- Keyboard navigation. Selection highlight tracks which row will
// receive j/k-driven actions (a/r/e/s). Enter opens the drawer. ---
let selectedId = $state<string | null>(null);
let showHelp = $state(false);
// When the drawer opens, selection follows — so closing the drawer
// returns focus to the same row.
$effect(() => {
if (drawerId) selectedId = drawerId;
});
// Keep selection inside the current page; if the row scrolled away
// (page change / refresh), reset to the first row so j/k still work.
$effect(() => {
if (selectedId && !queue.find((r) => r.id === selectedId)) {
selectedId = queue[0]?.id ?? null;
}
});
function selectRelative(delta: number) {
if (queue.length === 0) return;
const idx = selectedId ? queue.findIndex((r) => r.id === selectedId) : -1;
const next = Math.max(0, Math.min(queue.length - 1, idx + delta));
selectedId = queue[next === -1 ? 0 : next].id;
// Scroll into view so the highlighted row stays visible during
// keyboard-driven scanning through a long list.
requestAnimationFrame(() => {
document
.querySelector(`[data-row-id="${selectedId}"]`)
?.scrollIntoView({ block: 'nearest' });
});
}
// Programmatically submit a form action for the selected row. Used by the
// a/r keyboard shortcuts — mirrors the Accept/Reject buttons' behaviour
// so use:enhance invalidations run.
function submitRowAction(action: string, id: string) {
const f = document.createElement('form');
f.method = 'POST';
f.action = `?/${action}`;
const idInput = document.createElement('input');
idInput.type = 'hidden';
idInput.name = 'id';
idInput.value = id;
f.appendChild(idInput);
document.body.appendChild(f);
f.submit();
}
// Typeable-target guard: don't intercept keys while the user is typing
// in an input/textarea (drawer edit form, in particular).
function isTypingTarget(el: EventTarget | null): boolean {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return el.isContentEditable;
}
function handleKeydown(event: KeyboardEvent) {
if (isTypingTarget(event.target)) return;
// Don't fight browser shortcuts (Cmd-R, Ctrl-F, etc.).
if (event.metaKey || event.ctrlKey || event.altKey) return;
switch (event.key) {
case 'j':
case 'ArrowDown':
event.preventDefault();
selectRelative(1);
break;
case 'k':
case 'ArrowUp':
event.preventDefault();
selectRelative(-1);
break;
case 'Enter':
if (selectedId && !drawerId) {
event.preventDefault();
openDrawer(selectedId);
}
break;
case 'Escape':
if (showHelp) {
event.preventDefault();
showHelp = false;
} else if (drawerId) {
event.preventDefault();
closeDrawer();
}
break;
case 'a':
if (selectedId) {
event.preventDefault();
submitRowAction('accept', selectedId);
}
break;
case 'r':
if (selectedId) {
event.preventDefault();
submitRowAction('reject', selectedId);
}
break;
case 'e':
// Open the drawer so the AI button is visible + clickable —
// avoids hidden side-effects. Operator then hits the button
// (or we could wire the action directly; keeping it explicit
// while the feature is young).
if (selectedId && !drawerId) {
event.preventDefault();
openDrawer(selectedId);
}
break;
case 's':
// Same as 'e' — drawer exposes the Similar section at the
// bottom, already auto-loaded on open.
if (selectedId && !drawerId) {
event.preventDefault();
openDrawer(selectedId);
}
break;
case '?':
event.preventDefault();
showHelp = !showHelp;
break;
}
}
// Pagination helpers.
const totalPages = $derived(Math.ceil((data.total ?? 0) / data.limit));
@@ -315,6 +440,8 @@
);
</script>
<svelte:window onkeydown={handleKeydown} />
<svelte:head>
<title>Admin · Discovery</title>
</svelte:head>
@@ -692,16 +819,23 @@
{#each queue as row (row.id)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<tr
data-row-id={row.id}
class="cursor-pointer border-b border-stone-100 align-top hover:bg-stone-50 dark:border-stone-800 dark:hover:bg-stone-900/50 {drawerId ===
row.id
? 'bg-stone-100 dark:bg-stone-800/50'
: ''}"
: selectedId === row.id
? 'bg-indigo-50 ring-1 ring-indigo-200 dark:bg-indigo-950/30 dark:ring-indigo-800'
: ''}"
onclick={(e) => {
// Ignore clicks on interactive descendants (Accept/Reject
// forms, sort header buttons, links). Everything else opens
// the drawer.
// forms, sort header buttons, links). Everything else
// selects + opens the drawer.
const target = e.target as HTMLElement;
if (target.closest('button, a, input, form, label, select')) return;
if (target.closest('button, a, input, form, label, select')) {
selectedId = row.id;
return;
}
selectedId = row.id;
openDrawer(row.id);
}}
role="button"
@@ -849,3 +983,69 @@
llmEnriching={openRow ? llmEnrichingIds.has(openRow.id) : false}
llmEnrichError={openRow && form?.llmEnrichErrorId === openRow.id ? (form.llmEnrichError ?? '') : ''}
/>
<!-- Keyboard shortcut help modal. Triggered by '?'. -->
{#if showHelp}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="fixed inset-0 z-[60] flex items-center justify-center bg-stone-900/50 p-4 backdrop-blur-sm dark:bg-stone-950/70"
onclick={() => (showHelp = false)}
role="button"
tabindex="-1"
aria-label="Hilfe schließen"
>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class="max-w-md rounded-lg border border-stone-200 bg-white p-6 shadow-xl dark:border-stone-700 dark:bg-stone-900"
role="dialog"
tabindex="-1"
aria-label="Tastaturkürzel"
onclick={(e) => e.stopPropagation()}
>
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold">Tastaturkürzel</h2>
<button
type="button"
onclick={() => (showHelp = false)}
class="rounded px-2 py-1 text-stone-500 hover:bg-stone-100 hover:text-stone-800 dark:hover:bg-stone-800 dark:hover:text-stone-100"
>
</button>
</div>
<dl class="mt-4 grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt class="font-mono text-stone-500">j / ↓</dt>
<dd>Nächste Zeile</dd>
<dt class="font-mono text-stone-500">k / ↑</dt>
<dd>Vorige Zeile</dd>
<dt class="font-mono text-stone-500">Enter</dt>
<dd>Drawer öffnen</dd>
<dt class="font-mono text-stone-500">Esc</dt>
<dd>Drawer / Hilfe schließen</dd>
<dt class="font-mono text-stone-500">a</dt>
<dd>Accept (aktuelle Zeile)</dd>
<dt class="font-mono text-stone-500">r</dt>
<dd>Reject (aktuelle Zeile)</dd>
<dt class="font-mono text-stone-500">e</dt>
<dd>Drawer öffnen → AI enrich sichtbar</dd>
<dt class="font-mono text-stone-500">s</dt>
<dd>Drawer öffnen → Similar sichtbar</dd>
<dt class="font-mono text-stone-500">?</dt>
<dd>Diese Hilfe</dd>
</dl>
<p class="mt-4 text-[10px] text-stone-500">
Kürzel sind inaktiv, während ein Textfeld Fokus hat.
</p>
</div>
</div>
{/if}
<!-- Persistent hint for discoverability. -->
<button
type="button"
onclick={() => (showHelp = true)}
class="fixed right-4 bottom-4 z-30 rounded-full border border-stone-200 bg-white px-3 py-1.5 text-xs text-stone-500 shadow-md hover:text-stone-800 dark:border-stone-700 dark:bg-stone-900 dark:hover:text-stone-100"
aria-label="Tastaturkürzel anzeigen"
title="Tastaturkürzel (?)"
>
?
</button>