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.
This commit is contained in:
2026-05-01 21:05:16 -04:00
parent 4412a4af0c
commit 4e47b2e7f5
3 changed files with 95 additions and 48 deletions
@@ -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(); });
</script>
<div class="row relative" aria-label={ariaLabel}>
<button
type="button"
class="arrow arrow-left"
aria-label="Scroll left"
onclick={() => page(-1)}
disabled={atStart}
>
<ChevronLeft size={16} strokeWidth={1} />
</button>
<div class="row" aria-label={ariaLabel}>
<header class="row-header">
{#if title}
<h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2>
{:else}
<span></span>
{/if}
<div class="arrows">
<button
type="button"
class="arrow"
aria-label="Scroll left"
onclick={() => page(-1)}
disabled={atStart}
>
<ChevronLeft size={16} strokeWidth={1} />
</button>
<button
type="button"
class="arrow"
aria-label="Scroll right"
onclick={() => page(1)}
disabled={atEnd}
>
<ChevronRight size={16} strokeWidth={1} />
</button>
</div>
</header>
<div
bind:this={scroller}
@@ -58,20 +78,27 @@
{@render item?.(it, i)}
{/each}
</div>
<button
type="button"
class="arrow arrow-right"
aria-label="Scroll right"
onclick={() => page(1)}
disabled={atEnd}
>
<ChevronRight size={16} strokeWidth={1} />
</button>
</div>
<style>
.row { padding: 0 36px; }
/* Bleed the row past the parent's right padding (16px from Shell's p-4)
so the scroller's items extend to the viewport's right edge. */
.row {
margin-right: -1rem;
}
.row-header {
display: flex;
align-items: center;
justify-content: space-between;
/* Re-add the right gutter so the header stays aligned with the rest
of the page content (the scroller below remains full-bleed). */
padding-right: 1rem;
margin-bottom: 0.5rem;
}
.arrows {
display: flex;
gap: 0.5rem;
}
.scroller {
scroll-snap-type: x mandatory;
scrollbar-width: thin;
@@ -81,9 +108,6 @@
scroll-snap-align: start;
}
.arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border-radius: 9999px;
@@ -93,14 +117,7 @@
align-items: center;
justify-content: center;
border: 1px solid var(--fs-pewter);
z-index: 1;
}
.arrow:hover:not(:disabled) { color: var(--fs-parchment); }
.arrow:disabled { opacity: 0.3; cursor: not-allowed; }
.arrow-left { left: 0; }
.arrow-right { right: 0; }
@media (hover: hover) {
.arrow { opacity: 0; transition: opacity 150ms ease; }
.row:hover .arrow { opacity: 1; }
}
</style>
@@ -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();
});
});
+37 -17
View File
@@ -30,14 +30,18 @@
{:else if data}
<!-- Recently added: 50 albums in 2 rows of 25 -->
<section class="space-y-3">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Recently added</h2>
</header>
{#if data.recently_added_albums.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Recently added</h2>
</header>
<p class="text-text-secondary">Nothing added yet. Scan a folder via the server's config.</p>
{:else}
{#each chunk(data.recently_added_albums, 25) as row, i (i)}
<HorizontalScrollRow items={row} ariaLabel="Recently added row {i + 1}">
<HorizontalScrollRow
items={row}
title={i === 0 ? 'Recently added' : undefined}
ariaLabel="Recently added row {i + 1}"
>
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-44"><AlbumCard {album} /></div>
{/snippet}
@@ -48,21 +52,29 @@
<!-- Rediscover: albums + artists -->
<section class="space-y-3">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Rediscover</h2>
</header>
{#if data.rediscover_albums.length === 0 && data.rediscover_artists.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Rediscover</h2>
</header>
<p class="text-text-secondary">No forgotten favourites yet. Like some albums or artists to fill this in.</p>
{:else}
{#if data.rediscover_albums.length > 0}
<HorizontalScrollRow items={data.rediscover_albums} ariaLabel="Rediscover albums">
<HorizontalScrollRow
items={data.rediscover_albums}
title="Rediscover"
ariaLabel="Rediscover albums"
>
{#snippet item(album: import('$lib/api/types').AlbumRef)}
<div class="w-44"><AlbumCard {album} /></div>
{/snippet}
</HorizontalScrollRow>
{/if}
{#if data.rediscover_artists.length > 0}
<HorizontalScrollRow items={data.rediscover_artists} ariaLabel="Rediscover artists">
<HorizontalScrollRow
items={data.rediscover_artists}
title={data.rediscover_albums.length === 0 ? 'Rediscover' : undefined}
ariaLabel="Rediscover artists"
>
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}
@@ -73,14 +85,18 @@
<!-- Most played: 75 tracks in 3 rows of 25 -->
<section class="space-y-3">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Most played</h2>
</header>
{#if data.most_played_tracks.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Most played</h2>
</header>
<p class="text-text-secondary">No plays to draw from. Listen to something.</p>
{:else}
{#each chunk(data.most_played_tracks, 25) as row, i (i)}
<HorizontalScrollRow items={row} ariaLabel="Most played row {i + 1}">
<HorizontalScrollRow
items={row}
title={i === 0 ? 'Most played' : undefined}
ariaLabel="Most played row {i + 1}"
>
{#snippet item(track: import('$lib/api/types').TrackRef, idx: number)}
<CompactTrackCard
{track}
@@ -95,13 +111,17 @@
<!-- Last played artists -->
<section class="space-y-3">
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Last played</h2>
</header>
{#if data.last_played_artists.length === 0}
<header>
<h2 class="font-display text-2xl font-medium text-text-primary">Last played</h2>
</header>
<p class="text-text-secondary">No recent plays.</p>
{:else}
<HorizontalScrollRow items={data.last_played_artists} ariaLabel="Last played artists">
<HorizontalScrollRow
items={data.last_played_artists}
title="Last played"
ariaLabel="Last played artists"
>
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
<div class="w-36"><ArtistCard {artist} /></div>
{/snippet}