Skip to content

Commit 9f5ef98

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 5d3f515 commit 9f5ef98

File tree

5 files changed

+97
-41
lines changed

5 files changed

+97
-41
lines changed

src/lib/menu/menu-directive.ts

Lines changed: 41 additions & 37 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;
@@ -248,23 +248,41 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
248248
}
249249

250250
ngAfterContentInit() {
251-
this._keyManager = new FocusKeyManager<MatMenuItem>(this._items).withWrap().withTypeAhead();
251+
this._updateDirectDescendants();
252+
this._keyManager = new FocusKeyManager(this._directDescendantItems).withWrap().withTypeAhead();
252253
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
253254
}
254255

255256
ngOnDestroy() {
257+
this._directDescendantItems.destroy();
256258
this._tabSubscription.unsubscribe();
257259
this.closed.complete();
258260
}
259261

260262
/** Stream that emits whenever the hovered menu item changes. */
261263
_hovered(): Observable<MatMenuItem> {
262-
return this._itemChanges.pipe(
263-
startWith(this._items),
264-
switchMap(items => merge(...items.map(item => item._hovered)))
264+
return this._directDescendantItems.changes.pipe(
265+
startWith(this._directDescendantItems),
266+
switchMap(items => merge(...items.map((item: MatMenuItem) => item._hovered)))
265267
);
266268
}
267269

270+
/*
271+
* Registers a menu item with the menu.
272+
* @docs-private
273+
* @deprecated No longer being used. To be removed.
274+
* @breaking-change 9.0.0
275+
*/
276+
addItem(_item: MatMenuItem) {}
277+
278+
/**
279+
* Removes an item from the menu.
280+
* @docs-private
281+
* @deprecated No longer being used. To be removed.
282+
* @breaking-change 9.0.0
283+
*/
284+
removeItem(_item: MatMenuItem) {}
285+
268286
/** Handle a keyboard event from the menu, delegating to the appropriate action. */
269287
_handleKeydown(event: KeyboardEvent) {
270288
const keyCode = event.keyCode;
@@ -334,35 +352,6 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
334352
}
335353
}
336354

337-
/**
338-
* Registers a menu item with the menu.
339-
* @docs-private
340-
*/
341-
addItem(item: MatMenuItem) {
342-
// We register the items through this method, rather than picking them up through
343-
// `ContentChildren`, because we need the items to be picked up by their closest
344-
// `mat-menu` ancestor. If we used `@ContentChildren(MatMenuItem, {descendants: true})`,
345-
// all descendant items will bleed into the top-level menu in the case where the consumer
346-
// has `mat-menu` instances nested inside each other.
347-
if (this._items.indexOf(item) === -1) {
348-
this._items.push(item);
349-
this._itemChanges.next(this._items);
350-
}
351-
}
352-
353-
/**
354-
* Removes an item from the menu.
355-
* @docs-private
356-
*/
357-
removeItem(item: MatMenuItem) {
358-
const index = this._items.indexOf(item);
359-
360-
if (this._items.indexOf(item) > -1) {
361-
this._items.splice(index, 1);
362-
this._itemChanges.next(this._items);
363-
}
364-
}
365-
366355
/**
367356
* Adds classes to the menu panel based on its position. Can be used by
368357
* consumers to add specific styling based on the position.
@@ -409,4 +398,19 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
409398
event.element.scrollTop = 0;
410399
}
411400
}
401+
402+
/**
403+
* Sets up a stream that will keep track of any newly-added menu items and will update the list
404+
* of direct descendants. We collect the descendants this way, because `_allItems` can include
405+
* items that are part of child menus, and using a custom way of registering items is unreliable
406+
* when it comes to maintaining the item order.
407+
*/
408+
private _updateDirectDescendants() {
409+
this._allItems.changes
410+
.pipe(startWith(this._allItems))
411+
.subscribe((items: QueryList<MatMenuItem>) => {
412+
this._directDescendantItems.reset(items.filter(item => item._parentMenu === this));
413+
this._directDescendantItems.notifyOnChanges();
414+
});
415+
}
412416
}

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+
* @breaking-change 8.0.0
44+
*/
4045
addItem?: (item: T) => void;
46+
47+
/**
48+
* @deprecated To be removed.
49+
* @breaking-change 8.0.0
50+
*/
4151
removeItem?: (item: T) => void;
4252
}

src/lib/menu/menu.spec.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,33 @@ describe('MatMenu', () => {
728728

729729
expect(item.textContent!.trim()).toBe('two');
730730
}));
731+
732+
it('should respect the DOM order, rather than insertion order, when moving focus using ' +
733+
'the arrow keys', fakeAsync(() => {
734+
let fixture = createComponent(SimpleMenuWithRepeater);
735+
736+
fixture.detectChanges();
737+
fixture.componentInstance.trigger.openMenu();
738+
fixture.detectChanges();
739+
tick(500);
740+
741+
let menuPanel = document.querySelector('.mat-menu-panel')!;
742+
let items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
743+
744+
expect(document.activeElement).toBe(items[0], 'Expected first item to be focused on open');
745+
746+
// Add a new item after the first one.
747+
fixture.componentInstance.items.splice(1, 0, 'Calzone');
748+
fixture.detectChanges();
749+
750+
items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
751+
dispatchKeyboardEvent(menuPanel, 'keydown', DOWN_ARROW);
752+
fixture.detectChanges();
753+
tick();
754+
755+
expect(document.activeElement).toBe(items[1], 'Expected second item to be focused');
756+
flush();
757+
}));
731758
});
732759

733760
describe('positions', () => {
@@ -2157,7 +2184,6 @@ class LazyMenuWithContext {
21572184
}
21582185

21592186

2160-
21612187
@Component({
21622188
template: `
21632189
<button [matMenuTriggerFor]="one">Toggle menu</button>
@@ -2190,3 +2216,19 @@ class DynamicPanelMenu {
21902216
class MenuWithCheckboxItems {
21912217
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
21922218
}
2219+
2220+
2221+
@Component({
2222+
template: `
2223+
<button [matMenuTriggerFor]="menu">Toggle menu</button>
2224+
<mat-menu #menu="matMenu">
2225+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
2226+
</mat-menu>
2227+
`
2228+
})
2229+
class SimpleMenuWithRepeater {
2230+
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
2231+
@ViewChild(MatMenu) menu: MatMenu;
2232+
items = ['Pizza', 'Pasta'];
2233+
}
2234+

tools/public_api_guard/lib/menu.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ export declare class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuIt
3232
_onAnimationStart(event: AnimationEvent): void;
3333
_resetAnimation(): void;
3434
_startAnimation(): void;
35-
addItem(item: MatMenuItem): void;
35+
addItem(_item: MatMenuItem): void;
3636
focusFirstItem(origin?: FocusOrigin): void;
3737
ngAfterContentInit(): void;
3838
ngOnDestroy(): void;
3939
ngOnInit(): void;
40-
removeItem(item: MatMenuItem): void;
40+
removeItem(_item: MatMenuItem): void;
4141
resetActiveItem(): void;
4242
setElevation(depth: number): void;
4343
setPositionClasses(posX?: MenuPositionX, posY?: MenuPositionY): void;

0 commit comments

Comments
 (0)