Files
minstrel/web/src/lib/components/AlphabeticalGrid.svelte
T
bvandeusen d4c6bb3f2d
test-web / test (push) Successful in 32s
feat(web): alphabet rail chases pages on click for unloaded letters
Operator: prior rail disabled empty buckets, so clicking a letter
like 'Z' did nothing if it wasn't loaded yet. AlphabeticalGrid now
accepts a paginated source and walks pages on click until the
target letter surfaces.

- New props: hasMore, onLoadMore. When hasMore=true, every empty
  bucket stays enabled (clicking will chase pages); when hasMore=
  false, only populated buckets are clickable.
- jumpTo(bucket): if populated, scrollIntoView. Otherwise loop
  onLoadMore + tick() until the bucket appears or no more pages.
  Loader2 spinner replaces the letter on the pending button;
  cursor:wait + aria-busy. Other rail buttons disable while one is
  pending so clicks don't stack.
- Library Artists + Albums pass hasMore + onLoadMore through to the
  grid. Existing InfiniteScrollSentinel still handles scroll-driven
  loading.

Test: new case asserts rail enables empty buckets when hasMore=true
(the click would trigger onLoadMore).
2026-06-01 22:29:13 -04:00

214 lines
6.6 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import { tick } from 'svelte';
import { Loader2 } from 'lucide-svelte';
let {
items,
getKey,
item,
hasMore = false,
onLoadMore
}: {
items: T[];
getKey: (it: T) => string;
item: Snippet<[T]>;
// When true, clicking a rail letter that's not in the currently
// loaded items will trigger onLoadMore() repeatedly until that
// letter surfaces (or no more pages exist). Lets a paginated
// source like createArtistsQuery still feel like a full jump
// rail without eager-loading the whole dataset.
hasMore?: boolean;
onLoadMore?: () => Promise<unknown> | unknown;
} = $props();
// Bucket the first character into one of three classes so the
// rail can show a stable A–Z layout with '#' for digit-starting
// entries (2 Mello, 88-Keys, etc.) and '&' for symbol-starting
// ones ($uicideboy$, !!!, etc.). Always uppercase letters.
function bucketFor(raw: string | undefined | null): string {
if (!raw) return '&';
const c = raw.charAt(0);
if (/[0-9]/.test(c)) return '#';
if (/[A-Za-z]/.test(c)) return c.toUpperCase();
return '&';
}
// Tag the first item of each bucket so the rail can scrollIntoView
// it. Continuous flow — no per-bucket row breaks — so packed
// alphabets don't waste a row for single-item buckets.
const segments = $derived.by(() => {
const out: { bucket: string | null; item: T }[] = [];
let last: string | null = null;
for (const it of items) {
const b = bucketFor(getKey(it));
if (b !== last) {
out.push({ bucket: b, item: it });
last = b;
} else {
out.push({ bucket: null, item: it });
}
}
return out;
});
// Buckets that contain at least one item — used to grey out the
// rail buttons for empty letters (they're still present so the
// rail always reads as a stable A–Z reference).
const populated = $derived.by(() => {
const s = new Set<string>();
for (const it of items) s.add(bucketFor(getKey(it)));
return s;
});
// Full rail: '#' (numbers) → A–Z → '&' (symbols). Always the same
// order so muscle memory works regardless of which letters are
// present in the current view.
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const railEntries: string[] = ['#', ...ALPHABET, '&'];
// Bucket the caller is currently chasing via onLoadMore. Used to
// show a spinner on the rail button while pages stream in.
let pendingBucket = $state<string | null>(null);
function scrollNow(bucket: string) {
const el = document.getElementById(`alpha-${bucket}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
async function jumpTo(bucket: string) {
// Already loaded — jump in the same frame.
if (populated.has(bucket)) {
scrollNow(bucket);
return;
}
// No paginated source wired in — nothing to load.
if (!hasMore || !onLoadMore) return;
// Don't stack loaders.
if (pendingBucket !== null) return;
pendingBucket = bucket;
try {
// Walk pages until the bucket appears or we exhaust the
// dataset. `populated` is $derived from `items`; `tick()`
// forces Svelte to flush the prop update before we re-read
// populated for the loop condition.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
while (hasMore && !populated.has(bucket)) {
await onLoadMore();
await tick();
}
if (populated.has(bucket)) scrollNow(bucket);
} finally {
pendingBucket = null;
}
}
</script>
<div class="alpha-layout">
<div class="alpha-grid">
{#each segments as seg, i (i)}
<div class="cell" id={seg.bucket ? `alpha-${seg.bucket}` : undefined}>
{@render item(seg.item)}
</div>
{/each}
</div>
{#if items.length > 0}
<!-- Sticky vertical alphabet rail. Always shows the full
'#' / A–Z / '&' set so the rail reads as a stable
reference regardless of which letters are present in the
current view; empty buckets render as disabled buttons so
the rail layout stays muscle-memory friendly. Stays
vertically centered via position:sticky + top:50%. -->
<nav class="alpha-rail" aria-label="Jump to letter">
{#each railEntries as entry}
{@const isLoaded = populated.has(entry)}
{@const couldLoad = !isLoaded && hasMore}
{@const enabled = isLoaded || couldLoad}
{@const isPending = pendingBucket === entry}
<button
type="button"
class="rail-btn"
class:disabled={!enabled}
class:pending={isPending}
disabled={!enabled || pendingBucket !== null}
onclick={() => enabled && jumpTo(entry)}
aria-label={enabled
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
aria-busy={isPending}
>
{#if isPending}
<Loader2 size={11} strokeWidth={2} class="spin" aria-hidden="true" />
{:else}
{entry}
{/if}
</button>
{/each}
</nav>
{/if}
</div>
<style>
.alpha-layout {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: start;
}
.alpha-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px;
min-width: 0;
}
.alpha-rail {
position: sticky;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
gap: 0;
padding: 4px 2px;
border-radius: 9999px;
background: color-mix(in srgb, var(--fs-iron) 70%, transparent);
backdrop-filter: blur(4px);
}
.rail-btn {
width: 22px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--fs-font-display);
font-size: 11px;
line-height: 1;
color: var(--fs-ash);
background: none;
border: none;
cursor: pointer;
border-radius: 9999px;
}
.rail-btn:hover:not(.disabled),
.rail-btn:focus-visible:not(.disabled) {
color: var(--fs-parchment);
background: color-mix(in srgb, var(--fs-accent) 40%, transparent);
outline: none;
}
.rail-btn.disabled {
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
cursor: default;
}
.rail-btn.pending {
color: var(--fs-accent);
cursor: wait;
}
:global(.rail-btn .spin) {
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>