Skip to content

Commit b0b7139

Browse files
committed
fix(menu): unable to open same sub-menu from different triggers and not picking up indirect descendant items
* Reworks the relationship between the menu items and the menu panel to allow for the items to be indirect descendants of the menu, while also allowing for `mat-menu` instances to be declared inside of other `mat-menu` instances without having the root `mat-menu` pick up all of the descendant items. * Adds the ability to pass in an array to the `ListKeyManager`, in addition to a `QueryList`. * Fixes not being able to re-use the same sub-menu between multiple sub-menu triggers. Fixes #9969. Fixes #9987.
1 parent e51fc4e commit b0b7139

File tree

7 files changed

+287
-55
lines changed

7 files changed

+287
-55
lines changed

src/cdk/a11y/key-manager/list-key-manager.ts

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -50,17 +50,22 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
5050
// Buffer for the letters that the user has pressed when the typeahead option is turned on.
5151
private _pressedLetters: string[] = [];
5252

53-
constructor(private _items: QueryList<T>) {
54-
_items.changes.subscribe((newItems: QueryList<T>) => {
55-
if (this._activeItem) {
56-
const itemArray = newItems.toArray();
57-
const newIndex = itemArray.indexOf(this._activeItem);
58-
59-
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
60-
this._activeItemIndex = newIndex;
53+
constructor(private _items: QueryList<T> | T[]) {
54+
// We allow for the items to be an array because, in some cases, the consumer may
55+
// not have access to a QueryList of the items they want to manage (e.g. when the
56+
// items aren't being collected via `ViewChildren` or `ContentChildren`).
57+
if (_items instanceof QueryList) {
58+
_items.changes.subscribe((newItems: QueryList<T>) => {
59+
if (this._activeItem) {
60+
const itemArray = newItems.toArray();
61+
const newIndex = itemArray.indexOf(this._activeItem);
62+
63+
if (newIndex > -1 && newIndex !== this._activeItemIndex) {
64+
this._activeItemIndex = newIndex;
65+
}
6166
}
62-
}
63-
});
67+
});
68+
}
6469
}
6570

6671
/**
@@ -120,7 +125,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
120125
filter(() => this._pressedLetters.length > 0),
121126
map(() => this._pressedLetters.join(''))
122127
).subscribe(inputString => {
123-
const items = this._items.toArray();
128+
const items = this._getItemsArray();
124129

125130
// Start at 1 because we want to start searching at the item immediately
126131
// following the current active item.
@@ -148,7 +153,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
148153
const previousIndex = this._activeItemIndex;
149154

150155
this._activeItemIndex = index;
151-
this._activeItem = this._items.toArray()[index];
156+
this._activeItem = this._getItemsArray()[index];
152157

153158
if (this._activeItemIndex !== previousIndex) {
154159
this.change.next(index);
@@ -267,17 +272,18 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
267272
* currently active item and the new active item. It will calculate differently
268273
* depending on whether wrap mode is turned on.
269274
*/
270-
private _setActiveItemByDelta(delta: -1 | 1, items = this._items.toArray()): void {
271-
this._wrap ? this._setActiveInWrapMode(delta, items)
272-
: this._setActiveInDefaultMode(delta, items);
275+
private _setActiveItemByDelta(delta: -1 | 1): void {
276+
this._wrap ? this._setActiveInWrapMode(delta) : this._setActiveInDefaultMode(delta);
273277
}
274278

275279
/**
276280
* Sets the active item properly given "wrap" mode. In other words, it will continue to move
277281
* down the list until it finds an item that is not disabled, and it will wrap if it
278282
* encounters either end of the list.
279283
*/
280-
private _setActiveInWrapMode(delta: -1 | 1, items: T[]): void {
284+
private _setActiveInWrapMode(delta: -1 | 1): void {
285+
const items = this._getItemsArray();
286+
281287
for (let i = 1; i <= items.length; i++) {
282288
const index = (this._activeItemIndex + (delta * i) + items.length) % items.length;
283289
const item = items[index];
@@ -294,17 +300,18 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
294300
* continue to move down the list until it finds an item that is not disabled. If
295301
* it encounters either end of the list, it will stop and not wrap.
296302
*/
297-
private _setActiveInDefaultMode(delta: -1 | 1, items: T[]): void {
298-
this._setActiveItemByIndex(this._activeItemIndex + delta, delta, items);
303+
private _setActiveInDefaultMode(delta: -1 | 1): void {
304+
this._setActiveItemByIndex(this._activeItemIndex + delta, delta);
299305
}
300306

301307
/**
302308
* Sets the active item to the first enabled item starting at the index specified. If the
303309
* item is disabled, it will move in the fallbackDelta direction until it either
304310
* finds an enabled item or encounters the end of the list.
305311
*/
306-
private _setActiveItemByIndex(index: number, fallbackDelta: -1 | 1,
307-
items = this._items.toArray()): void {
312+
private _setActiveItemByIndex(index: number, fallbackDelta: -1 | 1): void {
313+
const items = this._getItemsArray();
314+
308315
if (!items[index]) {
309316
return;
310317
}
@@ -319,4 +326,9 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
319326

320327
this.setActiveItem(index);
321328
}
329+
330+
/** Returns the items as an array. */
331+
private _getItemsArray(): T[] {
332+
return this._items instanceof QueryList ? this._items.toArray() : this._items;
333+
}
322334
}

src/lib/menu/menu-directive.ts

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,13 @@ import {
1717
ChangeDetectionStrategy,
1818
Component,
1919
ContentChild,
20-
ContentChildren,
2120
ElementRef,
2221
EventEmitter,
2322
Inject,
2423
InjectionToken,
2524
Input,
2625
OnDestroy,
2726
Output,
28-
QueryList,
2927
TemplateRef,
3028
ViewChild,
3129
ViewEncapsulation,
@@ -35,14 +33,16 @@ import {
3533
import {Observable} from 'rxjs/Observable';
3634
import {merge} from 'rxjs/observable/merge';
3735
import {Subscription} from 'rxjs/Subscription';
36+
import {Subject} from 'rxjs/Subject';
3837
import {matMenuAnimations} from './menu-animations';
3938
import {throwMatMenuInvalidPositionX, throwMatMenuInvalidPositionY} from './menu-errors';
4039
import {MatMenuItem} from './menu-item';
41-
import {MatMenuPanel} from './menu-panel';
40+
import {MatMenuPanel, MAT_MENU_PANEL} from './menu-panel';
4241
import {MatMenuContent} from './menu-content';
4342
import {MenuPositionX, MenuPositionY} from './menu-positions';
4443
import {coerceBooleanProperty} from '@angular/cdk/coercion';
4544
import {FocusOrigin} from '@angular/cdk/a11y';
45+
import {AnimationEvent} from '@angular/animations';
4646

4747

4848
/** Default `mat-menu` options that can be overridden. */
@@ -79,18 +79,27 @@ const MAT_MENU_BASE_ELEVATION = 2;
7979
changeDetection: ChangeDetectionStrategy.OnPush,
8080
encapsulation: ViewEncapsulation.None,
8181
preserveWhitespaces: false,
82+
exportAs: 'matMenu',
8283
animations: [
8384
matMenuAnimations.transformMenu,
8485
matMenuAnimations.fadeInItems
8586
],
86-
exportAs: 'matMenu'
87+
providers: [
88+
{provide: MAT_MENU_PANEL, useExisting: MatMenu}
89+
]
8790
})
88-
export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestroy {
91+
export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel<MatMenuItem>, OnDestroy {
8992
private _keyManager: FocusKeyManager<MatMenuItem>;
9093
private _xPosition: MenuPositionX = this._defaultOptions.xPosition;
9194
private _yPosition: MenuPositionY = this._defaultOptions.yPosition;
9295
private _previousElevation: string;
9396

97+
/** Menu items inside the current menu. */
98+
private _items: MatMenuItem[] = [];
99+
100+
/** Emits whenever the amount of menu items changes. */
101+
private _itemChanges = new Subject<MatMenuItem[]>();
102+
94103
/** Subscription to tab events on the menu panel */
95104
private _tabSubscription = Subscription.EMPTY;
96105

@@ -100,6 +109,12 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro
100109
/** Current state of the panel animation. */
101110
_panelAnimationState: 'void' | 'enter' = 'void';
102111

112+
/** Emits whenever an animation on the menu completes. */
113+
_animationDone = new Subject<AnimationEvent>();
114+
115+
/** Whether the menu is animating. */
116+
_isAnimating: boolean;
117+
103118
/** Parent menu of the current menu panel. */
104119
parentMenu: MatMenuPanel | undefined;
105120

@@ -134,9 +149,6 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro
134149
/** @docs-private */
135150
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
136151

137-
/** List of the items inside of a menu. */
138-
@ContentChildren(MatMenuItem) items: QueryList<MatMenuItem>;
139-
140152
/**
141153
* Menu content that will be rendered lazily.
142154
* @docs-private
@@ -202,7 +214,7 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro
202214
}
203215

204216
ngAfterContentInit() {
205-
this._keyManager = new FocusKeyManager<MatMenuItem>(this.items).withWrap().withTypeAhead();
217+
this._keyManager = new FocusKeyManager<MatMenuItem>(this._items).withWrap().withTypeAhead();
206218
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.close.emit('keydown'));
207219
}
208220

@@ -213,16 +225,10 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro
213225

214226
/** Stream that emits whenever the hovered menu item changes. */
215227
_hovered(): Observable<MatMenuItem> {
216-
if (this.items) {
217-
return this.items.changes.pipe(
218-
startWith(this.items),
219-
switchMap(items => merge(...items.map(item => item._hovered)))
220-
);
221-
}
222-
223-
return this._ngZone.onStable
224-
.asObservable()
225-
.pipe(take(1), switchMap(() => this._hovered()));
228+
return this._itemChanges.pipe(
229+
startWith(this._items),
230+
switchMap(items => merge(...items.map(item => item._hovered)))
231+
);
226232
}
227233

228234
/** Handle a keyboard event from the menu, delegating to the appropriate action. */
@@ -300,6 +306,35 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro
300306
}
301307
}
302308

309+
/**
310+
* Registers a menu item with the menu.
311+
* @docs-private
312+
*/
313+
addItem(item: MatMenuItem) {
314+
// We register the items through this method, rather than picking them up through
315+
// `ContentChildren`, because we need the items to be picked up by their closest
316+
// `mat-menu` ancestor. If we used `@ContentChildren(MatMenuItem, {descendants: true})`,
317+
// all descendant items will bleed into the top-level menu in the case where the consumer
318+
// has `mat-menu` instances nested inside each other.
319+
if (this._items.indexOf(item) === -1) {
320+
this._items.push(item);
321+
this._itemChanges.next(this._items);
322+
}
323+
}
324+
325+
/**
326+
* Removes an item from the menu.
327+
* @docs-private
328+
*/
329+
removeItem(item: MatMenuItem) {
330+
const index = this._items.indexOf(item);
331+
332+
if (this._items.indexOf(item) > -1) {
333+
this._items.splice(index, 1);
334+
this._itemChanges.next(this._items);
335+
}
336+
}
337+
303338
/** Starts the enter animation. */
304339
_startAnimation() {
305340
// @deletion-target 6.0.0 Combine with _resetAnimation.
@@ -313,7 +348,8 @@ export class MatMenu implements OnInit, AfterContentInit, MatMenuPanel, OnDestro
313348
}
314349

315350
/** Callback that is invoked when the panel animation completes. */
316-
_onAnimationDone(_event: AnimationEvent) {
317-
// @deletion-target 6.0.0 Not being used anymore. To be removed.
351+
_onAnimationDone(event: AnimationEvent) {
352+
this._animationDone.next(event);
353+
this._isAnimating = false;
318354
}
319355
}

src/lib/menu/menu-item.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
OnDestroy,
1515
ViewEncapsulation,
1616
Inject,
17+
Optional,
1718
} from '@angular/core';
1819
import {
1920
CanDisable,
@@ -23,6 +24,7 @@ import {
2324
} from '@angular/material/core';
2425
import {Subject} from 'rxjs/Subject';
2526
import {DOCUMENT} from '@angular/common';
27+
import {MAT_MENU_PANEL, MatMenuPanel} from './menu-panel';
2628

2729
// Boilerplate for applying mixins to MatMenuItem.
2830
/** @docs-private */
@@ -71,7 +73,8 @@ export class MatMenuItem extends _MatMenuItemMixinBase
7173
constructor(
7274
private _elementRef: ElementRef,
7375
@Inject(DOCUMENT) document?: any,
74-
private _focusMonitor?: FocusMonitor) {
76+
private _focusMonitor?: FocusMonitor,
77+
@Inject(MAT_MENU_PANEL) @Optional() private _parentMenu?: MatMenuPanel<MatMenuItem>) {
7578

7679
// @deletion-target 6.0.0 make `_focusMonitor` and `document` required params.
7780
super();
@@ -83,6 +86,10 @@ export class MatMenuItem extends _MatMenuItemMixinBase
8386
_focusMonitor.monitor(this._getHostElement(), false);
8487
}
8588

89+
if (_parentMenu && _parentMenu.addItem) {
90+
_parentMenu.addItem(this);
91+
}
92+
8693
this._document = document;
8794
}
8895

@@ -100,6 +107,10 @@ export class MatMenuItem extends _MatMenuItemMixinBase
100107
this._focusMonitor.stopMonitoring(this._getHostElement());
101108
}
102109

110+
if (this._parentMenu && this._parentMenu.removeItem) {
111+
this._parentMenu.removeItem(this);
112+
}
113+
103114
this._hovered.complete();
104115
}
105116

src/lib/menu/menu-panel.ts

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

9-
import {EventEmitter, TemplateRef} from '@angular/core';
9+
import {EventEmitter, TemplateRef, InjectionToken} from '@angular/core';
1010
import {MenuPositionX, MenuPositionY} from './menu-positions';
1111
import {Direction} from '@angular/cdk/bidi';
1212
import {FocusOrigin} from '@angular/cdk/a11y';
1313
import {MatMenuContent} from './menu-content';
1414

15+
/**
16+
* Injection token used to provide the parent menu to menu-specific components.
17+
* @docs-private
18+
*/
19+
export const MAT_MENU_PANEL = new InjectionToken<MatMenuPanel>('MAT_MENU_PANEL');
20+
1521
/**
1622
* Interface for a custom menu panel that can be used with `matMenuTriggerFor`.
1723
* @docs-private
1824
*/
19-
export interface MatMenuPanel {
25+
export interface MatMenuPanel<T = any> {
2026
xPosition: MenuPositionX;
2127
yPosition: MenuPositionY;
2228
overlapTrigger: boolean;
@@ -30,4 +36,6 @@ export interface MatMenuPanel {
3036
setElevation?(depth: number): void;
3137
lazyContent?: MatMenuContent;
3238
backdropClass?: string;
39+
addItem?: (item: T) => void;
40+
removeItem?: (item: T) => void;
3341
}

0 commit comments

Comments
 (0)