Skip to content

♿️ fix: trap focus in Modal and Drawer and fix menu semantics - #672

Merged
futjesus merged 1 commit into
mainfrom
fix/a11y-focus-and-roles
Jul 27, 2026
Merged

♿️ fix: trap focus in Modal and Drawer and fix menu semantics#672
futjesus merged 1 commit into
mainfrom
fix/a11y-focus-and-roles

Conversation

@futjesus

Copy link
Copy Markdown
Member

Summary

Third PR of the audit series (stacked on #671). Fixes the accessibility issues found in the audit, with tests for each.

Changes

Focus management

  • Modal and Drawer panels are now wrapped in FocusLock (react-focus-lock was already a dependency, previously unused): focus moves into the dialog on open, Tab/Shift+Tab are trapped inside, and focus returns to the trigger on close. The wrapper uses className="contents" so it doesn't affect grid/positioned layout. useModal's manual closeBtnRef restore was left untouched — it isn't wired into the library's own render path and removing it could affect consumers using the hook directly.

DropdownButton rewritten on Radix DropdownMenu (new dep: @radix-ui/react-dropdown-menu)

The previous hand-rolled menu was unusable by keyboard or screen reader (<li onClick> without role/tabIndex/key handlers, trigger without aria-haspopup/aria-expanded). Radix provides roving focus, typeahead, ARIA wiring and Escape/outside-click for free. Public props are unchanged; the hardcoded "Download Invoice as" trigger text is now a label prop that defaults to the old string (flagged for removal later). Content renders without a Portal so listClassName consumers keep the same DOM position; width matches the trigger via Radix's CSS var.

Semantics

  • Loading: role="status" + aria-label="Loading" (overridable via delegated props) — previously invisible to assistive tech.
  • ProgressBar: role="progressbar" with aria-valuenow/min/max (clamped), optional ariaLabel prop, and the status announced as visually-hidden text instead of color-only.
  • Autocomplete: removed the invalid button nested inside role="option"; the option itself is now the single interactive element, and keyboard Enter/Space activates it via the navigation hook.

Testing

  • New: focus-trap tests for Modal and Drawer (focus-in, Tab trap, restore), DropdownButton (menu semantics, mouse + keyboard selection, Escape, axe), Loading and ProgressBar (roles, clamping, axe).
  • Full suite green: 530 tests, lint, types, coverage thresholds.

@futjesus
futjesus force-pushed the ci/pr-checks-and-coverage branch from 9c8f505 to 1d5e16b Compare July 27, 2026 14:52
Base automatically changed from ci/pr-checks-and-coverage to main July 27, 2026 14:55
@futjesus
futjesus force-pushed the fix/a11y-focus-and-roles branch from 4620077 to 605bd9a Compare July 27, 2026 14:58
- Wrap Modal and Drawer panels in FocusLock (react-focus-lock, already a
  dependency): focus moves in on open, Tab is trapped, focus is restored on
  close
- Rewrite DropdownButton on @radix-ui/react-dropdown-menu: keyboard
  navigation, aria-haspopup/expanded and Escape/outside-click handling come
  from Radix; the hardcoded trigger text is now a label prop with the old
  default
- Loading exposes role=status with an overridable accessible name
- ProgressBar exposes role=progressbar with aria-valuenow/min/max, an
  optional ariaLabel and the status as visually hidden text
- Autocomplete options are a single interactive element instead of a button
  nested inside role=option; keyboard Enter/Space activates the option
@futjesus
futjesus force-pushed the fix/a11y-focus-and-roles branch from 605bd9a to 1ba36c2 Compare July 27, 2026 15:03
@futjesus
futjesus merged commit 01feb09 into main Jul 27, 2026
2 checks passed
@futjesus
futjesus deleted the fix/a11y-focus-and-roles branch July 27, 2026 15:07
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.

1 participant