Skip to content

Commit 2fd886a

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 bc8fc75 commit 2fd886a

File tree

4 files changed

+77
-38
lines changed

4 files changed

+77
-38
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
}
@@ -324,35 +326,6 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
324326
}
325327
}
326328

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

src/lib/menu/menu-item.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export class MatMenuItem extends _MatMenuItemMixinBase
7878
private _elementRef: ElementRef<HTMLElement>,
7979
@Inject(DOCUMENT) document?: any,
8080
private _focusMonitor?: FocusMonitor,
81-
@Inject(MAT_MENU_PANEL) @Optional() private _parentMenu?: MatMenuPanel<MatMenuItem>) {
81+
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>) {
8282

8383
// @breaking-change 8.0.0 make `_focusMonitor` and `document` required params.
8484
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: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,32 @@ describe('MatMenu', () => {
678678

679679
expect(item.textContent!.trim()).toBe('two');
680680
}));
681+
682+
it('should respect the DOM order, rather than insertion order, when moving focus using ' +
683+
'the arrow keys', fakeAsync(() => {
684+
let fixture = createComponent(SimpleMenuWithRepeater);
685+
686+
fixture.detectChanges();
687+
fixture.componentInstance.trigger.openMenu();
688+
fixture.detectChanges();
689+
tick(500);
690+
691+
let menuPanel = document.querySelector('.mat-menu-panel')!;
692+
let items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
693+
694+
expect(document.activeElement).toBe(items[0], 'Expected first item to be focused on open');
695+
696+
// Add a new item after the first one.
697+
fixture.componentInstance.items.splice(1, 0, 'Calzone');
698+
fixture.detectChanges();
699+
700+
items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
701+
dispatchKeyboardEvent(menuPanel, 'keydown', DOWN_ARROW);
702+
fixture.detectChanges();
703+
tick();
704+
705+
expect(document.activeElement).toBe(items[1], 'Expected second item to be focused');
706+
}));
681707
});
682708

683709
describe('positions', () => {
@@ -2106,7 +2132,6 @@ class LazyMenuWithContext {
21062132
}
21072133

21082134

2109-
21102135
@Component({
21112136
template: `
21122137
<button [matMenuTriggerFor]="one">Toggle menu</button>
@@ -2139,3 +2164,19 @@ class DynamicPanelMenu {
21392164
class MenuWithCheckboxItems {
21402165
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
21412166
}
2167+
2168+
2169+
@Component({
2170+
template: `
2171+
<button [matMenuTriggerFor]="menu">Toggle menu</button>
2172+
<mat-menu #menu="matMenu">
2173+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
2174+
</mat-menu>
2175+
`
2176+
})
2177+
class SimpleMenuWithRepeater {
2178+
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
2179+
@ViewChild(MatMenu) menu: MatMenu;
2180+
items = ['Pizza', 'Pasta'];
2181+
}
2182+

0 commit comments

Comments
 (0)