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
23 changes: 16 additions & 7 deletions goldens/aria/menu/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,22 @@
import * as _angular_cdk_bidi from '@angular/cdk/bidi';
import * as _angular_core from '@angular/core';
import { OnDestroy } from '@angular/core';
import { OnInit } from '@angular/core';
import { Signal } from '@angular/core';

// @public
export class Menu<V> {
export class Menu<V> implements OnDestroy {
constructor();
readonly _allItems: Signal<readonly MenuItem<V>[]>;
close(): void;
readonly _collection: SortedCollection<MenuItem<V>>;
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly element: HTMLElement;
readonly expansionDelay: _angular_core.InputSignal<number>;
readonly id: _angular_core.InputSignal<string>;
readonly _items: Signal<MenuItem<V>[]>;
readonly itemSelected: _angular_core.OutputEmitterRef<V>;
// (undocumented)
ngOnDestroy(): void;
readonly parent: _angular_core.WritableSignal<MenuTrigger<V> | MenuItem<V> | undefined>;
readonly _pattern: MenuPattern<V>;
readonly tabIndex: Signal<0 | -1>;
Expand All @@ -28,29 +31,31 @@ export class Menu<V> {
readonly visible: Signal<boolean>;
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Menu<any>, "[ngMenu]", ["ngMenu"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "expansionDelay": { "alias": "expansionDelay"; "required": false; "isSignal": true; }; }, { "itemSelected": "itemSelected"; }, ["_allItems"], never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Menu<any>, "[ngMenu]", ["ngMenu"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "expansionDelay": { "alias": "expansionDelay"; "required": false; "isSignal": true; }; }, { "itemSelected": "itemSelected"; }, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Menu<any>, never>;
}

// @public
export class MenuBar<V> {
export class MenuBar<V> implements OnDestroy {
constructor();
readonly _allItems: _angular_core.Signal<readonly MenuItem<V>[]>;
close(): void;
readonly _collection: SortedCollection<MenuItem<V>>;
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly element: HTMLElement;
// (undocumented)
readonly _items: SignalLike<MenuItem<V>[]>;
readonly itemSelected: _angular_core.OutputEmitterRef<V>;
// (undocumented)
ngOnDestroy(): void;
readonly _pattern: MenuBarPattern<V>;
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
readonly typeaheadDelay: _angular_core.InputSignal<number>;
readonly value: _angular_core.ModelSignal<V[]>;
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
// (undocumented)
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MenuBar<any>, "[ngMenuBar]", ["ngMenuBar"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; "itemSelected": "itemSelected"; }, ["_allItems"], never, true, never>;
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<MenuBar<any>, "[ngMenuBar]", ["ngMenuBar"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; "itemSelected": "itemSelected"; }, never, never, true, never>;
// (undocumented)
static ɵfac: _angular_core.ɵɵFactoryDeclaration<MenuBar<any>, never>;
}
Expand All @@ -64,7 +69,7 @@ export class MenuContent {
}

// @public
export class MenuItem<V> {
export class MenuItem<V> implements OnInit, OnDestroy {
constructor();
readonly active: _angular_core.Signal<boolean>;
close(): void;
Expand All @@ -73,6 +78,10 @@ export class MenuItem<V> {
readonly expanded: _angular_core.Signal<boolean | null>;
readonly hasPopup: _angular_core.Signal<boolean>;
readonly id: _angular_core.InputSignal<string>;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
open(): void;
readonly parent: Menu<V> | MenuBar<V> | null;
readonly _pattern: MenuItemPattern<V>;
Expand Down
1 change: 1 addition & 0 deletions src/aria/menu/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ ng_project(
"//:node_modules/@angular/core",
"//:node_modules/@angular/platform-browser",
"//:node_modules/axe-core",
"//src/aria/private/testing",
"//src/cdk/testing/private",
],
)
Expand Down
21 changes: 15 additions & 6 deletions src/aria/menu/menu-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,20 @@
*/

import {
afterNextRender,
afterRenderEffect,
booleanAttribute,
computed,
contentChildren,
Directive,
ElementRef,
inject,
input,
model,
OnDestroy,
output,
signal,
} from '@angular/core';
import {SignalLike, MenuBarPattern} from '../private';
import {SignalLike, MenuBarPattern, SortedCollection} from '../private';
import {Directionality} from '@angular/cdk/bidi';
import {MenuItem} from './menu-item';
import {MENU_COMPONENT} from './menu-tokens';
Expand Down Expand Up @@ -69,12 +70,12 @@ import {MENU_COMPONENT} from './menu-tokens';
},
providers: [{provide: MENU_COMPONENT, useExisting: MenuBar}],
})
export class MenuBar<V> {
/** The menu items contained in the menubar. */
readonly _allItems = contentChildren<MenuItem<V>>(MenuItem, {descendants: true});
export class MenuBar<V> implements OnDestroy {
/** The collection of menu items. */
readonly _collection = new SortedCollection<MenuItem<V>>();

readonly _items: SignalLike<MenuItem<V>[]> = () =>
this._allItems().filter(i => i.parent === this);
this._collection.orderedItems().filter(i => i.parent === this);

/** A reference to the host element. */
private readonly _elementRef = inject(ElementRef);
Expand Down Expand Up @@ -124,6 +125,14 @@ export class MenuBar<V> {
});

afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()});

afterNextRender(() => {
this._collection.startObserving(this.element);
});
}

ngOnDestroy() {
this._collection.stopObserving();
}

/** Closes the menubar. */
Expand Down
22 changes: 20 additions & 2 deletions src/aria/menu/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,17 @@
* found in the LICENSE file at https://angular.dev/license
*/

import {computed, Directive, effect, ElementRef, inject, input, model} from '@angular/core';
import {
computed,
Directive,
effect,
ElementRef,
inject,
input,
model,
OnDestroy,
OnInit,
} from '@angular/core';
import {MenuItemPattern} from '../private';
import {_IdGenerator} from '@angular/cdk/a11y';
import {MENU_COMPONENT} from './menu-tokens';
Expand Down Expand Up @@ -46,7 +56,7 @@ import type {MenuBar} from './menu-bar';
'[attr.aria-controls]': '_pattern.submenu()?.id()',
},
})
export class MenuItem<V> {
export class MenuItem<V> implements OnInit, OnDestroy {
/** A reference to the host element. */
private readonly _elementRef = inject(ElementRef);

Expand Down Expand Up @@ -95,6 +105,14 @@ export class MenuItem<V> {
effect(() => this.submenu()?.parent.set(this));
}

ngOnInit() {
this.parent?._collection.register(this);
}

ngOnDestroy() {
this.parent?._collection.unregister(this);
}

/** Opens the submenu focusing on the first menu item. */
open() {
this._pattern.open({first: true});
Expand Down
91 changes: 90 additions & 1 deletion src/aria/menu/menu.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Component, DebugElement, ChangeDetectionStrategy} from '@angular/core';
import {Component, DebugElement, ChangeDetectionStrategy, signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {provideFakeDirectionality} from '@angular/cdk/testing/private';
Expand All @@ -7,6 +7,7 @@ import {MenuBar} from './menu-bar';
import {MenuContent} from './menu-content';
import {MenuItem} from './menu-item';
import {MenuTrigger} from './menu-trigger';
import {waitForMicrotasks} from '../private/testing/test-helpers';

describe('Standalone Menu Pattern', () => {
let fixture: ComponentFixture<StandaloneMenuExample>;
Expand Down Expand Up @@ -63,6 +64,33 @@ describe('Standalone Menu Pattern', () => {
return items.find(item => item.textContent?.trim() === text) || null;
}

describe('dynamic updates', () => {
it('should update item order correctly after items are shuffled', async () => {
TestBed.configureTestingModule({imports: [ShuffledMenuExample]});
const shuffledFixture = TestBed.createComponent(ShuffledMenuExample);
shuffledFixture.detectChanges();
const menuDirective = shuffledFixture.debugElement
.query(By.directive(Menu))
.injector.get(Menu);

const itemsBefore = menuDirective._pattern.inputs.items();
expect(itemsBefore.length).toBe(3);
expect(itemsBefore[0].element()?.textContent?.trim()).toBe('Apple');

// Shuffle items: move first item to the end
const items = (shuffledFixture.componentInstance as unknown as ShuffledMenuExample).items();
const firstItem = items.shift()!;
items.push(firstItem);
(shuffledFixture.componentInstance as unknown as ShuffledMenuExample).items.set([...items]);
shuffledFixture.detectChanges();
await waitForMicrotasks();

const itemsAfter = menuDirective._pattern.inputs.items();
expect(itemsAfter.length).toBe(3);
expect(itemsAfter[0].element()?.textContent?.trim()).toBe('Banana');
});
});

describe('Navigation', () => {
beforeEach(() => setupMenu());

Expand Down Expand Up @@ -702,6 +730,37 @@ describe('Menu Bar Pattern', () => {
return getMenuBarItem(menuBarItemText)?.getAttribute('aria-expanded') === 'true';
}

describe('dynamic updates', () => {
it('should update item order correctly after items are shuffled', async () => {
TestBed.configureTestingModule({imports: [ShuffledMenuBarExample]});
const shuffledFixture = TestBed.createComponent(ShuffledMenuBarExample);
shuffledFixture.detectChanges();
const menuBarDirective = shuffledFixture.debugElement
.query(By.directive(MenuBar))
.injector.get(MenuBar);

const itemsBefore = menuBarDirective._pattern.inputs.items();
expect(itemsBefore.length).toBe(3);
expect(itemsBefore[0].element()?.textContent?.trim()).toBe('File');

// Shuffle items: move first item to the end
const items = (
shuffledFixture.componentInstance as unknown as ShuffledMenuBarExample
).items();
const firstItem = items.shift()!;
items.push(firstItem);
(shuffledFixture.componentInstance as unknown as ShuffledMenuBarExample).items.set([
...items,
]);
shuffledFixture.detectChanges();
await waitForMicrotasks();

const itemsAfter = menuBarDirective._pattern.inputs.items();
expect(itemsAfter.length).toBe(3);
expect(itemsAfter[0].element()?.textContent?.trim()).toBe('Edit');
});
});

describe('Navigation', () => {
beforeEach(() => setupMenu());

Expand Down Expand Up @@ -1061,3 +1120,33 @@ class MenuTriggerExample {
changeDetection: ChangeDetectionStrategy.Eager,
})
class MenuBarExample {}

@Component({
template: `
<div ngMenu>
@for (item of items(); track item) {
<div ngMenuItem [value]="item.value">{{item.value}}</div>
}
</div>
`,
imports: [Menu, MenuItem],
changeDetection: ChangeDetectionStrategy.Eager,
})
class ShuffledMenuExample {
items = signal([{value: 'Apple'}, {value: 'Banana'}, {value: 'Cherry'}]);
}

@Component({
template: `
<div ngMenuBar>
@for (item of items(); track item) {
<div ngMenuItem [value]="item.value">{{item.value}}</div>
}
</div>
`,
imports: [MenuBar, MenuItem],
changeDetection: ChangeDetectionStrategy.Eager,
})
class ShuffledMenuBarExample {
items = signal([{value: 'File'}, {value: 'Edit'}, {value: 'View'}]);
}
21 changes: 15 additions & 6 deletions src/aria/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,21 @@
*/

import {
afterNextRender,
afterRenderEffect,
booleanAttribute,
computed,
contentChildren,
Directive,
ElementRef,
inject,
input,
OnDestroy,
output,
Signal,
signal,
untracked,
} from '@angular/core';
import {MenuPattern, DeferredContentAware} from '../private';
import {MenuPattern, DeferredContentAware, SortedCollection} from '../private';
import {_IdGenerator} from '@angular/cdk/a11y';
import {Directionality} from '@angular/cdk/bidi';
import {MenuTrigger} from './menu-trigger';
Expand Down Expand Up @@ -79,16 +80,16 @@ import {MENU_COMPONENT} from './menu-tokens';
],
providers: [{provide: MENU_COMPONENT, useExisting: Menu}],
})
export class Menu<V> {
export class Menu<V> implements OnDestroy {
/** 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});
/** The collection of menu items. */
readonly _collection = new SortedCollection<MenuItem<V>>();

/** The menu items that are direct children of this menu. */
readonly _items: Signal<MenuItem<V>[]> = computed(() =>
this._allItems().filter(i => i.parent === this),
this._collection.orderedItems().filter(i => i.parent === this),
);

/** A reference to the host element. */
Expand Down Expand Up @@ -188,6 +189,14 @@ export class Menu<V> {
});

afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()});

afterNextRender(() => {
this._collection.startObserving(this.element);
});
}

ngOnDestroy() {
this._collection.stopObserving();
}

/** Closes the menu. */
Expand Down
Loading