Skip to content

Bring MenuBar keyboard and ARIA behavior closer to the APG menubar pattern - #23

Merged
gtritchie merged 18 commits into
rstudio/parrot-balsamfrom
bugfix/menubar-apg
Nov 5, 2026
Merged

gtritchie merged 18 commits into
rstudio/parrot-balsamfrom
bugfix/menubar-apg

Conversation

@gtritchie

@gtritchie gtritchie commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

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 MenuBar and MenuItem, 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

  • Down, Right, Enter and Space focus the submenu's first item. Down on a menubar item and Right on a submenu parent did nothing once the mouse had selected an item in the open submenu, because hovering selects without moving focus. Enter and Space opened a submenu but left focus on its parent. All four now open the submenu if needed and focus its first item. An item's command still takes precedence over its submenu for Enter and Space. Enter and Space on a menubar item whose submenu is already open, e.g. after a click, now move into it rather than closing it, as APG specifies; clicking the item still closes it.
  • Right on a leaf two levels down moves to the next menubar item instead of back to the start of its own submenu, and Down on a horizontal leaf likewise walks ancestors without re-entering submenus.
  • Up moves within a menu whose submenu was opened by hovering. It used to close the whole menu, or do nothing in a root menu.
  • Disabled items can be reached with the arrow keys, Home and End, as APG specifies; they still can't be activated. Moving onto a disabled item, a menubar item with no submenu, or one whose submenu is empty, closes the submenu that was open rather than leaving it open under the new selection. Empty submenus aren't opened from the keyboard at all.
  • Tab and Shift+Tab move relative to the root menu, not the popup appended to the end of <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 stops onPopupClosed() from refocusing any of them. Closing a submenu otherwise focuses its parent, and MenuBar.focus() is deferred on the safari user 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

  • An open submenu is owned by its parent menu and named by its parent item. While open, the parent menu sets aria-owns to it, so assistive technology shows it inside the menubar instead of as a separate top-level node, and the submenu is aria-labelledby the item that opened it, unless the app has already given it aria-label or aria-labelledby. Both attributes are removed when the submenu closes. aria-owns on the container follows APG's guidance for menus that aren't DOM siblings of their parent item.
  • aria-expanded is false on collapsed submenu parents rather than removed. It is still removed when an item's submenu is cleared.

Testing

New and updated tests in MenuBarTest and MenuItemTest cover 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.

  • MenuBarTest and MenuItemTest pass in HtmlUnit except testSetFocusOnHoverDisabled, which fails the same way on rstudio/parrot-balsam without these changes.
  • The tests can't catch the deferred-focus problems the Tab changes fix. HtmlUnit's default user agent focuses synchronously, FocusPanel.impl is static final so a test can't substitute the deferred implementation, and under -runStyle HtmlUnit:Chrome focus assertions fail even in the existing testSetFocusOnHoverEnabled. 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.
  • checkstyle passes, and ant clean dist-dev builds.
  • With RStudio Server built against this SDK, in Chrome on macOS: each behavior above was checked in the main menu, including Tab from an open menu landing where Tab from the closed menubar does. Axe reports no violations with a menu open inside the menubar.

The RStudio-side changes (focus restoration, the menubar's name, shortcut entry) are in a separate RStudio PR, which will also move GWT_VERSION to the release containing this.

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
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
gtritchie merged commit f343c27 into rstudio/parrot-balsam Nov 5, 2026
2 checks passed
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
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