From 93aa37c7b6d155a718042d3910915817facad6f9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 21:23:23 -0400 Subject: [PATCH] =?UTF-8?q?feat(web):=20Library=20=E2=80=94=20continuous?= =?UTF-8?q?=20grid=20+=20sticky=20alphabet=20rail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator UI review: artists/albums layout had a full-row letter divider per first-character, so a section like '2' (one artist) left 9 empty cells before '8' (one artist) started a new row. Lots of dead space across letters with few entries. Operator chose option 2: drop the dividers, add a side jump-bar. - AlphabeticalGrid no longer renders per-letter row-spanning dividers. Items flow continuously across the grid; first item of each letter gets id='alpha-' so the rail can target it via scrollIntoView. - New sticky vertical alphabet rail on the right. position:sticky + top:50% + translateY keeps it vertically centered in the viewport while the grid scrolls underneath. - Each rail entry is a focusable button with aria-label='Jump to '. Hover/focus tinted with accent. - Only renders when there's more than one distinct letter so small libraries (or filter-narrowed views) don't get an empty rail. Test rewritten — letters are buttons on the rail, not dividers in the grid. Three assertions: - one jump button per distinct first-letter - DOM order is items first then rail (was rail-then-items before) - first item of each letter carries the alpha- id --- .../lib/components/AlphabeticalGrid.svelte | 117 +++++++++++++----- .../lib/components/AlphabeticalGrid.test.ts | 35 ++++-- 2 files changed, 113 insertions(+), 39 deletions(-) diff --git a/web/src/lib/components/AlphabeticalGrid.svelte b/web/src/lib/components/AlphabeticalGrid.svelte index 4a735a67..d430f69a 100644 --- a/web/src/lib/components/AlphabeticalGrid.svelte +++ b/web/src/lib/components/AlphabeticalGrid.svelte @@ -11,62 +11,117 @@ item: Snippet<[T]>; } = $props(); - // Compute the divider letter for each item; if it differs from the - // previous item's letter, that index gets a divider above it. + // 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. const segments = $derived.by(() => { - const out: { divider: string | null; item: T }[] = []; + const out: { letter: 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({ divider: k, item: it }); + out.push({ letter: k, item: it }); last = k; } else { - out.push({ divider: null, item: it }); + out.push({ letter: 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; + }); + + function jumpTo(letter: string) { + const el = document.getElementById(`alpha-${letter}`); + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } -
- {#each segments as seg, i (i)} - {#if seg.divider} -
-

{seg.divider}

- +
+
+ {#each segments as seg, i (i)} +
+ {@render item(seg.item)}
- {/if} -
- {@render item(seg.item)} -
- {/each} + {/each} +
+ + {#if letters.length > 1} + + + {/if}
diff --git a/web/src/lib/components/AlphabeticalGrid.test.ts b/web/src/lib/components/AlphabeticalGrid.test.ts index c2822b8a..0ad79688 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('inserts a divider for each new key letter', () => { + test('emits a jump button for each distinct first-letter', () => { render(AlphabeticalGrid, { props: { items, @@ -31,14 +31,16 @@ describe('AlphabeticalGrid', () => { item: itemSnippet } as AnyProps }); - // Three dividers (A, B, D); C is absent because no items use C. - expect(screen.getByText('A')).toBeInTheDocument(); - expect(screen.getByText('B')).toBeInTheDocument(); - expect(screen.getByText('D')).toBeInTheDocument(); - expect(screen.queryByText('C')).not.toBeInTheDocument(); + // Rail has one button per distinct first-letter (A, B, D); + // C is absent because no items use C. Letters render as + //