Repository navigation
feat: restructure Settings into accessible tabbed sections - #696
Merged
Merged
Conversation
enaboapps
changed the base branch from
refactor/settings-optiongroup-684
to
main
September 8, 2026 13:50
Settings rendered all 17 AppSettings keys as one unbroken scroll, with the Pointer group alone stacking five sub-blocks, ten disabled-gated fieldsets and three always-visible paragraphs. Split it into five tabbed sections: General (default), Pointer, Cursor, Privacy, Updates. Cursor splits off along a seam that already existed: the overlay block was separately gated on capabilities.cursorOverlay, so on a platform without overlay support the tab simply does not render, matching the previous conditional. Adds a real Tabs primitive rather than copying the Support view's tablist, which is not a complete WAI-ARIA implementation (no roving tabindex, no arrow keys, no aria-controls). Tabs implements the APG pattern with manual activation: arrows and Home/End move focus with wraparound, selection changes only on click or Enter/Space, so focus never moves unexpectedly. Panels are role=tabpanel labelled by their tab. SettingGroup sections stay inside each panel, so the role=region landmarks, aria-labelledby names and heading structure are unchanged. focusUpdates now selects the Updates tab first, then scrolls and focuses the same #settings-updates region once its panel has mounted. The autosave state machine is untouched; because it keys off the settings object rather than mounted controls, edits made on a tab survive switching away from it, which is covered by a new test. Tests: 75 -> 82. The 44-line mega-test splits into three per-tab tests and the rebase test now switches tabs between its two assertions. New coverage for tablist keyboard behaviour and roving tabindex, the Cursor tab's absence without overlay capability, the banner deep link from a non-default tab, and pending edits surviving a tab switch. beforeEach now restores browserState.capabilities so the capability test cannot leak. Refs #685 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
enaboapps
force-pushed
the
feat/settings-tabs-685
branch
from
September 8, 2026 13:50
c9aecb2 to
8cb750c
Compare
enaboapps
marked this pull request as ready for review
September 8, 2026 13:50
Review findings on the tablist, all in the primitive the restructure
exists to provide.
Roving tabindex was keyed on the selected tab rather than the focused
one, so arrowing to a tab moved DOM focus without moving the tab stop:
tabbing out and back returned to the selected tab and discarded the
user's position. The tab stop now follows focus while the tablist has
it and falls back to the selected tab once focus leaves. Selecting a
tab also moves the stop, which matches a real browser click and is not
covered by jsdom's fireEvent.click.
aria-controls was set on every tab, but only the selected panel is
rendered, so inactive tabs pointed at ids that do not exist. Dangling
IDREFs break "go to controlled element" in screen readers and fail
axe's aria-valid-attr-value. It is now set only on the selected tab.
TabPanel hardcoded tabIndex={0} although every panel contains focusable
controls, adding a redundant tab stop and drawing a focus ring around
the whole panel before the first real control. Removed, along with the
now-unused .settings-panel:focus-visible rule.
Tests: 82 -> 84, covering the tab stop following focus, its return on
blur out of the tablist, and aria-controls matching the rendered panel.
Refs #685
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second review pass on the tablist. The tab stop now stays on the last tab that held focus instead of resetting when focus leaves the tablist, which is what the roving tabindex was for and what the previous commit's comment already claimed. It falls back to the selected tab only when the focused tab no longer exists. TabPanel dropped tabIndex unconditionally, but the Updates panel has no tabbable element while a check or install is in flight: the action button unmounts and its replacement is disabled. The panel now measures its own content and takes a tab stop only when nothing inside it can take focus, per the APG rule. Also removes a test that asserted the tab stop resets on remount. It exercised nothing: leaving and re-entering Settings unmounts SettingsView, so the state resets regardless of the guard it claimed to cover. Tests: 84 -> 85. Refs #685 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third review pass on the tablist. Selecting a tab programmatically — as the update banner does — left the roving tab stop on whichever tab last held focus, so the tablist's only tab stop could sit on a tab that is no longer selected while the selected one was unreachable by Tab. The stop now returns to the selection whenever it changes, which also covers ordinary clicks and removes the need to set focus state from the click handler. TabPanel's reactive tabIndex was removed from the element while it held focus: on the Updates panel, finishing a check swaps the disabled button for an enabled one, so the panel stopped being tabbable and browsers drop focus to the body. jsdom does not, which is why this needed an explicit assertion rather than a focus check. The panel now keeps its stop for as long as it holds focus. Restores .settings-panel:focus-visible, dropped when TabPanel lost its unconditional tabIndex but still needed now that it has a conditional one, so that stop uses the app focus ring rather than the UA default. Both regression tests were verified to fail with their fix reverted. An earlier version of the panel-focus test was vacuous — it mutated browserState without triggering a render — and now drives a real onState event instead. Tests: 85 -> 87. Refs #685 Co-Authored-By: Claude Opus 5 (1M context) <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.
Closes #685. Stacked on #693 — base is
refactor/settings-optiongroup-684, since this builds onOptionGroup.The headline change of the
v1.0.0-rc.5milestone, and the first one users will see.Problem
Settings rendered all 17
AppSettingskeys as one unbroken scroll with no navigation. The Pointer group alone stacked five sub-blocks, ~10 disabled-gated fieldsets and three always-visible explanatory paragraphs.Structure
Five tabbed sections inside the Settings view:
startWithSystemcapabilities.cursorOverlay)shareDiagnostics+ telemetry consentUpdateControlsCursor splits off along a seam that already existed: the overlay block was separately gated on
capabilities.cursorOverlay, so on a platform without overlay support the tab simply does not render, matching the previous conditional exactly.SettingGroupsections stay inside each panel, sorole="region"landmarks,aria-labelledbynames and heading structure are unchanged — existinggetByRole("region", { name: "Updates" })queries keep working.A real Tabs primitive, not a copy of Support's
The Support view's tablist (
src/App.tsx:458) looks reusable but is not a complete WAI-ARIA tabs implementation — no rovingtabindex, no arrow keys, noaria-controls/tabpanelassociation. It was deliberately not copied.Tabs.tsximplements the APG pattern with manual activation:tabindex; panels arerole="tabpanel"labelled by their tabRetrofitting Support onto this primitive is filed separately as #690.
Behaviour preserved
focusUpdatesdeep link now selects the Updates tab first, then scrolls and focuses the same#settings-updatesregion once its panel has mounted (two effects, so the focus target exists when it runs). The region and its accessible name are unchanged.AppSettingschanges. Pure presentation, per the persisted-schema rule inAGENTS.md.Tests: 75 → 82
selectTab(...)call.tabindex; Cursor tab absent whencursorOverlayisfalse(previously untested — the browser default istrue); banner deep link from a non-default tab; a pending edit surviving a tab switch.beforeEachnow restoresbrowserState.capabilities, which it did not previously reset, so the new capability test cannot leak into others.Validation
npm run lintnpm testnpm run buildcargo fmt --checkcargo clippy --all-targets -- -D warningscargo testNot verified visually. I could not load the dev server in the available preview browser (it cannot reach this machine's loopback —
curlreturns 200 where the browser getsERR_CONNECTION_REFUSED). Verification here is test-level only, so the tab layout and the new.settings-tabsgrid are worth a human look, particularly at the narrow<680pxbreakpoint.Out of scope
No control redesign. The exact-speed disclosure (#686) and the expandable help notes (#687) remain separate.
🤖 Generated with Claude Code
Known regression, tracked in #697
Update failures are surfaced nowhere while another Settings tab is selected.
UpdatesSectionmounts only when its tab is active, soUpdateControls'<p role="alert">never enters the DOM on failure, andUpdateBannerdeliberately returnsnullforfailed(an existing test asserts that). Start a download from Updates, switch to Pointer, and if it fails the banner disappears with no visual or screen-reader indication until the user returns to the tab. Same gap forcancelled.Before the restructure the Updates group was always rendered on the Settings page, so the alert fired immediately. This is a real consequence of tabbing the page, not an oversight.
It is not fixed here because every option is a product decision rather than a bug fix: rendering all panels with
hiddenrestores visibility-on-return but not announcement; routingfailedthrough the global banner contradicts an existing deliberate test; and flagging the Updates tab as needing attention is a new affordance beyond this issue's scope. Filed as #697 for that call.Manual checks that throw are unaffected —
runUpdatecatches and callssetError, and the app-level error banner renders outside the view regardless of tab.Review history
Three review passes on this branch; all findings were in the
Tabsprimitive and are fixed in commitsea86aae,0d0b4a1and0094395:aria-controlspointing at panel ids that do not exist for inactive tabsTabPanelunconditionally tabbable, then unconditionally not tabbable, then losing focus to<body>when its tabIndex was removed mid-focus.settings-panel:focus-visibleruleBoth regression tests added in
0094395were verified to fail with their fix reverted. An earlier version of the panel-focus test was vacuous — it mutatedbrowserStatewithout triggering a render — and now drives a realonStateevent.Tests: 82 → 87.