Merge branch 'feat/discovery-keyboard-shortcuts' — MR 8 keyboard shortcuts for queue review
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user