Skip to content

Commit 91f7bf7

Browse files
crisbetoandrewseguin
authored andcommitted
feat(menu): increase nested menu elevation based on depth (#5937)
* 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
1 parent f52c7f4 commit 91f7bf7

File tree

4 files changed

+153
-12
lines changed

4 files changed

+153
-12
lines changed

src/lib/menu/menu-directive.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,13 @@ export interface MdMenuDefaultOptions {
4747
export const MD_MENU_DEFAULT_OPTIONS =
4848
new InjectionToken<MdMenuDefaultOptions>('md-menu-default-options');
4949

50+
/**
51+
* Start elevation for the menu panel.
52+
* @docs-private
53+
*/
54+
const MD_MENU_BASE_ELEVATION = 2;
55+
56+
5057
@Component({
5158
moduleId: module.id,
5259
selector: 'md-menu, mat-menu',
@@ -64,6 +71,7 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
6471
private _keyManager: FocusKeyManager;
6572
private _xPosition: MenuPositionX = this._defaultOptions.xPosition;
6673
private _yPosition: MenuPositionY = this._defaultOptions.yPosition;
74+
private _previousElevation: string;
6775

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

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

8088
/** Layout direction of the menu. */
8189
direction: Direction;
@@ -162,12 +170,12 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
162170
this.close.emit('keydown');
163171
break;
164172
case LEFT_ARROW:
165-
if (this.isSubmenu && this.direction === 'ltr') {
173+
if (this.parentMenu && this.direction === 'ltr') {
166174
this.close.emit('keydown');
167175
}
168176
break;
169177
case RIGHT_ARROW:
170-
if (this.isSubmenu && this.direction === 'rtl') {
178+
if (this.parentMenu && this.direction === 'rtl') {
171179
this.close.emit('keydown');
172180
}
173181
break;
@@ -195,6 +203,25 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
195203
this._classList['mat-menu-below'] = posY === 'below';
196204
}
197205

206+
/**
207+
* Sets the menu panel elevation.
208+
* @param depth Number of parent menus that come before the menu.
209+
*/
210+
setElevation(depth: number): void {
211+
// The elevation starts at the base and increases by one for each level.
212+
const newElevation = `mat-elevation-z${MD_MENU_BASE_ELEVATION + depth}`;
213+
const customElevation = Object.keys(this._classList).find(c => c.startsWith('mat-elevation-z'));
214+
215+
if (!customElevation || customElevation === this._previousElevation) {
216+
if (this._previousElevation) {
217+
this._classList[this._previousElevation] = false;
218+
}
219+
220+
this._classList[newElevation] = true;
221+
this._previousElevation = newElevation;
222+
}
223+
}
224+
198225
/** Starts the enter animation. */
199226
_startAnimation() {
200227
this._panelAnimationState = 'enter-start';

src/lib/menu/menu-panel.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ export interface MdMenuPanel {
1616
overlapTrigger: boolean;
1717
templateRef: TemplateRef<any>;
1818
close: EventEmitter<void | 'click' | 'keydown'>;
19-
isSubmenu?: boolean;
19+
parentMenu?: MdMenuPanel | undefined;
2020
direction?: Direction;
2121
focusFirstItem: () => void;
2222
setPositionClasses: (x: MenuPositionX, y: MenuPositionY) => void;
23+
setElevation?(depth: number): void;
2324
}

src/lib/menu/menu-trigger.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,9 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
224224
* the menu was opened via the keyboard.
225225
*/
226226
private _initMenu(): void {
227-
this.menu.isSubmenu = this.triggersSubmenu();
227+
this.menu.parentMenu = this.triggersSubmenu() ? this._parentMenu : undefined;
228228
this.menu.direction = this.dir;
229+
this._setMenuElevation();
229230
this._setIsMenuOpen(true);
230231

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

240+
/** Updates the menu elevation based on the amount of parent menus that it has. */
241+
private _setMenuElevation(): void {
242+
if (this.menu.setElevation) {
243+
let depth = 0;
244+
let parentMenu = this.menu.parentMenu;
245+
246+
while (parentMenu) {
247+
depth++;
248+
parentMenu = parentMenu.parentMenu;
249+
}
250+
251+
this.menu.setElevation(depth);
252+
}
253+
}
254+
239255
/**
240256
* This method resets the menu when it's closed, most importantly restoring
241257
* focus to the menu trigger if the menu was opened via the keyboard.

src/lib/menu/menu.spec.ts

Lines changed: 103 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,8 @@ describe('MdMenu', () => {
4646
OverlapMenu,
4747
CustomMenuPanel,
4848
CustomMenu,
49-
NestedMenu
49+
NestedMenu,
50+
NestedMenuCustomElevation
5051
],
5152
providers: [
5253
{provide: OverlayContainer, useFactory: () => {
@@ -530,7 +531,7 @@ describe('MdMenu', () => {
530531
expect(instance.levelTwoTrigger.triggersSubmenu()).toBe(true);
531532
});
532533

533-
it('should set the `isSubmenu` flag on the menu instances', () => {
534+
it('should set the `parentMenu` on the sub-menu instances', () => {
534535
compileTestComponent();
535536
instance.rootTriggerEl.nativeElement.click();
536537
fixture.detectChanges();
@@ -541,9 +542,9 @@ describe('MdMenu', () => {
541542
instance.levelTwoTrigger.openMenu();
542543
fixture.detectChanges();
543544

544-
expect(instance.rootMenu.isSubmenu).toBe(false);
545-
expect(instance.levelOneMenu.isSubmenu).toBe(true);
546-
expect(instance.levelTwoMenu.isSubmenu).toBe(true);
545+
expect(instance.rootMenu.parentMenu).toBeFalsy();
546+
expect(instance.levelOneMenu.parentMenu).toBe(instance.rootMenu);
547+
expect(instance.levelTwoMenu.parentMenu).toBe(instance.levelOneMenu);
547548
});
548549

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

889+
it('should increase the sub-menu elevation based on its depth', () => {
890+
compileTestComponent();
891+
instance.rootTrigger.openMenu();
892+
fixture.detectChanges();
893+
894+
instance.levelOneTrigger.openMenu();
895+
fixture.detectChanges();
896+
897+
instance.levelTwoTrigger.openMenu();
898+
fixture.detectChanges();
899+
900+
const menus = overlay.querySelectorAll('.mat-menu-panel');
901+
902+
expect(menus[0].classList)
903+
.toContain('mat-elevation-z2', 'Expected root menu to have base elevation.');
904+
expect(menus[1].classList)
905+
.toContain('mat-elevation-z3', 'Expected first sub-menu to have base elevation + 1.');
906+
expect(menus[2].classList)
907+
.toContain('mat-elevation-z4', 'Expected second sub-menu to have base elevation + 2.');
908+
});
909+
910+
it('should update the elevation when the same menu is opened at a different depth', () => {
911+
compileTestComponent();
912+
instance.rootTrigger.openMenu();
913+
fixture.detectChanges();
914+
915+
instance.levelOneTrigger.openMenu();
916+
fixture.detectChanges();
917+
918+
instance.levelTwoTrigger.openMenu();
919+
fixture.detectChanges();
920+
921+
let lastMenu = overlay.querySelectorAll('.mat-menu-panel')[2];
922+
923+
expect(lastMenu.classList)
924+
.toContain('mat-elevation-z4', 'Expected menu to have the base elevation plus two.');
925+
926+
(overlay.querySelector('.cdk-overlay-backdrop')! as HTMLElement).click();
927+
fixture.detectChanges();
928+
929+
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(0, 'Expected no open menus');
930+
931+
instance.alternateTrigger.openMenu();
932+
fixture.detectChanges();
933+
934+
lastMenu = overlay.querySelector('.mat-menu-panel') as HTMLElement;
935+
936+
expect(lastMenu.classList)
937+
.not.toContain('mat-elevation-z4', 'Expected menu not to maintain old elevation.');
938+
expect(lastMenu.classList)
939+
.toContain('mat-elevation-z2', 'Expected menu to have the proper updated elevation.');
940+
});
941+
942+
it('should not increase the elevation if the user specified a custom one', () => {
943+
const elevationFixture = TestBed.createComponent(NestedMenuCustomElevation);
944+
945+
elevationFixture.detectChanges();
946+
elevationFixture.componentInstance.rootTrigger.openMenu();
947+
elevationFixture.detectChanges();
948+
949+
elevationFixture.componentInstance.levelOneTrigger.openMenu();
950+
elevationFixture.detectChanges();
951+
952+
const menuClasses = overlayContainerElement.querySelectorAll('.mat-menu-panel')[1].classList;
953+
954+
expect(menuClasses)
955+
.toContain('mat-elevation-z24', 'Expected user elevation to be maintained');
956+
expect(menuClasses)
957+
.not.toContain('mat-elevation-z3', 'Expected no stacked elevation.');
958+
});
959+
888960
});
889961

890962
});
@@ -976,7 +1048,7 @@ class CustomMenuPanel implements MdMenuPanel {
9761048
xPosition: MenuPositionX = 'after';
9771049
yPosition: MenuPositionY = 'below';
9781050
overlapTrigger = true;
979-
isSubmenu = false;
1051+
parentMenu: MdMenuPanel;
9801052

9811053
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
9821054
@Output() close = new EventEmitter<void | 'click' | 'keydown'>();
@@ -1004,6 +1076,10 @@ class CustomMenu {
10041076
#rootTrigger="mdMenuTrigger"
10051077
#rootTriggerEl>Toggle menu</button>
10061078
1079+
<button
1080+
[mdMenuTriggerFor]="levelTwo"
1081+
#alternateTrigger="mdMenuTrigger">Toggle alternate menu</button>
1082+
10071083
<md-menu #root="mdMenu">
10081084
<button md-menu-item
10091085
id="level-one-trigger"
@@ -1033,10 +1109,31 @@ class NestedMenu {
10331109
@ViewChild('root') rootMenu: MdMenu;
10341110
@ViewChild('rootTrigger') rootTrigger: MdMenuTrigger;
10351111
@ViewChild('rootTriggerEl') rootTriggerEl: ElementRef;
1112+
@ViewChild('alternateTrigger') alternateTrigger: MdMenuTrigger;
10361113

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

10401117
@ViewChild('levelTwo') levelTwoMenu: MdMenu;
10411118
@ViewChild('levelTwoTrigger') levelTwoTrigger: MdMenuTrigger;
10421119
}
1120+
1121+
@Component({
1122+
template: `
1123+
<button [mdMenuTriggerFor]="root" #rootTrigger="mdMenuTrigger">Toggle menu</button>
1124+
1125+
<md-menu #root="mdMenu">
1126+
<button md-menu-item
1127+
[mdMenuTriggerFor]="levelOne"
1128+
#levelOneTrigger="mdMenuTrigger">One</button>
1129+
</md-menu>
1130+
1131+
<md-menu #levelOne="mdMenu" class="mat-elevation-z24">
1132+
<button md-menu-item>Two</button>
1133+
</md-menu>
1134+
`
1135+
})
1136+
class NestedMenuCustomElevation {
1137+
@ViewChild('rootTrigger') rootTrigger: MdMenuTrigger;
1138+
@ViewChild('levelOneTrigger') levelOneTrigger: MdMenuTrigger;
1139+
}

0 commit comments

Comments
 (0)