Web Library: continuous grid + sticky alphabet rail #68

Merged
bvandeusen merged 1 commits from dev into main 2026-06-01 21:36:55 -04:00
2 changed files with 113 additions and 39 deletions
+86 -31
View File
@@ -11,62 +11,117 @@
item: Snippet<[T]>; item: Snippet<[T]>;
} = $props(); } = $props();
// Compute the divider letter for each item; if it differs from the // Tag the first item of each letter so the rail can scrollIntoView
// previous item's letter, that index gets a divider above it. // 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 segments = $derived.by(() => {
const out: { divider: string | null; item: T }[] = []; const out: { letter: string | null; item: T }[] = [];
let last: string | null = null; let last: string | null = null;
for (const it of items) { for (const it of items) {
const k = (getKey(it) || '').charAt(0).toUpperCase(); const k = (getKey(it) || '').charAt(0).toUpperCase();
if (k !== last) { if (k !== last) {
out.push({ divider: k, item: it }); out.push({ letter: k, item: it });
last = k; last = k;
} else { } else {
out.push({ divider: null, item: it }); out.push({ letter: null, item: it });
} }
} }
return out; 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> </script>
<div class="alpha-grid"> <div class="alpha-layout">
{#each segments as seg, i (i)} <div class="alpha-grid">
{#if seg.divider} {#each segments as seg, i (i)}
<div class="divider"> <div class="cell" id={seg.letter ? `alpha-${seg.letter}` : undefined}>
<h3 class="letter">{seg.divider}</h3> {@render item(seg.item)}
<span class="rule"></span>
</div> </div>
{/if} {/each}
<div class="cell"> </div>
{@render item(seg.item)}
</div> {#if letters.length > 1}
{/each} <!-- 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> </div>
<style> <style>
.alpha-layout {
display: grid;
grid-template-columns: 1fr auto;
gap: 16px;
align-items: start;
}
.alpha-grid { .alpha-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 16px; gap: 16px;
min-width: 0;
} }
.divider { .alpha-rail {
grid-column: 1 / -1; 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; display: flex;
align-items: center; align-items: center;
gap: 12px; justify-content: center;
padding-top: 8px;
}
.letter {
font-family: var(--fs-font-display); font-family: var(--fs-font-display);
font-size: 24px; font-size: 11px;
font-weight: 500; line-height: 1;
color: var(--fs-parchment); color: var(--fs-ash);
/* h3 default browser margin would push the divider; reset. */ background: none;
margin: 0; border: none;
cursor: pointer;
border-radius: 9999px;
} }
.rule { .rail-btn:hover,
flex: 1; .rail-btn:focus-visible {
height: 1px; color: var(--fs-parchment);
background: var(--fs-pewter); background: color-mix(in srgb, var(--fs-accent) 40%, transparent);
outline: none;
} }
</style> </style>
@@ -23,7 +23,7 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({
type AnyProps = any; type AnyProps = any;
describe('AlphabeticalGrid', () => { describe('AlphabeticalGrid', () => {
test('inserts a divider for each new key letter', () => { test('emits a jump button for each distinct first-letter', () => {
render(AlphabeticalGrid, { render(AlphabeticalGrid, {
props: { props: {
items, items,
@@ -31,14 +31,16 @@ describe('AlphabeticalGrid', () => {
item: itemSnippet item: itemSnippet
} as AnyProps } as AnyProps
}); });
// Three dividers (A, B, D); C is absent because no items use C. // Rail has one button per distinct first-letter (A, B, D);
expect(screen.getByText('A')).toBeInTheDocument(); // C is absent because no items use C. Letters render as
expect(screen.getByText('B')).toBeInTheDocument(); // <button> elements, not text dividers.
expect(screen.getByText('D')).toBeInTheDocument(); expect(screen.getByRole('button', { name: /jump to a/i })).toBeInTheDocument();
expect(screen.queryByText('C')).not.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, { const { container } = render(AlphabeticalGrid, {
props: { props: {
items, items,
@@ -46,6 +48,23 @@ describe('AlphabeticalGrid', () => {
item: itemSnippet item: itemSnippet
} as AnyProps } 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();
}); });
}); });