diff --git a/frontend/src/lib/components/tag/TagBadge.svelte b/frontend/src/lib/components/tag/TagBadge.svelte index 1c08c8c..c46d365 100644 --- a/frontend/src/lib/components/tag/TagBadge.svelte +++ b/frontend/src/lib/components/tag/TagBadge.svelte @@ -5,16 +5,27 @@ tag: Tag; onclick?: () => void; 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 style = color ? `background-color: #${color}` : ''; {#if onclick} - {:else} @@ -53,4 +64,12 @@ button.badge:hover { 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; + } diff --git a/frontend/src/lib/utils/rovingGrid.svelte.ts b/frontend/src/lib/utils/rovingGrid.svelte.ts new file mode 100644 index 0000000..9b98985 --- /dev/null +++ b/frontend/src/lib/utils/rovingGrid.svelte.ts @@ -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 { + /** 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(opts: RovingGridOptions) { + const selector = opts.itemSelector ?? '[data-item-index]'; + + let focusedId = $state(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(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 + }; +} diff --git a/frontend/src/routes/categories/+page.svelte b/frontend/src/routes/categories/+page.svelte index 2007a06..7b5f9ae 100644 --- a/frontend/src/routes/categories/+page.svelte +++ b/frontend/src/routes/categories/+page.svelte @@ -6,6 +6,7 @@ import InfiniteScroll from '$lib/components/common/InfiniteScroll.svelte'; import { saveSection, takeSection, type OffsetListSnapshot } from '$lib/stores/sectionCache'; import { restoreListScroll } from '$lib/stores/listScroll'; + import { createRovingGrid } from '$lib/utils/rovingGrid.svelte'; import type { Category, CategoryOffsetPage } from '$lib/api/types'; const LIMIT = 100; @@ -106,8 +107,19 @@ } 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({ + items: () => categories, + container: () => scrollEl, + onOpen: (cat) => goto(`/categories/${cat.id}`), + focusSearch: () => document.querySelector('.search-input')?.focus() + }); + + Categories | Tanabata @@ -192,10 +204,13 @@ {/if} -
- {#each categories as cat (cat.id)} + +
roving.clearKbActive()}> + {#each categories as cat, i (cat.id)}