feat(frontend): stage Escape in the tag editors to clear, then exit

In the single-file viewer's tag filter, Escape now clears a non-empty
filter first; on an empty filter it blurs and scrolls the preview back
to the top. In the bulk (multi-file) editor it clears, then releases
focus, so only the next Escape reaches the page handler and closes the
popup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 21:27:06 +03:00
parent ca3bca59e7
commit 6a3bb9ff51
3 changed files with 37 additions and 5 deletions
@@ -159,6 +159,20 @@
void remove(tag.id);
assignedFocusIdx = Math.min(assignedFocusIdx, assignedTags.length - 2);
}
} else if (e.key === 'Escape') {
// Staged exit: a non-empty filter clears first; once empty, Escape
// releases focus. Stop propagation so neither step reaches the page's
// window handler — only the *next* Escape (with focus already gone) does,
// and that one closes the popup.
e.preventDefault();
e.stopPropagation();
if (search) {
search = '';
assignedFocusIdx = -1;
} else {
assignedFocusIdx = -1;
(e.currentTarget as HTMLInputElement).blur();
}
}
}
</script>
@@ -184,9 +184,17 @@
}
// ---- Keyboard ----
let viewerPage = $state<HTMLElement>();
let tagsSection = $state<HTMLElement>();
let pendingTagFocus = false;
// Bring the preview back to the top of the scroll container (the overlay, or
// the page in the standalone route). scrollIntoView resolves the right
// scroller in either case. Called when Escape leaves the tag filter.
function revealPreview() {
viewerPage?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function handleKeydown(e: KeyboardEvent) {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
@@ -242,7 +250,7 @@
<svelte:window onkeydown={handleKeydown} />
<div class="viewer-page">
<div class="viewer-page" bind:this={viewerPage}>
<!-- Top bar -->
<div class="top-bar">
<button class="back-btn" onclick={onClose} aria-label="Back to files">
@@ -377,7 +385,7 @@
<section class="section" use:tagsSentinel bind:this={tagsSection}>
<div class="field-label">Tags</div>
{#if tagsLoaded}
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} />
<TagPicker {fileTags} onAdd={addTag} onRemove={removeTag} onExit={revealPreview} />
{:else}
<p class="tags-loading">Loading tags…</p>
{/if}
@@ -7,9 +7,12 @@
fileTags: Tag[];
onAdd: (tagId: string) => Promise<void>;
onRemove: (tagId: string) => Promise<void>;
/** Called when Escape leaves an already-empty filter, so the viewer can
* scroll the preview back into view. */
onExit?: () => void;
}
let { fileTags, onAdd, onRemove }: Props = $props();
let { fileTags, onAdd, onRemove, onExit }: Props = $props();
let allTags = $state<Tag[]>([]);
let search = $state('');
@@ -110,11 +113,18 @@
assignedFocusIdx = Math.min(assignedFocusIdx, filteredAssigned.length - 2);
}
} else if (e.key === 'Escape') {
// Let the keyboard leave the field: blur back to the page so arrow keys
// and Escape reach the viewer again (e.g. a second Esc closes it).
e.preventDefault();
if (search) {
// Non-empty filter: just clear it, keeping focus for more editing.
search = '';
assignedFocusIdx = -1;
return;
}
// Empty: blur back to the page (so arrow keys and a further Escape reach
// the viewer) and let it scroll the preview back into view.
assignedFocusIdx = -1;
(e.currentTarget as HTMLInputElement).blur();
onExit?.();
}
}
</script>