feat(frontend): keyboard roving-focus and bulk-action keys on the file grid
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
// ---- 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<string | null>(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<HTMLElement>(`[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<HTMLInputElement>('.tag-sheet input')?.focus());
|
||||
}
|
||||
|
||||
function openFilterAndFocus() {
|
||||
filterOpen = true;
|
||||
void tick().then(() => document.querySelector<HTMLInputElement>('.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 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleEscape} />
|
||||
<svelte:window onkeydown={handleKey} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Files | Tanabata</title>
|
||||
@@ -668,13 +791,15 @@
|
||||
<InfiniteScroll {loading} hasMore={hasPrev} onLoadMore={loadPrev} edge="top" />
|
||||
{/if}
|
||||
|
||||
<div class="grid">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="grid" onpointerdowncapture={() => (kbActive = false)}>
|
||||
{#each files as file, i (file.id)}
|
||||
<FileCard
|
||||
{file}
|
||||
index={i}
|
||||
selected={$selectionStore.ids.has(file.id ?? '')}
|
||||
selectionMode={$selectionActive}
|
||||
focused={kbActive && file.id === focusedId}
|
||||
onTap={(e) => handleTap(file, i, e)}
|
||||
onLongPress={(pt) => handleLongPress(file, i, pt)}
|
||||
/>
|
||||
@@ -706,7 +831,7 @@
|
||||
|
||||
{#if $selectionActive}
|
||||
<SelectionBar
|
||||
onEditTags={() => (tagEditorOpen = true)}
|
||||
onEditTags={openTagEditor}
|
||||
onAddToPool={openPoolPicker}
|
||||
onDelete={() => (confirmDeleteFiles = true)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user