feat(web): Library — continuous grid + sticky alphabet rail
test-web / test (push) Successful in 33s

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-<letter>' 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
  <letter>'. 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-<letter> id
This commit is contained in:
2026-06-01 21:23:23 -04:00
parent 185b8fa9b5
commit 93aa37c7b6
2 changed files with 113 additions and 39 deletions
+86 -31
View File
@@ -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<string>();
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' });
}
</script>
<div class="alpha-grid">
{#each segments as seg, i (i)}
{#if seg.divider}
<div class="divider">
<h3 class="letter">{seg.divider}</h3>
<span class="rule"></span>
<div class="alpha-layout">
<div class="alpha-grid">
{#each segments as seg, i (i)}
<div class="cell" id={seg.letter ? `alpha-${seg.letter}` : undefined}>
{@render item(seg.item)}
</div>
{/if}
<div class="cell">
{@render item(seg.item)}
</div>
{/each}
{/each}
</div>
{#if letters.length > 1}
<!-- Sticky vertical alphabet rail. Stays vertically centered in
the viewport via position:sticky + top:50% + translate; the
row of letters survives scroll until the grid leaves the
viewport. -->
<nav class="alpha-rail" aria-label="Jump to letter">
{#each letters as letter}
<button
type="button"
class="rail-btn"
onclick={() => jumpTo(letter)}
aria-label={`Jump to ${letter}`}
>{letter}</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;
}
.divider {
grid-column: 1 / -1;
.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;
gap: 12px;
padding-top: 8px;
}
.letter {
justify-content: center;
font-family: var(--fs-font-display);
font-size: 24px;
font-weight: 500;
color: var(--fs-parchment);
/* h3 default browser margin would push the divider; reset. */
margin: 0;
font-size: 11px;
line-height: 1;
color: var(--fs-ash);
background: none;
border: none;
cursor: pointer;
border-radius: 9999px;
}
.rule {
flex: 1;
height: 1px;
background: var(--fs-pewter);
.rail-btn:hover,
.rail-btn:focus-visible {
color: var(--fs-parchment);
background: color-mix(in srgb, var(--fs-accent) 40%, transparent);
outline: none;
}
</style>
@@ -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
// <button> elements, not text dividers.
expect(screen.getByRole('button', { name: /jump to a/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /jump to b/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /jump to d/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /jump to c/i })).not.toBeInTheDocument();
});
test('renders items in given order', () => {
test('renders items in given order followed by the jump rail', () => {
const { container } = render(AlphabeticalGrid, {
props: {
items,
@@ -46,6 +48,23 @@ describe('AlphabeticalGrid', () => {
item: itemSnippet
} as AnyProps
});
expect(container.textContent).toMatch(/A.*Apple.*Avocado.*B.*Banana.*D.*Date/s);
// New layout: continuous grid (items in order) then the
// sticky alphabet rail. Items come first in DOM order; rail
// is the trailing nav.
expect(container.textContent).toMatch(/Apple.*Avocado.*Banana.*Date.*A.*B.*D/s);
});
test('tags the first item of each letter with an alpha-<letter> id', () => {
const { container } = render(AlphabeticalGrid, {
props: {
items,
getKey: (it: Item) => it.key,
item: itemSnippet
} as AnyProps
});
expect(container.querySelector('#alpha-A')).not.toBeNull();
expect(container.querySelector('#alpha-B')).not.toBeNull();
expect(container.querySelector('#alpha-D')).not.toBeNull();
expect(container.querySelector('#alpha-C')).toBeNull();
});
});