feat(frontend): keyboard navigation for tag and category grids
deploy / deploy (push) Successful in 23s
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:
@@ -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<Category>({
|
||||
items: () => categories,
|
||||
container: () => scrollEl,
|
||||
onOpen: (cat) => goto(`/categories/${cat.id}`),
|
||||
focusSearch: () => document.querySelector<HTMLInputElement>('.search-input')?.focus()
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={roving.handleKey} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Categories | Tanabata</title>
|
||||
</svelte:head>
|
||||
@@ -192,10 +204,13 @@
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="category-grid">
|
||||
{#each categories as cat (cat.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="category-grid" onpointerdowncapture={() => roving.clearKbActive()}>
|
||||
{#each categories as cat, i (cat.id)}
|
||||
<button
|
||||
class="category-pill"
|
||||
class:focused={roving.kbActive && cat.id === roving.focusedId}
|
||||
data-item-index={i}
|
||||
style={cat.color ? `background-color: #${cat.color}` : ''}
|
||||
onclick={() => goto(`/categories/${cat.id}`)}
|
||||
>
|
||||
@@ -384,6 +399,14 @@
|
||||
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 {
|
||||
color: var(--color-danger);
|
||||
font-size: 0.875rem;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { Category, Tag, TagOffsetPage } from '$lib/api/types';
|
||||
import TagBadge from '$lib/components/tag/TagBadge.svelte';
|
||||
import ConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
|
||||
import { createRovingGrid } from '$lib/utils/rovingGrid.svelte';
|
||||
|
||||
let categoryId = $derived(page.params.id);
|
||||
|
||||
@@ -74,6 +75,33 @@
|
||||
|
||||
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() {
|
||||
if (!name.trim() || saving) return;
|
||||
saving = true;
|
||||
@@ -106,6 +134,8 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<svelte:head>
|
||||
<title>{category?.name ?? 'Category'} | Tanabata</title>
|
||||
</svelte:head>
|
||||
@@ -126,7 +156,7 @@
|
||||
<h1 class="page-title">{category?.name ?? 'Category'}</h1>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<main bind:this={scrollEl}>
|
||||
{#if loadError}
|
||||
<p class="error" role="alert">{loadError}</p>
|
||||
{:else if !loaded}
|
||||
@@ -219,9 +249,16 @@
|
||||
{:else if tags.length === 0}
|
||||
<p class="empty-tags">No tags in this category.</p>
|
||||
{:else}
|
||||
<div class="tag-grid">
|
||||
{#each tags as tag (tag.id)}
|
||||
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} size="sm" />
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="tag-grid" onpointerdowncapture={() => roving.clearKbActive()}>
|
||||
{#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}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,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 { Tag, TagOffsetPage } from '$lib/api/types';
|
||||
|
||||
const LIMIT = 100;
|
||||
@@ -118,8 +119,19 @@
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<svelte:window onkeydown={roving.handleKey} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Tags | Tanabata</title>
|
||||
</svelte:head>
|
||||
@@ -202,9 +214,15 @@
|
||||
<p class="error" role="alert">{error}</p>
|
||||
{/if}
|
||||
|
||||
<div class="tag-grid">
|
||||
{#each tags as tag (tag.id)}
|
||||
<TagBadge {tag} onclick={() => goto(`/tags/${tag.id}`)} />
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="tag-grid" onpointerdowncapture={() => roving.clearKbActive()}>
|
||||
{#each tags as tag, i (tag.id)}
|
||||
<TagBadge
|
||||
{tag}
|
||||
index={i}
|
||||
focused={roving.kbActive && tag.id === roving.focusedId}
|
||||
onclick={() => goto(`/tags/${tag.id}`)}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -88,8 +88,26 @@
|
||||
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>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<svelte:head>
|
||||
<title>{tag?.name ?? 'Tag'} | Tanabata</title>
|
||||
</svelte:head>
|
||||
|
||||
Reference in New Issue
Block a user