feat(web): alphabet rail always shows #/A-Z/& with disabled empty buckets
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:
2026-06-01 21:47:10 -04:00
parent 93aa37c7b6
commit ad1a000ad5
2 changed files with 91 additions and 50 deletions
+59 -37
View File
@@ -11,42 +11,53 @@
item: Snippet<[T]>;
} = $props();
// 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.
// Bucket the first character into one of three classes so the
// rail can show a stable A–Z layout with '#' for digit-starting
// 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 out: { letter: string | null; item: T }[] = [];
const out: { bucket: 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({ letter: k, item: it });
last = k;
const b = bucketFor(getKey(it));
if (b !== last) {
out.push({ bucket: b, item: it });
last = b;
} else {
out.push({ letter: null, item: it });
out.push({ bucket: 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;
// Buckets that contain at least one item — used to grey out the
// rail buttons for empty letters (they're still present so the
// rail always reads as a stable A–Z reference).
const populated = $derived.by(() => {
const s = new Set<string>();
for (const it of items) s.add(bucketFor(getKey(it)));
return s;
});
function jumpTo(letter: string) {
const el = document.getElementById(`alpha-${letter}`);
// Full rail: '#' (numbers) → A–Z → '&' (symbols). Always the same
// 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' });
}
</script>
@@ -54,25 +65,32 @@
<div class="alpha-layout">
<div class="alpha-grid">
{#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)}
</div>
{/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. -->
{#if items.length > 0}
<!-- Sticky vertical alphabet rail. Always shows the full
'#' / A–Z / '&' set so the rail reads as a stable
reference regardless of which letters are present in the
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">
{#each letters as letter}
{#each railEntries as entry}
{@const enabled = populated.has(entry)}
<button
type="button"
class="rail-btn"
onclick={() => jumpTo(letter)}
aria-label={`Jump to ${letter}`}
>{letter}</button>
class:disabled={!enabled}
disabled={!enabled}
onclick={() => enabled && jumpTo(entry)}
aria-label={enabled
? `Jump to ${entry === '#' ? 'numbers' : entry === '&' ? 'symbols' : entry}`
: `${entry === '#' ? 'Numbers' : entry === '&' ? 'Symbols' : entry} — no entries`}
>{entry}</button>
{/each}
</nav>
{/if}
@@ -118,10 +136,14 @@
cursor: pointer;
border-radius: 9999px;
}
.rail-btn:hover,
.rail-btn:focus-visible {
.rail-btn:hover:not(.disabled),
.rail-btn:focus-visible:not(.disabled) {
color: var(--fs-parchment);
background: color-mix(in srgb, var(--fs-accent) 40%, transparent);
outline: none;
}
.rail-btn.disabled {
color: color-mix(in srgb, var(--fs-ash) 50%, transparent);
cursor: default;
}
</style>