Repository navigation
Bring MenuBar keyboard and ARIA behavior closer to the APG menubar pattern - #23
Merged
Merged
Conversation
Down on a menubar item and Right on a submenu parent did nothing once the mouse had selected an item in the open submenu. Enter and Space opened a submenu but left focus on its parent, so Down then moved in the parent menu. All four keys now open the submenu if needed and focus its first item, as the APG menu pattern expects. An item's command still takes precedence over its submenu for Enter and Space. Addresses rstudio/rstudio#19324
The arrow keys, Home and End skipped disabled items, so screen reader users couldn't discover them. The APG menu pattern makes disabled items focusable but not activatable; they already can't be activated, and now they can be selected. Moving onto a disabled item while a submenu is open closes that submenu, since the disabled item can't open its own. Addresses rstudio/rstudio#19324
Submenu popups are appended to the end of the body, so Tab and Shift+Tab from an open submenu moved to whatever followed or preceded the popup. Tab now focuses the root menu before closing the submenus, and the browser's default action moves on from there, as the APG menubar pattern expects. Addresses rstudio/rstudio#19324
Submenu popups are appended to the end of the body, so assistive technology showed each open menu as a separate top-level node with no name. While a submenu is open, its parent menu now sets aria-owns to it, and the submenu is aria-labelledby the item that opened it, as the APG menubar pattern allows. Addresses rstudio/rstudio#19324
MenuItem removed aria-expanded when a submenu closed. The APG menu pattern sets it to false while the submenu is hidden. It is still removed when an item's submenu is cleared. Addresses rstudio/rstudio#19324
Right on an item without a submenu walked up through moveToNextItem(), which now enters the ancestor's selected submenu. On a leaf two levels down, that put focus back at the start of the submenu it was in instead of moving to the next menubar item. Ancestors are now walked with helpers that skip submenu entry, for both Right and Down. Addresses rstudio/rstudio#19324
MenuBar.focus() defers to a timeout on the safari user agent, which includes Chrome and Edge, so the root menu was focused after the browser's default Tab action and pulled focus back, and Tab could no longer leave the menubar. Tab now focuses the root element directly, and only when focus is in a submenu. The submenu focus tests now check focus after a deferred command, as the existing focus-on-hover test does. Addresses rstudio/rstudio#19324
With a submenu open, moving to a menubar item without a submenu called doItemAction(), which only closes the open submenu when autoOpen is set, so the old submenu stayed open and focused under the new selection. The submenu now closes and focus returns to the menu whenever the newly selected item can't open one of its own. Addresses rstudio/rstudio#19324
Tab while the menubar itself had focus with a menu open, e.g. after a click, closed the popup through a path that refocused the menubar. That focus is deferred on Chrome and Safari, so it ran after the browser's default Tab action and pulled focus back. Tab now closes the menus as if they were auto-closed, which doesn't move focus. Addresses rstudio/rstudio#19324
With the menubar focused and two menu levels open, closing them on Tab detached the middle menu, which focused itself while hiding its own submenu. Focus then fell to the body before the browser's default Tab action. Tab now focuses the root after closing the menus, and clears the selection that focusing makes. Addresses rstudio/rstudio#19324
Up in a vertical menu only moved the selection when no submenu was open. Hovering a submenu parent opens its submenu but leaves focus in the menu, so Up then closed the whole menu, or did nothing in a root menu. Up now always moves within the menu, which closes the hovered submenu, as Down already did. Addresses rstudio/rstudio#19324
gtritchie
changed the base branch from
rstudio/montauk-daisy
to
rstudio/parrot-balsam
November 5, 2026 21:38
Closing a submenu normally focuses its parent menu, even through hideChildMenu(false), because onPopupClosed() treats a non-auto close as a request to move focus. On Tab that focus could be deferred past the browser's default action, e.g. with popup animation on Chrome or Safari, and the previous workaround then refocused the root and cleared the item that focusing selected. The root menu now carries a flag while Tab closes the menus, and onPopupClosed() doesn't refocus while it is set. Tab focuses the root only when focus was in a submenu, and uses closeAllParentsAndChildren() again, so closeAllWithoutFocus() is gone. Other callers still refocus. Addresses rstudio/rstudio#19324
openPopup() set aria-labelledby on every submenu it opened and never removed it, so a label the app had set with aria-label stopped being used after the first open. A submenu that already has aria-label or aria-labelledby is now left alone, and the aria-labelledby that openPopup() adds is removed when the submenu closes. Addresses rstudio/rstudio#19324
Down, Right, Enter and Space skipped an item whose submenu had no items, but moving onto it with a submenu already open still opened it as a blank popup. Both paths now use canOpenSubMenu(), and moving onto such an item closes the open submenu instead. Addresses rstudio/rstudio#19324
moveDownFromChild() and moveRightFromChild() differed only in which orientation of ancestor they stopped at. selectNextInAncestor() takes that orientation as a parameter.
testDisabledItem asserted that no command fired right after the key presses, but doItemAction() runs commands with scheduleFinally(), which only flushes when the outermost entry exits, after the test method has returned. The count is now checked in a deferred command, after the test also activates an enabled item, so it expects exactly one. The test now fails if the disabled-item guard in doItemAction() is removed. Addresses rstudio/rstudio#19324
Enter and Space on an item with no command fell through to doItemAction() when focusSubMenu() declined, which opens any submenu, so an empty one opened as a blank popup while the arrow keys left it closed. They now only call doItemAction() for an item with a command or without a submenu. Clicks are unchanged. Addresses rstudio/rstudio#19324
gtritchie
added a commit
to rstudio/rstudio
that referenced
this pull request
Nov 6, 2026
* Restore focus after any main menubar entry, open menus at first item The main menubar only remembered where focus had been when a menu was opened by its shortcut, so entering it by Tab or click and running a command left focus on the body. The top-level bar now starts tracking whenever it gains focus from outside the menus. Focus moving elsewhere while no menu is in use now stops tracking instead of pulling focus back, and the focusin listener is now actually removed. The per-menu shortcuts open the menu with focus on its first item rather than on the menubar. Addresses #19324 * Give the main menubar an accessible name Addresses #19324 * Keep icon whitespace and shortcut symbols out of menu item names Command menu items' names included a leading newline from the empty icon cell and the shortcut, e.g. " Keyboard Shortcuts Help ⌥⇧K". The icon cell is now aria-hidden. Command items expose a single-key shortcut through aria-keyshortcuts and hide the visible shortcut text; shortcuts that format can't express stay in the name. Addresses #19324 * Keep main menu focus restore across reattachment and open switches The focus tracker registered its own command handler, which onUnload() removed along with the menubar's, so after the toolbar was toggled (which reattaches the menubar) commands stopped restoring focus. The menubar's own onCommand() now restores it. The per-menu shortcut now opens its menu explicitly before focusing it, so a different menu that is already open is switched rather than left showing. Addresses #19324 * Test aria-keyshortcuts on command menu items across shortcut changes Addresses #19324 * Add NEWS entry for main menubar APG fixes Addresses #19324 * Restore main menu focus after closing menus on a shortcut command Moving the restore into BaseMenuBar.onCommand() put it before closeAllChildren(), which can focus a parent menu and so overwrote the restored focus. The tracker's own handler used to run after the menubar's. Restore now runs last again. Addresses #19324 * Track main menu entry from iframes; never save a menu to restore Focus arriving from an iframe pane, such as Help or Viewer, has no relatedTarget, so clicking the menubar from one wasn't tracked and focus was left on the body after a command. The menubar now also checks the active element on mousedown, while the iframe still has focus. The per-menu shortcuts saved whatever had focus, including an open menu or the menubar itself, which then became the restore target. They now use the same check as Tab and click entry, which also skips the body. Addresses #19324 * Keep menu shortcuts in item names; drop aria-keyshortcuts VoiceOver reads the macOS shortcut symbols in menu item names correctly, and on other platforms the shortcut is plain text, so hiding it behind aria-keyshortcuts only lost it for screen readers that don't announce that attribute. Menu items show their shortcut text to assistive technology again. The icon cell stays aria-hidden, which keeps the leading whitespace out of item names. Addresses #19324 * Move GWT to 2.13.1-parrot-balsam The new build, from rstudio/gwt#23, brings MenuBar and MenuItem closer to the APG menubar pattern: Down, Right, Enter and Space focus a submenu's first item, Tab moves relative to the menubar, disabled items can be reached by keyboard, and open submenus are owned and named by their parent with aria-expanded="false" when collapsed. It is the first build from the fork's rstudio/parrot-balsam branch. Addresses #19324 * Create the menubar focus tracker up front The tracker adds no listeners until save(), so a lazy accessor and the null check in onCommand() weren't needed. Addresses #19324
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.
Fork-side fixes for RStudio Server's main menubar, which deviates from the WAI-ARIA APG menubar pattern in several places. Addresses rstudio/rstudio#19324.
These changes are in
MenuBarandMenuItem, so they apply to every GWT menu in RStudio, not just the main menu: toolbar menus, context menus, and the source tab overflow list too.Changes
Keyboard
<body>. Tab focuses the root element directly when focus is in a submenu, then closes the menus with a flag set on the root that stopsonPopupClosed()from refocusing any of them. Closing a submenu otherwise focuses its parent, andMenuBar.focus()is deferred on thesafariuser agent (Chrome, Edge and Safari), so that focus would run after the browser's default Tab action and pull focus back into the menubar. Other paths that close submenus still refocus, as before.ARIA
aria-ownsto it, so assistive technology shows it inside the menubar instead of as a separate top-level node, and the submenu isaria-labelledbythe item that opened it, unless the app has already given itaria-labeloraria-labelledby. Both attributes are removed when the submenu closes.aria-ownson the container follows APG's guidance for menus that aren't DOM siblings of their parent item.aria-expandedisfalseon collapsed submenu parents rather than removed. It is still removed when an item's submenu is cleared.Testing
New and updated tests in
MenuBarTestandMenuItemTestcover each change. The keyboard tests drive the menus with dispatched key events, the focus checks wait for deferred focus, and the disabled-item test checks commands after scheduled commands have run.MenuBarTestandMenuItemTestpass in HtmlUnit excepttestSetFocusOnHoverDisabled, which fails the same way onrstudio/parrot-balsamwithout these changes.FocusPanel.implisstatic finalso a test can't substitute the deferred implementation, and under-runStyle HtmlUnit:Chromefocus assertions fail even in the existingtestSetFocusOnHoverEnabled. Those paths were checked in Chrome instead: Tab and Shift+Tab with one menu open by click, two levels open by click and hover, and from inside a submenu.checkstylepasses, andant clean dist-devbuilds.The RStudio-side changes (focus restoration, the menubar's name, shortcut entry) are in a separate RStudio PR, which will also move
GWT_VERSIONto the release containing this.