Skip to content

feat: restructure Settings into accessible tabbed sections - #696

Merged
enaboapps merged 4 commits into
mainfrom
feat/settings-tabs-685
Sep 8, 2026
Merged

enaboapps merged 4 commits into
mainfrom
feat/settings-tabs-685

Conversation

@enaboapps

@enaboapps enaboapps commented Sep 8, 2026 •

Copy link
Copy Markdown
Contributor

Closes #685. Stacked on #693 — base is refactor/settings-optiongroup-684, since this builds on OptionGroup.

The headline change of the v1.0.0-rc.5 milestone, and the first one users will see.

Problem

Settings rendered all 17 AppSettings keys 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:

Tab Keys
General (default) startWithSystem
Pointer pointer speed, mouse repeat ×3, key repeat ×2, dwell ×2
Cursor (only when capabilities.cursorOverlay) overlay enabled, visibility, size, color, crosshairs
Privacy shareDiagnostics + telemetry consent
Updates UpdateControls

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 exactly.

SettingGroup sections stay inside each panel, so role="region" landmarks, aria-labelledby names and heading structure are unchanged — existing getByRole("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 roving tabindex, no arrow keys, no aria-controls/tabpanel association. It was deliberately not copied.

Tabs.tsx implements the APG pattern with manual activation:

  • Arrow Left/Right and Home/End move focus, with wraparound
  • Selection changes only on click or Enter/Space, so focus never moves unexpectedly
  • Single tab stop via roving tabindex; panels are role="tabpanel" labelled by their tab

Retrofitting Support onto this primitive is filed separately as #690.

Behaviour preserved

  • focusUpdates deep link now selects the Updates tab first, then scrolls and focuses the same #settings-updates region once its panel has mounted (two effects, so the focus target exists when it runs). The region and its accessible name are unchanged.
  • Autosave / diff / optimistic flow is untouched. Because it keys off the settings object rather than mounted controls, an edit made on one tab survives switching away and still saves — covered by a new test.
  • No AppSettings changes. Pure presentation, per the persisted-schema rule in AGENTS.md.

Tests: 75 → 82

  • The 44-line mega-test splits into three per-tab tests.
  • The rebase test now switches tabs between its Privacy and Pointer assertions, since those no longer co-render.
  • Most other settings tests needed only a selectTab(...) call.
  • New: tablist keyboard behaviour and roving tabindex; Cursor tab absent when cursorOverlay is false (previously untested — the browser default is true); banner deep link from a non-default tab; a pending edit surviving a tab switch.
  • beforeEach now restores browserState.capabilities, which it did not previously reset, so the new capability test cannot leak into others.

Validation

Check Result
npm run lint pass
npm test pass — 82 tests, 4 files
npm run build pass
cargo fmt --check exit 0
cargo clippy --all-targets -- -D warnings exit 0
cargo test exit 0

Not verified visually. I could not load the dev server in the available preview browser (it cannot reach this machine's loopback — curl returns 200 where the browser gets ERR_CONNECTION_REFUSED). Verification here is test-level only, so the tab layout and the new .settings-tabs grid are worth a human look, particularly at the narrow <680px breakpoint.

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. UpdatesSection mounts only when its tab is active, so UpdateControls' <p role="alert"> never enters the DOM on failure, and UpdateBanner deliberately returns null for failed (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 for cancelled.

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 hidden restores visibility-on-return but not announcement; routing failed through 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 — runUpdate catches and calls setError, 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 Tabs primitive and are fixed in commits ea86aae, 0d0b4a1 and 0094395:

  • Roving tabindex keyed on the selected tab rather than the focused one
  • aria-controls pointing at panel ids that do not exist for inactive tabs
  • TabPanel unconditionally tabbable, then unconditionally not tabbable, then losing focus to <body> when its tabIndex was removed mid-focus
  • The tab stop stranding on a non-selected tab when the banner selected Updates
  • A dropped .settings-panel:focus-visible rule

Both regression tests added in 0094395 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.

Tests: 82 → 87.

@enaboapps
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
enaboapps force-pushed the feat/settings-tabs-685 branch from c9aecb2 to 8cb750c Compare September 8, 2026 13:50
@enaboapps
enaboapps marked this pull request as ready for review September 8, 2026 13:50
OwenMcGirr and others added 2 commits September 8, 2026 14:59
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>
@enaboapps
enaboapps merged commit a4678e6 into main Sep 8, 2026
6 checks passed
@enaboapps
enaboapps deleted the feat/settings-tabs-685 branch September 8, 2026 14:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Restructure Settings into accessible tabbed sections

2 participants