Skip to content

Commit

Permalink
fix(menu): keyboard controls not respecting DOM order when items are …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
crisbeto committed Aug 26, 2018
1 parent f6cded8 commit 1276018
Show file tree
Hide file tree
Showing 4 changed files with 75 additions and 37 deletions.
60 changes: 24 additions & 36 deletions src/lib/menu/menu-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,11 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
private _yPosition: MenuPositionY = this._defaultOptions.yPosition;
private _previousElevation: string;

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

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

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

ngAfterContentInit() {
this._keyManager = new FocusKeyManager<MatMenuItem>(this._items).withWrap().withTypeAhead();
this._updateDirectDescendants();
this._keyManager = new FocusKeyManager(this._directDescendantItems).withWrap().withTypeAhead();
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.closed.emit('tab'));
}

ngOnDestroy() {
this._directDescendantItems.destroy();
this._tabSubscription.unsubscribe();
this.closed.complete();
}

/** Stream that emits whenever the hovered menu item changes. */
_hovered(): Observable<MatMenuItem> {
return this._itemChanges.pipe(
startWith(this._items),
return this._directDescendantItems.changes.pipe(
startWith(this._directDescendantItems),
switchMap(items => merge(...items.map(item => item._hovered)))
);
}
Expand Down Expand Up @@ -325,35 +327,6 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
}
}

/**
* Registers a menu item with the menu.
* @docs-private
*/
addItem(item: MatMenuItem) {
// We register the items through this method, rather than picking them up through
// `ContentChildren`, because we need the items to be picked up by their closest
// `mat-menu` ancestor. If we used `@ContentChildren(MatMenuItem, {descendants: true})`,
// all descendant items will bleed into the top-level menu in the case where the consumer
// has `mat-menu` instances nested inside each other.
if (this._items.indexOf(item) === -1) {
this._items.push(item);
this._itemChanges.next(this._items);
}
}

/**
* Removes an item from the menu.
* @docs-private
*/
removeItem(item: MatMenuItem) {
const index = this._items.indexOf(item);

if (this._items.indexOf(item) > -1) {
this._items.splice(index, 1);
this._itemChanges.next(this._items);
}
}

/**
* Adds classes to the menu panel based on its position. Can be used by
* consumers to add specific styling based on the position.
Expand Down Expand Up @@ -396,4 +369,19 @@ export class MatMenu implements AfterContentInit, MatMenuPanel<MatMenuItem>, OnI
event.element.scrollTop = 0;
}
}

/**
* Sets up a stream that will keep track of any newly-added menu items and will update the list
* of direct descendants. We collect the descendants this way, because `_allItems` can include
* items that are part of child menus, and using a custom way of registering items is unreliable
* when it comes to maintaining the item order.
*/
private _updateDirectDescendants() {
this._allItems.changes
.pipe(startWith(this._allItems))
.subscribe((items: QueryList<MatMenuItem>) => {
this._directDescendantItems.reset(items.filter(item => item._parentMenu === this));
this._directDescendantItems.notifyOnChanges();
});
}
}
2 changes: 1 addition & 1 deletion src/lib/menu/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class MatMenuItem extends _MatMenuItemMixinBase
private _elementRef: ElementRef,
@Inject(DOCUMENT) document?: any,
private _focusMonitor?: FocusMonitor,
@Inject(MAT_MENU_PANEL) @Optional() private _parentMenu?: MatMenuPanel<MatMenuItem>) {
@Inject(MAT_MENU_PANEL) @Optional() public _parentMenu?: MatMenuPanel<MatMenuItem>) {

// @breaking-change 7.0.0 make `_focusMonitor` and `document` required params.
super();
Expand Down
10 changes: 10 additions & 0 deletions src/lib/menu/menu-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export interface MatMenuPanel<T = any> {
lazyContent?: MatMenuContent;
backdropClass?: string;
hasBackdrop?: boolean;

/**
* @deprecated To be removed.
* @deletion-target 8.0.0
*/
addItem?: (item: T) => void;

/**
* @deprecated To be removed.
* @deletion-target 8.0.0
*/
removeItem?: (item: T) => void;
}
40 changes: 40 additions & 0 deletions src/lib/menu/menu.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,32 @@ describe('MatMenu', () => {

expect(item.textContent!.trim()).toBe('two');
}));

it('should respect the DOM order, rather than insertion order, when moving focus using ' +
'the arrow keys', fakeAsync(() => {
let fixture = createComponent(SimpleMenuWithRepeater);

fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
tick(500);

let menuPanel = document.querySelector('.mat-menu-panel')!;
let items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');

expect(document.activeElement).toBe(items[0], 'Expected first item to be focused on open');

// Add a new item after the first one.
fixture.componentInstance.items.splice(1, 0, 'Calzone');
fixture.detectChanges();

items = menuPanel.querySelectorAll('.mat-menu-panel [mat-menu-item]');
dispatchKeyboardEvent(menuPanel, 'keydown', DOWN_ARROW);
fixture.detectChanges();
tick();

expect(document.activeElement).toBe(items[1], 'Expected second item to be focused');
}));
});

describe('positions', () => {
Expand Down Expand Up @@ -1972,3 +1998,17 @@ class LazyMenuWithContext {
@ViewChild('triggerTwo') triggerTwo: MatMenuTrigger;
}

@Component({
template: `
<button [matMenuTriggerFor]="menu">Toggle menu</button>
<mat-menu #menu="matMenu">
<button *ngFor="let item of items" mat-menu-item>{{item}}</button>
</mat-menu>
`
})
class SimpleMenuWithRepeater {
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
@ViewChild(MatMenu) menu: MatMenu;
items = ['Pizza', 'Pasta'];
}

0 comments on commit 1276018

Please sign in to comment.