feat(web): alphabet rail chases pages on click for unloaded letters
test-web / test (push) Successful in 32s
test-web / test (push) Successful in 32s
Operator: prior rail disabled empty buckets, so clicking a letter like 'Z' did nothing if it wasn't loaded yet. AlphabeticalGrid now accepts a paginated source and walks pages on click until the target letter surfaces. - New props: hasMore, onLoadMore. When hasMore=true, every empty bucket stays enabled (clicking will chase pages); when hasMore= false, only populated buckets are clickable. - jumpTo(bucket): if populated, scrollIntoView. Otherwise loop onLoadMore + tick() until the bucket appears or no more pages. Loader2 spinner replaces the letter on the pending button; cursor:wait + aria-busy. Other rail buttons disable while one is pending so clicks don't stack. - Library Artists + Albums pass hasMore + onLoadMore through to the grid. Existing InfiniteScrollSentinel still handles scroll-driven loading. Test: new case asserts rail enables empty buckets when hasMore=true (the click would trigger onLoadMore).
This commit is contained in:
@@ -1,14 +1,25 @@
|
|||||||
<script lang="ts" generics="T">
|
<script lang="ts" generics="T">
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import { Loader2 } from 'lucide-svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
items,
|
items,
|
||||||
getKey,
|
getKey,
|
||||||
item
|
item,
|
||||||
|
hasMore = false,
|
||||||
|
onLoadMore
|
||||||
}: {
|
}: {
|
||||||
items: T[];
|
items: T[];
|
||||||
getKey: (it: T) => string;
|
getKey: (it: T) => string;
|
||||||
item: Snippet<[T]>;
|
item: Snippet<[T]>;
|
||||||
|
// When true, clicking a rail letter that's not in the currently
|
||||||
|
// loaded items will trigger onLoadMore() repeatedly until that
|
||||||
|
// letter surfaces (or no more pages exist). Lets a paginated
|
||||||
|
// source like createArtistsQuery still feel like a full jump
|
||||||
|
// rail without eager-loading the whole dataset.
|
||||||
|
hasMore?: boolean;
|
||||||
|
onLoadMore?: () => Promise<unknown> | unknown;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Bucket the first character into one of three classes so the
|
// Bucket the first character into one of three classes so the
|
||||||
@@ -56,10 +67,42 @@
|
|||||||
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
const railEntries: string[] = ['#', ...ALPHABET, '&'];
|
const railEntries: string[] = ['#', ...ALPHABET, '&'];
|
||||||
|
|
||||||
function jumpTo(bucket: string) {
|
// Bucket the caller is currently chasing via onLoadMore. Used to
|
||||||
|
// show a spinner on the rail button while pages stream in.
|
||||||
|
let pendingBucket = $state<string | null>(null);
|
||||||
|
|
||||||
|
function scrollNow(bucket: string) {
|
||||||
const el = document.getElementById(`alpha-${bucket}`);
|
const el = document.getElementById(`alpha-${bucket}`);
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function jumpTo(bucket: string) {
|
||||||
|
// Already loaded — jump in the same frame.
|
||||||
|
if (populated.has(bucket)) {
|
||||||
|
scrollNow(bucket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No paginated source wired in — nothing to load.
|
||||||
|
if (!hasMore || !onLoadMore) return;
|
||||||
|
// Don't stack loaders.
|
||||||
|
if (pendingBucket !== null) return;
|
||||||
|
|
||||||
|
pendingBucket = bucket;
|
||||||
|
try {
|
||||||
|
// Walk pages until the bucket appears or we exhaust the
|
||||||
|
// dataset. `populated` is $derived from `items`; `tick()`
|
||||||
|
// forces Svelte to flush the prop update before we re-read
|
||||||
|
// populated for the loop condition.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
while (hasMore && !populated.has(bucket)) {
|
||||||
|
await onLoadMore();
|
||||||
|
await tick();
|
||||||
|
}
|
||||||
|
if (populated.has(bucket)) scrollNow(bucket);
|
||||||
|
} finally {
|
||||||
|
pendingBucket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="alpha-layout">
|
<div class="alpha-layout">
|
||||||
@@ -80,17 +123,28 @@
|
|||||||
vertically centered via position:sticky + top:50%. -->
|
vertically centered via position:sticky + top:50%. -->
|
||||||
<nav class="alpha-rail" aria-label="Jump to letter">
|
<nav class="alpha-rail" aria-label="Jump to letter">
|
||||||
{#each railEntries as entry}
|
{#each railEntries as entry}
|
||||||
{@const enabled = populated.has(entry)}
|
{@const isLoaded = populated.has(entry)}
|
||||||
|
{@const couldLoad = !isLoaded && hasMore}
|
||||||
|
{@const enabled = isLoaded || couldLoad}
|
||||||
|
{@const isPending = pendingBucket === entry}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rail-btn"
|
class="rail-btn"
|
||||||
class:disabled={!enabled}
|
class:disabled={!enabled}
|
||||||
disabled={!enabled}
|
class:pending={isPending}
|
||||||
|
disabled={!enabled || pendingBucket !== null}
|
||||||
onclick={() => enabled && jumpTo(entry)}
|
onclick={() => enabled && jumpTo(entry)}
|
||||||
aria-label={enabled
|
aria-label={enabled
|
||||||
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
|
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
|
||||||
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
|
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
|
||||||
>{entry}</button>
|
aria-busy={isPending}
|
||||||
|
>
|
||||||
|
{#if isPending}
|
||||||
|
<Loader2 size={11} strokeWidth={2} class="spin" aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
{entry}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -146,4 +200,14 @@
|
|||||||
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
|
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
.rail-btn.pending {
|
||||||
|
color: var(--fs-accent);
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
:global(.rail-btn .spin) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,27 +23,43 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({
|
|||||||
type AnyProps = any;
|
type AnyProps = any;
|
||||||
|
|
||||||
describe('AlphabeticalGrid', () => {
|
describe('AlphabeticalGrid', () => {
|
||||||
test('rail renders the full #/A-Z/& set; empty buckets are disabled', () => {
|
test('rail renders the full #/A-Z/& set; empty buckets are disabled when no more data could load', () => {
|
||||||
render(AlphabeticalGrid, {
|
render(AlphabeticalGrid, {
|
||||||
props: {
|
props: {
|
||||||
items,
|
items,
|
||||||
getKey: (it: Item) => it.key,
|
getKey: (it: Item) => it.key,
|
||||||
item: itemSnippet
|
item: itemSnippet
|
||||||
|
// hasMore default = false → empty buckets stay disabled.
|
||||||
} as AnyProps
|
} as AnyProps
|
||||||
});
|
});
|
||||||
// Populated buckets are enabled with 'Jump to <letter>' label.
|
// Populated buckets are enabled with 'Jump to <letter>' label.
|
||||||
expect(screen.getByRole('button', { name: 'Jump to A' })).not.toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Jump to A' })).not.toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: 'Jump to B' })).not.toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Jump to B' })).not.toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled();
|
||||||
// Empty buckets still render but are disabled and labelled
|
// Empty buckets disabled when nothing more could load. aria-label
|
||||||
// 'X — no entries' so screen readers announce the empty state.
|
// changes too so screen readers announce the empty state.
|
||||||
expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: 'Z — no entries' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Z — no entries' })).toBeDisabled();
|
||||||
// # (numbers) and & (symbols) always present too.
|
|
||||||
expect(screen.getByRole('button', { name: 'Numbers — no entries' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Numbers — no entries' })).toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: 'Symbols — no entries' })).toBeDisabled();
|
expect(screen.getByRole('button', { name: 'Symbols — no entries' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('with hasMore=true, empty buckets are enabled (clicking would trigger onLoadMore)', () => {
|
||||||
|
render(AlphabeticalGrid, {
|
||||||
|
props: {
|
||||||
|
items,
|
||||||
|
getKey: (it: Item) => it.key,
|
||||||
|
item: itemSnippet,
|
||||||
|
hasMore: true,
|
||||||
|
onLoadMore: () => Promise.resolve()
|
||||||
|
} as AnyProps
|
||||||
|
});
|
||||||
|
// Even with no C/Z items loaded, the buttons remain enabled so a
|
||||||
|
// click can chase pages until the letter surfaces.
|
||||||
|
expect(screen.getByRole('button', { name: 'Jump to C' })).not.toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Jump to Z' })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
test('renders items in given order followed by the jump rail', () => {
|
test('renders items in given order followed by the jump rail', () => {
|
||||||
const { container } = render(AlphabeticalGrid, {
|
const { container } = render(AlphabeticalGrid, {
|
||||||
props: {
|
props: {
|
||||||
|
|||||||
@@ -73,6 +73,8 @@
|
|||||||
<AlphabeticalGrid
|
<AlphabeticalGrid
|
||||||
items={filtered}
|
items={filtered}
|
||||||
getKey={(a: AlbumRef) => a.sort_title || a.title}
|
getKey={(a: AlbumRef) => a.sort_title || a.title}
|
||||||
|
hasMore={!!query.hasNextPage}
|
||||||
|
onLoadMore={() => query.fetchNextPage()}
|
||||||
>
|
>
|
||||||
{#snippet item(album: AlbumRef)}
|
{#snippet item(album: AlbumRef)}
|
||||||
<AlbumCard {album} />
|
<AlbumCard {album} />
|
||||||
|
|||||||
@@ -71,6 +71,8 @@
|
|||||||
<AlphabeticalGrid
|
<AlphabeticalGrid
|
||||||
items={filtered}
|
items={filtered}
|
||||||
getKey={(a: ArtistRef) => a.sort_name || a.name}
|
getKey={(a: ArtistRef) => a.sort_name || a.name}
|
||||||
|
hasMore={!!query.hasNextPage}
|
||||||
|
onLoadMore={() => query.fetchNextPage()}
|
||||||
>
|
>
|
||||||
{#snippet item(a: ArtistRef)}
|
{#snippet item(a: ArtistRef)}
|
||||||
<ArtistCard artist={a} />
|
<ArtistCard artist={a} />
|
||||||
|
|||||||
Reference in New Issue
Block a user