feat(web): every paged list loads as you scroll; Liked becomes tabs (#5416)
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 21m41s
release / go (push) Successful in 1m53s
release / govulncheck (push) Successful in 49s
release / web (push) Successful in 1m50s
release / android (push) Successful in 6m44s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 21m41s
Preference 172: no "Load more" buttons. Eight remained, on Liked (three sections), the three Search overflow pages, Genres and Years. - ListContinuation: the one bottom-of-list block. It loads the next page ahead of the reader, announces "Loading more…" through aria-live, shows an optional end line, and on a failed page shows the error with Try again, dropping the sentinel so a dead endpoint isn't re-hit on every scroll. Used on all ten paged lists, including the four that already autoloaded. - A failed next page no longer replaces the list. TanStack sets isError for it, so the page-level error branches now apply only to a failed first load. Genres and Years did the same with their own loader; a later page's failure now keeps the grid. - Liked: Artists | Albums | Tracks tabs (operator's choice). Stacked, a long Artists list loading as you scroll would bury the other two. It opens on the first tab that has likes. - TabStrip: the in-page tab strip, now shared by Liked, Playback errors and Requests. - test-utils/intersectionObserver: a stand-in so tests can scroll to the end. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
// The bottom of every paged list (#5416, preference 172): the next page
|
||||
// loads on its own as the reader nears the end, with no "Load more"
|
||||
// button. What the button used to give for free is kept here:
|
||||
//
|
||||
// - In flight: a "Loading more…" line, announced through aria-live, so the
|
||||
// list doesn't look finished while it isn't.
|
||||
// - Finished: an optional quiet end line (`endLabel`).
|
||||
// - Failed: the error and a Try again button. The sentinel is removed while
|
||||
// a page has failed, so a dead endpoint isn't re-requested on every scroll.
|
||||
import InfiniteScrollSentinel from './InfiniteScrollSentinel.svelte';
|
||||
|
||||
let {
|
||||
hasMore,
|
||||
loading,
|
||||
failed = false,
|
||||
onMore,
|
||||
endLabel
|
||||
}: {
|
||||
/** Whether another page exists. */
|
||||
hasMore: boolean;
|
||||
/** A next page is in flight. */
|
||||
loading: boolean;
|
||||
/** The last next-page fetch failed. */
|
||||
failed?: boolean;
|
||||
/** Fetch the next page (TanStack's fetchNextPage, or a page's own loader). */
|
||||
onMore: () => void;
|
||||
/** Shown once the list is complete, e.g. "End of history". */
|
||||
endLabel?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if failed}
|
||||
<div role="alert" class="flex items-center justify-center gap-2 py-2 text-sm text-error-fg">
|
||||
Couldn't load more.
|
||||
<button
|
||||
type="button"
|
||||
class="rounded border border-border px-2 py-0.5 text-text-primary hover:bg-surface-hover
|
||||
focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
|
||||
onclick={onMore}>Try again</button
|
||||
>
|
||||
</div>
|
||||
{:else if hasMore}
|
||||
<InfiniteScrollSentinel enabled={!loading} onIntersect={onMore} />
|
||||
{/if}
|
||||
|
||||
<p class="py-2 text-center text-sm text-text-secondary empty:hidden" aria-live="polite">
|
||||
{#if !failed && hasMore && loading}Loading more…{:else if !hasMore && endLabel}{endLabel}{/if}
|
||||
</p>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import ListContinuation from './ListContinuation.svelte';
|
||||
|
||||
// InfiniteScrollSentinel renders <div aria-hidden="true" class="h-px">.
|
||||
const SENTINEL = 'div[aria-hidden="true"].h-px';
|
||||
|
||||
describe('ListContinuation', () => {
|
||||
test('more to come: a sentinel and no button', () => {
|
||||
const { container } = render(ListContinuation, {
|
||||
props: { hasMore: true, loading: false, onMore: vi.fn() }
|
||||
});
|
||||
expect(container.querySelector(SENTINEL)).not.toBeNull();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('in flight: announces loading', () => {
|
||||
render(ListContinuation, { props: { hasMore: true, loading: true, onMore: vi.fn() } });
|
||||
const status = screen.getByText('Loading more…');
|
||||
expect(status).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
test('finished: the end line, no sentinel', () => {
|
||||
const { container } = render(ListContinuation, {
|
||||
props: { hasMore: false, loading: false, onMore: vi.fn(), endLabel: 'End of history' }
|
||||
});
|
||||
expect(screen.getByText('End of history')).toBeInTheDocument();
|
||||
expect(container.querySelector(SENTINEL)).toBeNull();
|
||||
});
|
||||
|
||||
// A failed page must not be re-fetched by the observer on every scroll:
|
||||
// the sentinel goes away and the reader retries by hand.
|
||||
test('failed: an error with Try again, and no sentinel', async () => {
|
||||
const onMore = vi.fn();
|
||||
const { container } = render(ListContinuation, {
|
||||
props: { hasMore: true, loading: false, failed: true, onMore }
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load more.");
|
||||
expect(container.querySelector(SENTINEL)).toBeNull();
|
||||
await fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
|
||||
expect(onMore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts" generics="Id extends string">
|
||||
// In-page tabs: one strip of buttons switching what a single page shows
|
||||
// (#5416). The same look as AdminTabs and DiscoverTabs, which are route
|
||||
// navigation and so stay links; this is for state inside one page, such as
|
||||
// Liked's Artists | Albums | Tracks or a queue's status filter.
|
||||
type Item = {
|
||||
id: Id;
|
||||
label: string;
|
||||
/** Shown as a pill after the label. Omit for no pill. */
|
||||
count?: number;
|
||||
};
|
||||
|
||||
let {
|
||||
items,
|
||||
value = $bindable(),
|
||||
ariaLabel
|
||||
}: {
|
||||
items: Item[];
|
||||
value: Id;
|
||||
ariaLabel: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div role="tablist" aria-label={ariaLabel} class="flex gap-2 border-b border-border">
|
||||
{#each items as item (item.id)}
|
||||
{@const active = value === item.id}
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
class="border-b-2 px-3 py-2 text-sm transition-colors {active
|
||||
? 'border-accent text-text-primary'
|
||||
: 'border-transparent text-text-secondary hover:text-text-primary'}"
|
||||
onclick={() => (value = item.id)}
|
||||
>
|
||||
{item.label}
|
||||
{#if item.count !== undefined}
|
||||
<span
|
||||
class="ml-1.5 inline-flex items-center rounded-full bg-accent-tint px-2 py-0.5 text-xs text-accent-fg"
|
||||
data-testid="tab-count"
|
||||
>
|
||||
{item.count}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/svelte';
|
||||
import TabStrip from './TabStrip.svelte';
|
||||
|
||||
const items = [
|
||||
{ id: 'artists', label: 'Artists', count: 3 },
|
||||
{ id: 'albums', label: 'Albums' }
|
||||
];
|
||||
|
||||
describe('TabStrip', () => {
|
||||
test('a tablist with the selected tab marked, and counts where given', () => {
|
||||
render(TabStrip, { props: { items, value: 'artists', ariaLabel: 'Liked' } });
|
||||
expect(screen.getByRole('tablist', { name: 'Liked' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /artists/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: /albums/i })).toHaveAttribute('aria-selected', 'false');
|
||||
expect(screen.getAllByTestId('tab-count').map((c) => c.textContent?.trim())).toEqual(['3']);
|
||||
});
|
||||
|
||||
test('clicking a tab selects it', async () => {
|
||||
render(TabStrip, { props: { items, value: 'artists', ariaLabel: 'Liked' } });
|
||||
await fireEvent.click(screen.getByRole('tab', { name: /albums/i }));
|
||||
expect(screen.getByRole('tab', { name: /albums/i })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: /artists/i })).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user