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 => {