Repository navigation
fix(frontend): make sidebar plain links and move focus on navigation - #1371
Merged
Merged
Conversation
…ge, add sub-tab keys The sidebar declared the ARIA tab pattern (role=tablist/tab, aria-selected) without its keyboard model, so screen readers announced "tab, 2 of 6" and arrow keys did nothing. It is page navigation, so it is now plain links with aria-current="page", and the top-level page containers drop their tabpanel role. Activating a sidebar link, or going back/forward, left focus on the link and announced nothing. switchTab now takes a focusContent option, set by the sidebar click handler and the popstate router, which focuses a visually hidden <h2 tabindex="-1"> page heading at the top of each page. The no-access placeholder keeps that heading. The mobile drawer no longer pulls focus back to the hamburger when a link click has already moved it. The Inventory and Admin sub-tab lists keep role=tablist, so they now get its keyboard model (lib/tablist.ts): a roving tabindex kept in sync by the sub-tab switchers, Left/Right with wrap-around, Home/End, hidden admin-only tabs skipped. Activation is manual (Enter/Space) because each selection fetches data and pushes a history entry. Refs LeanerCloud#730 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…antics Jest tests for lib/tablist.ts (roving tabindex, arrows with wrap, Home/End, vertical orientation, hidden admin-only tabs, manual activation) and for index.html's tab semantics, plus a Playwright spec that drives the real keys in Chromium: Enter on a sidebar link focuses the new page heading, Back restores focus, and the Admin and Inventory sub-tabs move focus with the arrow keys and select with Enter/Space. Refs LeanerCloud#730 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
Conflicts: - frontend/src/app.ts: import block. Kept this branch's wireTablistKeyboard import and main's renderInitError import (LeanerCloud#1338). - frontend/src/index.html: Inventory sub-tab buttons. Main added a Trends sub-tab (LeanerCloud#1317); this branch added a static roving tabindex. Kept all four tabs in main's order (Active commitments, Coverage, Trends, RI Exchange) with tabindex 0 on the selected tab and -1 on the rest. syncTabStops and the tablist keyboard model are generic over the buttons, so Trends gets arrow/Home/End handling with no code change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Main added a fourth Inventory sub-tab (Trends, LeanerCloud#1317), so index.html now has 8 role=tab buttons (4 Inventory + 4 Admin). The merge gave Trends the same roving tabindex as the others; update the expected count. Refs LeanerCloud#730 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: - frontend/src/app.ts: import block; kept this branch's wireTablistKeyboard import and main's initPurchasingNav import (LeanerCloud#1336). - frontend/src/inventory.ts: import block; kept this branch's syncTabStops import and main's ownValue import (LeanerCloud#1344). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eyboard e2e The sidebar entries are links now (LeanerCloud#730), so the spec main added in LeanerCloud#1388 can no longer find them by role=tab. Refs LeanerCloud#730 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: - frontend/src/app.ts: main (LeanerCloud#1340) moved setupMobileNav and syncHeaderPlacement into frontend/src/nav/mobile-nav.ts. Kept both imports (wireTablistKeyboard from this branch, setupMobileNav from main) and took main's removal of the inline drawer code. - frontend/src/nav/mobile-nav.ts (merged cleanly, edited by hand): ported this branch's drawer change into the moved module. closeDrawer takes returnFocus 'always' | 'if-inside', and a sidebar link click closes with 'if-inside' so focus stays on the new page heading. The MobileNavHandle.close() main added wraps closeDrawer() so it keeps its always-return-focus behavior. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Conflicts: - frontend/src/app.ts: adjacent imports; kept both this branch's wireTablistKeyboard import and main's setOpenAccountsHandler import (LeanerCloud#1389 first-run empty state). - frontend/src/index.html: #home-tab keeps this branch's markup (no role=tabpanel/aria-labelledby, hidden page heading as the first child) and main's #home-onboarding section (LeanerCloud#1389) right after the heading, before #summary. The new "open Accounts" handler from main switches pages programmatically (no focusContent), the same as the existing RI Exchange settings shortcut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Collaborator
Author
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): APPROVE Blocking:
|
Collaborator
Author
|
Local verification at
|
IAmJSD
marked this pull request as ready for review
October 24, 2026 23:03
Collaborator
Author
|
Marked ready and merged by IAmJSD's agent, at IAmJSD's request (IAmJSD reviewed the before/after screenshots; cristim is traveling), after the independent review and local verification above, all at |
IAmJSD
added a commit
to IAmJSD/cloud-commitments-platform
that referenced
this pull request
Oct 24, 2026
Conflicts resolved: - frontend/src/toast.ts: kept this branch's hover/focus pause (startTimer/pauseTimer/resumeTimer and listeners) and main's LeanerCloud#742 stack layout. After the toast is appended, call layoutToastStack() and startWatching() from main, then startTimer() from this branch; dropped the duplicate appendChild. - frontend/src/__tests__/ladder.test.ts: kept both imports (refreshLadderTimeline from this branch, confirmDialog from main). - frontend/tests-e2e/mobile-header-drawer.spec.ts: combined main's 'navigation' role for Main navigation (LeanerCloud#1371) with this branch's "Log out" button name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IAmJSD
added a commit
to IAmJSD/cloud-commitments-platform
that referenced
this pull request
Oct 24, 2026
Conflicts resolved: - frontend/src/app.ts: import block only. Kept this branch's fanout-retry import and main's wireTablistKeyboard (LeanerCloud#730/LeanerCloud#1371) and setOpenAccountsHandler (LeanerCloud#1389) imports; the code using each merged cleanly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
The sidebar announced itself to screen readers as a tab list ("tab, 2 of 6") but none of the tab keys worked, the Inventory and Admin sub-tabs had the same gap, and after picking a page focus stayed on the sidebar link with nothing announcing the new page. This PR turns the sidebar into plain navigation links, moves focus to the new page on navigation, and gives the remaining in-page sub-tabs the keyboard model their role promises.
Closes #730
What changed
frontend/src/index.html: the sidebar<nav>dropsrole=tablist/aria-orientation; its links droprole=tab/aria-selected/aria-controls, and the current one getsaria-current="page". The six top-level page containers droprole=tabpanel/aria-labelledby, and each gets a visually hidden<h2 class="sr-only page-heading" tabindex="-1">(Home, Opportunities, Plans, Purchases, Inventory & Coverage, Admin). The Inventory and Admin sub-tabs get a static rovingtabindex.frontend/src/navigation.ts:setActiveNavtogglesaria-current.switchTabtakes a newfocusContentoption that focuses the page heading; the sidebar click handler (app.ts) and thepopstaterouter set it, programmatic switches (deep links etc.) don't.renderNoAccesskeeps the heading.frontend/src/app.ts: a link click in the mobile drawer only returns focus to the hamburger if focus is still inside the drawer, so it no longer pulls focus back from the new page heading.frontend/src/lib/tablist.ts: the APG keyboard model for the Inventory and Admin tab lists. Rovingtabindex(kept in sync byswitchSettingsSubTabandswitchInventorySubSection), Left/Right with wrap-around, Home/End, hidden admin-only tabs skipped, manual activation with Enter/Space.__tests__/tablist.test.ts,__tests__/route-focus.test.tsandtests-e2e/tab-keyboard.spec.ts; drawer-focus cases inmobile-nav.test.ts;sidebar-anchors,app,recommendations.spec.tsandmobile-header-drawer.spec.tsupdated to queryrole=link/role=navigationinstead oftab/tablist.Commits:
ee49c2ab(fix),addedbbe(tests).Why this approach
This follows the issue's suggested fix. The sidebar is page navigation with real
hrefs (open-in-new-tab, see #462), so links witharia-currentare the right semantics and arrow keys would be wrong there. The sub-tabs switch in-page panels, so they keep the tab pattern and get its keyboard model. Activation is manual rather than automatic because every sub-tab selection fetches data and pushes a history entry; automatic activation would fire a load and a history entry for each tab arrowed across. Focus goes to a hidden per-page heading because Home and Opportunities have no visible heading at the top (their first content renders dynamically), and focusing the first visibleh2would scroll halfway down Home. The hidden heading gives every page the same target and announces the page name.Screenshots
Before is main (
a14dc8f2), after is this branch. Both are headless Chromium at 1440x900 against the repo's mocked e2e API. Focus on a hidden heading can't be seen, so a black caption painted by the screenshot script showsdocument.activeElementand the relevant ARIA attributes.role=tablist/role=tab aria-selected.<nav>and the link hasaria-current="page".tabindex0/-1/-1/-1).Verification
Run on this branch's head
addedbbeon top of maina14dc8f2, fromfrontend/:npm run lint: 0 errors (142 warnings already on main, none in changed files). Typecheck and build pass.--randomize(44 tests).e2e-local.sh): 161 passed, including the 4 tests in the newtab-keyboard.spec.ts.scripts/test-git-secrets-allowlist.sh: 137 PASS.Notes for review
sr-only), so after a sidebar click sighted keyboard users see no focus ring; their next Tab continues from the top of the page content. If visible page titles are preferred, the sameh2s can drop thesr-onlyclass (a visual change).h2; the topbar brand stays the onlyh1.history.ts(Purchases) andsettings.ts(Accounts) also userole=tablist/tabwith no tabpanel. They are filter toggles and would fit better asrole=groupwitharia-pressedbuttons; that can be a follow-up./api/confige2e mock and appears in both shots; it is not caused by this change.Overlaps
No textual conflicts with the other UI branches. Branches touching the same files on other lines: #729 (
index.html,mobile-nav.test.ts), #52, #183, #189, #213, #676, #739 and #749 (index.html), and #434, #707 and #711 (app.ts). Related: #216 covers mobile drawer focus; this branch touches the drawer only so it stops taking focus back from the new page heading after a link click.Note for the UI owner
This changes UI code that @cristim owns. IAmJSD reviewed the before/after screenshots and asked for it to be merged; any design point you disagree with can be a follow-up.
🤖 Generated with Claude Code