fix: traverse the article navigator by keyboard and expose its hierarchy - #166
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
Tabpress per row. It is now an APGtree.role="tree", every row is atreeitem, andaria-levelcarries 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.aria-setsizeandaria-posinsetare scoped to siblings under one parent and computed in the row model, which is the only place that knows the real answer.Left/Rightto collapse and expand,Home/End, and type-to-jump. The sidebar costs oneTabto pass instead of one per article.Enter,Space, and click do.aria-selectedmarks the open document on every row andaria-currentis gone. Thedata-activevisual treatment is unchanged.Reveal in sidebarleaves focus on the revealed row, rather than scrolling to it and leaving the user to traverse the tree to reach it.disabledhad taken them out of the tab order entirely.asidelandmark and the tree carry distinct names, "Article navigator" and "Articles".Rows are no longer
Buttons. The virtualized<li>is itself thetreeitem, so nothing sits between the tree and its items, and the tree ownsEnterandSpaceoutright — a native button would have activated on both behind its back. Row styling comes frombuttonVariants({ variant: "ghost" }), so the focus treatment is the same onedocs/patterns.mdnames as the reference.VirtualListgainspinnedIndexes, which forces rows into the rendered range through TanStack'srangeExtractor. 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-selectedon every row with noaria-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,Spacesearching mid-search and opening otherwise, and reveal taking focus.Shell.test.tsx— the landmark and tree names being distinct, andFile→Reveal in sidebardriven through the real menu, which is where Radix's focus restore competes with the reveal.focus()call. TheShell.test.tsxone asserts directly rather than throughwaitFor, so focus being stolen back after the menu closes would fail it.Manual, on Windows 11 through
pnpm tauri dev:Home/End,Left/Right, and type-to-jump traverse the tree; the sidebar is one tab stop forward and back.Enter,Space, and click each open one.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.Reveal in sidebarscrolls the article into view and leaves focus on its row, from the keyboard and from the menu.Not verified: platforms other than Windows; a bundled build; forced-colors rendering, where
Button'soutline-noneremains 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 gitignoredcorpus/scratch/works — because the test environment renders every row and cannot reach them.Notes
scrollToIndexfollowed 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 onfocus()because it sits at its true offset.scrollToIndexis kept only for the reveal, which wants the row centred.<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.docs/patterns.mdis not there.VirtualListhas 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'sfocus-visible:styling still cannot fire and is left alone. The tree is the tab stop inside the scroll container, so addingtabIndex={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.treeitemcarriesaria-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.scrollable-region-focusablefailure; 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.