Skip to content

refactor(cdk-experimental/menu): refactor MenuStack to ensure submenus are popped off and closed #20006

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 21, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/cdk-experimental/menu/menu-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,11 @@ export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit,

/** Subscribe to the MenuStack close and empty observables. */
private _subscribeToMenuStack() {
this._menuStack.close
this._menuStack.closed
.pipe(takeUntil(this._destroyed))
.subscribe((item: MenuStackItem) => this._closeOpenMenu(item));

this._menuStack.empty
this._menuStack.emptied
.pipe(takeUntil(this._destroyed))
.subscribe((event: FocusNext) => this._toggleOpenMenu(event));
}
Expand Down
5 changes: 3 additions & 2 deletions src/cdk-experimental/menu/menu-item-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export class CdkMenuItemTrigger implements OnDestroy {

this._overlayRef!.detach();
}
this._getMenuStack().closeSubMenuOf(this._parentMenu);
}

/** Return true if the trigger has an attached menu */
Expand Down Expand Up @@ -150,7 +151,7 @@ export class CdkMenuItemTrigger implements OnDestroy {
if (this._isParentVertical()) {
event.preventDefault();
if (this._directionality?.value === 'rtl') {
this._getMenuStack().closeLatest(FocusNext.currentItem);
this._getMenuStack().close(this._parentMenu, FocusNext.currentItem);
} else {
this.openMenu();
this.menuPanel?._menu?.focusFirstItem('keyboard');
Expand All @@ -165,7 +166,7 @@ export class CdkMenuItemTrigger implements OnDestroy {
this.openMenu();
this.menuPanel?._menu?.focusFirstItem('keyboard');
} else {
this._getMenuStack().closeLatest(FocusNext.currentItem);
this._getMenuStack().close(this._parentMenu, FocusNext.currentItem);
}
}
break;
Expand Down
4 changes: 2 additions & 2 deletions src/cdk-experimental/menu/menu-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export class CdkMenuItem implements FocusableOption {
if (this._isParentVertical() && !this.hasMenu()) {
event.preventDefault();
this._dir?.value === 'rtl'
? this._getMenuStack().closeLatest(FocusNext.previousItem)
? this._getMenuStack().close(this._parentMenu, FocusNext.previousItem)
: this._getMenuStack().closeAll(FocusNext.nextItem);
}
break;
Expand All @@ -155,7 +155,7 @@ export class CdkMenuItem implements FocusableOption {
event.preventDefault();
this._dir?.value === 'rtl'
? this._getMenuStack().closeAll(FocusNext.nextItem)
: this._getMenuStack().closeLatest(FocusNext.previousItem);
: this._getMenuStack().close(this._parentMenu, FocusNext.previousItem);
}
break;
}
Expand Down
110 changes: 110 additions & 0 deletions src/cdk-experimental/menu/menu-stack.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import {QueryList, ViewChild, ViewChildren, Component} from '@angular/core';
import {CdkMenu} from './menu';
import {CdkMenuBar} from './menu-bar';
import {ComponentFixture, TestBed, async} from '@angular/core/testing';
import {CdkMenuItemTrigger} from './menu-item-trigger';
import {MenuStack} from './menu-stack';
import {CdkMenuModule} from './menu-module';

describe('MenuStack', () => {
let fixture: ComponentFixture<MultiMenuWithSubmenu>;
let menuStack: MenuStack;
let triggers: CdkMenuItemTrigger[];
let menus: CdkMenu[];

/** Fetch triggers, menus and the menu stack from the test component. */
function getElementsForTesting() {
fixture.detectChanges();
triggers = fixture.componentInstance.triggers.toArray();
menus = fixture.componentInstance.menus.toArray();
menuStack = fixture.componentInstance.menuBar._menuStack;
}

beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [CdkMenuModule],
declarations: [MultiMenuWithSubmenu],
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(MultiMenuWithSubmenu);
fixture.detectChanges();

getElementsForTesting();
});

/** Open up all of the menus in the test component. */
function openAllMenus() {
triggers[0].openMenu();
getElementsForTesting();
triggers[1].openMenu();
getElementsForTesting();
triggers[2].openMenu();
getElementsForTesting();
}

it(
'should fill the menu stack with the latest menu at the end of the stack and oldest at' +
' the start of the stack',
() => {
openAllMenus();
expect(menus.length).toBe(3);
const spy = jasmine.createSpy('menu stack closed spy');

menuStack.closed.subscribe(spy);
menuStack.closeAll();

expect(spy).toHaveBeenCalledTimes(3);
const callArgs = spy.calls.all().map((v: jasmine.CallInfo<jasmine.Func>) => v.args[0]);
expect(callArgs).toEqual(menus.reverse());
expect(menuStack.isEmpty()).toBeTrue();
}
);

it('should close triggering menu and all menus below it', () => {
openAllMenus();
expect(menus.length).toBe(3);

triggers[1].toggle();
getElementsForTesting();

expect(menus.length).toBe(1);
expect(menuStack.length()).withContext('menu stack should only have the single menu').toBe(1);
expect(menuStack.peek()).toEqual(menus[0]);
});
});

@Component({
template: `
<div>
<div cdkMenuBar id="menu_bar">
<button cdkMenuItem [cdkMenuTriggerFor]="file">File</button>
</div>

<ng-template cdkMenuPanel #file="cdkMenuPanel">
<div cdkMenu id="file_menu" [cdkMenuPanel]="file">
<button cdkMenuItem [cdkMenuTriggerFor]="share">Share</button>
</div>
</ng-template>

<ng-template cdkMenuPanel #share="cdkMenuPanel">
<div cdkMenu id="share_menu" [cdkMenuPanel]="share">
<button cdkMenuItem [cdkMenuTriggerFor]="chat">Chat</button>
</div>
</ng-template>

<ng-template cdkMenuPanel #chat="cdkMenuPanel">
<div cdkMenu id="chat_menu" [cdkMenuPanel]="chat">
<button cdkMenuItem>GVC</button>
</div>
</ng-template>
</div>
`,
})
class MultiMenuWithSubmenu {
@ViewChild(CdkMenuBar) menuBar: CdkMenuBar;

@ViewChildren(CdkMenuItemTrigger) triggers: QueryList<CdkMenuItemTrigger>;
@ViewChildren(CdkMenu) menus: QueryList<CdkMenu>;
}
59 changes: 47 additions & 12 deletions src/cdk-experimental/menu/menu-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,43 +40,63 @@ export class MenuStack {
private readonly _empty: Subject<FocusNext> = new Subject();

/** Observable which emits the MenuStackItem which has been requested to close. */
readonly close: Observable<MenuStackItem> = this._close;
readonly closed: Observable<MenuStackItem> = this._close.asObservable();

/**
* Observable which emits when the MenuStack is empty after popping off the last element. It
* emits a FocusNext event which specifies the action the closer has requested the listener
* perform.
*/
readonly empty: Observable<FocusNext> = this._empty;
readonly emptied: Observable<FocusNext> = this._empty.asObservable();

/** @param menu the MenuStackItem to put on the stack. */
push(menu: MenuStackItem) {
this._elements.push(menu);
}

/**
* Pop off the top most MenuStackItem and emit it on the close observable.
* @param focusNext the event to emit on the `empty` observable if the method call resulted in an
* empty stack. Does not emit if the stack was initially empty.
* Pop items off of the stack up to and including `lastItem` and emit each on the close
* observable. If the stack is empty or `lastItem` is not on the stack it does nothing.
* @param lastItem the last item to pop off the stack.
* @param focusNext the event to emit on the `empty` observable if the method call resulted in an
* empty stack. Does not emit if the stack was initially empty or if `lastItem` was not on the
* stack.
*/
closeLatest(focusNext?: FocusNext) {
const menuStackItem = this._elements.pop();
if (menuStackItem) {
this._close.next(menuStackItem);
if (this._elements.length === 0) {
close(lastItem: MenuStackItem, focusNext?: FocusNext) {
if (this._elements.indexOf(lastItem) >= 0) {
let poppedElement;
do {
poppedElement = this._elements.pop();
this._close.next(poppedElement);
} while (poppedElement !== lastItem);

if (this.isEmpty()) {
this._empty.next(focusNext);
}
}
}

/**
* Pop items off of the stack up to but excluding `lastItem` and emit each on the close
* observable. If the stack is empty or `lastItem` is not on the stack it does nothing.
* @param lastItem the element which should be left on the stack
*/
closeSubMenuOf(lastItem: MenuStackItem) {
if (this._elements.indexOf(lastItem) >= 0) {
while (this.peek() !== lastItem) {
this._close.next(this._elements.pop());
}
}
}

/**
* Pop off all MenuStackItems and emit each one on the `close` observable one by one.
* @param focusNext the event to emit on the `empty` observable once the stack is emptied. Does
* not emit if the stack was initially empty.
*/
closeAll(focusNext?: FocusNext) {
if (this._elements.length) {
while (this._elements.length) {
if (!this.isEmpty()) {
while (!this.isEmpty()) {
const menuStackItem = this._elements.pop();
if (menuStackItem) {
this._close.next(menuStackItem);
Expand All @@ -86,4 +106,19 @@ export class MenuStack {
this._empty.next(focusNext);
}
}

/** Return true if this stack is empty. */
isEmpty() {
return !this._elements.length;
}

/** Return the length of the stack. */
length() {
return this._elements.length;
}

/** Get the top most element on the stack. */
peek() {
return this._elements[this._elements.length - 1];
}
}
6 changes: 3 additions & 3 deletions src/cdk-experimental/menu/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI
case ESCAPE:
if (!hasModifierKey(event)) {
event.preventDefault();
this._menuStack.closeLatest(FocusNext.currentItem);
this._menuStack.close(this, FocusNext.currentItem);
}
break;

Expand Down Expand Up @@ -214,11 +214,11 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnI

/** Subscribe to the MenuStack close and empty observables. */
private _subscribeToMenuStack() {
this._menuStack.close
this._menuStack.closed
.pipe(takeUntil(this.closed))
.subscribe((item: MenuStackItem) => this._closeOpenMenu(item));

this._menuStack.empty
this._menuStack.emptied
.pipe(takeUntil(this.closed))
.subscribe((event: FocusNext) => this._toggleMenuFocus(event));
}
Expand Down