Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/aria/menu/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ng_project(
),
deps = [
"//:node_modules/@angular/core",
"//src/aria/deferred-content",
"//src/aria/private",
"//src/cdk/a11y",
"//src/cdk/bidi",
Expand Down
2 changes: 1 addition & 1 deletion src/aria/menu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@
* found in the LICENSE file at https://angular.dev/license
*/

export {Menu, MenuBar, MenuItem, MenuTrigger} from './menu';
export {Menu, MenuBar, MenuContent, MenuItem, MenuTrigger} from './menu';
60 changes: 30 additions & 30 deletions src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,49 +151,49 @@ describe('Standalone Menu Pattern', () => {

it('should select an item on click', () => {
const banana = getItem('Banana');
spyOn(fixture.componentInstance, 'onSubmit');
spyOn(fixture.componentInstance, 'onSelect');

click(banana!);
expect(fixture.componentInstance.onSubmit).toHaveBeenCalledWith('Banana');
expect(fixture.componentInstance.onSelect).toHaveBeenCalledWith('Banana');
});

it('should select an item on enter', () => {
const banana = getItem('Banana');
spyOn(fixture.componentInstance, 'onSubmit');
spyOn(fixture.componentInstance, 'onSelect');

keydown(document.activeElement!, 'ArrowDown'); // Move focus to Banana
expect(document.activeElement).toBe(banana);

keydown(banana!, 'Enter');
expect(fixture.componentInstance.onSubmit).toHaveBeenCalledWith('Banana');
expect(fixture.componentInstance.onSelect).toHaveBeenCalledWith('Banana');
});

it('should select an item on space', () => {
const banana = getItem('Banana');
spyOn(fixture.componentInstance, 'onSubmit');
spyOn(fixture.componentInstance, 'onSelect');

keydown(document.activeElement!, 'ArrowDown'); // Move focus to Banana
expect(document.activeElement).toBe(banana);

keydown(banana!, ' ');
expect(fixture.componentInstance.onSubmit).toHaveBeenCalledWith('Banana');
expect(fixture.componentInstance.onSelect).toHaveBeenCalledWith('Banana');
});

it('should not select a disabled item', () => {
const cherry = getItem('Cherry');
spyOn(fixture.componentInstance, 'onSubmit');
spyOn(fixture.componentInstance, 'onSelect');

click(cherry!);
expect(fixture.componentInstance.onSubmit).not.toHaveBeenCalled();
expect(fixture.componentInstance.onSelect).not.toHaveBeenCalled();

keydown(document.activeElement!, 'End');
expect(document.activeElement).toBe(cherry);

keydown(cherry!, 'Enter');
expect(fixture.componentInstance.onSubmit).not.toHaveBeenCalled();
expect(fixture.componentInstance.onSelect).not.toHaveBeenCalled();

keydown(cherry!, ' ');
expect(fixture.componentInstance.onSubmit).not.toHaveBeenCalled();
expect(fixture.componentInstance.onSelect).not.toHaveBeenCalled();
});
});

Expand Down Expand Up @@ -316,18 +316,18 @@ describe('Standalone Menu Pattern', () => {
}));

it('should close on selecting an item on click', () => {
spyOn(fixture.componentInstance, 'onSubmit');
spyOn(fixture.componentInstance, 'onSelect');
click(getItem('Berries')!); // open submenu
expect(isSubmenuExpanded()).toBe(true);

click(getItem('Blueberry')!);

expect(fixture.componentInstance.onSubmit).toHaveBeenCalledWith('Blueberry');
expect(fixture.componentInstance.onSelect).toHaveBeenCalledWith('Blueberry');
expect(isSubmenuExpanded()).toBe(false);
});

it('should close on selecting an item on enter', () => {
spyOn(fixture.componentInstance, 'onSubmit');
spyOn(fixture.componentInstance, 'onSelect');
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
Expand All @@ -341,12 +341,12 @@ describe('Standalone Menu Pattern', () => {

keydown(blueberry!, 'Enter');

expect(fixture.componentInstance.onSubmit).toHaveBeenCalledWith('Blueberry');
expect(fixture.componentInstance.onSelect).toHaveBeenCalledWith('Blueberry');
expect(isSubmenuExpanded()).toBe(false);
});

it('should close on selecting an item on space', () => {
spyOn(fixture.componentInstance, 'onSubmit');
spyOn(fixture.componentInstance, 'onSelect');
const apple = getItem('Apple');
const banana = getItem('Banana');
const berries = getItem('Berries');
Expand All @@ -360,7 +360,7 @@ describe('Standalone Menu Pattern', () => {

keydown(blueberry!, ' ');

expect(fixture.componentInstance.onSubmit).toHaveBeenCalledWith('Blueberry');
expect(fixture.componentInstance.onSelect).toHaveBeenCalledWith('Blueberry');
expect(isSubmenuExpanded()).toBe(false);
});

Expand Down Expand Up @@ -877,12 +877,12 @@ describe('Menu Bar Pattern', () => {

@Component({
template: `
<div ngMenu (onSubmit)="onSubmit($event)">
<div ngMenu (onSelect)="onSelect($event)">
<div ngMenuItem value='Apple' searchTerm='Apple'>Apple</div>
<div ngMenuItem value='Banana' searchTerm='Banana'>Banana</div>
<div ngMenuItem value='Berries' searchTerm='Berries' #berriesItem="ngMenuItem" [submenu]="berriesMenu">Berries</div>
<div ngMenuItem value='Berries' searchTerm='Berries' [submenu]="berriesMenu">Berries</div>

<div ngMenu [parent]="berriesItem" #berriesMenu="ngMenu">
<div ngMenu #berriesMenu="ngMenu">
<div ngMenuItem value='Blueberry' searchTerm='Blueberry'>Blueberry</div>
<div ngMenuItem value='Blackberry' searchTerm='Blackberry'>Blackberry</div>
<div ngMenuItem value='Strawberry' searchTerm='Strawberry'>Strawberry</div>
Expand All @@ -894,19 +894,19 @@ describe('Menu Bar Pattern', () => {
imports: [Menu, MenuItem],
})
class StandaloneMenuExample {
onSubmit(value: string) {}
onSelect(value: string) {}
}

@Component({
template: `
<button ngMenuTrigger #menuTrigger="ngMenuTrigger" [submenu]="menu">Open menu</button>
<button ngMenuTrigger [menu]="menu">Open menu</button>

<div ngMenu #menu="ngMenu" [parent]="menuTrigger">
<div ngMenu #menu="ngMenu">
<div ngMenuItem value='Apple' searchTerm='Apple'>Apple</div>
<div ngMenuItem value='Banana' searchTerm='Banana'>Banana</div>
<div ngMenuItem value='Berries' searchTerm='Berries' #berriesItem="ngMenuItem" [submenu]="berriesMenu">Berries</div>
<div ngMenuItem value='Berries' searchTerm='Berries' [submenu]="berriesMenu">Berries</div>

<div ngMenu [parent]="berriesItem" #berriesMenu="ngMenu">
<div ngMenu #berriesMenu="ngMenu">
<div ngMenuItem value='Blueberry' searchTerm='Blueberry'>Blueberry</div>
<div ngMenuItem value='Blackberry' searchTerm='Blackberry'>Blackberry</div>
<div ngMenuItem value='Strawberry' searchTerm='Strawberry'>Strawberry</div>
Expand All @@ -923,24 +923,24 @@ class MenuTriggerExample {}
template: `
<div ngMenuBar>
<div ngMenuItem value='File' searchTerm='File'>File</div>
<div ngMenuItem value='Edit' searchTerm='Edit' [submenu]="editMenu" #editItem="ngMenuItem">Edit</div>
<div ngMenuItem value='Edit' searchTerm='Edit' [submenu]="editMenu">Edit</div>

<div ngMenu [parent]="editItem" #editMenu="ngMenu">
<div ngMenu #editMenu="ngMenu">
<div ngMenuItem value='Undo' searchTerm='Undo'>Undo</div>
<div ngMenuItem value='Redo' searchTerm='Redo'>Redo</div>
</div>

<div ngMenuItem #viewItem="ngMenuItem" [submenu]="viewMenu" value='View' searchTerm='View'>View</div>
<div ngMenuItem [submenu]="viewMenu" value='View' searchTerm='View'>View</div>

<div ngMenu [parent]="viewItem" #viewMenu="ngMenu">
<div ngMenu #viewMenu="ngMenu">
<div ngMenuItem value='Zoom In' searchTerm='Zoom In'>Zoom In</div>
<div ngMenuItem value='Zoom Out' searchTerm='Zoom Out'>Zoom Out</div>
<div ngMenuItem value='Full Screen' searchTerm='Full Screen'>Full Screen</div>
</div>

<div ngMenuItem #helpItem="ngMenuItem" [submenu]="helpMenu" value='Help' searchTerm='Help'>Help</div>
<div ngMenuItem [submenu]="helpMenu" value='Help' searchTerm='Help'>Help</div>

<div ngMenu [parent]="helpItem" #helpMenu="ngMenu">
<div ngMenu #helpMenu="ngMenu">
<div ngMenuItem value='Documentation' searchTerm='Documentation'>Documentation</div>
<div ngMenuItem value='About' searchTerm='About'>About</div>
</div>
Expand Down
58 changes: 41 additions & 17 deletions src/aria/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
computed,
contentChildren,
Directive,
effect,
ElementRef,
inject,
input,
Expand All @@ -29,6 +30,7 @@ import {
} from '@angular/aria/private';
import {toSignal} from '@angular/core/rxjs-interop';
import {Directionality} from '@angular/cdk/bidi';
import {DeferredContent, DeferredContentAware} from '@angular/aria/deferred-content';

/**
* A trigger for a menu.
Expand All @@ -44,7 +46,7 @@ import {Directionality} from '@angular/cdk/bidi';
'[attr.tabindex]': '_pattern.tabindex()',
'[attr.aria-haspopup]': '_pattern.hasPopup()',
'[attr.aria-expanded]': '_pattern.expanded()',
'[attr.aria-controls]': '_pattern.submenu()?.id()',
'[attr.aria-controls]': '_pattern.menu()?.id()',
'(click)': '_pattern.onClick()',
'(keydown)': '_pattern.onKeydown($event)',
'(focusout)': '_pattern.onFocusOut($event)',
Expand All @@ -59,18 +61,18 @@ export class MenuTrigger<V> {

// TODO(wagnermaciel): See we can remove the need to pass in a submenu.

/** The submenu associated with the menu trigger. */
submenu = input<Menu<V> | undefined>(undefined);

/** A callback function triggered when a menu item is selected. */
onSubmit = output<V>();
/** The menu associated with the trigger. */
menu = input<Menu<V> | undefined>(undefined);

/** The menu trigger ui pattern instance. */
readonly _pattern: MenuTriggerPattern<V> = new MenuTriggerPattern({
onSubmit: (value: V) => this.onSubmit.emit(value),
_pattern: MenuTriggerPattern<V> = new MenuTriggerPattern({
element: computed(() => this._elementRef.nativeElement),
submenu: computed(() => this.submenu()?._pattern),
menu: computed(() => this.menu()?._pattern),
});

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

/**
Expand Down Expand Up @@ -104,8 +106,17 @@ export class MenuTrigger<V> {
'(focusin)': '_pattern.onFocusIn()',
'(click)': '_pattern.onClick($event)',
},
hostDirectives: [
{
directive: DeferredContentAware,
inputs: ['preserveContent'],
},
],
})
export class Menu<V> {
/** The DeferredContentAware host directive. */
private readonly _deferredContentAware = inject(DeferredContentAware, {optional: true});

/** The menu items contained in the menu. */
readonly _allItems = contentChildren<MenuItem<V>>(MenuItem, {descendants: true});

Expand All @@ -128,9 +139,6 @@ export class Menu<V> {
initialValue: this._directionality.value,
});

/** The submenu associated with the menu. */
readonly submenu = input<Menu<V> | undefined>(undefined);

/** The unique ID of the menu. */
readonly id = input<string>(Math.random().toString(36).substring(2, 10));

Expand All @@ -141,7 +149,7 @@ export class Menu<V> {
readonly typeaheadDelay = input<number>(0.5); // Picked arbitrarily.

/** A reference to the parent menu item or menu trigger. */
readonly parent = input<MenuTrigger<V> | MenuItem<V>>();
readonly parent = signal<MenuTrigger<V> | MenuItem<V> | undefined>(undefined);

/** The menu ui pattern instance. */
readonly _pattern: MenuPattern<V>;
Expand All @@ -159,7 +167,7 @@ export class Menu<V> {
isVisible = computed(() => this._pattern.isVisible());

/** A callback function triggered when a menu item is selected. */
onSubmit = output<V>();
onSelect = output<V>();

constructor() {
this._pattern = new MenuPattern({
Expand All @@ -172,7 +180,11 @@ export class Menu<V> {
selectionMode: () => 'explicit',
activeItem: signal(undefined),
element: computed(() => this._elementRef.nativeElement),
onSubmit: (value: V) => this.onSubmit.emit(value),
onSelect: (value: V) => this.onSelect.emit(value),
});

afterRenderEffect(() => {
this._deferredContentAware?.contentVisible.set(this._pattern.isVisible());
});

// TODO(wagnermaciel): This is a redundancy needed for if the user uses display: none to hide
Expand Down Expand Up @@ -271,7 +283,7 @@ export class MenuBar<V> {
readonly items = signal<MenuItemPattern<V>[]>([]);

/** A callback function triggered when a menu item is selected. */
onSubmit = output<V>();
onSelect = output<V>();

constructor() {
this._pattern = new MenuBarPattern({
Expand All @@ -281,7 +293,7 @@ export class MenuBar<V> {
focusMode: () => 'roving',
orientation: () => 'horizontal',
selectionMode: () => 'explicit',
onSubmit: (value: V) => this.onSubmit.emit(value),
onSelect: (value: V) => this.onSelect.emit(value),
activeItem: signal(undefined),
element: computed(() => this._elementRef.nativeElement),
});
Expand Down Expand Up @@ -360,4 +372,16 @@ export class MenuItem<V> {
parent: computed(() => this.parent?._pattern),
submenu: computed(() => this.submenu()?._pattern),
});

constructor() {
effect(() => this.submenu()?.parent.set(this));
}
}

/** Defers the rendering of the menu content. */
@Directive({
selector: 'ng-template[ngMenuContent]',
exportAs: 'ngMenuContent',
hostDirectives: [DeferredContent],
})
export class MenuContent {}
Loading
Loading