Repository navigation
bug(aria/menu, aria/tabs): Enter acts on the active item, not on the item that has the focus, when the focus was moved by a script #33934
Description
Is this a regression?
- Yes, this behavior used to work in the previous version
Description
ngMenu and ngTabList keep their own active item and act on it when Enter (or Space) is pressed. The active item
moves only through the primitive's own keys and pointer handlers. When anything else moves the DOM focus to another
item (element.focus() from a script, a spatial navigation library driving a television remote, an assistive technology
that moves the focus), the primitive's focusin handler records that it was interacted with and nothing more, so:
- in a menu, Enter submits the previous active item, not the focused one (
MenuPattern.trigger()thensubmit(),
default argumentthis.inputs.activeItem()); - in a tab list, Enter opens the previous active tab, not the focused one (
TabListPattern.open()with
tab ??= this.activeTab()), and the next arrow key moves from the previous active tab.
ngListbox has the same model and offers gotoIndex() (from #33483), which lets an application keep the active option
on the focused one. ngMenu and ngTabList offer no public way to move their active item: Menu exposes close(),
TabList exposes open(value), which selects a tab without moving the active one.
Package lines read in @angular/aria@22.1.7 (node_modules/@angular/aria/fesm2022/); the same handlers are on main
(src/aria/private/menu/menu.ts onFocusIn, src/aria/private/tabs/tabs.ts onFocusIn and the Enter binding):
_menu-chunk.mjs:keydownManagerbindsEnterand the space key tothis.trigger();onFocusIn()only sets
isFocusedandhasBeenInteracted;trigger()andsubmit(item = this.inputs.activeItem())act on the active item._tabs-chunk.mjs:keydownbinds' 'and'Enter'tothis.open();onFocusIn()only setshasBeenInteracted;
open(tab)starts withtab ??= this.activeTab()._violations-chunk.mjs:KeyboardEventManagerdefaults topreventDefault: trueandstopPropagation: true, so the
focused item's own native activation never runs either.
Reproduction
Plain Angular 22.2, @angular/aria 22.1.7 (the same behaviour on 22.2.x per the source above). This reproduction is written from the package source and from our application's failing component tests (Vitest browser mode); it has not been run as a standalone app. The button "Focus the
third item" stands for any external focus manager.
import { Component, ElementRef, signal, viewChildren } from '@angular/core';
import { Menu, MenuItem } from '@angular/aria/menu';
import { Tab, TabContent, TabList, TabPanel, Tabs } from '@angular/aria/tabs';
@Component({
selector: 'app-root',
imports: [Menu, MenuItem, Tabs, TabList, Tab, TabPanel, TabContent],
template: `
<div ngMenu (itemSelected)="chosen.set($event)" aria-label="Actions">
@for (item of items; track item) {
<button #menuItem ngMenuItem [value]="item" type="button">{{ item }}</button>
}
</div>
<button type="button" (click)="focusMenuItem(2)">Focus the third menu item</button>
<p>Chosen: {{ chosen() }}</p>
<div ngTabs>
<ul ngTabList selectionMode="explicit" [(selectedTab)]="selectedTab" aria-label="Sections">
@for (item of items; track item) {
<li #tab ngTab [value]="item">{{ item }}</li>
}
</ul>
@for (item of items; track item) {
<div ngTabPanel [value]="item"><ng-template ngTabContent>{{ item }} panel</ng-template></div>
}
</div>
<button type="button" (click)="focusTab(2)">Focus the third tab</button>
<p>Selected tab: {{ selectedTab() }}</p>
`,
})
export class App {
protected readonly items = ['one', 'two', 'three'];
protected readonly chosen = signal<string | undefined>(undefined);
protected readonly selectedTab = signal<string | undefined>('one');
private readonly menuItems = viewChildren('menuItem', { read: ElementRef });
private readonly tabs = viewChildren('tab', { read: ElementRef });
protected focusMenuItem(index: number): void {
this.menuItems()[index].nativeElement.focus();
}
protected focusTab(index: number): void {
this.tabs()[index].nativeElement.focus();
}
}Steps:
- Click "Focus the third menu item" (the focus lands on "three"), then press Enter.
- Click "Focus the third tab" (the focus lands on "three"), press Enter, then press ArrowLeft.
Expected behavior
itemSelectedemitsthree: Enter acts on the item that has the focus.- Tab "three" opens; ArrowLeft then moves the focus to "two". The active item follows the focus, as it does when the
primitive's own keys move it.
Actual behavior
itemSelectedemitsone, the menu's active item, although "three" has the focus.- Tab "one" stays selected (or is selected again); ArrowLeft moves from "one", not from "three".
Proposed fix
Either of:
- the patterns'
onFocusIn(event)sets the active item to the item that containsevent.target(each pattern already
maps an event target to its item for pointer events:_getItem(event)inTabListPattern,
this.inputs.items().find(i => i.element()?.contains(_getEventTarget(event)))inMenuPattern), so the active item always follows the DOM focus infocusMode: 'roving'; - or a public method on
MenuandTabListthat moves the active item, likeListbox.gotoIndex().
Workaround in use
A television web client driven by a remote through a spatial navigation library (the library moves the DOM focus with
element.focus()):
ngListbox: each option's(focus)callsListbox.gotoIndex(index);ngTabList: each tab's own(keydown.enter)and(keydown.space)stop the event before the tab list handles it and
call the tab element'sclick(), the primitive's pointer path, which moves the active tab and opens it (TabList.open(value)
alone leaves the active tab stale for the arrows);ngMenu: none; the spatial navigation never moves the focus inside an open menu, so only a script or an assistive
technology reaches the defect there.
Environment
- Angular 22.2.1,
@angular/aria22.1.7,@angular/cdk22.1.7 - Chromium (desktop Chrome; measured in Vitest browser mode on Playwright's Chromium)
- macOS
Activity