jellytau/src/lib/components/library/AlphabetScrollBar.svelte
Duncan Tourolle 1836615dc0
All checks were successful
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s
feat(library): genre sliders, artist links, and navigation utils
- music landing: diverse per-genre album sliders (online counts /
  offline wide-probe fallback) and home-screen library shortcuts
- add ArtistLinks component and shared navigation/genreDiversity utils
- player/playback-mode refinements across Rust and frontend
2026-06-25 19:18:06 +02:00

143 lines
4.7 KiB
Svelte

<!-- TRACES: UR-007 | DR-007 -->
<script lang="ts">
/**
* A vertical A-Z index strip for long, alphabetically-sorted lists.
* Tapping or dragging a letter jumps to the first item that starts with it.
*
* The parent owns the actual scrolling: it passes `availableLetters`
* (which letters have items) and an `onJump(letter)` callback.
*/
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const HASH = "#"; // bucket for names starting with a digit/symbol
interface Props {
/** Uppercase letters (or "#") that currently have at least one item. */
availableLetters: Set<string>;
/** Called with the chosen letter when the user picks one. */
onJump: (letter: string) => void;
/**
* CSS length reserved at the bottom of the viewport for the bottom nav /
* mini-player bars. The strip stretches to fill the space between the top
* sticky offset and this gap, so it ends just above those bars.
*/
bottomGap?: string;
}
let { availableLetters, onJump, bottomGap = "5rem" }: Props = $props();
// The strip stretches from where it sits down to just above the bottom nav /
// mini-player bars. Those bars are pinned to the bottom of the screen, so the
// hard floor is `window.innerHeight - bottomGap`. We measure the strip's own
// top against that floor (clamped to non-negative) and update on scroll/resize
// so it never slides under the bars regardless of header or platform.
let container = $state<HTMLDivElement | null>(null);
let stripHeight = $state(0);
function measure() {
if (!container) return;
const top = container.getBoundingClientRect().top;
const floor = window.innerHeight - remToPx(bottomGap);
stripHeight = Math.max(0, floor - top);
}
function remToPx(len: string): number {
const n = parseFloat(len);
if (len.trim().endsWith("rem")) {
const root = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return n * root;
}
return n; // assume px otherwise
}
$effect(() => {
measure();
const scroller = container?.closest("main");
const ro = new ResizeObserver(measure);
if (scroller) ro.observe(scroller);
scroller?.addEventListener("scroll", measure, { passive: true });
window.addEventListener("resize", measure);
return () => {
ro.disconnect();
scroller?.removeEventListener("scroll", measure);
window.removeEventListener("resize", measure);
};
});
// Recompute when the reserved bottom gap changes (mini-player shows/hides).
$effect(() => {
bottomGap;
measure();
});
const letters = $derived([HASH, ...ALPHABET]);
let activeLetter = $state<string | null>(null);
function jumpTo(letter: string) {
if (!availableLetters.has(letter)) return;
activeLetter = letter;
onJump(letter);
}
// Allow dragging a finger/mouse down the strip to scrub through letters.
function letterAt(clientY: number): string | null {
const el = document.elementFromPoint(
// x is supplied by the caller via the bound container; we read from the
// element under the pointer instead to stay layout-agnostic.
lastClientX,
clientY
);
const letter = el?.getAttribute?.("data-letter");
return letter ?? null;
}
let lastClientX = $state(0);
let isScrubbing = $state(false);
function handlePointerDown(e: PointerEvent) {
isScrubbing = true;
lastClientX = e.clientX;
const letter = letterAt(e.clientY);
if (letter) jumpTo(letter);
}
function handlePointerMove(e: PointerEvent) {
if (!isScrubbing) return;
lastClientX = e.clientX;
const letter = letterAt(e.clientY);
if (letter && letter !== activeLetter) jumpTo(letter);
}
function handlePointerUp() {
isScrubbing = false;
}
</script>
<svelte:window onpointerup={handlePointerUp} onpointercancel={handlePointerUp} />
<div
bind:this={container}
class="flex flex-col items-center justify-between select-none touch-none py-1"
style="height: {stripHeight}px"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
role="navigation"
aria-label="Jump to letter"
>
{#each letters as letter (letter)}
{@const enabled = availableLetters.has(letter)}
<button
type="button"
data-letter={letter}
disabled={!enabled}
onclick={() => jumpTo(letter)}
class="w-5 leading-tight text-[10px] sm:text-xs font-semibold transition-colors
{enabled ? 'text-gray-400 hover:text-[var(--color-jellyfin)]' : 'text-gray-700 cursor-default'}
{activeLetter === letter && enabled ? 'text-[var(--color-jellyfin)] scale-125' : ''}"
aria-label={`Jump to ${letter}`}
>
{letter}
</button>
{/each}
</div>