From 49e68cc263a43d20ab802460ae29a86b0c7a7cab Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 17:42:02 +0300 Subject: [PATCH] feat(frontend): keyboard nav for the bulk tag editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid's `e` opens the bulk tag editor, which has its own UI rather than the shared TagPicker, so it needed the same keyboard handling: from the search input, ↓/↑ highlight a suggestion and Enter adds it to all selected files (focus stays for chaining); with the input empty ←/→ walk the assigned tags and Del removes the focused one. Mirrors the viewer's tag picker. Co-Authored-By: Claude Opus 4.8 --- .../lib/components/file/BulkTagEditor.svelte | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/components/file/BulkTagEditor.svelte b/frontend/src/lib/components/file/BulkTagEditor.svelte index 38d4a89..444422c 100644 --- a/frontend/src/lib/components/file/BulkTagEditor.svelte +++ b/frontend/src/lib/components/file/BulkTagEditor.svelte @@ -116,6 +116,51 @@ busy = false; } } + + // ---- Keyboard navigation (from the search input) ---- + // ↓/↑ highlight a suggestion, Enter adds it (focus stays); with the input empty + // ←/→ walk the assigned tags and Del removes the focused one from all files. + let highlightIdx = $state(0); + let assignedFocusIdx = $state(-1); + + $effect(() => { + if (highlightIdx > availableTags.length - 1) { + highlightIdx = Math.max(0, availableTags.length - 1); + } + }); + + function onSearchKeydown(e: KeyboardEvent) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + assignedFocusIdx = -1; + if (availableTags.length) highlightIdx = Math.min(highlightIdx + 1, availableTags.length - 1); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + assignedFocusIdx = -1; + highlightIdx = Math.max(highlightIdx - 1, 0); + } else if (e.key === 'Enter') { + const tag = availableTags[highlightIdx]; + if (tag?.id) { + e.preventDefault(); + void add(tag.id); + } + } else if (e.key === 'ArrowRight' && search === '') { + e.preventDefault(); + const n = assignedTags.length; + if (n) assignedFocusIdx = assignedFocusIdx < 0 ? 0 : Math.min(assignedFocusIdx + 1, n - 1); + } else if (e.key === 'ArrowLeft' && search === '') { + e.preventDefault(); + const n = assignedTags.length; + if (n) assignedFocusIdx = assignedFocusIdx < 0 ? n - 1 : Math.max(assignedFocusIdx - 1, 0); + } else if (e.key === 'Delete' && assignedFocusIdx >= 0) { + const tag = assignedTags[assignedFocusIdx]; + if (tag?.id) { + e.preventDefault(); + void remove(tag.id); + assignedFocusIdx = Math.min(assignedFocusIdx, assignedTags.length - 2); + } + } + }
@@ -131,12 +176,13 @@ — partial tags shown with dashed border, click to apply to all
- {#each assignedTags as tag (tag.id)} + {#each assignedTags as tag, i (tag.id)} {@const isPartial = partialIds.has(tag.id ?? '')}