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 {
|
let {
|
||||||
items,
|
items,
|
||||||
item,
|
item,
|
||||||
|
title,
|
||||||
ariaLabel
|
ariaLabel
|
||||||
}: {
|
}: {
|
||||||
items: T[];
|
items: T[];
|
||||||
item?: Snippet<[T, number]>;
|
item?: Snippet<[T, number]>;
|
||||||
|
title?: string;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -37,16 +39,34 @@
|
|||||||
$effect(() => { refreshMetrics(); });
|
$effect(() => { refreshMetrics(); });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="row relative" aria-label={ariaLabel}>
|
<div class="row" aria-label={ariaLabel}>
|
||||||
<button
|
<header class="row-header">
|
||||||
type="button"
|
{#if title}
|
||||||
class="arrow arrow-left"
|
<h2 class="font-display text-2xl font-medium text-text-primary">{title}</h2>
|
||||||
aria-label="Scroll left"
|
{:else}
|
||||||
onclick={() => page(-1)}
|
<span></span>
|
||||||
disabled={atStart}
|
{/if}
|
||||||
>
|
<div class="arrows">
|
||||||
<ChevronLeft size={16} strokeWidth={1} />
|
<button
|
||||||
</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
|
<div
|
||||||
bind:this={scroller}
|
bind:this={scroller}
|
||||||
@@ -58,20 +78,27 @@
|
|||||||
{@render item?.(it, i)}
|
{@render item?.(it, i)}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="arrow arrow-right"
|
|
||||||
aria-label="Scroll right"
|
|
||||||
onclick={() => page(1)}
|
|
||||||
disabled={atEnd}
|
|
||||||
>
|
|
||||||
<ChevronRight size={16} strokeWidth={1} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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 {
|
.scroller {
|
||||||
scroll-snap-type: x mandatory;
|
scroll-snap-type: x mandatory;
|
||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
@@ -81,9 +108,6 @@
|
|||||||
scroll-snap-align: start;
|
scroll-snap-align: start;
|
||||||
}
|
}
|
||||||
.arrow {
|
.arrow {
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
@@ -93,14 +117,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border: 1px solid var(--fs-pewter);
|
border: 1px solid var(--fs-pewter);
|
||||||
z-index: 1;
|
|
||||||
}
|
}
|
||||||
.arrow:hover:not(:disabled) { color: var(--fs-parchment); }
|
.arrow:hover:not(:disabled) { color: var(--fs-parchment); }
|
||||||
.arrow:disabled { opacity: 0.3; cursor: not-allowed; }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -29,4 +29,14 @@ describe('HorizontalScrollRow', () => {
|
|||||||
expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' });
|
expect(spy).toHaveBeenCalledWith({ left: expect.any(Number), behavior: 'smooth' });
|
||||||
expect(spy.mock.calls[0][0].left).toBeGreaterThan(0);
|
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}
|
{:else if data}
|
||||||
<!-- Recently added: 50 albums in 2 rows of 25 -->
|
<!-- Recently added: 50 albums in 2 rows of 25 -->
|
||||||
<section class="space-y-3">
|
<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}
|
{#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>
|
<p class="text-text-secondary">Nothing added yet. Scan a folder via the server's config.</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each chunk(data.recently_added_albums, 25) as row, i (i)}
|
{#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)}
|
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
||||||
<div class="w-44"><AlbumCard {album} /></div>
|
<div class="w-44"><AlbumCard {album} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -48,21 +52,29 @@
|
|||||||
|
|
||||||
<!-- Rediscover: albums + artists -->
|
<!-- Rediscover: albums + artists -->
|
||||||
<section class="space-y-3">
|
<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}
|
{#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>
|
<p class="text-text-secondary">No forgotten favourites yet. Like some albums or artists to fill this in.</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#if data.rediscover_albums.length > 0}
|
{#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)}
|
{#snippet item(album: import('$lib/api/types').AlbumRef)}
|
||||||
<div class="w-44"><AlbumCard {album} /></div>
|
<div class="w-44"><AlbumCard {album} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</HorizontalScrollRow>
|
</HorizontalScrollRow>
|
||||||
{/if}
|
{/if}
|
||||||
{#if data.rediscover_artists.length > 0}
|
{#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)}
|
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
|
||||||
<div class="w-36"><ArtistCard {artist} /></div>
|
<div class="w-36"><ArtistCard {artist} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -73,14 +85,18 @@
|
|||||||
|
|
||||||
<!-- Most played: 75 tracks in 3 rows of 25 -->
|
<!-- Most played: 75 tracks in 3 rows of 25 -->
|
||||||
<section class="space-y-3">
|
<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}
|
{#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>
|
<p class="text-text-secondary">No plays to draw from. Listen to something.</p>
|
||||||
{:else}
|
{:else}
|
||||||
{#each chunk(data.most_played_tracks, 25) as row, i (i)}
|
{#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)}
|
{#snippet item(track: import('$lib/api/types').TrackRef, idx: number)}
|
||||||
<CompactTrackCard
|
<CompactTrackCard
|
||||||
{track}
|
{track}
|
||||||
@@ -95,13 +111,17 @@
|
|||||||
|
|
||||||
<!-- Last played artists -->
|
<!-- Last played artists -->
|
||||||
<section class="space-y-3">
|
<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}
|
{#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>
|
<p class="text-text-secondary">No recent plays.</p>
|
||||||
{:else}
|
{: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)}
|
{#snippet item(artist: import('$lib/api/types').ArtistRef)}
|
||||||
<div class="w-36"><ArtistCard {artist} /></div>
|
<div class="w-36"><ArtistCard {artist} /></div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
Reference in New Issue
Block a user