test-web / test (push) Successful in 32s
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).
214 lines
6.6 KiB
Svelte
214 lines
6.6 KiB
Svelte
<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>
|