Skip to content

fix: traverse the article navigator by keyboard and expose its hierarchy - #166

Merged
Azganoth merged 5 commits into
mainfrom
bug/article-navigator-tree
Aug 2, 2026
Merged

fix: traverse the article navigator by keyboard and expose its hierarchy#166
Azganoth merged 5 commits into
mainfrom
bug/article-navigator-tree

Conversation

@Azganoth

@Azganoth Azganoth commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Summary

The article navigator rendered a folder hierarchy as a flat list of individually-tabbable buttons inside a virtualized scroll container. Nesting existed only as left padding, the item counts assistive technology announced described the viewport window rather than the folder, and traversing a large folder cost one Tab press per row. It is now an APG tree.

  • Hierarchy is announced. The scrolling list carries role="tree", every row is a treeitem, and aria-level carries depth. role="group" is omitted because virtualization renders only a window of rows and a group wrapper would have to enclose children that do not exist.
  • Counts describe the folder, not the window. aria-setsize and aria-posinset are scoped to siblings under one parent and computed in the row model, which is the only place that knows the real answer.
  • One tab stop. Roving tabindex across rows, with arrow traversal, Left/Right to collapse and expand, Home/End, and type-to-jump. The sidebar costs one Tab to pass instead of one per article.
  • Selection does not follow focus. Moving focus never opens a document; Enter, Space, and click do. aria-selected marks the open document on every row and aria-current is gone. The data-active visual treatment is unchanged.
  • Reveal in sidebar leaves focus on the revealed row, rather than scrolling to it and leaving the user to traverse the tree to reach it.
  • Empty directories are ordinary rows. disabled had taken them out of the tab order entirely.
  • The aside landmark and the tree carry distinct names, "Article navigator" and "Articles".

Rows are no longer Buttons. The virtualized <li> is itself the treeitem, so nothing sits between the tree and its items, and the tree owns Enter and Space outright — a native button would have activated on both behind its back. Row styling comes from buttonVariants({ variant: "ghost" }), so the focus treatment is the same one docs/patterns.md names as the reference.

VirtualList gains pinnedIndexes, which forces rows into the rendered range through TanStack's rangeExtractor. The row holding the tab stop has to stay mounted: scrolling it out by mouse would otherwise drop focus to <body> and leave the navigator with no tab stop at all, which would take the scroll region out of the tab sequence — the defect the tree is meant to fix. The revealed row is pinned by the same mechanism so it is mounted by the time the reveal reaches for it.

Related Issue

Closes #121

Verification

Automated (pnpm check:frontend, Windows 11, 987 tests across 94 files):

  • articleNavigatorRows.test.ts — sibling scoping of position and size across nesting, a collapsed parent leaving its siblings' counts alone, and parent linkage at depth two.
  • articleNavigatorTraversal.test.ts — every binding as a pure decision: movement and its stops at both ends, expand-then-descend and collapse-then-ascend, the rows with nowhere to go, activation, typeahead including a growing search, a repeated character cycling, wrap-around and no match, and the focused-index fallback to the deepest surviving ancestor.
  • ArticleNavigator.test.tsx — the tree role and name, level/position/size and expanded state across a nested fixture, aria-selected on every row with no aria-current, the single tab stop following focus, traversal not opening a document, expand/descend/collapse by keyboard, the tab stop surviving a collapse, typeahead with its expiry, Space searching mid-search and opening otherwise, and reveal taking focus.
  • Shell.test.tsx — the landmark and tree names being distinct, and FileReveal in sidebar driven through the real menu, which is where Radix's focus restore competes with the reveal.
  • The three reveal assertions were confirmed to fail against the unmodified component by disabling only the focus() call. The Shell.test.tsx one asserts directly rather than through waitFor, so focus being stolen back after the menu closes would fail it.
  • Coverage thresholds clear without change and rose on all four axes: statements 90.66%, branches 81.40%, functions 92.22%, lines 92.09%.

Manual, on Windows 11 through pnpm tauri dev:

  1. Nesting depth, expanded state, and position are announced per row, and the counts describe the folder rather than the rendered window.
  2. Arrow keys, Home/End, Left/Right, and type-to-jump traverse the tree; the sidebar is one tab stop forward and back.
  3. Focus moves through the tree without opening a document, and Enter, Space, and click each open one.
  4. Focus survives virtualization: traversal past the viewport edge, Home/End, and typeahead all land on rows that were not rendered, and scrolling by mouse while a row holds focus keeps the sidebar in the tab sequence.
  5. Reveal in sidebar scrolls the article into view and leaves focus on its row, from the keyboard and from the menu.
  6. Empty directories are reachable and readable, and the focus ring renders unclipped on ordinary and active rows alike.

Not verified: platforms other than Windows; a bundled build; forced-colors rendering, where Button's outline-none remains the pre-existing gap #118 recorded.

Reviewer reproduction: open corpus/environment/article-navigator/ for nesting and empty folders. The virtualized cases need a folder larger than the viewport — a generated one under the gitignored corpus/scratch/ works — because the test environment renders every row and cannot reach them.

Notes

  • The issue's hazard list called for scrollToIndex followed by focusing after the render commits. Pinning replaces that: the row mounts in the same render that requests it, so the focus needs no wait, and the browser scrolls it into view on focus() because it sits at its true offset. scrollToIndex is kept only for the reveal, which wants the row centred.
  • The reveal records the request id it handled. Expanding a directory renumbers the revealed row and re-runs the effect, which must not pull focus back a second time.
  • Typeahead expires by elapsed time rather than on a timer. Nothing reads the buffer between keystrokes, so a timer would only add a lifecycle to unwind on unmount, and it made the reset untestable — user-event's own waits deadlock against faked timers, while faking only the clock leaves them working.
  • Focus tracks a row's path rather than its index, because expanding a directory renumbers every row below it. When the path is gone, the deepest surviving ancestor takes the tab stop, which is what happens when a directory containing the focused row collapses.
  • A reveal request is never retired, so the revealed row stays pinned for the life of the folder context. The cost is one rendered row, and retiring the request would mean reworking reveal mechanics for no behavior a user could notice.
  • A rows rebuild that removes the focused row — an external delete, a folder refresh — still drops focus to <body>, because the row's element is gone before anything can move focus off it. The tab stop itself survives on the nearest ancestor, so the navigator stays reachable. This is not a regression: a focused row that disappeared behaved the same way when rows were buttons. Restoring focus needs the tree to know it held focus before the rebuild, or a background refresh would pull focus out of the editor, so it is left to Article navigator focus is lost when a rows rebuild removes the focused row #165.
  • The virtualized-position rule the issue wanted in docs/patterns.md is not there. VirtualList has one consumer, and every rule in that file governs recurring call sites, so the reason lives in the decision record next to the sibling-scoped attributes it explains. A pattern section earns its place once a second virtualized collection exists to write it from; Article navigator is not keyboard traversable and exposes no hierarchy #121 records the change.
  • ScrollArea.tsx:20's focus-visible: styling still cannot fire and is left alone. The tree is the tab stop inside the scroll container, so adding tabIndex={0} to the Radix viewport would only put a second tab stop ahead of it, and removing the rule would touch a shared primitive for no behavior change.
  • Every treeitem carries aria-selected, so directory rows are announced as "not selected". That is the all-or-nothing consequence the issue settled on: a tree where only some items carry it has the rest announced the same way regardless.
  • Article navigator is not keyboard traversable and exposes no hierarchy #121 was corrected before implementation. Its third defect had claimed an axe scrollable-region-focusable failure; the region contains tabbable buttons today, so the rule passes and the defect is the cost of the only available path. The unmountable tab stop and the test environment's inability to observe virtualization were added there as well.
  • Out of scope and unchanged: sorting, filtering, and multi-select; the row context menu the navigator does not have; automated accessibility checks in CI, which Hand-built interactive surfaces are keyboard-operable and correctly labeled #122 excludes.
  • This is the last sub-issue of Hand-built interactive surfaces are keyboard-operable and correctly labeled #122, which needs its group-level keyboard pass before it closes.

A tree reports position within its own level, so `aria-setsize` and
`aria-posinset` cannot come from the flat row index. Parent linkage is
what traversal to an ancestor row needs.
The `<li>` is the `treeitem` rather than a wrapper around a button, so
nothing sits between the tree and its items and the tree owns row keys
outright — a native button would activate on `Enter` and `Space` behind
its back.

Empty directories stop being disabled buttons. `disabled` took them out
of the tab order entirely, which is the opposite of what an unselectable
but real folder should do.
Focus is tracked by path rather than index, because expanding a
directory renumbers every row below it. A path that is gone resolves to
the deepest surviving ancestor, which is where focus lands when a
directory collapses over it.

`VirtualList` gains a pinned index so the row holding the tab stop stays
rendered when it scrolls out of the window. Unmounting it drops focus to
the body and leaves the navigator with no tab stop at all — the defect
the tree is meant to fix.
`Space` extends a running search rather than opening a document, because
a space can appear in a file name.

The search expires by elapsed time rather than on a timer: nothing reads
the buffer between keystrokes, so a timer would only add a lifecycle to
unwind on unmount.
The revealed row is pinned alongside the focused one so it is mounted by
the time the reveal reaches for it, and the request id is recorded once
handled — expanding a directory renumbers the revealed row and re-runs
the effect, which must not pull focus back a second time.
@Azganoth
Azganoth enabled auto-merge (squash) August 2, 2026 08:53
@Azganoth
Azganoth merged commit 95649bc into main Aug 2, 2026
2 checks passed
@Azganoth
Azganoth deleted the bug/article-navigator-tree branch August 2, 2026 08:54
@Azganoth Azganoth self-assigned this Aug 4, 2026
@Azganoth Azganoth added the Bug Something isn't working label Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Article navigator is not keyboard traversable and exposes no hierarchy

1 participant