feat(frontend): keyboard control for the add-to-pool dialog

The file viewer could only open the pool picker via its top-right button —
there was no `p` shortcut there (only the grid had one), so pressing `p`
on the view page did nothing. Add `p` to open the picker from the viewer,
and give the picker itself full keyboard control: `/` focuses the search
box, arrows move a highlight through the pool list, Enter adds to the
highlighted pool, and Escape clears the search first, then closes.

Both the viewer and the grid now yield the keyboard entirely to the open
picker (the picker owns Escape via its own window handler) so the
clear-then-close behaviour isn't pre-empted by the host's own Escape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:15:12 +03:00
parent b8d08925a2
commit c9b7f0701b
3 changed files with 83 additions and 15 deletions
@@ -237,17 +237,11 @@
} }
function handleKeydown(e: KeyboardEvent) { function handleKeydown(e: KeyboardEvent) {
// While the pool picker is open it owns the keyboard: Escape closes it // While the pool picker is open it owns the keyboard entirely (its own
// (even from its search field), and every other key is swallowed so the // window handler drives search focus, arrow navigation, Enter to add, and
// viewer's shortcuts don't fire behind the modal. Typing still works — // Escape to clear-then-close). Yield so the viewer's shortcuts don't fire
// non-Escape keys aren't prevented, only ignored here. // behind the modal and don't race the picker for Escape.
if (poolPickerOpen) { if (poolPickerOpen) return;
if (e.key === 'Escape') {
e.preventDefault();
poolPickerOpen = false;
}
return;
}
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
if (e.ctrlKey || e.metaKey || e.altKey) return; if (e.ctrlKey || e.metaKey || e.altKey) return;
// Letter keys are matched by physical position (e.code) so j/k/e work on any // Letter keys are matched by physical position (e.code) so j/k/e work on any
@@ -259,6 +253,11 @@
} else if (e.code === 'KeyE') { } else if (e.code === 'KeyE') {
e.preventDefault(); e.preventDefault();
jumpToTags(); jumpToTags();
} else if (e.code === 'KeyP') {
if (file) {
e.preventDefault();
poolPickerOpen = true;
}
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
onClose(); onClose();
} }
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { api } from '$lib/api/client'; import { api } from '$lib/api/client';
import { tick } from 'svelte';
import type { Pool, PoolOffsetPage } from '$lib/api/types'; import type { Pool, PoolOffsetPage } from '$lib/api/types';
interface Props { interface Props {
@@ -19,6 +20,10 @@
let addError = $state(''); let addError = $state('');
let search = $state(''); let search = $state('');
let busy = $state(false); let busy = $state(false);
// Index of the keyboard-highlighted pool within `filtered`.
let highlight = $state(0);
let searchEl = $state<HTMLInputElement | null>(null);
let listEl = $state<HTMLUListElement | null>(null);
$effect(() => { $effect(() => {
void load(); void load();
@@ -43,6 +48,58 @@
: pools : pools
); );
// Snap the highlight back to the top whenever the result set changes.
$effect(() => {
filtered;
highlight = 0;
});
function moveHighlight(delta: number) {
const n = filtered.length;
if (n === 0) return;
highlight = Math.min(n - 1, Math.max(0, highlight + delta));
void tick().then(() =>
listEl?.querySelector('.picker-item.highlighted')?.scrollIntoView({ block: 'nearest' })
);
}
// Keyboard control for the open picker: arrows move the highlight, Enter adds
// to the highlighted pool, "/" jumps to the search box, and Escape clears the
// search first, then closes.
function onKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'Escape':
e.preventDefault();
if (search) search = '';
else onClose();
return;
case '/':
// Don't steal "/" while typing in the box — let it filter literally.
if (document.activeElement !== searchEl) {
e.preventDefault();
searchEl?.focus();
searchEl?.select();
}
return;
case 'ArrowDown':
e.preventDefault();
moveHighlight(1);
return;
case 'ArrowUp':
e.preventDefault();
moveHighlight(-1);
return;
case 'Enter': {
const pool = filtered[highlight];
if (pool?.id) {
e.preventDefault();
void add(pool.id);
}
return;
}
}
}
async function add(poolId: string) { async function add(poolId: string) {
if (busy) return; if (busy) return;
busy = true; busy = true;
@@ -60,6 +117,8 @@
let count = $derived(fileIds.length); let count = $derived(fileIds.length);
</script> </script>
<svelte:window onkeydown={onKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="picker-backdrop" role="presentation" onclick={onClose}></div> <div class="picker-backdrop" role="presentation" onclick={onClose}></div>
<div class="picker-sheet" class:busy role="dialog" aria-label="Add to pool"> <div class="picker-sheet" class:busy role="dialog" aria-label="Add to pool">
@@ -83,6 +142,7 @@
type="search" type="search"
placeholder="Search pools…" placeholder="Search pools…"
bind:value={search} bind:value={search}
bind:this={searchEl}
autocomplete="off" autocomplete="off"
/> />
</div> </div>
@@ -98,10 +158,15 @@
{#if filtered.length === 0} {#if filtered.length === 0}
<p class="picker-empty">No pools found.</p> <p class="picker-empty">No pools found.</p>
{:else} {:else}
<ul class="picker-list"> <ul class="picker-list" bind:this={listEl}>
{#each filtered as pool (pool.id)} {#each filtered as pool, i (pool.id)}
<li> <li>
<button class="picker-item" onclick={() => pool.id && add(pool.id)}> <button
class="picker-item"
class:highlighted={i === highlight}
onmouseenter={() => (highlight = i)}
onclick={() => pool.id && add(pool.id)}
>
<span class="picker-item-name">{pool.name}</span> <span class="picker-item-name">{pool.name}</span>
<span class="picker-item-count">{pool.file_count ?? 0} files</span> <span class="picker-item-count">{pool.file_count ?? 0} files</span>
</button> </button>
@@ -226,6 +291,10 @@
background-color: color-mix(in srgb, var(--color-accent) 12%, transparent); background-color: color-mix(in srgb, var(--color-accent) 12%, transparent);
} }
.picker-item.highlighted {
background-color: color-mix(in srgb, var(--color-accent) 22%, transparent);
}
.picker-item-name { .picker-item-name {
flex: 1; flex: 1;
font-size: 0.95rem; font-size: 0.95rem;
+1 -1
View File
@@ -148,7 +148,7 @@
function handleKey(e: KeyboardEvent) { function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (tagEditorOpen) tagEditorOpen = false; if (tagEditorOpen) tagEditorOpen = false;
else if (poolPickerOpen) poolPickerOpen = false; else if (poolPickerOpen) return; // PoolPicker owns Escape (clear search, then close)
else if (confirmDeleteFiles) confirmDeleteFiles = false; else if (confirmDeleteFiles) confirmDeleteFiles = false;
else if (activeFileId) return; else if (activeFileId) return;
else if ($selectionActive) selectionStore.exit(); else if ($selectionActive) selectionStore.exit();