feat(web): Library — continuous grid + sticky alphabet rail
test-web / test (push) Successful in 33s

Operator UI review: artists/albums layout had a full-row letter
divider per first-character, so a section like '2' (one artist)
left 9 empty cells before '8' (one artist) started a new row. Lots
of dead space across letters with few entries.

Operator chose option 2: drop the dividers, add a side jump-bar.

- AlphabeticalGrid no longer renders per-letter row-spanning
  dividers. Items flow continuously across the grid; first item
  of each letter gets id='alpha-<letter>' so the rail can target
  it via scrollIntoView.
- New sticky vertical alphabet rail on the right. position:sticky
  + top:50% + translateY keeps it vertically centered in the
  viewport while the grid scrolls underneath.
- Each rail entry is a focusable button with aria-label='Jump to
  <letter>'. Hover/focus tinted with accent.
- Only renders when there's more than one distinct letter so
  small libraries (or filter-narrowed views) don't get an empty
  rail.

Test rewritten — letters are buttons on the rail, not dividers in
the grid. Three assertions:
- one jump button per distinct first-letter
- DOM order is items first then rail (was rail-then-items before)
- first item of each letter carries the alpha-<letter> id
This commit is contained in:
2026-06-01 21:23:23 -04:00
parent 185b8fa9b5
commit 93aa37c7b6
2 changed files with 113 additions and 39 deletions
@@ -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();
});
});