Files
minstrel/web/src/lib/components/HorizontalScrollRow.svelte
T
bvandeusenandClaude Opus 5.5 e16d182417
release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m27s
release / go (push) Successful in 1m44s
release / integration (push) Successful in 4m47s
release / android (push) Successful in 5m17s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m23s
release / Verify release artifacts (tag releases only) (push) Skipped
fix(web): drop the shelf-header accent rule Tailwind 4 started drawing (#5021)
The rule after each shelf title used `after:bg-accent/60`. Tailwind 3
cannot put an opacity modifier on a `var()` colour, so it emitted nothing
for that class and the rule never showed. Tailwind 4 mixes the opacity
in with color-mix, so the rule appeared after the upgrade. Removed, so
the headers look as they did before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:17:15 -04:00

165 lines
4.7 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. -->
<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}
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>