test-web / test (push) Failing after 33s
First wave of Home visual polish (UI tasks 78/79/81/85): - CardActionCluster like/queue/menu cluster is now opacity-0 and fades in on group-hover or focus-within. The group class moves from the inner anchor to the outer card wrapper so the cluster (positioned outside the anchor) participates in the same hover scope. PlaylistCard refresh kebab gets the same treatment. - AlbumCard drops the year line. Title plus artist is enough on Home tiles; the album detail page still shows the year. - HorizontalScrollRow h2 picks up a flex-baseline layout with a trailing 12-wide accent rule (after:bg-accent/60), so section headers read as chapter breaks instead of identical plain text. - AlbumCard art-wrap is shadow-sm by default and lifts to shadow-lg plus ring-accent/40 on hover. Pairs with the existing group-hover:scale-1.03 for a clean lift-on-mouseover feel.
171 lines
5.0 KiB
Svelte
171 lines
5.0 KiB
Svelte
<script lang="ts" generics="T">
|
|
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
|
|
import type { Snippet } from 'svelte';
|
|
|
|
let {
|
|
rows,
|
|
item,
|
|
title,
|
|
ariaLabel
|
|
}: {
|
|
/** One or more visual rows of items rendered inside a single shared
|
|
horizontal scroller. Pass [items] for a single-row section. */
|
|
rows: T[][];
|
|
/** Render snippet for a single item. The second arg is the global
|
|
index across the flattened sequence (rows[0] then rows[1] etc.) —
|
|
useful when the snippet needs section-wide indexing, e.g. a
|
|
compact track card whose play action takes its position in the
|
|
whole section's track list. */
|
|
item?: Snippet<[T, number]>;
|
|
/** Section heading shown to the left of the arrows. Omit for an
|
|
unlabeled scroller. */
|
|
title?: string;
|
|
/** Accessible label for the section as a whole. */
|
|
ariaLabel?: string;
|
|
} = $props();
|
|
|
|
let scroller: HTMLElement | undefined = $state();
|
|
let scrollLeft = $state(0);
|
|
let scrollWidth = $state(0);
|
|
let clientWidth = $state(0);
|
|
|
|
const atStart = $derived(scrollLeft <= 1);
|
|
const atEnd = $derived(scrollLeft + clientWidth >= scrollWidth - 1);
|
|
|
|
// Per-row offsets so the snippet's second arg can be the global index.
|
|
// [0, len(rows[0]), len(rows[0])+len(rows[1]), ...]
|
|
const rowOffsets = $derived.by(() => {
|
|
const out: number[] = [0];
|
|
for (let i = 0; i < rows.length - 1; i++) {
|
|
out.push(out[i] + rows[i].length);
|
|
}
|
|
return out;
|
|
});
|
|
|
|
function refreshMetrics() {
|
|
if (!scroller) return;
|
|
scrollLeft = scroller.scrollLeft;
|
|
scrollWidth = scroller.scrollWidth;
|
|
clientWidth = scroller.clientWidth;
|
|
}
|
|
|
|
function page(direction: -1 | 1) {
|
|
if (!scroller) return;
|
|
const delta = direction * scroller.clientWidth * 0.85;
|
|
scroller.scrollBy({ left: delta, behavior: 'smooth' });
|
|
setTimeout(refreshMetrics, 350);
|
|
}
|
|
|
|
$effect(() => { refreshMetrics(); });
|
|
</script>
|
|
|
|
<div class="section" aria-label={ariaLabel}>
|
|
<header class="section-header">
|
|
{#if title}
|
|
<!-- Section header: Fraunces (font-display) at 24px with an
|
|
inline accent rule that bleeds out from the title — gives
|
|
each shelf a clear chapter break without adding chrome. -->
|
|
<h2
|
|
class="font-display text-2xl font-medium text-text-primary
|
|
flex items-baseline gap-3 after:h-[2px] after:w-12 after:flex-shrink-0
|
|
after:rounded-full after:bg-accent/60 after:content-['']"
|
|
>{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}
|
|
onscroll={refreshMetrics}
|
|
data-testid="scroll-container"
|
|
class="scroller overflow-x-auto"
|
|
>
|
|
<div class="track inline-flex flex-col gap-4">
|
|
{#each rows as row, rowIdx (rowIdx)}
|
|
<div class="rack flex gap-4">
|
|
{#each row as it, colIdx (colIdx)}
|
|
{@render item?.(it, rowOffsets[rowIdx] + colIdx)}
|
|
{/each}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
/* Bleed the section past the parent's right padding (16px from Shell's p-4)
|
|
so the scroller's items extend to the viewport's right edge. */
|
|
.section {
|
|
margin-right: -1rem;
|
|
}
|
|
.section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
/* Re-add the right gutter so the header stays aligned with 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: none;
|
|
}
|
|
.scroller::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
/* Cards are two levels deep (.scroller > .track > .rack > card-wrapper),
|
|
so target them via the rack. flex-shrink: 0 keeps each card at its
|
|
intended width even though the rack tries to fit the scroller width. */
|
|
.rack > :global(*) {
|
|
flex-shrink: 0;
|
|
scroll-snap-align: start;
|
|
}
|
|
.arrow {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 9999px;
|
|
background: var(--fs-iron);
|
|
color: var(--fs-vellum);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border: 1px solid var(--fs-pewter);
|
|
}
|
|
.arrow:hover:not(:disabled) { color: var(--fs-parchment); }
|
|
.arrow:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
/* Coarse pointers (touch screens) get larger arrows for finger-friendly
|
|
hit area; mouse pointers keep the visually compact 32px size. */
|
|
@media (pointer: coarse) {
|
|
.arrow {
|
|
width: 40px;
|
|
height: 40px;
|
|
}
|
|
}
|
|
</style>
|