4b101481fc
- AddToPlaylistMenu: anchor submenu bottom to parent bottom so it grows upward (top-0 → bottom-0) — kebab row is usually near the bottom of the track menu and the submenu kept extending off-screen. - HorizontalScrollRow: scrollbar-width: none + ::-webkit-scrollbar display:none. Page arrows already drive the scroll; the scrollbar was just consuming vertical real estate.
156 lines
4.4 KiB
Svelte
156 lines
4.4 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}
|
|
<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; }
|
|
</style>
|