feat(web): alphabet rail always shows #/A-Z/& with disabled empty buckets
test-web / test (push) Successful in 33s
test-web / test (push) Successful in 33s
Operator: prior rail only emitted buttons for letters with items, so
jumping to a letter required scrolling to that section first — not
useful as a navigation tool. Rail now always renders the full set so
the page reads as a stable A-Z reference regardless of which letters
are present.
- New bucketFor() classifies the first character into '#' (digits),
'A'-'Z' (letters), or '&' (everything else). Anchor ids switch
from per-letter to per-bucket: alpha-#, alpha-A, ..., alpha-&.
- ALPHABET + railEntries hardcode the full # / A-Z / & order.
- Buttons for empty buckets render disabled with a low-opacity tint
+ cursor:default so they read as 'no entries here' rather than
broken jumps. aria-label changes too — 'Jump to A' (enabled) vs
'C — no entries' (disabled) so screen readers announce state.
- # / & get verbose labels ('numbers'/'symbols') because the bare
glyph isn't readable.
Tests rewritten — 4 cases: full-rail-with-disabled-buckets,
DOM-order, populated-bucket ids, and a separate fixture confirming
digit-starting items bucket under '#'.
This commit is contained in:
@@ -11,42 +11,53 @@
|
|||||||
item: Snippet<[T]>;
|
item: Snippet<[T]>;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
// Tag the first item of each letter so the rail can scrollIntoView
|
// Bucket the first character into one of three classes so the
|
||||||
// it. Continuous flow — no per-letter row breaks — so packed
|
// rail can show a stable A–Z layout with '#' for digit-starting
|
||||||
// alphabets don't waste a row for single-item letters.
|
// entries (2 Mello, 88-Keys, etc.) and '&' for symbol-starting
|
||||||
|
// ones ($uicideboy$, !!!, etc.). Always uppercase letters.
|
||||||
|
function bucketFor(raw: string | undefined | null): string {
|
||||||
|
if (!raw) return '&';
|
||||||
|
const c = raw.charAt(0);
|
||||||
|
if (/[0-9]/.test(c)) return '#';
|
||||||
|
if (/[A-Za-z]/.test(c)) return c.toUpperCase();
|
||||||
|
return '&';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag the first item of each bucket so the rail can scrollIntoView
|
||||||
|
// it. Continuous flow — no per-bucket row breaks — so packed
|
||||||
|
// alphabets don't waste a row for single-item buckets.
|
||||||
const segments = $derived.by(() => {
|
const segments = $derived.by(() => {
|
||||||
const out: { letter: string | null; item: T }[] = [];
|
const out: { bucket: 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 b = bucketFor(getKey(it));
|
||||||
if (k !== last) {
|
if (b !== last) {
|
||||||
out.push({ letter: k, item: it });
|
out.push({ bucket: b, item: it });
|
||||||
last = k;
|
last = b;
|
||||||
} else {
|
} else {
|
||||||
out.push({ letter: null, item: it });
|
out.push({ bucket: null, item: it });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Distinct first-letters in the order they appear (preserves the
|
// Buckets that contain at least one item — used to grey out the
|
||||||
// upstream sort: 2 → 8 → A → B → … instead of pure alpha order
|
// rail buttons for empty letters (they're still present so the
|
||||||
// because the source is already pre-sorted).
|
// rail always reads as a stable A–Z reference).
|
||||||
const letters = $derived.by(() => {
|
const populated = $derived.by(() => {
|
||||||
const seen: string[] = [];
|
const s = new Set<string>();
|
||||||
const seenSet = new Set<string>();
|
for (const it of items) s.add(bucketFor(getKey(it)));
|
||||||
for (const it of items) {
|
return s;
|
||||||
const k = (getKey(it) || '').charAt(0).toUpperCase();
|
|
||||||
if (k && !seenSet.has(k)) {
|
|
||||||
seenSet.add(k);
|
|
||||||
seen.push(k);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return seen;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function jumpTo(letter: string) {
|
// Full rail: '#' (numbers) → A–Z → '&' (symbols). Always the same
|
||||||
const el = document.getElementById(`alpha-${letter}`);
|
// order so muscle memory works regardless of which letters are
|
||||||
|
// present in the current view.
|
||||||
|
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||||
|
const railEntries: string[] = ['#', ...ALPHABET, '&'];
|
||||||
|
|
||||||
|
function jumpTo(bucket: string) {
|
||||||
|
const el = document.getElementById(`alpha-${bucket}`);
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -54,25 +65,32 @@
|
|||||||
<div class="alpha-layout">
|
<div class="alpha-layout">
|
||||||
<div class="alpha-grid">
|
<div class="alpha-grid">
|
||||||
{#each segments as seg, i (i)}
|
{#each segments as seg, i (i)}
|
||||||
<div class="cell" id={seg.letter ? `alpha-${seg.letter}` : undefined}>
|
<div class="cell" id={seg.bucket ? `alpha-${seg.bucket}` : undefined}>
|
||||||
{@render item(seg.item)}
|
{@render item(seg.item)}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if letters.length > 1}
|
{#if items.length > 0}
|
||||||
<!-- Sticky vertical alphabet rail. Stays vertically centered in
|
<!-- Sticky vertical alphabet rail. Always shows the full
|
||||||
the viewport via position:sticky + top:50% + translate; the
|
'#' / A–Z / '&' set so the rail reads as a stable
|
||||||
row of letters survives scroll until the grid leaves the
|
reference regardless of which letters are present in the
|
||||||
viewport. -->
|
current view; empty buckets render as disabled buttons so
|
||||||
|
the rail layout stays muscle-memory friendly. Stays
|
||||||
|
vertically centered via position:sticky + top:50%. -->
|
||||||
<nav class="alpha-rail" aria-label="Jump to letter">
|
<nav class="alpha-rail" aria-label="Jump to letter">
|
||||||
{#each letters as letter}
|
{#each railEntries as entry}
|
||||||
|
{@const enabled = populated.has(entry)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="rail-btn"
|
class="rail-btn"
|
||||||
onclick={() => jumpTo(letter)}
|
class:disabled={!enabled}
|
||||||
aria-label={`Jump to ${letter}`}
|
disabled={!enabled}
|
||||||
>{letter}</button>
|
onclick={() => enabled && jumpTo(entry)}
|
||||||
|
aria-label={enabled
|
||||||
|
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
|
||||||
|
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
|
||||||
|
>{entry}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -118,10 +136,14 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
.rail-btn:hover,
|
.rail-btn:hover:not(.disabled),
|
||||||
.rail-btn:focus-visible {
|
.rail-btn:focus-visible:not(.disabled) {
|
||||||
color: var(--fs-parchment);
|
color: var(--fs-parchment);
|
||||||
background: color-mix(in srgb, var(--fs-accent) 40%, transparent);
|
background: color-mix(in srgb, var(--fs-accent) 40%, transparent);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
.rail-btn.disabled {
|
||||||
|
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const itemSnippet = createRawSnippet<[Item]>((getIt) => ({
|
|||||||
type AnyProps = any;
|
type AnyProps = any;
|
||||||
|
|
||||||
describe('AlphabeticalGrid', () => {
|
describe('AlphabeticalGrid', () => {
|
||||||
test('emits a jump button for each distinct first-letter', () => {
|
test('rail renders the full #/A-Z/& set; empty buckets are disabled', () => {
|
||||||
render(AlphabeticalGrid, {
|
render(AlphabeticalGrid, {
|
||||||
props: {
|
props: {
|
||||||
items,
|
items,
|
||||||
@@ -31,13 +31,17 @@ describe('AlphabeticalGrid', () => {
|
|||||||
item: itemSnippet
|
item: itemSnippet
|
||||||
} as AnyProps
|
} as AnyProps
|
||||||
});
|
});
|
||||||
// Rail has one button per distinct first-letter (A, B, D);
|
// Populated buckets are enabled with 'Jump to <letter>' label.
|
||||||
// C is absent because no items use C. Letters render as
|
expect(screen.getByRole('button', { name: 'Jump to A' })).not.toBeDisabled();
|
||||||
// <button> elements, not text dividers.
|
expect(screen.getByRole('button', { name: 'Jump to B' })).not.toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: /jump to a/i })).toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'Jump to D' })).not.toBeDisabled();
|
||||||
expect(screen.getByRole('button', { name: /jump to b/i })).toBeInTheDocument();
|
// Empty buckets still render but are disabled and labelled
|
||||||
expect(screen.getByRole('button', { name: /jump to d/i })).toBeInTheDocument();
|
// 'X — no entries' so screen readers announce the empty state.
|
||||||
expect(screen.queryByRole('button', { name: /jump to c/i })).not.toBeInTheDocument();
|
expect(screen.getByRole('button', { name: 'C — no entries' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Z — no entries' })).toBeDisabled();
|
||||||
|
// # (numbers) and & (symbols) always present too.
|
||||||
|
expect(screen.getByRole('button', { name: 'Numbers — no entries' })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: 'Symbols — no entries' })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders items in given order followed by the jump rail', () => {
|
test('renders items in given order followed by the jump rail', () => {
|
||||||
@@ -48,13 +52,10 @@ describe('AlphabeticalGrid', () => {
|
|||||||
item: itemSnippet
|
item: itemSnippet
|
||||||
} as AnyProps
|
} as AnyProps
|
||||||
});
|
});
|
||||||
// New layout: continuous grid (items in order) then the
|
expect(container.textContent).toMatch(/Apple.*Avocado.*Banana.*Date.*A.*B.*C.*D/s);
|
||||||
// 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', () => {
|
test('tags the first item of each populated bucket with an alpha-<bucket> id', () => {
|
||||||
const { container } = render(AlphabeticalGrid, {
|
const { container } = render(AlphabeticalGrid, {
|
||||||
props: {
|
props: {
|
||||||
items,
|
items,
|
||||||
@@ -65,6 +66,24 @@ describe('AlphabeticalGrid', () => {
|
|||||||
expect(container.querySelector('#alpha-A')).not.toBeNull();
|
expect(container.querySelector('#alpha-A')).not.toBeNull();
|
||||||
expect(container.querySelector('#alpha-B')).not.toBeNull();
|
expect(container.querySelector('#alpha-B')).not.toBeNull();
|
||||||
expect(container.querySelector('#alpha-D')).not.toBeNull();
|
expect(container.querySelector('#alpha-D')).not.toBeNull();
|
||||||
|
// C has no items so no anchor exists in the grid.
|
||||||
expect(container.querySelector('#alpha-C')).toBeNull();
|
expect(container.querySelector('#alpha-C')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('digit-starting items bucket under #', () => {
|
||||||
|
const mixed: Item[] = [
|
||||||
|
{ id: 'n1', key: '2', label: '2-Mello' },
|
||||||
|
{ id: 'n2', key: '88', label: '88-Keys' },
|
||||||
|
{ id: 'n3', key: 'A', label: 'Apple' }
|
||||||
|
];
|
||||||
|
const { container } = render(AlphabeticalGrid, {
|
||||||
|
props: {
|
||||||
|
items: mixed,
|
||||||
|
getKey: (it: Item) => it.key,
|
||||||
|
item: itemSnippet
|
||||||
|
} as AnyProps
|
||||||
|
});
|
||||||
|
expect(container.querySelector('#alpha-\\#')).not.toBeNull();
|
||||||
|
expect(screen.getByRole('button', { name: 'Jump to numbers' })).not.toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user