From 3a0dbc9ba7519726b176b21aa97e59c0b8ee1820 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 17:40:03 +0300 Subject: [PATCH] feat(frontend): keyboard nav for the viewer, tag editor and filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/lib/components/file/FileViewer.svelte | 33 +++++++- .../src/lib/components/file/FilterBar.svelte | 80 ++++++++++++++++++- .../src/lib/components/file/TagPicker.svelte | 65 ++++++++++++++- 3 files changed, 172 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/components/file/FileViewer.svelte b/frontend/src/lib/components/file/FileViewer.svelte index 241293f..47a3b1b 100644 --- a/frontend/src/lib/components/file/FileViewer.svelte +++ b/frontend/src/lib/components/file/FileViewer.svelte @@ -184,17 +184,44 @@ } // ---- Keyboard ---- + let tagsSection = $state(); + 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('input')?.focus()); + } + + $effect(() => { + if (tagsLoaded && pendingTagFocus) { + pendingTagFocus = false; + focusTagInput(); + } + }); + // ---- Helpers ---- function formatDatetime(iso: string | null | undefined): string { if (!iso) return '—'; @@ -344,7 +371,7 @@ -
+
Tags
{#if tagsLoaded} diff --git a/frontend/src/lib/components/file/FilterBar.svelte b/frontend/src/lib/components/file/FilterBar.svelte index e2fa892..5cd65cd 100644 --- a/frontend/src/lib/components/file/FilterBar.svelte +++ b/frontend/src/lib/components/file/FilterBar.svelte @@ -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(null); let dropIndex = $state(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" />
- {#each filteredTags as tag (tag.id)} + {#each filteredTags as tag, i (tag.id)}