Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions goldens/aria/private/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,8 @@ export class MenuTriggerPattern<V> {
first?: boolean;
last?: boolean;
}): void;
readonly pendingFocus: WritableSignalLike<"first" | "last" | undefined>;
pendingFocusEffect(): void;
readonly role: () => string;
readonly tabIndex: SignalLike<-1 | 0>;
}
Expand Down
2 changes: 2 additions & 0 deletions src/aria/menu/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
)
Expand Down
1 change: 1 addition & 0 deletions src/aria/menu/menu-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export class MenuTrigger<V> {

constructor() {
effect(() => this.menu()?.parent.set(this));
effect(() => this._pattern.pendingFocusEffect());
}

/** Opens the menu focusing on the first menu item. */
Expand Down
152 changes: 151 additions & 1 deletion src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -715,6 +725,96 @@ describe('Menu Trigger Pattern', () => {
});
});

describe('CDK Overlay Menu Pattern', () => {
let fixture: ComponentFixture<CdkOverlayMenuExample>;

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<MenuBarExample>;

Expand Down Expand Up @@ -1227,3 +1327,53 @@ class MenuWithDuplicateValues {}
changeDetection: ChangeDetectionStrategy.Eager,
})
class MenuItemOutsideMenu {}

@Component({
template: `
<ng-container *ngTemplateOutlet="menuTemplate"></ng-container>

<ng-template #menuTemplate>
<button
ngMenuTrigger
#menuTrigger="ngMenuTrigger"
[menu]="myMenu"
cdkOverlayOrigin
#origin="cdkOverlayOrigin"
>
Open Menu
</button>

<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOpen]="menuTrigger.expanded()"
>
<div ngMenu #overlayMenu="ngMenu">
<ng-template ngMenuContent>
<div ngMenuItem value="Apple" searchTerm="Apple">Apple</div>
<div ngMenuItem value="Banana" searchTerm="Banana">Banana</div>
</ng-template>
</div>
</ng-template>
</ng-template>
`,
imports: [CommonModule, OverlayModule, Menu, MenuTrigger, MenuItem, MenuContent],
changeDetection: ChangeDetectionStrategy.Eager,
})
class CdkOverlayMenuExample {
@ViewChild('overlayMenu') _myMenu!: Menu<any>;
private _cachedMenu?: Menu<any>;
private readonly _cdr = inject(ChangeDetectorRef);

get myMenu() {
if (this._myMenu) {
this._cachedMenu = this._myMenu;
}
return this._cachedMenu;
}

clearMenu() {
this._cachedMenu = undefined;
this._cdr.markForCheck();
}
}
13 changes: 13 additions & 0 deletions src/aria/private/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Comment on lines +52 to +60
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does await fixture.whenStable() in the test cases help instead of calling this method?

Copy link
Copy Markdown
Member Author

@ok7sai ok7sai May 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the UI patterns pendingFocusEffect() doesn't get called automatically because it's exposed for directives to actually use them as effect(() => pattern.pendingFocusEffect()). In the UI pattern tests the function has to be called manually.


return trigger;
}

Expand Down
22 changes: 20 additions & 2 deletions src/aria/private/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,9 @@ export class MenuTriggerPattern<V> {
/** 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';

Expand Down Expand Up @@ -669,6 +672,20 @@ export class MenuTriggerPattern<V> {
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()) {
Expand Down Expand Up @@ -708,15 +725,16 @@ export class MenuTriggerPattern<V> {
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) {
Expand Down
1 change: 1 addition & 0 deletions src/components-examples/aria/menu/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ ng_project(
"**/*.css",
]),
deps = [
"//:node_modules/@angular/common",
"//:node_modules/@angular/core",
"//src/aria/menu",
"//src/cdk/a11y",
Expand Down
1 change: 1 addition & 0 deletions src/components-examples/aria/menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!-- Stamped out dynamically via *ngTemplateOutlet -->
<ng-container *ngTemplateOutlet="menuTemplate"></ng-container>

<ng-template #menuTemplate>
<div class="trigger-container">
<button
ngMenuTrigger
#menuTrigger="ngMenuTrigger"
[menu]="myMenu"
cdkOverlayOrigin
#origin="cdkOverlayOrigin"
aria-label="Open menu"
class="example-menu-trigger"
>
<span aria-hidden="true" class="material-symbols-outlined example-icon">menu</span>
<span style="margin-left: 8px; font-size: 0.875rem;">Open Menu</span>
</button>

<!-- Dynamic Popover Portal -->
<ng-template
cdkConnectedOverlay
[cdkConnectedOverlayOrigin]="origin"
[cdkConnectedOverlayOpen]="menuTrigger.expanded()"
>
<div ngMenu #myMenu="ngMenu" class="example-menu" [style.width]="'200px'">
<ng-template ngMenuContent>
<div ng-menu-item value="Item 1">
<span ng-menu-item-icon>star</span>
<span ng-menu-item-text>Item 1</span>
</div>
<div ng-menu-item value="Item 2">
<span ng-menu-item-icon>settings</span>
<span ng-menu-item-text>Item 2</span>
</div>
<div ng-menu-item value="Item 3">
<span ng-menu-item-icon>help</span>
<span ng-menu-item-text>Item 3</span>
</div>
</ng-template>
</div>
</ng-template>
</div>
</ng-template>
Original file line number Diff line number Diff line change
@@ -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<any>;
}
5 changes: 5 additions & 0 deletions src/dev-app/aria-menu/menu-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,10 @@ <h2>Disabled Standalone Menu Example</h2>
<h2>Context Menu Example</h2>
<menu-context-example></menu-context-example>
</div>

<div class="example-menu-container">
<h2>Menu CDK Overlay Example</h2>
<menu-cdk-overlay-example></menu-cdk-overlay-example>
</div>
</div>
</div>
2 changes: 2 additions & 0 deletions src/dev-app/aria-menu/menu-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
MenuStandaloneExample,
MenuStandaloneDisabledExample,
MenuTriggerDisabledExample,
MenuCdkOverlayExample,
} from '@angular/components-examples/aria/menu';

@Component({
Expand All @@ -26,6 +27,7 @@ import {
MenuTriggerDisabledExample,
MenuStandaloneExample,
MenuStandaloneDisabledExample,
MenuCdkOverlayExample,
],
})
export class MenuDemo {}
Loading