Compare commits
2 Commits
95db88388b
...
70d12615b8
| Author | SHA1 | Date | |
|---|---|---|---|
| 70d12615b8 | |||
| 97d6daaa13 |
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -484,32 +484,14 @@
|
|||||||
// between) so there's no visible jump. Shares the `loading` guard with loadMore
|
// between) so there's no visible jump. Shares the `loading` guard with loadMore
|
||||||
// so the two never mutate files concurrently.
|
// so the two never mutate files concurrently.
|
||||||
async function loadPrev() {
|
async function loadPrev() {
|
||||||
if (loading || !hasPrev) return;
|
if (loading || !hasPrev || !prevCursor) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
let items: File[];
|
const params = baseListParams();
|
||||||
let newPrevCursor: string | null;
|
params.set('cursor', prevCursor);
|
||||||
if (prevCursor) {
|
params.set('direction', 'backward');
|
||||||
const params = baseListParams();
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
params.set('cursor', prevCursor);
|
const items = res.items ?? [];
|
||||||
params.set('direction', 'backward');
|
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
|
||||||
items = res.items ?? [];
|
|
||||||
newPrevCursor = res.prev_cursor ?? null;
|
|
||||||
} else {
|
|
||||||
// The head cursor was dropped when the window trimmed its top. Refetch
|
|
||||||
// the rows just before the current first file from an anchored window.
|
|
||||||
const firstId = files[0]?.id;
|
|
||||||
if (!firstId) {
|
|
||||||
hasPrev = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await fetchAnchorWindow(firstId);
|
|
||||||
const all = res.items ?? [];
|
|
||||||
const idx = all.findIndex((f) => f.id === firstId);
|
|
||||||
items = idx > 0 ? all.slice(0, idx) : [];
|
|
||||||
newPrevCursor = res.prev_cursor ?? null;
|
|
||||||
}
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
hasPrev = false;
|
hasPrev = false;
|
||||||
return;
|
return;
|
||||||
@@ -522,13 +504,11 @@
|
|||||||
const beforeHeight = scroller.scrollHeight;
|
const beforeHeight = scroller.scrollHeight;
|
||||||
|
|
||||||
files = [...items, ...files];
|
files = [...items, ...files];
|
||||||
prevCursor = newPrevCursor;
|
prevCursor = res.prev_cursor ?? null;
|
||||||
hasPrev = !!newPrevCursor;
|
hasPrev = !!prevCursor;
|
||||||
|
|
||||||
flushSync(); // apply the prepend now, before the browser paints
|
flushSync(); // apply the prepend now, before the browser paints
|
||||||
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
||||||
|
|
||||||
trimTail();
|
|
||||||
} catch {
|
} catch {
|
||||||
hasPrev = false;
|
hasPrev = false;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -550,85 +530,26 @@
|
|||||||
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
|
return (document.scrollingElement as HTMLElement | null) ?? document.documentElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Windowing -----------------------------------------------------------
|
// ---- Loaded grid -----------------------------------------------------------
|
||||||
// The grid keeps at most ~4 viewports of rows in memory. As it grows past the
|
// The grid accumulates every loaded row for the lifetime of the visit. It is
|
||||||
// cap on one end, the off-screen rows on the other end are trimmed; the cursor
|
// only cleared on a sort/filter change or when leaving the page (the reset
|
||||||
// for the trimmed boundary is dropped (set null) and the opposite `has*` flag
|
// effect above; the section cache restores it on return). Rows are never
|
||||||
// is raised, so scrolling back refills that side from an anchored window.
|
// trimmed while scrolling, so a long drag- or shift-select can span the whole
|
||||||
|
// loaded list without earlier cards disappearing underneath it.
|
||||||
|
|
||||||
const CARD_PITCH = 162; // 160px thumbnail + 2px grid gap
|
const CARD_PITCH = 162; // 160px thumbnail + 2px grid gap (used for grid math)
|
||||||
|
|
||||||
function windowCap(): number {
|
|
||||||
const scroller = getScroller();
|
|
||||||
const w = scroller.clientWidth || 390;
|
|
||||||
const h = scroller.clientHeight || 700;
|
|
||||||
const cols = Math.max(1, Math.floor(w / CARD_PITCH));
|
|
||||||
const rows = Math.max(1, Math.ceil(h / CARD_PITCH));
|
|
||||||
return Math.max(4 * cols * rows, 2 * LIMIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch a window centred on a file (with its boundary cursors), used to refill
|
|
||||||
// a trimmed edge where the original cursor is no longer held.
|
|
||||||
function fetchAnchorWindow(anchorId: string): Promise<FileCursorPage> {
|
|
||||||
const a = baseListParams();
|
|
||||||
a.set('anchor', anchorId);
|
|
||||||
return api.get<FileCursorPage>(`/files?${a}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop the off-screen rows above the viewport once the grid grew past the cap.
|
|
||||||
// Run after appended rows have painted so the height delta measures only the
|
|
||||||
// removed top; scroll is compensated so the visible rows don't jump.
|
|
||||||
function trimHead() {
|
|
||||||
const cap = windowCap();
|
|
||||||
if (files.length <= cap) return;
|
|
||||||
flushSync(); // paint the just-appended (below-fold) rows before measuring
|
|
||||||
const scroller = getScroller();
|
|
||||||
const beforeTop = scroller.scrollTop;
|
|
||||||
const beforeHeight = scroller.scrollHeight;
|
|
||||||
files = files.slice(files.length - cap);
|
|
||||||
prevCursor = null;
|
|
||||||
hasPrev = true;
|
|
||||||
flushSync();
|
|
||||||
scroller.scrollTop = beforeTop + (scroller.scrollHeight - beforeHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Symmetric to trimHead for upward growth: drop the off-screen rows below the
|
|
||||||
// viewport. No scroll compensation — the removed rows are past the fold.
|
|
||||||
function trimTail() {
|
|
||||||
const cap = windowCap();
|
|
||||||
if (files.length <= cap) return;
|
|
||||||
files = files.slice(0, cap);
|
|
||||||
nextCursor = null;
|
|
||||||
hasMore = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadMore() {
|
async function loadMore() {
|
||||||
if (loading || !hasMore) return;
|
if (loading || !hasMore) return;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
let newItems: File[];
|
const params = baseListParams();
|
||||||
let newNextCursor: string | null;
|
if (nextCursor) params.set('cursor', nextCursor);
|
||||||
if (nextCursor == null && files.length > 0) {
|
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
||||||
// The tail cursor was dropped when the window trimmed its bottom.
|
files = [...files, ...(res.items ?? [])];
|
||||||
// Refetch the rows after the current last file from an anchored window.
|
nextCursor = res.next_cursor ?? null;
|
||||||
const lastId = files[files.length - 1]?.id;
|
hasMore = !!nextCursor;
|
||||||
const res = await fetchAnchorWindow(lastId!);
|
|
||||||
const all = res.items ?? [];
|
|
||||||
const idx = all.findIndex((f) => f.id === lastId);
|
|
||||||
newItems = idx >= 0 ? all.slice(idx + 1) : [];
|
|
||||||
newNextCursor = res.next_cursor ?? null;
|
|
||||||
} else {
|
|
||||||
const params = baseListParams();
|
|
||||||
if (nextCursor) params.set('cursor', nextCursor);
|
|
||||||
const res = await api.get<FileCursorPage>(`/files?${params}`);
|
|
||||||
newItems = res.items ?? [];
|
|
||||||
newNextCursor = res.next_cursor ?? null;
|
|
||||||
}
|
|
||||||
files = [...files, ...newItems];
|
|
||||||
nextCursor = newNextCursor;
|
|
||||||
hasMore = !!newNextCursor;
|
|
||||||
trimHead();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
error = err instanceof ApiError ? err.message : 'Failed to load files';
|
||||||
hasMore = false;
|
hasMore = false;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user