From ad1a000ad5fe109c46f26bf17cdbf5ea5953ed18 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 21:47:10 -0400 Subject: [PATCH] feat(web): alphabet rail always shows #/A-Z/& with disabled empty buckets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator: prior rail only emitted buttons for letters with items, so jumping to a letter required scrolling to that section first — not useful as a navigation tool. Rail now always renders the full set so the page reads as a stable A-Z reference regardless of which letters are present. - New bucketFor() classifies the first character into '#' (digits), 'A'-'Z' (letters), or '&' (everything else). Anchor ids switch from per-letter to per-bucket: alpha-#, alpha-A, ..., alpha-&. - ALPHABET + railEntries hardcode the full # / A-Z / & order. - Buttons for empty buckets render disabled with a low-opacity tint + cursor:default so they read as 'no entries here' rather than broken jumps. aria-label changes too — 'Jump to A' (enabled) vs 'C — no entries' (disabled) so screen readers announce state. - # / & get verbose labels ('numbers'/'symbols') because the bare glyph isn't readable. Tests rewritten — 4 cases: full-rail-with-disabled-buckets, DOM-order, populated-bucket ids, and a separate fixture confirming digit-starting items bucket under '#'. --- .../lib/components/AlphabeticalGrid.svelte | 96 ++++++++++++------- .../lib/components/AlphabeticalGrid.test.ts | 45 ++++++--- 2 files changed, 91 insertions(+), 50 deletions(-) diff --git a/web/src/lib/components/AlphabeticalGrid.svelte b/web/src/lib/components/AlphabeticalGrid.svelte index d430f69a..667cf54d 100644 --- a/web/src/lib/components/AlphabeticalGrid.svelte +++ b/web/src/lib/components/AlphabeticalGrid.svelte @@ -11,42 +11,53 @@ item: Snippet<[T]>; } = $props(); - // Tag the first item of each letter so the rail can scrollIntoView - // it. Continuous flow — no per-letter row breaks — so packed - // alphabets don't waste a row for single-item letters. + // 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: { letter: string | null; item: T }[] = []; + const out: { bucket: string | null; item: T }[] = []; let last: string | null = null; for (const it of items) { - const k = (getKey(it) || '').charAt(0).toUpperCase(); - if (k !== last) { - out.push({ letter: k, item: it }); - last = k; + const b = bucketFor(getKey(it)); + if (b !== last) { + out.push({ bucket: b, item: it }); + last = b; } else { - out.push({ letter: null, item: it }); + out.push({ bucket: null, item: it }); } } return out; }); - // Distinct first-letters in the order they appear (preserves the - // upstream sort: 2 → 8 → A → B → … instead of pure alpha order - // because the source is already pre-sorted). - const letters = $derived.by(() => { - const seen: string[] = []; - const seenSet = new Set(); - for (const it of items) { - const k = (getKey(it) || '').charAt(0).toUpperCase(); - if (k && !seenSet.has(k)) { - seenSet.add(k); - seen.push(k); - } - } - return seen; + // 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(); + for (const it of items) s.add(bucketFor(getKey(it))); + return s; }); - function jumpTo(letter: string) { - const el = document.getElementById(`alpha-${letter}`); + // 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, '&']; + + function jumpTo(bucket: string) { + const el = document.getElementById(`alpha-${bucket}`); el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } @@ -54,25 +65,32 @@
{#each segments as seg, i (i)} -
+
{@render item(seg.item)}
{/each}
- {#if letters.length > 1} - + {#if items.length > 0} + {/if} @@ -118,10 +136,14 @@ cursor: pointer; border-radius: 9999px; } - .rail-btn:hover, - .rail-btn:focus-visible { + .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; + } diff --git a/web/src/lib/components/AlphabeticalGrid.test.ts b/web/src/lib/components/AlphabeticalGrid.test.ts index 0ad79688..6edb768e 100644 --- a/web/src/lib/components/AlphabeticalGrid.test.ts +++ b/web/src/lib/components/AlphabeticalGrid.test.ts @@ -23,7 +23,7 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({ type AnyProps = any; describe('AlphabeticalGrid', () => { - test('emits a jump button for each distinct first-letter', () => { + test('rail renders the full #/A-Z/& set; empty buckets are disabled', () => { render(AlphabeticalGrid, { props: { items, @@ -31,13 +31,17 @@ describe('AlphabeticalGrid', () => { item: itemSnippet } as AnyProps }); - // Rail has one button per distinct first-letter (A, B, D); - // C is absent because no items use C. Letters render as - //