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:
@@ -237,17 +237,11 @@
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
// While the pool picker is open it owns the keyboard: Escape closes it
|
||||
// (even from its search field), and every other key is swallowed so the
|
||||
// viewer's shortcuts don't fire behind the modal. Typing still works —
|
||||
// non-Escape keys aren't prevented, only ignored here.
|
||||
if (poolPickerOpen) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
poolPickerOpen = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// While the pool picker is open it owns the keyboard entirely (its own
|
||||
// window handler drives search focus, arrow navigation, Enter to add, and
|
||||
// Escape to clear-then-close). Yield so the viewer's shortcuts don't fire
|
||||
// behind the modal and don't race the picker for Escape.
|
||||
if (poolPickerOpen) return;
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) 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
|
||||
@@ -259,6 +253,11 @@
|
||||
} else if (e.code === 'KeyE') {
|
||||
e.preventDefault();
|
||||
jumpToTags();
|
||||
} else if (e.code === 'KeyP') {
|
||||
if (file) {
|
||||
e.preventDefault();
|
||||
poolPickerOpen = true;
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { api } from '$lib/api/client';
|
||||
import { tick } from 'svelte';
|
||||
import type { Pool, PoolOffsetPage } from '$lib/api/types';
|
||||
|
||||
interface Props {
|
||||
@@ -19,6 +20,10 @@
|
||||
let addError = $state('');
|
||||
let search = $state('');
|
||||
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(() => {
|
||||
void load();
|
||||
@@ -43,6 +48,58 @@
|
||||
: 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) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
@@ -60,6 +117,8 @@
|
||||
let count = $derived(fileIds.length);
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKeydown} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="picker-backdrop" role="presentation" onclick={onClose}></div>
|
||||
<div class="picker-sheet" class:busy role="dialog" aria-label="Add to pool">
|
||||
@@ -83,6 +142,7 @@
|
||||
type="search"
|
||||
placeholder="Search pools…"
|
||||
bind:value={search}
|
||||
bind:this={searchEl}
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,10 +158,15 @@
|
||||
{#if filtered.length === 0}
|
||||
<p class="picker-empty">No pools found.</p>
|
||||
{:else}
|
||||
<ul class="picker-list">
|
||||
{#each filtered as pool (pool.id)}
|
||||
<ul class="picker-list" bind:this={listEl}>
|
||||
{#each filtered as pool, i (pool.id)}
|
||||
<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-count">{pool.file_count ?? 0} files</span>
|
||||
</button>
|
||||
@@ -226,6 +291,10 @@
|
||||
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 {
|
||||
flex: 1;
|
||||
font-size: 0.95rem;
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
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 (activeFileId) return;
|
||||
else if ($selectionActive) selectionStore.exit();
|
||||
|
||||
Reference in New Issue
Block a user