From 4e47b2e7f5b42e63893707ef43fbf59313a1724f Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 1 May 2026 21:05:16 -0400 Subject: [PATCH] feat(web): pair scroll arrows beside section title; bleed scroller to viewport edge Restructures HorizontalScrollRow so paired arrows sit in a flex header beside an optional section title (instead of absolute-positioned at the row's left/right edges). Adds margin-right: -1rem to the row so the scroller's items extend to the viewport's right edge, escaping Shell's p-4 padding. The header bar keeps its right gutter so titles align with the rest of the page content. Home page passes 'title' to the first row of each section; subsequent rows in multi-row sections render arrow-only headers. --- .../lib/components/HorizontalScrollRow.svelte | 79 +++++++++++-------- .../components/HorizontalScrollRow.test.ts | 10 +++ web/src/routes/+page.svelte | 54 +++++++++---- 3 files changed, 95 insertions(+), 48 deletions(-) diff --git a/web/src/lib/components/HorizontalScrollRow.svelte b/web/src/lib/components/HorizontalScrollRow.svelte index fe953700..a6c5ec4a 100644 --- a/web/src/lib/components/HorizontalScrollRow.svelte +++ b/web/src/lib/components/HorizontalScrollRow.svelte @@ -5,10 +5,12 @@ let { items, item, + title, ariaLabel }: { items: T[]; item?: Snippet<[T, number]>; + title?: string; ariaLabel?: string; } = $props(); @@ -37,16 +39,34 @@ $effect(() => { refreshMetrics(); }); -
- +
+
+ {#if title} +

{title}

+ {:else} + + {/if} +
+ + +
+
- -
diff --git a/web/src/lib/components/HorizontalScrollRow.test.ts b/web/src/lib/components/HorizontalScrollRow.test.ts index 55ee2e9d..9f640c15 100644 --- a/web/src/lib/components/HorizontalScrollRow.test.ts +++ b/web/src/lib/components/HorizontalScrollRow.test.ts @@ -29,4 +29,14 @@ describe('HorizontalScrollRow', () => { expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' }); expect(spy.mock.calls[0][0].left).toBeGreaterThan(0); }); + + test('renders title as a heading when prop is provided', () => { + render(HorizontalScrollRow, { props: { items: ['a'], title: 'My section' } }); + expect(screen.getByRole('heading', { name: 'My section' })).toBeInTheDocument(); + }); + + test('omits heading when title prop is absent', () => { + render(HorizontalScrollRow, { props: { items: ['a'] } }); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); }); diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index de76c01f..6334abb3 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -30,14 +30,18 @@ {:else if data}
-
-

Recently added

-
{#if data.recently_added_albums.length === 0} +
+

Recently added

+

Nothing added yet. Scan a folder via the server's config.

{:else} {#each chunk(data.recently_added_albums, 25) as row, i (i)} - + {#snippet item(album: import('$lib/api/types').AlbumRef)}
{/snippet} @@ -48,21 +52,29 @@
-
-

Rediscover

-
{#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0} +
+

Rediscover

+

No forgotten favourites yet. Like some albums or artists to fill this in.

{:else} {#if data.rediscover_albums.length > 0} - + {#snippet item(album: import('$lib/api/types').AlbumRef)}
{/snippet}
{/if} {#if data.rediscover_artists.length > 0} - + {#snippet item(artist: import('$lib/api/types').ArtistRef)}
{/snippet} @@ -73,14 +85,18 @@
-
-

Most played

-
{#if data.most_played_tracks.length === 0} +
+

Most played

+

No plays to draw from. Listen to something.

{:else} {#each chunk(data.most_played_tracks, 25) as row, i (i)} - + {#snippet item(track: import('$lib/api/types').TrackRef, idx: number)}
-
-

Last played

-
{#if data.last_played_artists.length === 0} +
+

Last played

+

No recent plays.

{:else} - + {#snippet item(artist: import('$lib/api/types').ArtistRef)}
{/snippet}