MainMenu accessibility improvements#13878
Conversation
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
✅ PR preview is ready!
|
📈 Frontend coverage change detectedThe frontend unit test (vitest) coverage has increased by 0.0500%
🎉 Great job on improving test coverage! |
MainMenu accessibility improvementsMainMenu accessibility improvements
There was a problem hiding this comment.
Pull request overview
This PR improves the frontend MainMenu to follow the WAI-ARIA menu button pattern more closely, including keyboard navigation, ARIA semantics, and focus behavior. It also adjusts the clear-cache confirmation dialog focus behavior and fixes a theme focus ring color issue on dark backgrounds.
Changes:
- Add roving-tabindex keyboard navigation + ARIA roles/attributes for
MainMenu(and tests). - Update clear-cache dialog to autofocus the non-destructive “Cancel” action and remove
defaultActionplumbing. - Fix
focusRingMutedshadow color selection for dark backgrounds (and tests).
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/lib/src/theme/getShadows.ts | Use gray10 on dark backgrounds for focusRingMuted. |
| frontend/lib/src/theme/getShadows.test.ts | Update unit tests to validate the new focusRingMuted behavior. |
| frontend/lib/src/components/shared/BaseButton/styled-components.ts | Extend BaseButton props to support aria-haspopup / aria-expanded (optionally). |
| frontend/lib/src/components/shared/BaseButton/BaseButton.tsx | Forward aria-haspopup / aria-expanded to the rendered button element. |
| frontend/app/src/components/StreamlitDialog/StreamlitDialog.tsx | Move autofocus in Clear Cache dialog from destructive to non-destructive action; remove defaultAction. |
| frontend/app/src/components/StreamlitDialog/StreamlitDialog.test.tsx | Update dialog focus expectation to “Cancel”. |
| frontend/app/src/components/MainMenu/styled-components.ts | Add visible keyboard focus ring styling for menu items. |
| frontend/app/src/components/MainMenu/MainMenu.tsx | Implement ARIA menu semantics, roving tabindex, keyboard navigation, and focus restoration strategy. |
| frontend/app/src/components/MainMenu/MainMenu.test.tsx | Add unit tests covering keyboard navigation, ARIA attributes, roving tabindex, and focus behavior. |
| frontend/app/src/App.tsx | Remove defaultAction usage when opening Clear Cache dialog. |
| e2e_playwright/main_menu_test.py | Add Playwright E2E coverage for MainMenu ARIA attributes/roles and keyboard interactions. |
SummaryThis PR adds comprehensive WAI-ARIA menu-button pattern support to the
Files changed: 11 (770 additions, 35 deletions). Code QualityThe code is well-structured and follows existing patterns. Specific observations: Strengths:
Minor observations (non-blocking):
// In handlePopoverClose:
clearTimeout(focusReturnTimerRef.current)
focusReturnTimerRef.current = setTimeout(...)Similarly, const handlePopoverOpen = useCallback((): void => {
clearTimeout(focusReturnTimerRef.current)
setIsMenuOpen(true)
}, [])This is extremely unlikely to manifest in practice (requires close-then-reopen within 50ms) but would be more robust.
Test CoverageExcellent coverage across all layers: Frontend unit tests (MainMenu.test.tsx) — ~30 new tests:
E2E tests (main_menu_test.py) — 6 new tests:
StreamlitDialog tests: Updated for getShadows tests: Added assertions for Best practices compliance:
Backwards CompatibilityNo breaking changes for end users:
Security & RiskNo security concerns identified:
Regression risk: Low. The changes are well-isolated to the MainMenu component and its immediate dependencies. The 50ms setTimeout for focus return is the main "fragile" point, but it's thoroughly tested and well-documented. AccessibilityThis PR is specifically an accessibility improvement. Assessment of the implementation:
Recommendations
VerdictAPPROVED: Excellent accessibility PR that implements the WAI-ARIA menu-button pattern correctly with comprehensive test coverage across unit and E2E layers. The code is well-structured, thoroughly documented, and backwards compatible. The one minor timer race observation is non-blocking. This is an automated AI review using |
sfc-gh-bnisco
left a comment
There was a problem hiding this comment.
LGTM after addressing a few comments!
| // Focus the item at a given index and update state for tabIndex tracking. | ||
| // Called directly from keyboard handlers rather than going through a | ||
| // state → render → effect cycle. | ||
| const focusAndSetIndex = (index: number): void => { |
There was a problem hiding this comment.
suggestion: I think we need to call focusAndSetIndex on click/focus of each element, or else the focusIndex state will be incorrect when a user interacts with a mouse and then a keyboard.
There was a problem hiding this comment.
Makes sense, updated!
Adds full WAI-ARIA menu pattern support to the `MainMenu`, including keyboard navigation, proper ARIA semantics, and focus management. **Keyboard navigation** - **Open menu**: `Enter` / `Space` on the menu button (handles legacy `Spacebar` / `Space` key values) - **Navigate items**: `ArrowDown` / `ArrowUp` with wrapping at boundaries - **Jump to ends**: `Home` / `End` - **Activate item**: `Enter` / `Space` on focused menu item - **Close menu**: `Escape` returns focus to the trigger; `Tab` / `Shift+Tab` close the menu and advance focus to the next/previous tabbable element - **Disabled items**: All items are focusable and navigable, including disabled ones (per [WAI-ARIA menuitem role guidance](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menuitem_role)). Disabled items use `aria-disabled` instead of the native `disabled` attribute; clicks are blocked via the `onClick` handler. **ARIA attributes** - Menu button: `aria-haspopup="menu"`, `aria-expanded` (tracks open/close state), `aria-label="Main menu"` - Menu container: `role="menu"`, `aria-label="Main menu"` - Menu items: `role="menuitem"`, `aria-disabled` for disabled items (native `disabled` removed to keep items focusable) - Dividers: `role="separator"`, `aria-hidden="true"` - Roving tabindex: focused item gets `tabIndex={0}`, all others get `tabIndex={-1}` **Focus management** - Focus moves to the first menu item when the menu opens - Focus returns to the menu button after `Escape` or item click, via react-focus-lock's `returnFocus` callback - `Tab` / `Shift+Tab` close the menu and programmatically advance focus to the next/previous tabbable element using `focusNextElement`/`focusPrevElement` from `focus-lock` - **WebKit limitation**: On Safari, focus restoration after menu close may not work because react-focus-lock invokes the `returnFocus` callback after BaseWeb's close animation timer, which puts the `.focus()` call outside the user-activation context **Clear cache dialog (WAI-ARIA best practice)** - Moves `autoFocus` from the destructive "Clear caches" button to the non-destructive "Cancel" button, per WAI-ARIA guidance for confirmation dialogs - Removes the now-unnecessary `defaultAction` prop from `ClearCacheProps` **Theme fix** - `focusRingMuted` shadow now uses `gray10` on dark backgrounds (was using `gray90`, which was invisible)
Adds a theme toggle (System / Light / Dark) directly into the main menu as `menuitemradio` elements, building on the accessibility refactor in #13878. **Changes:** - Three radio items (System, Light, Dark) appear at the top of the menu, inside a `role="group"` container with `aria-label="Theme"`. - Each item uses `role="menuitemradio"` with `aria-checked` to reflect the active theme. - Selecting a theme calls `setTheme` via `ThemeContext` without closing the menu, so users can preview different themes. - Keyboard navigation (ArrowUp/Down, Home/End, Enter/Space) flows seamlessly between radio items and action items in a single roving tabindex.
Adds full WAI-ARIA menu pattern support to the `MainMenu`, including keyboard navigation, proper ARIA semantics, and focus management. **Keyboard navigation** - **Open menu**: `Enter` / `Space` on the menu button (handles legacy `Spacebar` / `Space` key values) - **Navigate items**: `ArrowDown` / `ArrowUp` with wrapping at boundaries - **Jump to ends**: `Home` / `End` - **Activate item**: `Enter` / `Space` on focused menu item - **Close menu**: `Escape` returns focus to the trigger; `Tab` / `Shift+Tab` close the menu and advance focus to the next/previous tabbable element - **Disabled items**: All items are focusable and navigable, including disabled ones (per [WAI-ARIA menuitem role guidance](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menuitem_role)). Disabled items use `aria-disabled` instead of the native `disabled` attribute; clicks are blocked via the `onClick` handler. **ARIA attributes** - Menu button: `aria-haspopup="menu"`, `aria-expanded` (tracks open/close state), `aria-label="Main menu"` - Menu container: `role="menu"`, `aria-label="Main menu"` - Menu items: `role="menuitem"`, `aria-disabled` for disabled items (native `disabled` removed to keep items focusable) - Dividers: `role="separator"`, `aria-hidden="true"` - Roving tabindex: focused item gets `tabIndex={0}`, all others get `tabIndex={-1}` **Focus management** - Focus moves to the first menu item when the menu opens - Focus returns to the menu button after `Escape` or item click, via react-focus-lock's `returnFocus` callback - `Tab` / `Shift+Tab` close the menu and programmatically advance focus to the next/previous tabbable element using `focusNextElement`/`focusPrevElement` from `focus-lock` - **WebKit limitation**: On Safari, focus restoration after menu close may not work because react-focus-lock invokes the `returnFocus` callback after BaseWeb's close animation timer, which puts the `.focus()` call outside the user-activation context **Clear cache dialog (WAI-ARIA best practice)** - Moves `autoFocus` from the destructive "Clear caches" button to the non-destructive "Cancel" button, per WAI-ARIA guidance for confirmation dialogs - Removes the now-unnecessary `defaultAction` prop from `ClearCacheProps` **Theme fix** - `focusRingMuted` shadow now uses `gray10` on dark backgrounds (was using `gray90`, which was invisible)
Adds a theme toggle (System / Light / Dark) directly into the main menu as `menuitemradio` elements, building on the accessibility refactor in #13878. **Changes:** - Three radio items (System, Light, Dark) appear at the top of the menu, inside a `role="group"` container with `aria-label="Theme"`. - Each item uses `role="menuitemradio"` with `aria-checked` to reflect the active theme. - Selecting a theme calls `setTheme` via `ThemeContext` without closing the menu, so users can preview different themes. - Keyboard navigation (ArrowUp/Down, Home/End, Enter/Space) flows seamlessly between radio items and action items in a single roving tabindex.
Adds full WAI-ARIA menu pattern support to the `MainMenu`, including keyboard navigation, proper ARIA semantics, and focus management. **Keyboard navigation** - **Open menu**: `Enter` / `Space` on the menu button (handles legacy `Spacebar` / `Space` key values) - **Navigate items**: `ArrowDown` / `ArrowUp` with wrapping at boundaries - **Jump to ends**: `Home` / `End` - **Activate item**: `Enter` / `Space` on focused menu item - **Close menu**: `Escape` returns focus to the trigger; `Tab` / `Shift+Tab` close the menu and advance focus to the next/previous tabbable element - **Disabled items**: All items are focusable and navigable, including disabled ones (per [WAI-ARIA menuitem role guidance](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menuitem_role)). Disabled items use `aria-disabled` instead of the native `disabled` attribute; clicks are blocked via the `onClick` handler. **ARIA attributes** - Menu button: `aria-haspopup="menu"`, `aria-expanded` (tracks open/close state), `aria-label="Main menu"` - Menu container: `role="menu"`, `aria-label="Main menu"` - Menu items: `role="menuitem"`, `aria-disabled` for disabled items (native `disabled` removed to keep items focusable) - Dividers: `role="separator"`, `aria-hidden="true"` - Roving tabindex: focused item gets `tabIndex={0}`, all others get `tabIndex={-1}` **Focus management** - Focus moves to the first menu item when the menu opens - Focus returns to the menu button after `Escape` or item click, via react-focus-lock's `returnFocus` callback - `Tab` / `Shift+Tab` close the menu and programmatically advance focus to the next/previous tabbable element using `focusNextElement`/`focusPrevElement` from `focus-lock` - **WebKit limitation**: On Safari, focus restoration after menu close may not work because react-focus-lock invokes the `returnFocus` callback after BaseWeb's close animation timer, which puts the `.focus()` call outside the user-activation context **Clear cache dialog (WAI-ARIA best practice)** - Moves `autoFocus` from the destructive "Clear caches" button to the non-destructive "Cancel" button, per WAI-ARIA guidance for confirmation dialogs - Removes the now-unnecessary `defaultAction` prop from `ClearCacheProps` **Theme fix** - `focusRingMuted` shadow now uses `gray10` on dark backgrounds (was using `gray90`, which was invisible)
Adds a theme toggle (System / Light / Dark) directly into the main menu as `menuitemradio` elements, building on the accessibility refactor in #13878. **Changes:** - Three radio items (System, Light, Dark) appear at the top of the menu, inside a `role="group"` container with `aria-label="Theme"`. - Each item uses `role="menuitemradio"` with `aria-checked` to reflect the active theme. - Selecting a theme calls `setTheme` via `ThemeContext` without closing the menu, so users can preview different themes. - Keyboard navigation (ArrowUp/Down, Home/End, Enter/Space) flows seamlessly between radio items and action items in a single roving tabindex.
This PR is the culmination of the following PRs approved into this feature branch: - [Core `MainMenu` Refactor](#13833) - [`MainMenu` accessibility improvements](#13878) - [Add theme switcher to `MainMenu`](#13937) - [Add auto-rerun toggle to `MainMenu`](#13988) - [Add version footer to `MainMenu`](#14028) - [Remove `SettingsDialog`](#14048)
Describe your changes
Adds full WAI-ARIA menu pattern support to the
MainMenu, including keyboard navigation, proper ARIA semantics, and focus management.Keyboard navigation
Enter/Spaceon the menu button (handles legacySpacebar/Spacekey values)ArrowDown/ArrowUpwith wrapping at boundariesHome/EndEnter/Spaceon focused menu itemEscapereturns focus to the trigger;Tab/Shift+Tabclose the menu and advance focus to the next/previous tabbable elementaria-disabledinstead of the nativedisabledattribute; clicks are blocked via theonClickhandler.ARIA attributes
aria-haspopup="menu",aria-expanded(tracks open/close state),aria-label="Main menu"role="menu",aria-label="Main menu"role="menuitem",aria-disabledfor disabled items (nativedisabledremoved to keep items focusable)role="separator",aria-hidden="true"tabIndex={0}, all others gettabIndex={-1}Focus management
Escapeor item click, via react-focus-lock'sreturnFocuscallbackTab/Shift+Tabclose the menu and programmatically advance focus to the next/previous tabbable element usingfocusNextElement/focusPrevElementfromfocus-lockreturnFocuscallback after BaseWeb's close animation timer, which puts the.focus()call outside the user-activation contextClear cache dialog (WAI-ARIA best practice)
autoFocusfrom the destructive "Clear caches" button to the non-destructive "Cancel" button, per WAI-ARIA guidance for confirmation dialogsdefaultActionprop fromClearCachePropsTheme fix
focusRingMutedshadow now usesgray10on dark backgrounds (was usinggray90, which was invisible)Testing Plan