Skip to content

Commit 49e8c59

Browse files
crisbetojelbourn
authored andcommitted
fix(menu): keyboard controls not respecting DOM order when items are added at a later point (#11720)
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 207dba6 commit 49e8c59

File tree

5 files changed

+99
-41
lines changed

5 files changed

+99
-41
lines changed

src/material/menu/menu-item.ts

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

8282
// @breaking-change 8.0.0 make `_focusMonitor` and `document` required params.
8383
super();

src/material/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/material/menu/menu.spec.ts

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

866866
expect(item.textContent!.trim()).toBe('two');
867867
}));
868+
869+
it('should respect the DOM order, rather than insertion order, when moving focus using ' +
870+
'the arrow keys', fakeAsync(() => {
871+
let fixture = createComponent(SimpleMenuWithRepeater);
872+
873+
fixture.detectChanges();
874+
fixture.componentInstance.trigger.openMenu();
875+
fixture.detectChanges();
876+
tick(500);
877+
878+
let menuPanel = document.querySelector('.mat-menu-panel')!;
879+
let items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
880+
881+
expect(document.activeElement).toBe(items[0], 'Expected first item to be focused on open');
882+
883+
// Add a new item after the first one.
884+
fixture.componentInstance.items.splice(1, 0, 'Calzone');
885+
fixture.detectChanges();
886+
887+
items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
888+
dispatchKeyboardEvent(menuPanel, 'keydown', DOWN_ARROW);
889+
fixture.detectChanges();
890+
tick();
891+
892+
expect(document.activeElement).toBe(items[1], 'Expected second item to be focused');
893+
flush();
894+
}));
868895
});
869896

870897
describe('positions', () => {
@@ -2298,7 +2325,6 @@ class LazyMenuWithContext {
22982325
}
22992326

23002327

2301-
23022328
@Component({
23032329
template: `
23042330
<button [matMenuTriggerFor]="one">Toggle menu</button>
@@ -2331,3 +2357,19 @@ class DynamicPanelMenu {
23312357
class MenuWithCheckboxItems {
23322358
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
23332359
}
2360+
2361+
2362+
@Component({
2363+
template: `
2364+
<button [matMenuTriggerFor]="menu">Toggle menu</button>
2365+
<mat-menu #menu="matMenu">
2366+
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
2367+
</mat-menu>
2368+
`
2369+
})
2370+
class SimpleMenuWithRepeater {
2371+
@ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
2372+
@ViewChild(MatMenu, {static: false}) menu: MatMenu;
2373+
items = ['Pizza', 'Pasta'];
2374+
}
2375+

src/material/menu/menu.ts

Lines changed: 41 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,11 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>
9898
private _yPosition: MenuPositionY = this._defaultOptions.yPosition;
9999
private _previousElevation: string;
100100

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

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

107107
/** Subscription to tab events on the menu panel */
108108
private _tabSubscription = Subscription.EMPTY;
@@ -242,23 +242,41 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>
242242
}
243243

244244
ngAfterContentInit() {
245-
this._keyManager = new FocusKeyManager<MatMenuItem>(this._items).withWrap().withTypeAhead();
245+
this._updateDirectDescendants();
246+
this._keyManager = new FocusKeyManager(this._directDescendantItems).withWrap().withTypeAhead();
246247
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
247248
}
248249

249250
ngOnDestroy() {
251+
this._directDescendantItems.destroy();
250252
this._tabSubscription.unsubscribe();
251253
this.closed.complete();
252254
}
253255

254256
/** Stream that emits whenever the hovered menu item changes. */
255257
_hovered(): Observable<MatMenuItem> {
256-
return this._itemChanges.pipe(
257-
startWith(this._items),
258-
switchMap(items => merge(...items.map(item => item._hovered)))
258+
return this._directDescendantItems.changes.pipe(
259+
startWith(this._directDescendantItems),
260+
switchMap(items => merge<MatMenuItem>(...items.map((item: MatMenuItem) => item._hovered)))
259261
);
260262
}
261263

264+
/*
265+
* Registers a menu item with the menu.
266+
* @docs-private
267+
* @deprecated No longer being used. To be removed.
268+
* @breaking-change 9.0.0
269+
*/
270+
addItem(_item: MatMenuItem) {}
271+
272+
/**
273+
* Removes an item from the menu.
274+
* @docs-private
275+
* @deprecated No longer being used. To be removed.
276+
* @breaking-change 9.0.0
277+
*/
278+
removeItem(_item: MatMenuItem) {}
279+
262280
/** Handle a keyboard event from the menu, delegating to the appropriate action. */
263281
_handleKeydown(event: KeyboardEvent) {
264282
const keyCode = event.keyCode;
@@ -339,35 +357,6 @@ export class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>
339357
}
340358
}
341359

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

419423
/** @docs-private We show the "_MatMenu" class as "MatMenu" in the docs. */

tools/public_api_guard/material/menu.d.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export declare class _MatMenu extends MatMenu {
33
}
44

55
export declare class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnInit, OnDestroy {
6+
_allItems: QueryList<MatMenuItem>;
67
_animationDone: Subject<AnimationEvent>;
78
_classList: {
89
[key: string]: boolean;
@@ -30,12 +31,12 @@ export declare class _MatMenuBase implements AfterContentInit, MatMenuPanel<MatM
3031
_onAnimationStart(event: AnimationEvent): void;
3132
_resetAnimation(): void;
3233
_startAnimation(): void;
33-
addItem(item: MatMenuItem): void;
34+
addItem(_item: MatMenuItem): void;
3435
focusFirstItem(origin?: FocusOrigin): void;
3536
ngAfterContentInit(): void;
3637
ngOnDestroy(): void;
3738
ngOnInit(): void;
38-
removeItem(item: MatMenuItem): void;
39+
removeItem(_item: MatMenuItem): void;
3940
resetActiveItem(): void;
4041
setElevation(depth: number): void;
4142
setPositionClasses(posX?: MenuPositionX, posY?: MenuPositionY): void;
@@ -79,6 +80,7 @@ export interface MatMenuDefaultOptions {
7980
export declare class MatMenuItem extends _MatMenuItemMixinBase implements FocusableOption, CanDisable, CanDisableRipple, OnDestroy {
8081
_highlighted: boolean;
8182
readonly _hovered: Subject<MatMenuItem>;
83+
_parentMenu?: MatMenuPanel<MatMenuItem> | undefined;
8284
_triggersSubmenu: boolean;
8385
role: 'menuitem' | 'menuitemradio' | 'menuitemcheckbox';
8486
constructor(_elementRef: ElementRef<HTMLElement>, document?: any, _focusMonitor?: FocusMonitor | undefined, _parentMenu?: MatMenuPanel<MatMenuItem> | undefined);

0 commit comments

Comments
 (0)