Web Library: continuous grid + sticky alphabet rail #68
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user