Files
minstrel/web/src/lib/components/HorizontalScrollRow.svelte
T
bvandeusen 4e47b2e7f5 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.
2026-05-01 21:05:16 -04:00

124 lines
3.0 KiB
Svelte

<script lang="ts" generics="T">
import { ChevronLeft, ChevronRight } from 'lucide-svelte';
import type { Snippet } from 'svelte';
let {
items,
item,
title,
ariaLabel
}: {
items: T[];
item?: Snippet<[T, number]>;
title?: string;
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);
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="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}
onscroll={refreshMetrics}
data-testid="scroll-container"
class="scroller flex gap-4 overflow-x-auto"
>
{#each items as it, i (i)}
{@render item?.(it, i)}
{/each}
</div>
</div>
<style>
/* 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;
}
.scroller > :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; }
</style>