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:
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user