diff --git a/goldens/aria/menu/index.api.md b/goldens/aria/menu/index.api.md index 5e0a364526e3..71c20bf93c87 100644 --- a/goldens/aria/menu/index.api.md +++ b/goldens/aria/menu/index.api.md @@ -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 { +export class Menu implements OnDestroy { constructor(); - readonly _allItems: Signal[]>; close(): void; + readonly _collection: SortedCollection>; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; readonly expansionDelay: _angular_core.InputSignal; readonly id: _angular_core.InputSignal; readonly _items: Signal[]>; readonly itemSelected: _angular_core.OutputEmitterRef; + // (undocumented) + ngOnDestroy(): void; readonly parent: _angular_core.WritableSignal | MenuItem | undefined>; readonly _pattern: MenuPattern; readonly tabIndex: Signal<0 | -1>; @@ -28,21 +31,23 @@ export class Menu { readonly visible: Signal; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[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, "[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, never>; } // @public -export class MenuBar { +export class MenuBar implements OnDestroy { constructor(); - readonly _allItems: _angular_core.Signal[]>; close(): void; + readonly _collection: SortedCollection>; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; // (undocumented) readonly _items: SignalLike[]>; readonly itemSelected: _angular_core.OutputEmitterRef; + // (undocumented) + ngOnDestroy(): void; readonly _pattern: MenuBarPattern; readonly softDisabled: _angular_core.InputSignalWithTransform; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; @@ -50,7 +55,7 @@ export class MenuBar { readonly value: _angular_core.ModelSignal; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration, "[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, "[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, never>; } @@ -64,7 +69,7 @@ export class MenuContent { } // @public -export class MenuItem { +export class MenuItem implements OnInit, OnDestroy { constructor(); readonly active: _angular_core.Signal; close(): void; @@ -73,6 +78,10 @@ export class MenuItem { readonly expanded: _angular_core.Signal; readonly hasPopup: _angular_core.Signal; readonly id: _angular_core.InputSignal; + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; open(): void; readonly parent: Menu | MenuBar | null; readonly _pattern: MenuItemPattern; diff --git a/src/aria/menu/BUILD.bazel b/src/aria/menu/BUILD.bazel index 5a70236fb388..d62ba2c23360 100644 --- a/src/aria/menu/BUILD.bazel +++ b/src/aria/menu/BUILD.bazel @@ -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", ], ) diff --git a/src/aria/menu/menu-bar.ts b/src/aria/menu/menu-bar.ts index e2c2749e8eb9..3dda635a1619 100644 --- a/src/aria/menu/menu-bar.ts +++ b/src/aria/menu/menu-bar.ts @@ -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'; @@ -69,12 +70,12 @@ import {MENU_COMPONENT} from './menu-tokens'; }, providers: [{provide: MENU_COMPONENT, useExisting: MenuBar}], }) -export class MenuBar { - /** The menu items contained in the menubar. */ - readonly _allItems = contentChildren>(MenuItem, {descendants: true}); +export class MenuBar implements OnDestroy { + /** The collection of menu items. */ + readonly _collection = new SortedCollection>(); readonly _items: SignalLike[]> = () => - 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); @@ -124,6 +125,14 @@ export class MenuBar { }); afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); + + afterNextRender(() => { + this._collection.startObserving(this.element); + }); + } + + ngOnDestroy() { + this._collection.stopObserving(); } /** Closes the menubar. */ diff --git a/src/aria/menu/menu-item.ts b/src/aria/menu/menu-item.ts index f2f94a7687a4..988f2def8487 100644 --- a/src/aria/menu/menu-item.ts +++ b/src/aria/menu/menu-item.ts @@ -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'; @@ -46,7 +56,7 @@ import type {MenuBar} from './menu-bar'; '[attr.aria-controls]': '_pattern.submenu()?.id()', }, }) -export class MenuItem { +export class MenuItem implements OnInit, OnDestroy { /** A reference to the host element. */ private readonly _elementRef = inject(ElementRef); @@ -95,6 +105,14 @@ export class MenuItem { 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}); diff --git a/src/aria/menu/menu.spec.ts b/src/aria/menu/menu.spec.ts index 96a26a0c46b3..603b00b0ff4b 100644 --- a/src/aria/menu/menu.spec.ts +++ b/src/aria/menu/menu.spec.ts @@ -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'; @@ -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; @@ -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()); @@ -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()); @@ -1061,3 +1120,33 @@ class MenuTriggerExample { changeDetection: ChangeDetectionStrategy.Eager, }) class MenuBarExample {} + +@Component({ + template: ` +
+ @for (item of items(); track item) { +
{{item.value}}
+ } +
+ `, + imports: [Menu, MenuItem], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ShuffledMenuExample { + items = signal([{value: 'Apple'}, {value: 'Banana'}, {value: 'Cherry'}]); +} + +@Component({ + template: ` +
+ @for (item of items(); track item) { +
{{item.value}}
+ } +
+ `, + imports: [MenuBar, MenuItem], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class ShuffledMenuBarExample { + items = signal([{value: 'File'}, {value: 'Edit'}, {value: 'View'}]); +} diff --git a/src/aria/menu/menu.ts b/src/aria/menu/menu.ts index 3e455a3f9c04..b03bf8af6aea 100644 --- a/src/aria/menu/menu.ts +++ b/src/aria/menu/menu.ts @@ -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'; @@ -79,16 +80,16 @@ import {MENU_COMPONENT} from './menu-tokens'; ], providers: [{provide: MENU_COMPONENT, useExisting: Menu}], }) -export class Menu { +export class Menu implements OnDestroy { /** The DeferredContentAware host directive. */ private readonly _deferredContentAware = inject(DeferredContentAware, {optional: true}); - /** The menu items contained in the menu. */ - readonly _allItems = contentChildren>(MenuItem, {descendants: true}); + /** The collection of menu items. */ + readonly _collection = new SortedCollection>(); /** The menu items that are direct children of this menu. */ readonly _items: Signal[]> = computed(() => - this._allItems().filter(i => i.parent === this), + this._collection.orderedItems().filter(i => i.parent === this), ); /** A reference to the host element. */ @@ -188,6 +189,14 @@ export class Menu { }); afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); + + afterNextRender(() => { + this._collection.startObserving(this.element); + }); + } + + ngOnDestroy() { + this._collection.stopObserving(); } /** Closes the menu. */