Files
jellytau/src/lib/components/library/AlphabetScrollBar.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
Formatting was configured but never enforced: `bun run format:check`
reported 199 unformatted files and ran in no workflow and in no git hook,
so .prettierrc (printWidth 100, trailing commas) described an intention
rather than the tree.

This is the one-time sweep that makes the check gateable. Whitespace and
token-reflow only -- no behavioural change: `bun run check` reports 0
errors and all 1053 frontend tests pass before and after.

Kept out of every other commit on purpose. A 199-file diff mixed with
real changes is unreviewable, and the next commit turns format:check
into a hard CI gate so this cannot silently accumulate again.
2026-08-21 17:41:44 +02:00

148 lines
4.9 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(() => {
// Bare read: registers `bottomGap` as a dependency of this effect. Svelte 5
// idiom, not a stray expression.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
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>