From d4c6bb3f2d049cf679c36feeefb08c29fd465294 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 22:29:13 -0400 Subject: [PATCH] feat(web): alphabet rail chases pages on click for unloaded letters 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). --- .../lib/components/AlphabeticalGrid.svelte | 74 +++++++++++++++++-- .../lib/components/AlphabeticalGrid.test.ts | 24 +++++- web/src/routes/library/albums/+page.svelte | 2 + web/src/routes/library/artists/+page.svelte | 2 + 4 files changed, 93 insertions(+), 9 deletions(-) diff --git a/web/src/lib/components/AlphabeticalGrid.svelte b/web/src/lib/components/AlphabeticalGrid.svelte index 667cf54d..39781dc5 100644 --- a/web/src/lib/components/AlphabeticalGrid.svelte +++ b/web/src/lib/components/AlphabeticalGrid.svelte @@ -1,14 +1,25 @@
@@ -80,17 +123,28 @@ vertically centered via position:sticky + top:50%. --> {/if} @@ -146,4 +200,14 @@ color: color-mix(in srgb, var(--fs-ash) 50%, transparent); 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); } + } diff --git a/web/src/lib/components/AlphabeticalGrid.test.ts b/web/src/lib/components/AlphabeticalGrid.test.ts index 6edb768e..b7b10a80 100644 --- a/web/src/lib/components/AlphabeticalGrid.test.ts +++ b/web/src/lib/components/AlphabeticalGrid.test.ts @@ -23,27 +23,43 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({ type AnyProps = any; 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, { props: { items, getKey: (it: Item) => it.key, item: itemSnippet + // hasMore default = false → empty buckets stay disabled. } as AnyProps }); // Populated buckets are enabled with 'Jump to ' label. 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 D' })).not.toBeDisabled(); - // Empty buckets still render but are disabled and labelled - // 'X — no entries' so screen readers announce the empty state. + // Empty buckets disabled when nothing more could load. aria-label + // changes too so screen readers announce the empty state. expect(screen.getByRole('button', { name: 'C — 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: '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', () => { const { container } = render(AlphabeticalGrid, { props: { diff --git a/web/src/routes/library/albums/+page.svelte b/web/src/routes/library/albums/+page.svelte index 53bdd960..9c0eb881 100644 --- a/web/src/routes/library/albums/+page.svelte +++ b/web/src/routes/library/albums/+page.svelte @@ -73,6 +73,8 @@ a.sort_title || a.title} + hasMore={!!query.hasNextPage} + onLoadMore={() => query.fetchNextPage()} > {#snippet item(album: AlbumRef)} diff --git a/web/src/routes/library/artists/+page.svelte b/web/src/routes/library/artists/+page.svelte index fe36e085..edaf6287 100644 --- a/web/src/routes/library/artists/+page.svelte +++ b/web/src/routes/library/artists/+page.svelte @@ -71,6 +71,8 @@ a.sort_name || a.name} + hasMore={!!query.hasNextPage} + onLoadMore={() => query.fetchNextPage()} > {#snippet item(a: ArtistRef)}