From 7d0ba63e9840c10091842102699464d2553f2a86 Mon Sep 17 00:00:00 2001 From: Cheng-Hsuan Tsai Date: Thu, 14 May 2026 23:00:06 +0000 Subject: [PATCH] fix(aria/menu): defer menu item focus in case menus in cdk overlay When menus are placed in a `CdkConnectedOverlay`, the menu trigger tries to focus the first menu item even before the menus are rendered and registered to the menu trigger. --- goldens/aria/private/index.api.md | 2 + src/aria/menu/BUILD.bazel | 2 + src/aria/menu/menu-trigger.ts | 1 + src/aria/menu/menu.spec.ts | 152 +++++++++++++++++- src/aria/private/menu/menu.spec.ts | 13 ++ src/aria/private/menu/menu.ts | 22 ++- src/components-examples/aria/menu/BUILD.bazel | 1 + src/components-examples/aria/menu/index.ts | 1 + .../menu-cdk-overlay-example.html | 43 +++++ .../menu-cdk-overlay-example.ts | 27 ++++ src/dev-app/aria-menu/menu-demo.html | 5 + src/dev-app/aria-menu/menu-demo.ts | 2 + 12 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 src/components-examples/aria/menu/menu-cdk-overlay/menu-cdk-overlay-example.html create mode 100644 src/components-examples/aria/menu/menu-cdk-overlay/menu-cdk-overlay-example.ts diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index dd477e85709d..98eddbb32798 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -497,6 +497,8 @@ export class MenuTriggerPattern { first?: boolean; last?: boolean; }): void; + readonly pendingFocus: WritableSignalLike<"first" | "last" | undefined>; + pendingFocusEffect(): void; readonly role: () => string; readonly tabIndex: SignalLike<-1 | 0>; } diff --git a/src/aria/menu/BUILD.bazel b/src/aria/menu/BUILD.bazel index d62ba2c23360..95aa30742de1 100644 --- a/src/aria/menu/BUILD.bazel +++ b/src/aria/menu/BUILD.bazel @@ -25,10 +25,12 @@ ng_project( ), deps = [ ":menu", + "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//:node_modules/@angular/platform-browser", "//:node_modules/axe-core", "//src/aria/private/testing", + "//src/cdk/overlay", "//src/cdk/testing/private", ], ) diff --git a/src/aria/menu/menu-trigger.ts b/src/aria/menu/menu-trigger.ts index aaf1125329ec..08aa16614ca8 100644 --- a/src/aria/menu/menu-trigger.ts +++ b/src/aria/menu/menu-trigger.ts @@ -89,6 +89,7 @@ export class MenuTrigger { constructor() { effect(() => this.menu()?.parent.set(this)); + effect(() => this._pattern.pendingFocusEffect()); } /** Opens the menu focusing on the first menu item. */ diff --git a/src/aria/menu/menu.spec.ts b/src/aria/menu/menu.spec.ts index 5030ec322f6d..29eb4fb51d64 100644 --- a/src/aria/menu/menu.spec.ts +++ b/src/aria/menu/menu.spec.ts @@ -1,4 +1,14 @@ -import {Component, DebugElement, ChangeDetectionStrategy, signal} from '@angular/core'; +import { + Component, + DebugElement, + ChangeDetectionStrategy, + signal, + ViewChild, + inject, + ChangeDetectorRef, +} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {provideFakeDirectionality} from '@angular/cdk/testing/private'; @@ -715,6 +725,96 @@ describe('Menu Trigger Pattern', () => { }); }); +describe('CDK Overlay Menu Pattern', () => { + let fixture: ComponentFixture; + + const focusin = (element: Element) => { + element.dispatchEvent(new FocusEvent('focusin', {bubbles: true})); + fixture.detectChanges(); + }; + + const keydown = async (element: Element, key: string, modifierKeys: {} = {}) => { + focusin(element); + element.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + await waitForMicrotasks(); + fixture.detectChanges(); + }; + + const click = async (element: Element, eventInit?: PointerEventInit) => { + focusin(element); + element.dispatchEvent(new PointerEvent('click', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + await waitForMicrotasks(); + fixture.detectChanges(); + }; + + function setupMenu() { + fixture = TestBed.createComponent(CdkOverlayMenuExample); + fixture.detectChanges(); + } + + function getTrigger(): HTMLElement { + return fixture.debugElement.query(By.directive(MenuTrigger)).nativeElement as HTMLElement; + } + + function getItem(text: string): HTMLElement | null { + const items = fixture.debugElement + .queryAll(By.directive(MenuItem)) + .map((debugEl: DebugElement) => debugEl.nativeElement as HTMLElement); + return items.find(item => item.textContent?.trim() === text) || null; + } + + beforeEach(() => setupMenu()); + + it('should focus the first item when opened via arrow down', async () => { + await keydown(getTrigger(), 'ArrowDown'); + expect(document.activeElement).toBe(getItem('Apple')); + }); + + it('should focus the first item when opened via enter', async () => { + await keydown(getTrigger(), 'Enter'); + expect(document.activeElement).toBe(getItem('Apple')); + }); + + it('should focus the first item when opened via space', async () => { + await keydown(getTrigger(), ' '); + expect(document.activeElement).toBe(getItem('Apple')); + }); + + it('should focus the first item when opened via click', async () => { + await click(getTrigger()); + expect(document.activeElement).toBe(getItem('Apple')); + }); + + it('should focus the first item stably when opened, closed via escape, and opened again', async () => { + const trigger = getTrigger(); + + // First open + await keydown(trigger, 'Enter'); + expect(document.activeElement).toBe(getItem('Apple')); + + // Close via escape + await keydown(getItem('Apple')!, 'Escape'); + expect(trigger.getAttribute('aria-expanded')).toBe('false'); + expect(document.activeElement).toBe(trigger); + + // Explicitly clear cached menu before second open + fixture.componentInstance.clearMenu(); + fixture.detectChanges(); + + // Second open + await keydown(trigger, 'Enter'); + expect(document.activeElement).toBe(getItem('Apple')); + }); +}); + describe('Menu Bar Pattern', () => { let fixture: ComponentFixture; @@ -1227,3 +1327,53 @@ class MenuWithDuplicateValues {} changeDetection: ChangeDetectionStrategy.Eager, }) class MenuItemOutsideMenu {} + +@Component({ + template: ` + + + + + + +
+ +
Apple
+
Banana
+
+
+
+
+ `, + imports: [CommonModule, OverlayModule, Menu, MenuTrigger, MenuItem, MenuContent], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class CdkOverlayMenuExample { + @ViewChild('overlayMenu') _myMenu!: Menu; + private _cachedMenu?: Menu; + private readonly _cdr = inject(ChangeDetectorRef); + + get myMenu() { + if (this._myMenu) { + this._cachedMenu = this._myMenu; + } + return this._cachedMenu; + } + + clearMenu() { + this._cachedMenu = undefined; + this._cdr.markForCheck(); + } +} diff --git a/src/aria/private/menu/menu.spec.ts b/src/aria/private/menu/menu.spec.ts index b564a085e254..ba915182587b 100644 --- a/src/aria/private/menu/menu.spec.ts +++ b/src/aria/private/menu/menu.spec.ts @@ -46,6 +46,19 @@ function getMenuTriggerPattern(opts?: {textDirection: 'ltr' | 'rtl'}) { menu: submenu, disabled: signal(false), }); + + const originalOnClick = trigger.onClick.bind(trigger); + trigger.onClick = () => { + originalOnClick(); + trigger.pendingFocusEffect(); + }; + + const originalOnKeydown = trigger.onKeydown.bind(trigger); + trigger.onKeydown = (event: KeyboardEvent) => { + originalOnKeydown(event); + trigger.pendingFocusEffect(); + }; + return trigger; } diff --git a/src/aria/private/menu/menu.ts b/src/aria/private/menu/menu.ts index 1a3ebb877610..cffcf84aebfd 100644 --- a/src/aria/private/menu/menu.ts +++ b/src/aria/private/menu/menu.ts @@ -638,6 +638,9 @@ export class MenuTriggerPattern { /** Whether the menu trigger has received interaction. */ readonly hasBeenInteracted = signal(false); + /** The pending focus target when the menu is opened before the menu instance is available. */ + readonly pendingFocus = signal<'first' | 'last' | undefined>(undefined); + /** The role of the menu trigger. */ readonly role = () => 'button'; @@ -669,6 +672,20 @@ export class MenuTriggerPattern { this.menu = this.inputs.menu; } + /** Flushes any pending focus when the menu instance becomes available. */ + pendingFocusEffect(): void { + const menu = this.inputs.menu(); + const intent = this.pendingFocus(); + if (menu && intent) { + if (intent === 'first') { + menu.first(); + } else if (intent === 'last') { + menu.last(); + } + this.pendingFocus.set(undefined); + } + } + /** Handles keyboard events for the menu trigger. */ onKeydown(event: KeyboardEvent) { if (!this.inputs.disabled()) { @@ -708,15 +725,16 @@ export class MenuTriggerPattern { this.expanded.set(true); if (opts?.first) { - this.inputs.menu()?.first(); + this.pendingFocus.set('first'); } else if (opts?.last) { - this.inputs.menu()?.last(); + this.pendingFocus.set('last'); } } /** Closes the menu. */ close(opts: {refocus?: boolean} = {}) { this.expanded.set(false); + this.pendingFocus.set(undefined); this.menu()?.listBehavior.unfocus(); if (opts.refocus) { diff --git a/src/components-examples/aria/menu/BUILD.bazel b/src/components-examples/aria/menu/BUILD.bazel index 548211a6572c..cc55d838e4f2 100644 --- a/src/components-examples/aria/menu/BUILD.bazel +++ b/src/components-examples/aria/menu/BUILD.bazel @@ -10,6 +10,7 @@ ng_project( "**/*.css", ]), deps = [ + "//:node_modules/@angular/common", "//:node_modules/@angular/core", "//src/aria/menu", "//src/cdk/a11y", diff --git a/src/components-examples/aria/menu/index.ts b/src/components-examples/aria/menu/index.ts index e5afd68153a5..02e202e13e7a 100644 --- a/src/components-examples/aria/menu/index.ts +++ b/src/components-examples/aria/menu/index.ts @@ -3,3 +3,4 @@ export {MenuTriggerExample} from './menu-trigger/menu-trigger-example'; export {MenuTriggerDisabledExample} from './menu-trigger-disabled/menu-trigger-disabled-example'; export {MenuStandaloneExample} from './menu-standalone/menu-standalone-example'; export {MenuStandaloneDisabledExample} from './menu-standalone-disabled/menu-standalone-disabled-example'; +export {MenuCdkOverlayExample} from './menu-cdk-overlay/menu-cdk-overlay-example'; diff --git a/src/components-examples/aria/menu/menu-cdk-overlay/menu-cdk-overlay-example.html b/src/components-examples/aria/menu/menu-cdk-overlay/menu-cdk-overlay-example.html new file mode 100644 index 000000000000..e0b0758c37da --- /dev/null +++ b/src/components-examples/aria/menu/menu-cdk-overlay/menu-cdk-overlay-example.html @@ -0,0 +1,43 @@ + + + + +
+ + + + +
+ +
+ star + Item 1 +
+
+ settings + Item 2 +
+
+ help + Item 3 +
+
+
+
+
+
diff --git a/src/components-examples/aria/menu/menu-cdk-overlay/menu-cdk-overlay-example.ts b/src/components-examples/aria/menu/menu-cdk-overlay/menu-cdk-overlay-example.ts new file mode 100644 index 000000000000..daad39df0104 --- /dev/null +++ b/src/components-examples/aria/menu/menu-cdk-overlay/menu-cdk-overlay-example.ts @@ -0,0 +1,27 @@ +import {Component, ViewChild} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {Menu, MenuTrigger, MenuContent} from '@angular/aria/menu'; +import {SimpleMenuItem, SimpleMenuItemIcon, SimpleMenuItemText} from '../simple-menu'; + +/** + * @title Menu CDK overlay example + */ +@Component({ + selector: 'menu-cdk-overlay-example', + templateUrl: 'menu-cdk-overlay-example.html', + styleUrl: '../menu-example.css', + imports: [ + CommonModule, + OverlayModule, + Menu, + MenuTrigger, + MenuContent, + SimpleMenuItem, + SimpleMenuItemIcon, + SimpleMenuItemText, + ], +}) +export class MenuCdkOverlayExample { + @ViewChild('myMenu') myMenu!: Menu; +} diff --git a/src/dev-app/aria-menu/menu-demo.html b/src/dev-app/aria-menu/menu-demo.html index e17b06194775..ead6faddd1a2 100644 --- a/src/dev-app/aria-menu/menu-demo.html +++ b/src/dev-app/aria-menu/menu-demo.html @@ -24,5 +24,10 @@

Disabled Standalone Menu Example

Context Menu Example

+ +
+

Menu CDK Overlay Example

+ +
diff --git a/src/dev-app/aria-menu/menu-demo.ts b/src/dev-app/aria-menu/menu-demo.ts index 1f2ef3b4ae29..4cbb2420da24 100644 --- a/src/dev-app/aria-menu/menu-demo.ts +++ b/src/dev-app/aria-menu/menu-demo.ts @@ -13,6 +13,7 @@ import { MenuStandaloneExample, MenuStandaloneDisabledExample, MenuTriggerDisabledExample, + MenuCdkOverlayExample, } from '@angular/components-examples/aria/menu'; @Component({ @@ -26,6 +27,7 @@ import { MenuTriggerDisabledExample, MenuStandaloneExample, MenuStandaloneDisabledExample, + MenuCdkOverlayExample, ], }) export class MenuDemo {}