Merge pull request 'Web Library: continuous grid + sticky alphabet rail' (#68) from dev into main
This commit was merged in pull request #68.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user