feat(frontend): keyboard nav for the bulk tag editor

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 17:42:02 +03:00
parent 3a0dbc9ba7
commit 49e68cc263
@@ -116,6 +116,51 @@
busy = false; 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);
}
}
}
</script> </script>
<div class="editor" class:busy> <div class="editor" class:busy>
@@ -131,12 +176,13 @@
<span class="hint">— partial tags shown with dashed border, click to apply to all</span> <span class="hint">— partial tags shown with dashed border, click to apply to all</span>
</div> </div>
<div class="tag-row"> <div class="tag-row">
{#each assignedTags as tag (tag.id)} {#each assignedTags as tag, i (tag.id)}
{@const isPartial = partialIds.has(tag.id ?? '')} {@const isPartial = partialIds.has(tag.id ?? '')}
<div class="tag-wrap"> <div class="tag-wrap">
<button <button
class="tag assigned" class="tag assigned"
class:partial={isPartial} class:partial={isPartial}
class:kbfocus={assignedFocusIdx === i}
style={tagStyle(tag)} style={tagStyle(tag)}
onclick={() => (isPartial ? promotePartial(tag.id!) : remove(tag.id!))} onclick={() => (isPartial ? promotePartial(tag.id!) : remove(tag.id!))}
title={isPartial title={isPartial
@@ -162,6 +208,7 @@
type="search" type="search"
placeholder="Search tags…" placeholder="Search tags…"
bind:value={search} bind:value={search}
onkeydown={onSearchKeydown}
autocomplete="off" autocomplete="off"
/> />
{#if search} {#if search}
@@ -182,9 +229,10 @@
{#if availableTags.length > 0} {#if availableTags.length > 0}
<div class="section-label">Add tag</div> <div class="section-label">Add tag</div>
<div class="tag-row available-row"> <div class="tag-row available-row">
{#each availableTags as tag (tag.id)} {#each availableTags as tag, i (tag.id)}
<button <button
class="tag available" class="tag available"
class:hl={highlightIdx === i}
style={tagStyle(tag)} style={tagStyle(tag)}
onclick={() => add(tag.id!)} onclick={() => add(tag.id!)}
title="Add to all selected files" title="Add to all selected files"
@@ -310,6 +358,17 @@
filter: brightness(1.1); 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 { .search-wrap {
position: relative; position: relative;
display: flex; display: flex;