feat(frontend): keyboard nav for the viewer, tag editor and filter

Viewer: j/k mirror the arrow keys, and `e` scrolls to the (lazy) Tags
section and drops the cursor into its filter, forcing the load so focus
lands even before the section is reached.

Tag picker & filter bar: from the search input, ↓/↑ highlight a
suggestion and Enter adds it (focus stays for chaining); with the input
empty ←/→ walk the added tags/tokens and Del removes the focused one. The
filter bar also inserts an operator token on & | ! ( ), applies on
Ctrl+Enter, resets on Ctrl+Backspace and closes on Esc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:40:03 +03:00
parent 9a20cc1c84
commit 3a0dbc9ba7
3 changed files with 172 additions and 6 deletions
@@ -184,17 +184,44 @@
}
// ---- Keyboard ----
let tagsSection = $state<HTMLElement>();
let pendingTagFocus = false;
function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.key === 'ArrowLeft') {
if (e.key === 'ArrowLeft' || e.key === 'k') {
if (prevId) onNavigate(prevId);
} else if (e.key === 'ArrowRight') {
} else if (e.key === 'ArrowRight' || e.key === 'j') {
if (nextId) onNavigate(nextId);
} else if (e.key === 'e') {
e.preventDefault();
jumpToTags();
} else if (e.key === 'Escape') {
onClose();
}
}
// Scroll the (lazily loaded) Tags section into view and drop the cursor into
// its filter. Forces the load so the focus lands even before the user reaches
// the section by scrolling.
function jumpToTags() {
tagsVisible = true;
tagsSection?.scrollIntoView({ behavior: 'smooth', block: 'start' });
pendingTagFocus = true;
focusTagInput();
}
function focusTagInput() {
requestAnimationFrame(() => tagsSection?.querySelector<HTMLInputElement>('input')?.focus());
}
$effect(() => {
if (tagsLoaded && pendingTagFocus) {
pendingTagFocus = false;
focusTagInput();
}
});
// ---- Helpers ----
function formatDatetime(iso: string | null | undefined): string {
if (!iso) return '—';
@@ -344,7 +371,7 @@
</button>
<!-- Tags (loaded lazily on scroll) -->
<section class="section" use:tagsSentinel>
<section class="section" use:tagsSentinel bind:this={tagsSection}>
<div class="field-label">Tags</div>
{#if tagsLoaded}
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
@@ -53,6 +53,71 @@
onApply(null);
}
// ---- Keyboard navigation (from the search input) ----
// ↓/↑ highlight a tag, Enter adds it as a token; the operator chars insert an
// operator token; with the input empty ←/→ walk the active tokens and Del
// removes the focused one. Mod+Enter applies, Mod+Backspace resets, Esc closes.
let highlightIdx = $state(0);
let tokenFocusIdx = $state(-1);
const OP_KEYS = ['&', '|', '!', '(', ')'];
$effect(() => {
if (highlightIdx > filteredTags.length - 1) {
highlightIdx = Math.max(0, filteredTags.length - 1);
}
});
function onSearchKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
apply();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Backspace') {
e.preventDefault();
reset();
return;
}
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (OP_KEYS.includes(e.key)) {
e.preventDefault();
addToken(e.key);
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
tokenFocusIdx = -1;
if (filteredTags.length) highlightIdx = Math.min(highlightIdx + 1, filteredTags.length - 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
tokenFocusIdx = -1;
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
const tag = filteredTags[highlightIdx];
if (tag?.id) {
e.preventDefault();
addToken(`t=${tag.id}`);
}
} else if (e.key === 'ArrowRight' && search === '') {
e.preventDefault();
const n = tokens.length;
if (n) tokenFocusIdx = tokenFocusIdx < 0 ? 0 : Math.min(tokenFocusIdx + 1, n - 1);
} else if (e.key === 'ArrowLeft' && search === '') {
e.preventDefault();
const n = tokens.length;
if (n) tokenFocusIdx = tokenFocusIdx < 0 ? n - 1 : Math.max(tokenFocusIdx - 1, 0);
} else if (e.key === 'Delete' && tokenFocusIdx >= 0) {
e.preventDefault();
removeToken(tokenFocusIdx);
tokenFocusIdx = Math.min(tokenFocusIdx, tokens.length - 2);
} else if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}
// --- Drag-and-drop reordering ---
let dragIndex = $state<number | null>(null);
let dropIndex = $state<number | null>(null);
@@ -99,6 +164,7 @@
class="token active-token"
class:dragging={dragIndex === i}
class:drop-before={dropIndex === i && dragIndex !== null && dragIndex !== i}
class:kbfocus={tokenFocusIdx === i}
draggable="true"
role="button"
tabindex="0"
@@ -129,14 +195,16 @@
type="search"
placeholder="Search tags…"
bind:value={search}
onkeydown={onSearchKeydown}
autocomplete="off"
/>
<!-- Tag list -->
<div class="tag-list">
{#each filteredTags as tag (tag.id)}
{#each filteredTags as tag, i (tag.id)}
<button
class="token tag-token"
class:hl={highlightIdx === i}
style="background-color: {tag.color
? '#' + tag.color
: tag.category_color
@@ -232,6 +300,11 @@
outline-offset: 2px;
}
.active-token.kbfocus {
outline: 2px solid var(--color-danger);
outline-offset: 2px;
}
.op-token {
background-color: color-mix(in srgb, var(--color-accent) 18%, var(--color-bg-elevated));
color: var(--color-text-primary);
@@ -278,6 +351,11 @@
filter: brightness(1.15);
}
.tag-token.hl {
outline: 2px solid var(--color-text-primary);
outline-offset: 1px;
}
.no-tags {
font-size: 0.8rem;
color: var(--color-text-muted);
@@ -64,6 +64,53 @@
const color = tag.color ?? tag.category_color;
return color ? `background-color: #${color}` : '';
}
// ---- Keyboard navigation (from the search input) ----
// ↓/↑ highlight a suggestion, Enter adds it (focus stays for chaining); with the
// input empty, ←/→ walk the assigned pills and Del removes the focused one.
let highlightIdx = $state(0);
let assignedFocusIdx = $state(-1);
$effect(() => {
if (highlightIdx > filteredAvailable.length - 1) {
highlightIdx = Math.max(0, filteredAvailable.length - 1);
}
});
function onSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault();
assignedFocusIdx = -1;
if (filteredAvailable.length) {
highlightIdx = Math.min(highlightIdx + 1, filteredAvailable.length - 1);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
assignedFocusIdx = -1;
highlightIdx = Math.max(highlightIdx - 1, 0);
} else if (e.key === 'Enter') {
const tag = filteredAvailable[highlightIdx];
if (tag?.id) {
e.preventDefault();
void handleAdd(tag.id);
}
} else if (e.key === 'ArrowRight' && search === '') {
e.preventDefault();
const n = filteredAssigned.length;
if (n) assignedFocusIdx = assignedFocusIdx < 0 ? 0 : Math.min(assignedFocusIdx + 1, n - 1);
} else if (e.key === 'ArrowLeft' && search === '') {
e.preventDefault();
const n = filteredAssigned.length;
if (n) assignedFocusIdx = assignedFocusIdx < 0 ? n - 1 : Math.max(assignedFocusIdx - 1, 0);
} else if (e.key === 'Delete' && assignedFocusIdx >= 0) {
const tag = filteredAssigned[assignedFocusIdx];
if (tag?.id) {
e.preventDefault();
void handleRemove(tag.id);
assignedFocusIdx = Math.min(assignedFocusIdx, filteredAssigned.length - 2);
}
}
}
</script>
<div class="picker" class:busy>
@@ -71,9 +118,10 @@
{#if fileTags.length > 0}
<div class="section-label">Assigned</div>
<div class="tag-row">
{#each filteredAssigned as tag (tag.id)}
{#each filteredAssigned as tag, i (tag.id)}
<button
class="tag assigned"
class:kbfocus={assignedFocusIdx === i}
style={tagStyle(tag)}
onclick={() => handleRemove(tag.id!)}
title="Remove tag"
@@ -92,6 +140,7 @@
type="search"
placeholder="Search tags…"
bind:value={search}
onkeydown={onSearchKeydown}
autocomplete="off"
/>
{#if search}
@@ -112,9 +161,10 @@
{#if filteredAvailable.length > 0}
<div class="section-label">Add tag</div>
<div class="tag-row available-row">
{#each filteredAvailable as tag (tag.id)}
{#each filteredAvailable as tag, i (tag.id)}
<button
class="tag available"
class:hl={highlightIdx === i}
style={tagStyle(tag)}
onclick={() => handleAdd(tag.id!)}
title="Add tag"
@@ -198,6 +248,17 @@
filter: brightness(1.1);
}
.tag.available.hl {
opacity: 1;
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.tag.assigned.kbfocus {
outline: 2px solid var(--color-danger);
outline-offset: 1px;
}
.search-wrap {
position: relative;
display: flex;