diff --git a/src/aria/menu/menu.spec.ts b/src/aria/menu/menu.spec.ts index e6d422b29a5c..2645c1709481 100644 --- a/src/aria/menu/menu.spec.ts +++ b/src/aria/menu/menu.spec.ts @@ -799,6 +799,42 @@ describe('Menu Trigger Pattern', () => { expect(fixture.componentInstance.itemSelected).toHaveBeenCalledWith('Apple'); }); }); + + it('should keep nested submenus open when the DOM inside them changes', async () => { + const nestedFixture = TestBed.createComponent(NestedMenuExample); + await nestedFixture.whenStable(); + + const getNestedItem = (text: string) => + nestedFixture.debugElement + .queryAll(By.directive(MenuItem)) + .map(debugEl => debugEl.nativeElement as HTMLElement) + .find(item => item.textContent?.trim() === text)!; + + const press = async (text: string, key: string) => { + getNestedItem(text).dispatchEvent(new KeyboardEvent('keydown', {key, bubbles: true})); + await nestedFixture.whenStable(); + }; + + const trigger = nestedFixture.debugElement.query(By.directive(MenuTrigger)) + .nativeElement as HTMLElement; + trigger.focus(); + trigger.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', bubbles: true})); + await nestedFixture.whenStable(); + await press('Apple', 'ArrowDown'); + await press('Berries', 'ArrowRight'); + await press('Blueberry', 'ArrowDown'); + await press('Strawberry', 'ArrowRight'); + + const wildStrawberry = getNestedItem('Wild Strawberry'); + expect(document.activeElement).toBe(wildStrawberry); + + nestedFixture.componentInstance.showBadge.set(true); + await waitForMicrotasks(); + await nestedFixture.whenStable(); + + expect(getNestedItem('Strawberry').getAttribute('aria-expanded')).toBe('true'); + expect(document.activeElement).toBe(wildStrawberry); + }); }); describe('CDK Overlay Menu Pattern', () => { @@ -1371,6 +1407,36 @@ class ShuffledMenuExample { items = signal([{value: 'Apple'}, {value: 'Banana'}, {value: 'Cherry'}]); } +@Component({ + template: ` + + +
+
Apple
+
Berries
+ +
+
Blueberry
+
Strawberry
+ +
+
+ Wild Strawberry + @if (showBadge()) { + + } +
+
+
+
+ `, + imports: [Menu, MenuItem, MenuTrigger], + changeDetection: ChangeDetectionStrategy.Eager, +}) +class NestedMenuExample { + showBadge = signal(false); +} + @Component({ template: `
diff --git a/src/aria/private/utils/collection.spec.ts b/src/aria/private/utils/collection.spec.ts index 0745e1e3e0d2..87bf8cd51c8f 100644 --- a/src/aria/private/utils/collection.spec.ts +++ b/src/aria/private/utils/collection.spec.ts @@ -74,6 +74,15 @@ describe('SortedCollection with real directives', () => { expect(orderedItems[0].value).toBe('C'); expect(orderedItems[2].value).toBe('A'); }); + + it('should keep the same ordered items when the DOM changes without moving items', async () => { + const orderedItems = collectionDirective._collection.orderedItems(); + + collectionDirective.element.appendChild(document.createElement('span')); + await waitForMicrotasks(); + + expect(collectionDirective._collection.orderedItems()).toBe(orderedItems); + }); }); @Component({ diff --git a/src/aria/private/utils/collection.ts b/src/aria/private/utils/collection.ts index 0cabc6343401..ed2f356bcd3c 100644 --- a/src/aria/private/utils/collection.ts +++ b/src/aria/private/utils/collection.ts @@ -21,11 +21,14 @@ export class SortedCollection { private readonly _version = signal(0); private _observer?: MutationObserver; - readonly orderedItems: Signal = computed(() => { - this._version(); // Track DOM changes - const itemsArray = Array.from(this._items()); - return itemsArray.sort(sortDirectives); - }); + readonly orderedItems: Signal = computed( + () => { + this._version(); // Track DOM changes + const itemsArray = Array.from(this._items()); + return itemsArray.sort(sortDirectives); + }, + {equal: (a, b) => a.length === b.length && a.every((item, i) => item === b[i])}, + ); register(item: T) { this._items.update(set => {