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)}