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