Skip to content

Commit

Permalink
feat(menu): increase nested menu elevation based on depth (#5937)
Browse files Browse the repository at this point in the history
* feat(menu): increase nested menu elevation based on depth

Increases the sub-menu elevation, based on its depth. [Spec for reference](https://material.io/guidelines/material-design/elevation-shadows.html)

* refactor: address feedback
  • Loading branch information
crisbeto authored and andrewseguin committed Jul 28, 2017
1 parent f52c7f4 commit 91f7bf7
Show file tree
Hide file tree
Showing 4 changed files with 153 additions and 12 deletions.
35 changes: 31 additions & 4 deletions src/lib/menu/menu-directive.ts
Expand Up @@ -47,6 +47,13 @@ export interface MdMenuDefaultOptions {
export const MD_MENU_DEFAULT_OPTIONS =
new InjectionToken<MdMenuDefaultOptions>('md-menu-default-options');

/**
* Start elevation for the menu panel.
* @docs-private
*/
const MD_MENU_BASE_ELEVATION = 2;


@Component({
moduleId: module.id,
selector: 'md-menu, mat-menu',
Expand All @@ -64,6 +71,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
private _keyManager: FocusKeyManager;
private _xPosition: MenuPositionX = this._defaultOptions.xPosition;
private _yPosition: MenuPositionY = this._defaultOptions.yPosition;
private _previousElevation: string;

/** Subscription to tab events on the menu panel */
private _tabSubscription: Subscription;
Expand All @@ -74,8 +82,8 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
/** Current state of the panel animation. */
_panelAnimationState: 'void' | 'enter-start' | 'enter' = 'void';

/** Whether the menu is a sub-menu or a top-level menu. */
isSubmenu: boolean = false;
/** Parent menu of the current menu panel. */
parentMenu: MdMenuPanel | undefined;

/** Layout direction of the menu. */
direction: Direction;
Expand Down Expand Up @@ -162,12 +170,12 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
this.close.emit('keydown');
break;
case LEFT_ARROW:
if (this.isSubmenu && this.direction === 'ltr') {
if (this.parentMenu && this.direction === 'ltr') {
this.close.emit('keydown');
}
break;
case RIGHT_ARROW:
if (this.isSubmenu && this.direction === 'rtl') {
if (this.parentMenu && this.direction === 'rtl') {
this.close.emit('keydown');
}
break;
Expand Down Expand Up @@ -195,6 +203,25 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
this._classList['mat-menu-below'] = posY === 'below';
}

/**
* Sets the menu panel elevation.
* @param depth Number of parent menus that come before the menu.
*/
setElevation(depth: number): void {
// The elevation starts at the base and increases by one for each level.
const newElevation = `mat-elevation-z${MD_MENU_BASE_ELEVATION + depth}`;
const customElevation = Object.keys(this._classList).find(c => c.startsWith('mat-elevation-z'));

if (!customElevation || customElevation === this._previousElevation) {
if (this._previousElevation) {
this._classList[this._previousElevation] = false;
}

this._classList[newElevation] = true;
this._previousElevation = newElevation;
}
}

/** Starts the enter animation. */
_startAnimation() {
this._panelAnimationState = 'enter-start';
Expand Down
3 changes: 2 additions & 1 deletion src/lib/menu/menu-panel.ts
Expand Up @@ -16,8 +16,9 @@ export interface MdMenuPanel {
overlapTrigger: boolean;
templateRef: TemplateRef<any>;
close: EventEmitter<void | 'click' | 'keydown'>;
isSubmenu?: boolean;
parentMenu?: MdMenuPanel | undefined;
direction?: Direction;
focusFirstItem: () => void;
setPositionClasses: (x: MenuPositionX, y: MenuPositionY) => void;
setElevation?(depth: number): void;
}
18 changes: 17 additions & 1 deletion src/lib/menu/menu-trigger.ts
Expand Up @@ -224,8 +224,9 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
* the menu was opened via the keyboard.
*/
private _initMenu(): void {
this.menu.isSubmenu = this.triggersSubmenu();
this.menu.parentMenu = this.triggersSubmenu() ? this._parentMenu : undefined;
this.menu.direction = this.dir;
this._setMenuElevation();
this._setIsMenuOpen(true);

// Should only set focus if opened via the keyboard, so keyboard users can
Expand All @@ -236,6 +237,21 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
}
}

/** Updates the menu elevation based on the amount of parent menus that it has. */
private _setMenuElevation(): void {
if (this.menu.setElevation) {
let depth = 0;
let parentMenu = this.menu.parentMenu;

while (parentMenu) {
depth++;
parentMenu = parentMenu.parentMenu;
}

this.menu.setElevation(depth);
}
}

/**
* This method resets the menu when it's closed, most importantly restoring
* focus to the menu trigger if the menu was opened via the keyboard.
Expand Down
109 changes: 103 additions & 6 deletions src/lib/menu/menu.spec.ts
Expand Up @@ -46,7 +46,8 @@ describe('MdMenu', () => {
OverlapMenu,
CustomMenuPanel,
CustomMenu,
NestedMenu
NestedMenu,
NestedMenuCustomElevation
],
providers: [
{provide: OverlayContainer, useFactory: () => {
Expand Down Expand Up @@ -530,7 +531,7 @@ describe('MdMenu', () => {
expect(instance.levelTwoTrigger.triggersSubmenu()).toBe(true);
});

it('should set the `isSubmenu` flag on the menu instances', () => {
it('should set the `parentMenu` on the sub-menu instances', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
Expand All @@ -541,9 +542,9 @@ describe('MdMenu', () => {
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();

expect(instance.rootMenu.isSubmenu).toBe(false);
expect(instance.levelOneMenu.isSubmenu).toBe(true);
expect(instance.levelTwoMenu.isSubmenu).toBe(true);
expect(instance.rootMenu.parentMenu).toBeFalsy();
expect(instance.levelOneMenu.parentMenu).toBe(instance.rootMenu);
expect(instance.levelTwoMenu.parentMenu).toBe(instance.levelOneMenu);
});

it('should pass the layout direction the nested menus', () => {
Expand Down Expand Up @@ -885,6 +886,77 @@ describe('MdMenu', () => {
expect(menuItems[1].classList).not.toContain('mat-menu-item-submenu-trigger');
});

it('should increase the sub-menu elevation based on its depth', () => {
compileTestComponent();
instance.rootTrigger.openMenu();
fixture.detectChanges();

instance.levelOneTrigger.openMenu();
fixture.detectChanges();

instance.levelTwoTrigger.openMenu();
fixture.detectChanges();

const menus = overlay.querySelectorAll('.mat-menu-panel');

expect(menus[0].classList)
.toContain('mat-elevation-z2', 'Expected root menu to have base elevation.');
expect(menus[1].classList)
.toContain('mat-elevation-z3', 'Expected first sub-menu to have base elevation + 1.');
expect(menus[2].classList)
.toContain('mat-elevation-z4', 'Expected second sub-menu to have base elevation + 2.');
});

it('should update the elevation when the same menu is opened at a different depth', () => {
compileTestComponent();
instance.rootTrigger.openMenu();
fixture.detectChanges();

instance.levelOneTrigger.openMenu();
fixture.detectChanges();

instance.levelTwoTrigger.openMenu();
fixture.detectChanges();

let lastMenu = overlay.querySelectorAll('.mat-menu-panel')[2];

expect(lastMenu.classList)
.toContain('mat-elevation-z4', 'Expected menu to have the base elevation plus two.');

(overlay.querySelector('.cdk-overlay-backdrop')! as HTMLElement).click();
fixture.detectChanges();

expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(0, 'Expected no open menus');

instance.alternateTrigger.openMenu();
fixture.detectChanges();

lastMenu = overlay.querySelector('.mat-menu-panel') as HTMLElement;

expect(lastMenu.classList)
.not.toContain('mat-elevation-z4', 'Expected menu not to maintain old elevation.');
expect(lastMenu.classList)
.toContain('mat-elevation-z2', 'Expected menu to have the proper updated elevation.');
});

it('should not increase the elevation if the user specified a custom one', () => {
const elevationFixture = TestBed.createComponent(NestedMenuCustomElevation);

elevationFixture.detectChanges();
elevationFixture.componentInstance.rootTrigger.openMenu();
elevationFixture.detectChanges();

elevationFixture.componentInstance.levelOneTrigger.openMenu();
elevationFixture.detectChanges();

const menuClasses = overlayContainerElement.querySelectorAll('.mat-menu-panel')[1].classList;

expect(menuClasses)
.toContain('mat-elevation-z24', 'Expected user elevation to be maintained');
expect(menuClasses)
.not.toContain('mat-elevation-z3', 'Expected no stacked elevation.');
});

});

});
Expand Down Expand Up @@ -976,7 +1048,7 @@ class CustomMenuPanel implements MdMenuPanel {
xPosition: MenuPositionX = 'after';
yPosition: MenuPositionY = 'below';
overlapTrigger = true;
isSubmenu = false;
parentMenu: MdMenuPanel;

@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
@Output() close = new EventEmitter<void | 'click' | 'keydown'>();
Expand Down Expand Up @@ -1004,6 +1076,10 @@ class CustomMenu {
#rootTrigger="mdMenuTrigger"
#rootTriggerEl>Toggle menu</button>
<button
[mdMenuTriggerFor]="levelTwo"
#alternateTrigger="mdMenuTrigger">Toggle alternate menu</button>
<md-menu #root="mdMenu">
<button md-menu-item
id="level-one-trigger"
Expand Down Expand Up @@ -1033,10 +1109,31 @@ class NestedMenu {
@ViewChild('root') rootMenu: MdMenu;
@ViewChild('rootTrigger') rootTrigger: MdMenuTrigger;
@ViewChild('rootTriggerEl') rootTriggerEl: ElementRef;
@ViewChild('alternateTrigger') alternateTrigger: MdMenuTrigger;

@ViewChild('levelOne') levelOneMenu: MdMenu;
@ViewChild('levelOneTrigger') levelOneTrigger: MdMenuTrigger;

@ViewChild('levelTwo') levelTwoMenu: MdMenu;
@ViewChild('levelTwoTrigger') levelTwoTrigger: MdMenuTrigger;
}

@Component({
template: `
<button [mdMenuTriggerFor]="root" #rootTrigger="mdMenuTrigger">Toggle menu</button>
<md-menu #root="mdMenu">
<button md-menu-item
[mdMenuTriggerFor]="levelOne"
#levelOneTrigger="mdMenuTrigger">One</button>
</md-menu>
<md-menu #levelOne="mdMenu" class="mat-elevation-z24">
<button md-menu-item>Two</button>
</md-menu>
`
})
class NestedMenuCustomElevation {
@ViewChild('rootTrigger') rootTrigger: MdMenuTrigger;
@ViewChild('levelOneTrigger') levelOneTrigger: MdMenuTrigger;
}

0 comments on commit 91f7bf7

Please sign in to comment.