Skip to content

Commit 6a0d93b

Browse files
committed
refactor(aria/menu): use SortedCollection
1 parent 619a53d commit 6a0d93b

5 files changed

Lines changed: 141 additions & 15 deletions

File tree

src/aria/menu/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ ng_project(
2828
"//:node_modules/@angular/core",
2929
"//:node_modules/@angular/platform-browser",
3030
"//:node_modules/axe-core",
31+
"//src/aria/private/testing",
3132
"//src/cdk/testing/private",
3233
],
3334
)

src/aria/menu/menu-bar.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@
77
*/
88

99
import {
10+
afterNextRender,
1011
afterRenderEffect,
1112
booleanAttribute,
1213
computed,
13-
contentChildren,
1414
Directive,
1515
ElementRef,
1616
inject,
1717
input,
1818
model,
19+
OnDestroy,
1920
output,
2021
signal,
2122
} from '@angular/core';
22-
import {SignalLike, MenuBarPattern} from '../private';
23+
import {SignalLike, MenuBarPattern, SortedCollection} from '../private';
2324
import {Directionality} from '@angular/cdk/bidi';
2425
import {MenuItem} from './menu-item';
2526
import {MENU_COMPONENT} from './menu-tokens';
@@ -69,12 +70,12 @@ import {MENU_COMPONENT} from './menu-tokens';
6970
},
7071
providers: [{provide: MENU_COMPONENT, useExisting: MenuBar}],
7172
})
72-
export class MenuBar<V> {
73-
/** The menu items contained in the menubar. */
74-
readonly _allItems = contentChildren<MenuItem<V>>(MenuItem, {descendants: true});
73+
export class MenuBar<V> implements OnDestroy {
74+
/** The collection of menu items. */
75+
readonly _collection = new SortedCollection<MenuItem<V>>();
7576

7677
readonly _items: SignalLike<MenuItem<V>[]> = () =>
77-
this._allItems().filter(i => i.parent === this);
78+
this._collection.orderedItems().filter(i => i.parent === this);
7879

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

126127
afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()});
128+
129+
afterNextRender(() => {
130+
this._collection.startObserving(this.element);
131+
});
132+
}
133+
134+
ngOnDestroy() {
135+
this._collection.stopObserving();
127136
}
128137

129138
/** Closes the menubar. */

src/aria/menu/menu-item.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,17 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, Directive, effect, ElementRef, inject, input, model} from '@angular/core';
9+
import {
10+
computed,
11+
Directive,
12+
effect,
13+
ElementRef,
14+
inject,
15+
input,
16+
model,
17+
OnDestroy,
18+
OnInit,
19+
} from '@angular/core';
1020
import {MenuItemPattern} from '../private';
1121
import {_IdGenerator} from '@angular/cdk/a11y';
1222
import {MENU_COMPONENT} from './menu-tokens';
@@ -46,7 +56,7 @@ import type {MenuBar} from './menu-bar';
4656
'[attr.aria-controls]': '_pattern.submenu()?.id()',
4757
},
4858
})
49-
export class MenuItem<V> {
59+
export class MenuItem<V> implements OnInit, OnDestroy {
5060
/** A reference to the host element. */
5161
private readonly _elementRef = inject(ElementRef);
5262

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

108+
ngOnInit() {
109+
this.parent?._collection.register(this);
110+
}
111+
112+
ngOnDestroy() {
113+
this.parent?._collection.unregister(this);
114+
}
115+
98116
/** Opens the submenu focusing on the first menu item. */
99117
open() {
100118
this._pattern.open({first: true});

src/aria/menu/menu.spec.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Component, DebugElement, ChangeDetectionStrategy} from '@angular/core';
1+
import {Component, DebugElement, ChangeDetectionStrategy, signal} from '@angular/core';
22
import {ComponentFixture, TestBed} from '@angular/core/testing';
33
import {By} from '@angular/platform-browser';
44
import {provideFakeDirectionality} from '@angular/cdk/testing/private';
@@ -7,6 +7,7 @@ import {MenuBar} from './menu-bar';
77
import {MenuContent} from './menu-content';
88
import {MenuItem} from './menu-item';
99
import {MenuTrigger} from './menu-trigger';
10+
import {waitForMicrotasks} from '../private/testing/test-helpers';
1011

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

67+
describe('dynamic updates', () => {
68+
it('should update item order correctly after items are shuffled', async () => {
69+
TestBed.configureTestingModule({imports: [ShuffledMenuExample]});
70+
const shuffledFixture = TestBed.createComponent(ShuffledMenuExample);
71+
shuffledFixture.detectChanges();
72+
const menuDirective = shuffledFixture.debugElement
73+
.query(By.directive(Menu))
74+
.injector.get(Menu);
75+
76+
const itemsBefore = menuDirective._pattern.inputs.items();
77+
expect(itemsBefore.length).toBe(3);
78+
expect(itemsBefore[0].element()?.textContent?.trim()).toBe('Apple');
79+
80+
// Shuffle items: move first item to the end
81+
const items = (shuffledFixture.componentInstance as unknown as ShuffledMenuExample).items();
82+
const firstItem = items.shift()!;
83+
items.push(firstItem);
84+
(shuffledFixture.componentInstance as unknown as ShuffledMenuExample).items.set([...items]);
85+
shuffledFixture.detectChanges();
86+
await waitForMicrotasks();
87+
88+
const itemsAfter = menuDirective._pattern.inputs.items();
89+
expect(itemsAfter.length).toBe(3);
90+
expect(itemsAfter[0].element()?.textContent?.trim()).toBe('Banana');
91+
});
92+
});
93+
6694
describe('Navigation', () => {
6795
beforeEach(() => setupMenu());
6896

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

733+
describe('dynamic updates', () => {
734+
it('should update item order correctly after items are shuffled', async () => {
735+
TestBed.configureTestingModule({imports: [ShuffledMenuBarExample]});
736+
const shuffledFixture = TestBed.createComponent(ShuffledMenuBarExample);
737+
shuffledFixture.detectChanges();
738+
const menuBarDirective = shuffledFixture.debugElement
739+
.query(By.directive(MenuBar))
740+
.injector.get(MenuBar);
741+
742+
const itemsBefore = menuBarDirective._pattern.inputs.items();
743+
expect(itemsBefore.length).toBe(3);
744+
expect(itemsBefore[0].element()?.textContent?.trim()).toBe('File');
745+
746+
// Shuffle items: move first item to the end
747+
const items = (
748+
shuffledFixture.componentInstance as unknown as ShuffledMenuBarExample
749+
).items();
750+
const firstItem = items.shift()!;
751+
items.push(firstItem);
752+
(shuffledFixture.componentInstance as unknown as ShuffledMenuBarExample).items.set([
753+
...items,
754+
]);
755+
shuffledFixture.detectChanges();
756+
await waitForMicrotasks();
757+
758+
const itemsAfter = menuBarDirective._pattern.inputs.items();
759+
expect(itemsAfter.length).toBe(3);
760+
expect(itemsAfter[0].element()?.textContent?.trim()).toBe('Edit');
761+
});
762+
});
763+
705764
describe('Navigation', () => {
706765
beforeEach(() => setupMenu());
707766

@@ -1061,3 +1120,33 @@ class MenuTriggerExample {
10611120
changeDetection: ChangeDetectionStrategy.Eager,
10621121
})
10631122
class MenuBarExample {}
1123+
1124+
@Component({
1125+
template: `
1126+
<div ngMenu>
1127+
@for (item of items(); track item) {
1128+
<div ngMenuItem [value]="item.value">{{item.value}}</div>
1129+
}
1130+
</div>
1131+
`,
1132+
imports: [Menu, MenuItem],
1133+
changeDetection: ChangeDetectionStrategy.Eager,
1134+
})
1135+
class ShuffledMenuExample {
1136+
items = signal([{value: 'Apple'}, {value: 'Banana'}, {value: 'Cherry'}]);
1137+
}
1138+
1139+
@Component({
1140+
template: `
1141+
<div ngMenuBar>
1142+
@for (item of items(); track item) {
1143+
<div ngMenuItem [value]="item.value">{{item.value}}</div>
1144+
}
1145+
</div>
1146+
`,
1147+
imports: [MenuBar, MenuItem],
1148+
changeDetection: ChangeDetectionStrategy.Eager,
1149+
})
1150+
class ShuffledMenuBarExample {
1151+
items = signal([{value: 'File'}, {value: 'Edit'}, {value: 'View'}]);
1152+
}

src/aria/menu/menu.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,21 @@
77
*/
88

99
import {
10+
afterNextRender,
1011
afterRenderEffect,
1112
booleanAttribute,
1213
computed,
13-
contentChildren,
1414
Directive,
1515
ElementRef,
1616
inject,
1717
input,
18+
OnDestroy,
1819
output,
1920
Signal,
2021
signal,
2122
untracked,
2223
} from '@angular/core';
23-
import {MenuPattern, DeferredContentAware} from '../private';
24+
import {MenuPattern, DeferredContentAware, SortedCollection} from '../private';
2425
import {_IdGenerator} from '@angular/cdk/a11y';
2526
import {Directionality} from '@angular/cdk/bidi';
2627
import {MenuTrigger} from './menu-trigger';
@@ -79,16 +80,16 @@ import {MENU_COMPONENT} from './menu-tokens';
7980
],
8081
providers: [{provide: MENU_COMPONENT, useExisting: Menu}],
8182
})
82-
export class Menu<V> {
83+
export class Menu<V> implements OnDestroy {
8384
/** The DeferredContentAware host directive. */
8485
private readonly _deferredContentAware = inject(DeferredContentAware, {optional: true});
8586

86-
/** The menu items contained in the menu. */
87-
readonly _allItems = contentChildren<MenuItem<V>>(MenuItem, {descendants: true});
87+
/** The collection of menu items. */
88+
readonly _collection = new SortedCollection<MenuItem<V>>();
8889

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

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

190191
afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()});
192+
193+
afterNextRender(() => {
194+
this._collection.startObserving(this.element);
195+
});
196+
}
197+
198+
ngOnDestroy() {
199+
this._collection.stopObserving();
191200
}
192201

193202
/** Closes the menu. */

0 commit comments

Comments
 (0)