feat(frontend): keyboard navigation for tag and category grids
deploy / deploy (push) Successful in 23s

Mirror the Files grid's roving keyboard focus on the tag and category
lists (and the tags shown on a category page): arrows move a focus ring,
Enter opens the focused item, "/" jumps to search, Escape drops the ring.
Extracts the model into a reusable createRovingGrid controller; vertical
movement is geometric since the pills wrap at variable widths. The
tag/category edit pages gain Escape-to-leave parity with the file viewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 23:36:36 +03:00
parent 97d6daaa13
commit 70d12615b8
6 changed files with 299 additions and 11 deletions
@@ -5,16 +5,27 @@
tag: Tag; tag: Tag;
onclick?: () => void; onclick?: () => void;
size?: 'sm' | 'md'; size?: 'sm' | 'md';
/** Roving keyboard-focus ring (shown only during keyboard navigation). */
focused?: boolean;
/** Position in a roving-focus grid; exposed as data-item-index for nav. */
index?: number;
} }
let { tag, onclick, size = 'md' }: Props = $props(); let { tag, onclick, size = 'md', focused = false, index }: Props = $props();
const color = tag.color ?? tag.category_color; const color = tag.color ?? tag.category_color;
const style = color ? `background-color: #${color}` : ''; const style = color ? `background-color: #${color}` : '';
</script> </script>
{#if onclick} {#if onclick}
<button class="badge {size}" {style} {onclick} type="button"> <button
class="badge {size}"
class:focused
{style}
{onclick}
type="button"
data-item-index={index}
>
{tag.name} {tag.name}
</button> </button>
{:else} {:else}
@@ -53,4 +64,12 @@
button.badge:hover { button.badge:hover {
filter: brightness(1.15); filter: brightness(1.15);
} }
.badge.focused {
outline: 2px solid var(--color-text-primary);
outline-offset: 2px;
/* Keep the ring clear of the fixed bottom navbar when scrolled into view. */
scroll-margin-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
scroll-margin-top: 52px;
}
</style> </style>
+173
View File
@@ -0,0 +1,173 @@
// Reusable roving keyboard-focus for a wrap/grid of items navigated by id —
// the same model the Files grid uses (arrows move a focus ring, Enter opens,
// "/" jumps to search, Escape drops the ring), generalised so the tag and
// category lists can share it.
//
// Unlike the Files grid (fixed 160px cards → computable columns), tags and
// categories are variable-width pills that wrap, so vertical movement is
// geometric: pick the nearest item in the target row by horizontal centre.
interface Item {
id?: string;
}
interface RovingGridOptions<T extends Item> {
/** Reactive getter for the current item list (in render order). */
items: () => T[];
/** The scroll container holding the item elements. */
container: () => HTMLElement | undefined;
/** Open / activate the focused item (Enter). */
onOpen: (item: T) => void;
/** Focus the page's search box ("/"). Optional. */
focusSearch?: () => void;
/** CSS selector for the focusable item elements within the container. */
itemSelector?: string;
}
export function createRovingGrid<T extends Item>(opts: RovingGridOptions<T>) {
const selector = opts.itemSelector ?? '[data-item-index]';
let focusedId = $state<string | null>(null);
// Gate the focus ring so it only shows once the user navigates by keyboard.
let kbActive = $state(false);
// Drop the focus if its item leaves the loaded/filtered list.
$effect(() => {
const list = opts.items();
if (focusedId && !list.some((it) => it.id === focusedId)) {
focusedId = null;
}
});
function isFormTarget(t: EventTarget | null): boolean {
return (
t instanceof HTMLElement &&
(t.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT', 'BUTTON', 'A'].includes(t.tagName))
);
}
function els(): HTMLElement[] {
const root = opts.container();
return root ? [...root.querySelectorAll<HTMLElement>(selector)] : [];
}
function currentIndex(list: T[]): number {
return focusedId ? list.findIndex((it) => it.id === focusedId) : -1;
}
function focusAt(idx: number, list: T[]) {
const id = list[idx]?.id;
if (!id) return;
kbActive = true;
focusedId = id;
requestAnimationFrame(() => {
els()[idx]?.scrollIntoView({ block: 'nearest' });
});
}
// Horizontal step is index-based; vertical step is geometric (items wrap at
// variable widths, so there's no fixed column count to add/subtract).
function move(dir: 'left' | 'right' | 'up' | 'down') {
const list = opts.items();
if (list.length === 0) return;
const cur = currentIndex(list);
if (cur < 0) {
focusAt(0, list);
return;
}
if (dir === 'left') {
focusAt(Math.max(0, cur - 1), list);
return;
}
if (dir === 'right') {
focusAt(Math.min(list.length - 1, cur + 1), list);
return;
}
// up / down: nearest item in the target direction, preferring the closest
// row, then the closest horizontal centre.
const nodes = els();
const curRect = nodes[cur]?.getBoundingClientRect();
if (!curRect) return;
const curMidX = curRect.left + curRect.width / 2;
let best = -1;
let bestScore = Infinity;
for (let i = 0; i < nodes.length; i++) {
if (i === cur) continue;
const r = nodes[i].getBoundingClientRect();
const wanted = dir === 'down' ? r.top > curRect.top + 1 : r.top < curRect.top - 1;
if (!wanted) continue;
const dy = Math.abs(r.top - curRect.top);
const dx = Math.abs(r.left + r.width / 2 - curMidX);
const score = dy * 100000 + dx; // row distance dominates, x breaks ties
if (score < bestScore) {
bestScore = score;
best = i;
}
}
if (best >= 0) focusAt(best, list);
}
function handleKey(e: KeyboardEvent) {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.key === 'Escape') {
if (focusedId) {
focusedId = null;
kbActive = false;
}
return;
}
// "/" focuses the search box from anywhere outside a field.
if (e.key === '/' && opts.focusSearch && !isFormTarget(e.target)) {
e.preventDefault();
opts.focusSearch();
return;
}
if (isFormTarget(e.target)) return;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
move('right');
return;
case 'ArrowLeft':
e.preventDefault();
move('left');
return;
case 'ArrowDown':
e.preventDefault();
move('down');
return;
case 'ArrowUp':
e.preventDefault();
move('up');
return;
case 'Enter': {
const list = opts.items();
const cur = currentIndex(list);
if (cur >= 0) {
e.preventDefault();
opts.onOpen(list[cur]);
}
return;
}
}
}
return {
get focusedId() {
return focusedId;
},
get kbActive() {
return kbActive;
},
/** Clear the keyboard ring (e.g. on a pointer interaction). */
clearKbActive() {
kbActive = false;
},
handleKey
};
}
+25 -2
View File
@@ -6,6 +6,7 @@
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache'; import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache';
import { restoreListScroll } from '$lib/stores/listScroll'; import { restoreListScroll } from '$lib/stores/listScroll';
import { createRovingGrid } from '$lib/utils/rovingGrid.svelte';
import type { Category, CategoryOffsetPage } from '$lib/api/types'; import type { Category, CategoryOffsetPage } from '$lib/api/types';
const LIMIT = 100; const LIMIT = 100;
@@ -106,8 +107,19 @@
} }
let hasMore = $derived(categories.length < total); let hasMore = $derived(categories.length < total);
// Keyboard navigation: same roving-focus model as the Files grid (arrows move
// the ring, Enter opens, "/" focuses search, Escape drops the ring).
const roving = createRovingGrid<Category>({
items: () => categories,
container: () => scrollEl,
onOpen: (cat) => goto(`/categories/${cat.id}`),
focusSearch: () => document.querySelector<HTMLInputElement>('.search-input')?.focus()
});
</script> </script>
<svelte:window onkeydown={roving.handleKey} />
<svelte:head> <svelte:head>
<title>Categories | Tanabata</title> <title>Categories | Tanabata</title>
</svelte:head> </svelte:head>
@@ -192,10 +204,13 @@
<p class="error" role="alert">{error}</p> <p class="error" role="alert">{error}</p>
{/if} {/if}
<div class="category-grid"> <!-- svelte-ignore a11y_no_static_element_interactions -->
{#each categories as cat (cat.id)} <div class="category-grid" onpointerdowncapture={() => roving.clearKbActive()}>
{#each categories as cat, i (cat.id)}
<button <button
class="category-pill" class="category-pill"
class:focused={roving.kbActive && cat.id === roving.focusedId}
data-item-index={i}
style={cat.color ? `background-color: #${cat.color}` : ''} style={cat.color ? `background-color: #${cat.color}` : ''}
onclick={() => goto(`/categories/${cat.id}`)} onclick={() => goto(`/categories/${cat.id}`)}
> >
@@ -384,6 +399,14 @@
filter: brightness(1.15); filter: brightness(1.15);
} }
.category-pill.focused {
outline: 2px solid var(--color-text-primary);
outline-offset: 2px;
/* Keep the ring clear of the fixed bottom navbar when scrolled into view. */
scroll-margin-bottom: calc(72px + env(safe-area-inset-bottom, 0px));
scroll-margin-top: 52px;
}
.error { .error {
color: var(--color-danger); color: var(--color-danger);
font-size: 0.875rem; font-size: 0.875rem;
@@ -5,6 +5,7 @@
import type { Category, Tag, TagOffsetPage } from '$lib/api/types'; import type { Category, Tag, TagOffsetPage } from '$lib/api/types';
import TagBadge from '$lib/components/tag/TagBadge.svelte'; import TagBadge from '$lib/components/tag/TagBadge.svelte';
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import { createRovingGrid } from '$lib/utils/rovingGrid.svelte';
let categoryId = $derived(page.params.id); let categoryId = $derived(page.params.id);
@@ -74,6 +75,33 @@
let tagsHasMore = $derived(tags.length < tagsTotal); let tagsHasMore = $derived(tags.length < tagsTotal);
// Keyboard nav over the category's tags — same roving-focus model as the Files
// grid (arrows move the ring, Enter opens the focused tag).
let scrollEl = $state<HTMLElement>();
const roving = createRovingGrid<Tag>({
items: () => tags,
container: () => scrollEl,
onOpen: (tag) => goto(`/tags/${tag.id}`)
});
function isField(t: EventTarget | null): boolean {
return (
t instanceof HTMLElement &&
(t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName))
);
}
// Arrows / Enter drive the roving grid; Escape peels one layer — first the
// focus ring, then (with no ring, no dialog, not mid-edit) leaves the page,
// mirroring the file viewer's Escape-to-close.
function onKey(e: KeyboardEvent) {
const hadFocus = roving.focusedId !== null;
roving.handleKey(e);
if (e.key === 'Escape' && !hadFocus && !confirmDelete && !isField(e.target)) {
goto('/categories');
}
}
async function save() { async function save() {
if (!name.trim() || saving) return; if (!name.trim() || saving) return;
saving = true; saving = true;
@@ -106,6 +134,8 @@
} }
</script> </script>
<svelte:window onkeydown={onKey} />
<svelte:head> <svelte:head>
<title>{category?.name ?? 'Category'} | Tanabata</title> <title>{category?.name ?? 'Category'} | Tanabata</title>
</svelte:head> </svelte:head>
@@ -126,7 +156,7 @@
<h1 class="page-title">{category?.name ?? 'Category'}</h1> <h1 class="page-title">{category?.name ?? 'Category'}</h1>
</header> </header>
<main> <main bind:this={scrollEl}>
{#if loadError} {#if loadError}
<p class="error" role="alert">{loadError}</p> <p class="error" role="alert">{loadError}</p>
{:else if !loaded} {:else if !loaded}
@@ -219,9 +249,16 @@
{:else if tags.length === 0} {:else if tags.length === 0}
<p class="empty-tags">No tags in this category.</p> <p class="empty-tags">No tags in this category.</p>
{:else} {:else}
<div class="tag-grid"> <!-- svelte-ignore a11y_no_static_element_interactions -->
{#each tags as tag (tag.id)} <div class="tag-grid" onpointerdowncapture={() => roving.clearKbActive()}>
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} size="sm" /> {#each tags as tag, i (tag.id)}
<TagBadge
{tag}
index={i}
focused={roving.kbActive && tag.id === roving.focusedId}
onclick={() => goto(`/tags/${tag.id}`)}
size="sm"
/>
{/each} {/each}
</div> </div>
+21 -3
View File
@@ -7,6 +7,7 @@
import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte';
import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache'; import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache';
import { restoreListScroll } from '$lib/stores/listScroll'; import { restoreListScroll } from '$lib/stores/listScroll';
import { createRovingGrid } from '$lib/utils/rovingGrid.svelte';
import type { Tag, TagOffsetPage } from '$lib/api/types'; import type { Tag, TagOffsetPage } from '$lib/api/types';
const LIMIT = 100; const LIMIT = 100;
@@ -118,8 +119,19 @@
} }
let hasMore = $derived(tags.length < total); let hasMore = $derived(tags.length < total);
// Keyboard navigation: same roving-focus model as the Files grid (arrows move
// the ring, Enter opens, "/" focuses search, Escape drops the ring).
const roving = createRovingGrid<Tag>({
items: () => tags,
container: () => scrollEl,
onOpen: (tag) => goto(`/tags/${tag.id}`),
focusSearch: () => document.querySelector<HTMLInputElement>('.search-input')?.focus()
});
</script> </script>
<svelte:window onkeydown={roving.handleKey} />
<svelte:head> <svelte:head>
<title>Tags | Tanabata</title> <title>Tags | Tanabata</title>
</svelte:head> </svelte:head>
@@ -202,9 +214,15 @@
<p class="error" role="alert">{error}</p> <p class="error" role="alert">{error}</p>
{/if} {/if}
<div class="tag-grid"> <!-- svelte-ignore a11y_no_static_element_interactions -->
{#each tags as tag (tag.id)} <div class="tag-grid" onpointerdowncapture={() => roving.clearKbActive()}>
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} /> {#each tags as tag, i (tag.id)}
<TagBadge
{tag}
index={i}
focused={roving.kbActive && tag.id === roving.focusedId}
onclick={() => goto(`/tags/${tag.id}`)}
/>
{/each} {/each}
</div> </div>
@@ -88,8 +88,26 @@
deleting = false; deleting = false;
} }
} }
function isField(t: EventTarget | null): boolean {
return (
t instanceof HTMLElement &&
(t.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(t.tagName))
);
}
// This page is an edit form (no grid to rove), so the only file-keyboard parity
// is Escape-to-leave — mirroring the file viewer's close — guarded so it never
// fires mid-edit or over the delete dialog.
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && !confirmDelete && !isField(e.target)) {
goto('/tags');
}
}
</script> </script>
<svelte:window onkeydown={onKey} />
<svelte:head> <svelte:head>
<title>{tag?.name ?? 'Tag'} | Tanabata</title> <title>{tag?.name ?? 'Tag'} | Tanabata</title>
</svelte:head> </svelte:head>