Skip to content

Commit 7b64465

Browse files
committed
fix(menu): keyboard controls not respecting DOM order when items are added at a later point
A while ago we reworked the menu not to pick up the items of other child menus. As a result, we had to use a custom way of registering and de-registering the menu items, however that method ends up adding any newly-created items to the end of the item list. This will throw off the keyboard navigation if an item is inserted in the beginning or the middle of the list. With these changes we switch to picking up the items using `ContentChildren` and filtering out all of the indirect descendants. Fixes #11652.
1 parent 4ee0b87 commit 7b64465

File tree

4 files changed

+75
-37
lines changed

4 files changed

+75
-37
lines changed

src/lib/menu/menu-directive.ts

Lines changed: 24 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -104,11 +104,11 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
104104
private _yPosition: MenuPositionY = this._defaultOptions.yPosition;
105105
private _previousElevation: string;
106106

107-
/** Menu items inside the current menu. */
108-
private _items: MatMenuItem[] = [];
107+
/** All items inside the menu. Includes items nested inside another menu. */
108+
@ContentChildren(MatMenuItem, {descendants: true}) _allItems: QueryList<MatMenuItem>;
109109

110-
/** Emits whenever the amount of menu items changes. */
111-
private _itemChanges = new Subject<MatMenuItem[]>();
110+
/** Only the direct descendant menu items. */
111+
private _directDescendantItems = new QueryList<MatMenuItem>();
112112

113113
/** Subscription to tab events on the menu panel */
114114
private _tabSubscription = Subscription.EMPTY;
@@ -238,19 +238,21 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
238238
}
239239

240240
ngAfterContentInit() {
241-
this._keyManager = new FocusKeyManager<MatMenuItem>(this._items).withWrap().withTypeAhead();
241+
this._updateDirectDescendants();
242+
this._keyManager = new FocusKeyManager(this._directDescendantItems).withWrap().withTypeAhead();
242243
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
243244
}
244245

245246
ngOnDestroy() {
247+
this._directDescendantItems.destroy();
246248
this._tabSubscription.unsubscribe();
247249
this.closed.complete();
248250
}
249251

250252
/** Stream that emits whenever the hovered menu item changes. */
251253
_hovered(): Observable<MatMenuItem> {
252-
return this._itemChanges.pipe(
253-
startWith(this._items),
254+
return this._directDescendantItems.changes.pipe(
255+
startWith(this._directDescendantItems),
254256
switchMap(items => merge(...items.map(item => item._hovered)))
255257
);
256258
}
@@ -325,35 +327,6 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
325327
}
326328
}
327329

328-
/**
329-
* Registers a menu item with the menu.
330-
* @docs-private
331-
*/
332-
addItem(item: MatMenuItem) {
333-
// We register the items through this method, rather than picking them up through
334-
// `ContentChildren`, because we need the items to be picked up by their closest
335-
// `mat-menu` ancestor. If we used `@ContentChildren(MatMenuItem, {descendants: true})`,
336-
// all descendant items will bleed into the top-level menu in the case where the consumer
337-
// has `mat-menu` instances nested inside each other.
338-
if (this._items.indexOf(item) === -1) {
339-
this._items.push(item);
340-
this._itemChanges.next(this._items);
341-
}
342-
}
343-
344-
/**
345-
* Removes an item from the menu.
346-
* @docs-private
347-
*/
348-
removeItem(item: MatMenuItem) {
349-
const index = this._items.indexOf(item);
350-
351-
if (this._items.indexOf(item) > -1) {
352-
this._items.splice(index, 1);
353-
this._itemChanges.next(this._items);
354-
}
355-
}
356-
357330
/**
358331
* Adds classes to the menu panel based on its position. Can be used by
359332
* consumers to add specific styling based on the position.
@@ -386,4 +359,19 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
386359
this._animationDone.next(event);
387360
this._isAnimating = false;
388361
}
362+
363+
/**
364+
* Sets up a stream that will keep track of any newly-added menu items and will update the list
365+
* of direct descendants. We collect the descendants this way, because `_allItems` can include
366+
* items that are part of child menus, and using a custom way of registering items is unreliable
367+
* when it comes to maintaining the item order.
368+
*/
369+
private _updateDirectDescendants() {
370+
this._allItems.changes
371+
.pipe(startWith(this._allItems))
372+
.subscribe((items: QueryList<MatMenuItem>) => {
373+
this._directDescendantItems.reset(items.filter(item => item._parentMenu === this));
374+
this._directDescendantItems.notifyOnChanges();
375+
});
376+
}
389377
}

src/lib/menu/menu-item.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class MatMenuItem extends _MatMenuItemMixinBase
7373
private _elementRef: ElementRef,
7474
@Inject(DOCUMENT) document?: any,
7575
private _focusMonitor?: FocusMonitor,
76-
@Inject(MAT_MENU_PANEL) @Optional() private _parentMenu?: MatMenuPanel<MatMenuItem>) {
76+
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>) {
7777

7878
// @deletion-target 7.0.0 make `_focusMonitor` and `document` required params.
7979
super();

src/lib/menu/menu-panel.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ export interface MatMenuPanel<T = any> {
3737
lazyContent?: MatMenuContent;
3838
backdropClass?: string;
3939
hasBackdrop?: boolean;
40+
41+
/**
42+
* @deprecated To be removed.
43+
* @deletion-target 8.0.0
44+
*/
4045
addItem?: (item: T) => void;
46+
47+
/**
48+
* @deprecated To be removed.
49+
* @deletion-target 8.0.0
50+
*/
4151
removeItem?: (item: T) => void;
4252
}

src/lib/menu/menu.spec.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,32 @@ describe('MatMenu', () => {
490490

491491
expect(item.textContent!.trim()).toBe('two');
492492
}));
493+
494+
it('should respect the DOM order, rather than insertion order, when moving focus using ' +
495+
'the arrow keys', fakeAsync(() => {
496+
let fixture = createComponent(SimpleMenuWithRepeater);
497+
498+
fixture.detectChanges();
499+
fixture.componentInstance.trigger.openMenu();
500+
fixture.detectChanges();
501+
tick(500);
502+
503+
let menuPanel = document.querySelector('.mat-menu-panel')!;
504+
let items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
505+
506+
expect(document.activeElement).toBe(items[0], 'Expected first item to be focused on open');
507+
508+
// Add a new item after the first one.
509+
fixture.componentInstance.items.splice(1, 0, 'Calzone');
510+
fixture.detectChanges();
511+
512+
items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
513+
dispatchKeyboardEvent(menuPanel, 'keydown', DOWN_ARROW);
514+
fixture.detectChanges();
515+
tick();
516+
517+
expect(document.activeElement).toBe(items[1], 'Expected second item to be focused');
518+
}));
493519
});
494520

495521
describe('positions', () => {
@@ -1881,3 +1907,17 @@ class LazyMenuWithContext {
18811907
@ViewChild('triggerTwo') triggerTwo: MatMenuTrigger;
18821908
}
18831909

1910+
@Component({
1911+
template: `
1912+
<button [matMenuTriggerFor]="menu">Toggle menu</button>
1913+
<mat-menu #menu="matMenu">
1914+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
1915+
</mat-menu>
1916+
`
1917+
})
1918+
class SimpleMenuWithRepeater {
1919+
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
1920+
@ViewChild(MatMenu) menu: MatMenu;
1921+
items = ['Pizza', 'Pasta'];
1922+
}
1923+

0 commit comments

Comments
 (0)