Keep disabled controls in the accessibility tree so their reason is announced #49

Merged
jknapp merged 2 commits from fix/disabled-control-accessibility into main 2026-09-02 18:53:20 +00:00
Owner

What a screen-reader user hits today

Open the sidebar with a screen reader and tab through a stopped project's row. The Claude terminal button is not there. Not "there but greyed" — absent. Native disabled takes an element out of the tab order and out of the accessibility tree, so there is nothing to focus and nothing to announce. The reason it cannot be used ("the container is not running") is announced nowhere, because there is no longer an element to announce it on. A sighted mouse user gets a title tooltip on hover; everyone else gets silence and a control that seems not to exist.

Add Project is the same defect with a sharper edge. Submit the dialog and the button goes disabled while the add is in flight. A keyboard user who was standing on that button has focus yanked out from under them to document.body, and the one thing that would have explained it — the label swapping to "Adding…" — is now on an element assistive technology cannot reach.

The fix

aria-disabled="true" plus aria-describedby pointing at a real sr-only element, instead of the native disabled attribute. The control stays focusable, stays in the accessibility tree, announces its state, and announces the reason.

The hazard is that aria-disabled is advisory: unlike disabled it blocks nothing, so a converted control will happily fire on click and on Enter/Space. Forgetting the guard turns a cosmetic accessibility fix into a functional regression (here: opening a terminal against a stopped container, and double-submitting a project add).

So the guard is not left to the call site. app/src/components/ui/unavailable.tsx returns the attributes and the guarded handlers together, as one object you spread — you cannot take the announcement without taking the guard.

Shared component, not per-site. ui/Button.tsx gets the pattern as an opt-in unavailable / unavailableReason pair. Opt-in is what makes this safe: 37 files render Button, and every one of them is byte-identical in behaviour after this change — nothing happens unless a call site passes unavailable. The underlying useUnavailable hook is exported separately because one of the two call sites (ProjectRow) uses a raw <button>, not Button; a hook serves both without forcing a rewrite of the sidebar's icon buttons.

Two details worth a reviewer's eye:

  • Tailwind's disabled: variant only matches the native attribute, which this pattern deliberately does not set, so the "unavailable" look needed aria-disabled: mirrors alongside the existing disabled: classes in Button's variant strings. Confirmed present in the built CSS as [aria-disabled=true] rules, not just in source. The two lists must be kept in step; there is a comment saying so.
  • The sr-only reason renders as a sibling of the button, not inside it. Inside, it would join the accessible name rather than the description. Button.test.tsx asserts the name stays "Save".

Survey

grep -rn "disabled" app/src --include=*.tsx → 291 lines; 254 outside tests; ~208 real disabled props/identifiers after excluding Tailwind disabled: variants and --text-disabled token references. 82 title= lines in non-test TSX.

Elements with both disabled and a title-like prop on the same tag: 6 distinct, plus ~18 bulk aria-label list-row cases.

Elements where that title-like prop actually explains the disabled state: 0. The literal "disabled + explanatory title" pattern does not exist in this codebase — worth stating plainly, since it is what the fix was originally scoped around.

Converted: 2.

Site Why
ProjectRow.tsx — Claude terminal button disabled={!isRunning}. Precondition the user must act on; explanation existed nowhere in the accessibility tree.
AddProjectDialog.tsx — submit button disabled={loading}. Explanation existed only as a label swap on an unreachable element.

Rejected, by category:

  • Title is a name, not a reason (~24 sites). ProjectRow's start/stop button (title is Start/Stop/Force stop {name} — conditional on isRunning, not on the busy that disables it), MicrophoneSettings.tsx:91 ("Refresh microphone list"), FilesTab.tsx:538, ClaudeAuthModal.tsx:291, ClaudeInstructionsEditor.tsx:39, and the aria-label list rows in PortMappingsEditor, EnvVarsEditor, WorkspaceSection, ClaudeCodeSettingsEditor, Toggle, SegmentedControl, OverflowMenu. Converting these would announce a state without adding any information, which is churn.
  • No explanatory text anywhere (~50 sites). DockerSettings.tsx:151,159 is the tempting one — disabled={working || !dockerAvailable}, and "Docker is not running" is exactly the missing sentence — but it is missing, so fixing it means inventing copy, not preserving it. Explicitly out of scope. Same for OpenPageDialog, ExportSettingsModal, ImportSettingsModal, GatewaySettings, CaCertPathInput, SttSettings, and the 21 sites in ModelSection.tsx.
  • Deferred, and the largest finding: 17 components carry a disabledReason string rendered as an unassociated sibling <p>. ClaudeInstructionsEditor, EnvVarsEditor, PortMappingsEditor, ClaudeCodeSettingsEditor, PermissionModeControl, RuntimeSection, AccessSection, ConfigTab, ContainerMigrationBanner, SharedAuthSettings, AutomationTab, CapabilityTiles, BrowserTab, MigrateContainerModal and the three config modals. These pass the "explains why it is unavailable" test — PermissionModeControl's prop doc literally says so — but they are a different, weaker defect: the reason is visible text in the same region, so it is discoverable, just not programmatically associated with the control (no id, no aria-describedby). Nothing is lost, only unlinked. That is a real WCAG 1.3.1/4.1.2 gap and it should be fixed, but it is a 17-component change with its own design question (does Field/ConfigGroup grow the association, or each editor?) and folding it in here would make this diff unreviewable. Filed as follow-up, not silently skipped.

The line this PR draws: fix where the reason is absent from the accessibility tree entirely; defer where it is present but unlinked; reject where there is no reason to preserve.

Testing

Vitest + React Testing Library, matching the house idiom (component hooks mocked at the hook boundary; no @tauri-apps/api/core mocking anywhere).

New coverage, written before the fix and watched fail:

  • ui/Button.test.tsx (5) — unavailable button is queryable by role and not toBeDisabled(); accessible description carries the reason; accessible name stays clean; click and Enter/Space are ignored; non-activation keys still reach onKeyDown; available button clicks normally; native disabled still works, which is the regression guard for the other 36 call sites.
  • projects/ProjectRow.test.tsx (4 new/rewritten) — the pre-existing toBeDisabled() assertion failed on this change and was rewritten, which is the honest signal that behaviour moved.
  • projects/AddProjectDialog.test.tsx (5, new file) — including fireEvent.submit(form) while loading, which exercises the second guard specifically: Enter inside a text field submits a form without ever touching the submit button, so handleSubmit has to guard itself. The hook cannot cover that path.

The click guard was mutation-tested, not assumed. Neutering both guards in useUnavailable (passing the raw handlers through) fails exactly the two guard tests and nothing else:

× guards clicks and Enter/Space while unavailable
× ignores clicks and Enter/Space on the terminal button while stopped
Tests  2 failed | 13 passed (15)

Guards restored, and the numbers:

$ npm test -- --run
 Test Files  52 passed (52)
      Tests  656 passed (656)

$ npx tsc --noEmit
(exit 0, no output)

$ npm run build
✓ built in 1.04s

Baseline on main was 50 files / 643 tests. Nothing under src-tauri was touched, so cargo test was not run.

Deliberately not done

  • The 17-component disabledReason association gap described above.
  • Tooltip.tsx has no role="tooltip" and no aria-describedby wiring at any of its 27 call sites, so it is visual-only. Out of scope here, but it is adjacent and real.
  • Nothing from feat/project-notes; this branch is cut from origin/main.

🤖 Generated with Claude Code

https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm

## What a screen-reader user hits today Open the sidebar with a screen reader and tab through a stopped project's row. The Claude terminal button is not there. Not "there but greyed" — absent. Native `disabled` takes an element out of the tab order *and* out of the accessibility tree, so there is nothing to focus and nothing to announce. The reason it cannot be used ("the container is not running") is announced nowhere, because there is no longer an element to announce it on. A sighted mouse user gets a `title` tooltip on hover; everyone else gets silence and a control that seems not to exist. Add Project is the same defect with a sharper edge. Submit the dialog and the button goes `disabled` while the add is in flight. A keyboard user who was standing on that button has focus yanked out from under them to `document.body`, and the one thing that would have explained it — the label swapping to "Adding…" — is now on an element assistive technology cannot reach. ## The fix `aria-disabled="true"` plus `aria-describedby` pointing at a real `sr-only` element, instead of the native `disabled` attribute. The control stays focusable, stays in the accessibility tree, announces its state, and announces the reason. The hazard is that `aria-disabled` is advisory: unlike `disabled` it blocks nothing, so a converted control will happily fire on click and on Enter/Space. Forgetting the guard turns a cosmetic accessibility fix into a functional regression (here: opening a terminal against a stopped container, and double-submitting a project add). So the guard is not left to the call site. `app/src/components/ui/unavailable.tsx` returns the attributes and the guarded handlers together, as one object you spread — you cannot take the announcement without taking the guard. **Shared component, not per-site.** `ui/Button.tsx` gets the pattern as an opt-in `unavailable` / `unavailableReason` pair. Opt-in is what makes this safe: 37 files render `Button`, and every one of them is byte-identical in behaviour after this change — nothing happens unless a call site passes `unavailable`. The underlying `useUnavailable` hook is exported separately because one of the two call sites (`ProjectRow`) uses a raw `<button>`, not `Button`; a hook serves both without forcing a rewrite of the sidebar's icon buttons. Two details worth a reviewer's eye: - **Tailwind's `disabled:` variant only matches the native attribute**, which this pattern deliberately does not set, so the "unavailable" look needed `aria-disabled:` mirrors alongside the existing `disabled:` classes in `Button`'s variant strings. Confirmed present in the built CSS as `[aria-disabled=true]` rules, not just in source. The two lists must be kept in step; there is a comment saying so. - **The `sr-only` reason renders as a sibling of the button, not inside it.** Inside, it would join the accessible *name* rather than the description. `Button.test.tsx` asserts the name stays `"Save"`. ## Survey `grep -rn "disabled" app/src --include=*.tsx` → 291 lines; 254 outside tests; ~208 real `disabled` props/identifiers after excluding Tailwind `disabled:` variants and `--text-disabled` token references. 82 `title=` lines in non-test TSX. Elements with **both** `disabled` and a title-like prop on the same tag: **6** distinct, plus ~18 bulk `aria-label` list-row cases. Elements where that title-like prop actually **explains the disabled state**: **0**. The literal "disabled + explanatory `title`" pattern does not exist in this codebase — worth stating plainly, since it is what the fix was originally scoped around. **Converted: 2.** | Site | Why | | --- | --- | | `ProjectRow.tsx` — Claude terminal button | `disabled={!isRunning}`. Precondition the user must act on; explanation existed nowhere in the accessibility tree. | | `AddProjectDialog.tsx` — submit button | `disabled={loading}`. Explanation existed only as a label swap on an unreachable element. | **Rejected, by category:** - **Title is a name, not a reason (~24 sites).** `ProjectRow`'s start/stop button (`title` is `Start/Stop/Force stop {name}` — conditional on `isRunning`, not on the `busy` that disables it), `MicrophoneSettings.tsx:91` (`"Refresh microphone list"`), `FilesTab.tsx:538`, `ClaudeAuthModal.tsx:291`, `ClaudeInstructionsEditor.tsx:39`, and the `aria-label` list rows in `PortMappingsEditor`, `EnvVarsEditor`, `WorkspaceSection`, `ClaudeCodeSettingsEditor`, `Toggle`, `SegmentedControl`, `OverflowMenu`. Converting these would announce a state without adding any information, which is churn. - **No explanatory text anywhere (~50 sites).** `DockerSettings.tsx:151,159` is the tempting one — `disabled={working || !dockerAvailable}`, and "Docker is not running" is exactly the missing sentence — but it is missing, so fixing it means inventing copy, not preserving it. Explicitly out of scope. Same for `OpenPageDialog`, `ExportSettingsModal`, `ImportSettingsModal`, `GatewaySettings`, `CaCertPathInput`, `SttSettings`, and the 21 sites in `ModelSection.tsx`. - **Deferred, and the largest finding: 17 components carry a `disabledReason` string rendered as an unassociated sibling `<p>`.** `ClaudeInstructionsEditor`, `EnvVarsEditor`, `PortMappingsEditor`, `ClaudeCodeSettingsEditor`, `PermissionModeControl`, `RuntimeSection`, `AccessSection`, `ConfigTab`, `ContainerMigrationBanner`, `SharedAuthSettings`, `AutomationTab`, `CapabilityTiles`, `BrowserTab`, `MigrateContainerModal` and the three config modals. These pass the "explains why it is unavailable" test — `PermissionModeControl`'s prop doc literally says so — but they are a different, weaker defect: the reason is *visible text in the same region*, so it is discoverable, just not programmatically associated with the control (no `id`, no `aria-describedby`). Nothing is lost, only unlinked. That is a real WCAG 1.3.1/4.1.2 gap and it should be fixed, but it is a 17-component change with its own design question (does `Field`/`ConfigGroup` grow the association, or each editor?) and folding it in here would make this diff unreviewable. Filed as follow-up, not silently skipped. The line this PR draws: **fix where the reason is absent from the accessibility tree entirely; defer where it is present but unlinked; reject where there is no reason to preserve.** ## Testing Vitest + React Testing Library, matching the house idiom (component hooks mocked at the hook boundary; no `@tauri-apps/api/core` mocking anywhere). New coverage, written before the fix and watched fail: - `ui/Button.test.tsx` (5) — unavailable button is queryable by role and not `toBeDisabled()`; accessible description carries the reason; accessible name stays clean; click and Enter/Space are ignored; non-activation keys still reach `onKeyDown`; available button clicks normally; **native `disabled` still works**, which is the regression guard for the other 36 call sites. - `projects/ProjectRow.test.tsx` (4 new/rewritten) — the pre-existing `toBeDisabled()` assertion failed on this change and was rewritten, which is the honest signal that behaviour moved. - `projects/AddProjectDialog.test.tsx` (5, new file) — including `fireEvent.submit(form)` while loading, which exercises the second guard specifically: Enter inside a text field submits a form without ever touching the submit button, so `handleSubmit` has to guard itself. The hook cannot cover that path. **The click guard was mutation-tested, not assumed.** Neutering both guards in `useUnavailable` (passing the raw handlers through) fails exactly the two guard tests and nothing else: ``` × guards clicks and Enter/Space while unavailable × ignores clicks and Enter/Space on the terminal button while stopped Tests 2 failed | 13 passed (15) ``` Guards restored, and the numbers: ``` $ npm test -- --run Test Files 52 passed (52) Tests 656 passed (656) $ npx tsc --noEmit (exit 0, no output) $ npm run build ✓ built in 1.04s ``` Baseline on `main` was 50 files / 643 tests. Nothing under `src-tauri` was touched, so `cargo test` was not run. ## Deliberately not done - The 17-component `disabledReason` association gap described above. - `Tooltip.tsx` has no `role="tooltip"` and no `aria-describedby` wiring at any of its 27 call sites, so it is visual-only. Out of scope here, but it is adjacent and real. - Nothing from `feat/project-notes`; this branch is cut from `origin/main`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
jknapp added 2 commits 2026-09-02 16:09:50 +00:00
Native `disabled` removes an element from the tab order and from the
accessibility tree, so any explanation of why a control cannot be used is
delivered only to a sighted user with a mouse. `useUnavailable` is the way
out: `aria-disabled` keeps the control focusable and announced,
`aria-describedby` carries the reason, and — because `aria-disabled` is
advisory and blocks nothing — the hook hands back the click and Enter/Space
guards along with the attributes, so a call site cannot take the
announcement without the guard.

`Button` gets it as an opt-in `unavailable` / `unavailableReason` pair.
Opt-in matters: 37 files render this button and none of them change. The
`aria-disabled:` class mirrors exist because Tailwind's `disabled:` variant
only matches the native attribute, which this pattern deliberately omits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
Give the terminal and Add Project buttons a reason a screen reader can hear
Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 6m47s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
1eb91a35eb
Both were the defect the new hook exists for. The sidebar's Claude terminal
button is disabled whenever the container is not running and never said so —
its `title` names the action, so the precondition appeared nowhere in the
accessibility tree at all. Add Project's submit button is disabled while an
add is in flight, and its only signal is the label swapping to "Adding…" on
an element a screen reader can no longer reach.

The submit button needs a second guard the hook cannot supply: Enter inside
a text field submits a form without touching the submit button, so
`handleSubmit` now returns early while loading. Without it, swapping
`disabled` for `aria-disabled` would have turned an accessibility fix into a
double-submit bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
jknapp merged commit b24807bd5f into main 2026-09-02 18:53:20 +00:00
jknapp deleted branch fix/disabled-control-accessibility 2026-09-02 18:53:20 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: CyberCoveLLC/Triple-C#49