From 9a20cc1c84030197ea2c49325e406ebb414d2070 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 17:30:13 +0300 Subject: [PATCH] feat(frontend): keyboard roving-focus and bulk-action keys on the file grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arrow keys move a focus ring across the grid (column count derived from the layout, scrolling the focused card into view and pulling the next page near the end). Enter opens the focused file; Space/x select; e edits tags (opening the sheet and focusing its tag filter); p adds to a pool; Del moves to trash — each falling back to the focused card when nothing is selected. / opens the filter and focuses its search. The ring only appears once keyboard navigation starts and is dismissed on pointer use, so it never distracts mouse users. Escape layering is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../src/lib/components/file/FileCard.svelte | 10 ++ frontend/src/routes/files/+page.svelte | 151 ++++++++++++++++-- 2 files changed, 148 insertions(+), 13 deletions(-) diff --git a/frontend/src/lib/components/file/FileCard.svelte b/frontend/src/lib/components/file/FileCard.svelte index 9ed3196..51712fb 100644 --- a/frontend/src/lib/components/file/FileCard.svelte +++ b/frontend/src/lib/components/file/FileCard.svelte @@ -11,6 +11,8 @@ index: number; selected?: boolean; selectionMode?: boolean; + /** Roving keyboard-focus ring (shown only during keyboard navigation). */ + focused?: boolean; onTap?: (e: MouseEvent) => void; /** Called when long-press fires; receives the pointerType of the gesture. */ onLongPress?: (pointerType: string) => void; @@ -21,6 +23,7 @@ index, selected = false, selectionMode = false, + focused = false, onTap, onLongPress }: Props = $props(); @@ -108,6 +111,7 @@ class="card" class:loaded={!!imgSrc} class:selected + class:focused data-file-index={index} onpointerdown={onPointerDown} onpointermove={onPointerMoveInternal} @@ -215,6 +219,12 @@ background-color: color-mix(in srgb, var(--color-accent) 35%, transparent); } + .card.focused { + outline: 3px solid var(--color-accent); + outline-offset: -3px; + z-index: 1; + } + .check { position: absolute; top: 6px; diff --git a/frontend/src/routes/files/+page.svelte b/frontend/src/routes/files/+page.svelte index aa0d4bf..0beb395 100644 --- a/frontend/src/routes/files/+page.svelte +++ b/frontend/src/routes/files/+page.svelte @@ -39,16 +39,139 @@ // ---- Bulk tag editor ---- let tagEditorOpen = $state(false); - // Escape dismisses one layer at a time: an open overlay (tag editor / pool - // picker / delete confirm) first, then the selection. The file viewer owns - // its own Escape, so we bail out while it's up. - function handleEscape(e: KeyboardEvent) { - if (e.key !== 'Escape') return; - if (tagEditorOpen) tagEditorOpen = false; - else if (poolPickerOpen) poolPickerOpen = false; - else if (confirmDeleteFiles) confirmDeleteFiles = false; - else if (activeFileId) return; - else if ($selectionActive) selectionStore.exit(); + // ---- Keyboard roving focus ---- + // The id of the grid's keyboard-focused file, plus a flag that gates the focus + // ring so it only shows once the user actually starts navigating by keyboard. + let focusedId = $state(null); + let kbActive = $state(false); + + function isFormTarget(t: EventTarget | null): boolean { + return ( + t instanceof HTMLElement && + (t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(t.tagName)) + ); + } + + function gridCols(): number { + const w = scrollContainer?.clientWidth ?? 0; + return Math.max(1, Math.floor((w || 360) / CARD_PITCH)); + } + + function focusedFile(): File | undefined { + return focusedId ? files.find((f) => f.id === focusedId) : undefined; + } + + // Move the roving focus by `delta` positions, clamped to the loaded grid, and + // scroll the new card into view. Pulls the next page when nearing the end. + function moveFocus(delta: number) { + if (files.length === 0) return; + kbActive = true; + const cur = focusedId ? files.findIndex((f) => f.id === focusedId) : -1; + const next = Math.max(0, Math.min(files.length - 1, cur < 0 ? 0 : cur + delta)); + focusedId = files[next]?.id ?? null; + if (next >= files.length - gridCols() * 2 && hasMore && !loading) void loadMore(); + const id = focusedId; + requestAnimationFrame(() => { + const idx = files.findIndex((f) => f.id === id); + scrollContainer + ?.querySelector(`[data-file-index="${idx}"]`) + ?.scrollIntoView({ block: 'nearest' }); + }); + } + + // Action keys operate on the selection; with nothing selected they fall back to + // the focused card (selecting it first so the bulk sheets have a target). + function ensureSelectedFocused() { + const f = focusedFile(); + if (f?.id && !$selectionStore.ids.has(f.id)) selectionStore.select(f.id); + } + + function openTagEditor() { + tagEditorOpen = true; + void tick().then(() => document.querySelector('.tag-sheet input')?.focus()); + } + + function openFilterAndFocus() { + filterOpen = true; + void tick().then(() => document.querySelector('.bar .search')?.focus()); + } + + // Single window handler for the grid: Escape peels one layer at a time (overlay + // → selection; the viewer owns its own Escape), and the rest drives roving + // focus + bulk actions while the bare list is in front. + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') { + if (tagEditorOpen) tagEditorOpen = false; + else if (poolPickerOpen) poolPickerOpen = false; + else if (confirmDeleteFiles) confirmDeleteFiles = false; + else if (activeFileId) return; + else if ($selectionActive) selectionStore.exit(); + return; + } + + if (activeFileId || tagEditorOpen || poolPickerOpen || confirmDeleteFiles) return; + if (isFormTarget(e.target) || e.metaKey || e.ctrlKey || e.altKey) return; + + switch (e.key) { + case 'ArrowRight': + e.preventDefault(); + moveFocus(1); + break; + case 'ArrowLeft': + e.preventDefault(); + moveFocus(-1); + break; + case 'ArrowDown': + e.preventDefault(); + moveFocus(gridCols()); + break; + case 'ArrowUp': + e.preventDefault(); + moveFocus(-gridCols()); + break; + case 'Enter': { + const f = focusedFile(); + if (f) { + e.preventDefault(); + openFile(f); + } + break; + } + case ' ': + case 'x': { + const f = focusedFile(); + if (f?.id) { + e.preventDefault(); + selectionStore.toggle(f.id); + } + break; + } + case 'e': + if ($selectionActive || focusedFile()) { + e.preventDefault(); + ensureSelectedFocused(); + openTagEditor(); + } + break; + case 'p': + if ($selectionActive || focusedFile()) { + e.preventDefault(); + ensureSelectedFocused(); + void openPoolPicker(); + } + break; + case 'Delete': + if ($selectionActive || focusedFile()) { + e.preventDefault(); + ensureSelectedFocused(); + confirmDeleteFiles = true; + } + break; + case '/': + e.preventDefault(); + openFilterAndFocus(); + break; + } } // ---- Add to pool picker ---- @@ -635,7 +758,7 @@ }); - + Files | Tanabata @@ -668,13 +791,15 @@ {/if} -
+ +
(kbActive = false)}> {#each files as file, i (file.id)} handleTap(file, i, e)} onLongPress={(pt) => handleLongPress(file, i, pt)} /> @@ -706,7 +831,7 @@ {#if $selectionActive} (tagEditorOpen = true)} + onEditTags={openTagEditor} onAddToPool={openPoolPicker} onDelete={() => (confirmDeleteFiles = true)} />