feat(menu): add nested menu functionality (#5493)

This commit is contained in:
Kristiyan Kostadinov
2017-07-21 00:50:17 +03:00
committed by Kara
parent 8c1e80388b
commit 1e0c1fca6b
9 changed files with 760 additions and 140 deletions
+55 -1
View File
@@ -15,7 +15,61 @@
</md-menu>
</div>
<div class="menu-section">
<p> Clicking these will navigate:</p>
<p>Nested menu</p>
<md-toolbar>
<button md-icon-button [mdMenuTriggerFor]="animals">
<md-icon>more_vert</md-icon>
</button>
</md-toolbar>
<md-menu #animals="mdMenu">
<button md-menu-item [mdMenuTriggerFor]="vertebrates">Vertebrates</button>
<button md-menu-item [mdMenuTriggerFor]="invertebrates">Invertebrates</button>
</md-menu>
<md-menu #vertebrates="mdMenu">
<button md-menu-item [mdMenuTriggerFor]="fish">Fishes</button>
<button md-menu-item [mdMenuTriggerFor]="amphibians">Amphibians</button>
<button md-menu-item [mdMenuTriggerFor]="reptiles">Reptiles</button>
<button md-menu-item>Birds</button>
<button md-menu-item>Mammals</button>
</md-menu>
<md-menu #invertebrates="mdMenu">
<button md-menu-item>Insects</button>
<button md-menu-item>Molluscs</button>
<button md-menu-item>Crustaceans</button>
<button md-menu-item>Corals</button>
<button md-menu-item>Arachnids</button>
<button md-menu-item>Velvet worms</button>
<button md-menu-item>Horseshoe crabs</button>
</md-menu>
<md-menu #fish="mdMenu">
<button md-menu-item>Baikal oilfish</button>
<button md-menu-item>Bala shark</button>
<button md-menu-item>Ballan wrasse</button>
<button md-menu-item>Bamboo shark</button>
<button md-menu-item>Banded killifish</button>
</md-menu>
<md-menu #amphibians="mdMenu">
<button md-menu-item>Sonoran desert toad</button>
<button md-menu-item>Western toad</button>
<button md-menu-item>Arroyo toad</button>
<button md-menu-item>Yosemite toad</button>
</md-menu>
<md-menu #reptiles="mdMenu">
<button md-menu-item>Banded Day Gecko</button>
<button md-menu-item>Banded Gila Monster</button>
<button md-menu-item>Black Tree Monitor</button>
<button md-menu-item>Blue Spiny Lizard</button>
</md-menu>
</div>
<div class="menu-section">
<p>Clicking these will navigate:</p>
<md-toolbar>
<button md-icon-button [mdMenuTriggerFor]="anchorMenu" aria-label="Open anchor menu">
<md-icon>more_vert</md-icon>
+6 -1
View File
@@ -24,7 +24,12 @@
vertical-align: middle;
}
&:hover:not([disabled]), &:focus:not([disabled]) {
}
.mat-menu-item:hover,
.mat-menu-item:focus,
.mat-menu-item-highlighted {
&:not([disabled]) {
background: mat-color($background, 'hover');
}
}
+30 -14
View File
@@ -29,7 +29,10 @@ import {FocusKeyManager} from '../core/a11y/focus-key-manager';
import {MdMenuPanel} from './menu-panel';
import {Subscription} from 'rxjs/Subscription';
import {transformMenu, fadeInItems} from './menu-animations';
import {ESCAPE} from '../core/keyboard/keycodes';
import {ESCAPE, LEFT_ARROW, RIGHT_ARROW} from '../core/keyboard/keycodes';
import {merge} from 'rxjs/observable/merge';
import {Observable} from 'rxjs/Observable';
import {Direction} from '../core';
@Component({
@@ -59,6 +62,12 @@ 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;
/** Layout direction of the menu. */
direction: Direction;
/** Position of the menu in the X axis. */
@Input()
get xPosition() { return this._xPosition; }
@@ -109,13 +118,13 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
}
/** Event emitted when the menu is closed. */
@Output() close = new EventEmitter<void>();
@Output() close = new EventEmitter<void | 'click' | 'keydown'>();
constructor(private _elementRef: ElementRef) { }
ngAfterContentInit() {
this._keyManager = new FocusKeyManager(this.items).withWrap();
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this._emitCloseEvent());
this._tabSubscription = this._keyManager.tabOut.subscribe(() => this.close.emit('keydown'));
}
ngOnDestroy() {
@@ -123,16 +132,31 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
this._tabSubscription.unsubscribe();
}
this._emitCloseEvent();
this.close.emit();
this.close.complete();
}
/** Stream that emits whenever the hovered menu item changes. */
hover(): Observable<MdMenuItem> {
return merge(...this.items.map(item => item.hover));
}
/** Handle a keyboard event from the menu, delegating to the appropriate action. */
_handleKeydown(event: KeyboardEvent) {
switch (event.keyCode) {
case ESCAPE:
this._emitCloseEvent();
return;
this.close.emit('keydown');
break;
case LEFT_ARROW:
if (this.isSubmenu && this.direction === 'ltr') {
this.close.emit('keydown');
}
break;
case RIGHT_ARROW:
if (this.isSubmenu && this.direction === 'rtl') {
this.close.emit('keydown');
}
break;
default:
this._keyManager.onKeydown(event);
}
@@ -146,14 +170,6 @@ export class MdMenu implements AfterContentInit, MdMenuPanel, OnDestroy {
this._keyManager.setFirstItemActive();
}
/**
* This emits a close event to which the trigger is subscribed. When emitted, the
* trigger will close the menu.
*/
_emitCloseEvent(): void {
this.close.emit();
}
/**
* It's necessary to set position-based classes to ensure the menu panel animation
* folds out from the correct direction.
+22 -2
View File
@@ -6,9 +6,10 @@
* found in the LICENSE file at https://angular.io/license
*/
import {Component, ElementRef, ChangeDetectionStrategy} from '@angular/core';
import {Component, ElementRef, OnDestroy, ChangeDetectionStrategy} from '@angular/core';
import {Focusable} from '../core/a11y/focus-key-manager';
import {CanDisable, mixinDisabled} from '../core/common-behaviors/disabled';
import {Subject} from 'rxjs/Subject';
// Boilerplate for applying mixins to MdMenuItem.
/** @docs-private */
@@ -26,16 +27,23 @@ export const _MdMenuItemMixinBase = mixinDisabled(MdMenuItemBase);
host: {
'role': 'menuitem',
'class': 'mat-menu-item',
'[class.mat-menu-item-highlighted]': '_highlighted',
'[attr.tabindex]': '_getTabIndex()',
'[attr.aria-disabled]': 'disabled.toString()',
'[attr.disabled]': 'disabled || null',
'(click)': '_checkDisabled($event)',
'(mouseenter)': '_emitHoverEvent()',
},
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'menu-item.html',
exportAs: 'mdMenuItem',
})
export class MdMenuItem extends _MdMenuItemMixinBase implements Focusable, CanDisable {
export class MdMenuItem extends _MdMenuItemMixinBase implements Focusable, CanDisable, OnDestroy {
/** Stream that emits when the menu item is hovered. */
hover: Subject<MdMenuItem> = new Subject();
/** Whether the menu item is highlighted. */
_highlighted: boolean = false;
constructor(private _elementRef: ElementRef) {
super();
@@ -46,6 +54,10 @@ export class MdMenuItem extends _MdMenuItemMixinBase implements Focusable, CanDi
this._getHostElement().focus();
}
ngOnDestroy() {
this.hover.complete();
}
/** Used to set the `tabindex`. */
_getTabIndex(): string {
return this.disabled ? '-1' : '0';
@@ -63,5 +75,13 @@ export class MdMenuItem extends _MdMenuItemMixinBase implements Focusable, CanDi
event.stopPropagation();
}
}
/** Emits to the hover stream. */
_emitHoverEvent() {
if (!this.disabled) {
this.hover.next(this);
}
}
}
+4 -2
View File
@@ -8,14 +8,16 @@
import {EventEmitter, TemplateRef} from '@angular/core';
import {MenuPositionX, MenuPositionY} from './menu-positions';
import {Direction} from '../core';
export interface MdMenuPanel {
xPosition: MenuPositionX;
yPosition: MenuPositionY;
overlapTrigger: boolean;
templateRef: TemplateRef<any>;
close: EventEmitter<void>;
close: EventEmitter<void | 'click' | 'keydown'>;
isSubmenu?: boolean;
direction?: Direction;
focusFirstItem: () => void;
setPositionClasses: (x: MenuPositionX, y: MenuPositionY) => void;
_emitCloseEvent: () => void;
}
+174 -97
View File
@@ -7,40 +7,47 @@
*/
import {
AfterViewInit,
Directive,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Optional,
Output,
ViewContainerRef,
InjectionToken,
Inject,
AfterViewInit,
Directive,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Optional,
Output,
ViewContainerRef,
Self,
Inject,
InjectionToken,
} from '@angular/core';
import {MdMenuPanel} from './menu-panel';
import {throwMdMenuMissingError} from './menu-errors';
import {
isFakeMousedownFromScreenReader,
Directionality,
Direction,
Overlay,
OverlayState,
OverlayRef,
TemplatePortal,
ConnectedPositionStrategy,
HorizontalConnectionPos,
VerticalConnectionPos,
RepositionScrollStrategy,
// This import is only used to define a generic type. The current TypeScript version incorrectly
// considers such imports as unused (https://github.com/Microsoft/TypeScript/issues/14953)
// tslint:disable-next-line:no-unused-variable
ScrollStrategy,
isFakeMousedownFromScreenReader,
Directionality,
Direction,
Overlay,
OverlayState,
OverlayRef,
TemplatePortal,
ConnectedPositionStrategy,
HorizontalConnectionPos,
VerticalConnectionPos,
RIGHT_ARROW,
LEFT_ARROW,
RepositionScrollStrategy,
// This import is only used to define a generic type. The current TypeScript version incorrectly
// considers such imports as unused (https://github.com/Microsoft/TypeScript/issues/14953)
// tslint:disable-next-line:no-unused-variable
ScrollStrategy,
} from '../core';
import {Subscription} from 'rxjs/Subscription';
import {MenuPositionX, MenuPositionY} from './menu-positions';
import {MdMenu} from './menu-directive';
import {MdMenuItem} from './menu-item';
import {MdMenuPanel} from './menu-panel';
import {MenuPositionX, MenuPositionY} from './menu-positions';
import {throwMdMenuMissingError} from './menu-errors';
import {RxChain, filter} from '../core/rxjs/index';
import {of as observableOf} from 'rxjs/observable/of';
import {merge} from 'rxjs/observable/merge';
import {Subscription} from 'rxjs/Subscription';
/** Injection token that determines the scroll handling while the menu is open. */
export const MD_MENU_SCROLL_STRATEGY =
@@ -61,6 +68,9 @@ export const MD_MENU_SCROLL_STRATEGY_PROVIDER = {
// TODO(andrewseguin): Remove the kebab versions in favor of camelCased attribute selectors
/** Default top padding of the menu panel. */
export const MENU_PANEL_TOP_PADDING = 8;
/**
* This directive is intended to be used in conjunction with an md-menu tag. It is
* responsible for toggling the display of the provided menu instance.
@@ -71,7 +81,8 @@ export const MD_MENU_SCROLL_STRATEGY_PROVIDER = {
host: {
'aria-haspopup': 'true',
'(mousedown)': '_handleMousedown($event)',
'(click)': 'toggleMenu()',
'(keydown)': '_handleKeydown($event)',
'(click)': '_handleClick($event)',
},
exportAs: 'mdMenuTrigger'
})
@@ -79,10 +90,11 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
private _portal: TemplatePortal;
private _overlayRef: OverlayRef | null = null;
private _menuOpen: boolean = false;
private _backdropSubscription: Subscription;
private _closeSubscription: Subscription;
private _positionSubscription: Subscription;
private _hoverSubscription: Subscription;
// tracking input type is necessary so it's possible to only auto-focus
// Tracking input type is necessary so it's possible to only auto-focus
// the first item of the list when the menu is opened via the keyboard
private _openedByMouse: boolean = false;
@@ -110,20 +122,60 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
/** Event emitted when the associated menu is closed. */
@Output() onMenuClose = new EventEmitter<void>();
constructor(private _overlay: Overlay, private _element: ElementRef,
constructor(private _overlay: Overlay,
private _element: ElementRef,
private _viewContainerRef: ViewContainerRef,
@Inject(MD_MENU_SCROLL_STRATEGY) private _scrollStrategy,
@Optional() private _parentMenu: MdMenu,
@Optional() @Self() private _menuItemInstance: MdMenuItem,
@Optional() private _dir: Directionality) { }
ngAfterViewInit() {
this._checkMenu();
this.menu.close.subscribe(() => this.closeMenu());
this.menu.close.subscribe(reason => {
this.closeMenu();
// If a click closed the menu, we should close the entire chain of nested menus.
if (reason === 'click' && this._parentMenu) {
this._parentMenu.close.emit(reason);
}
});
if (this.triggersSubmenu()) {
// Subscribe to changes in the hovered item in order to toggle the panel.
this._hoverSubscription = filter
.call(this._parentMenu.hover(), active => active === this._menuItemInstance)
.subscribe(() => {
this._openedByMouse = true;
this.openMenu();
});
}
}
ngOnDestroy() { this.destroyMenu(); }
ngOnDestroy() {
if (this._overlayRef) {
this._overlayRef.dispose();
this._overlayRef = null;
}
this._cleanUpSubscriptions();
}
/** Whether the menu is open. */
get menuOpen(): boolean { return this._menuOpen; }
get menuOpen(): boolean {
return this._menuOpen;
}
/** The text direction of the containing app. */
get dir(): Direction {
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';
}
/** Whether the menu triggers a sub-menu or a top-level one. */
triggersSubmenu(): boolean {
return !!(this._menuItemInstance && this._parentMenu);
}
/** Toggles the menu between the open and closed states. */
toggleMenu(): void {
@@ -134,7 +186,7 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
openMenu(): void {
if (!this._menuOpen) {
this._createOverlay().attach(this._portal);
this._subscribeToBackdrop();
this._closeSubscription = this._menuClosingActions().subscribe(() => this.menu.close.emit());
this._initMenu();
if (this.menu instanceof MdMenu) {
@@ -145,9 +197,9 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
/** Closes the menu. */
closeMenu(): void {
if (this._overlayRef) {
if (this._overlayRef && this.menuOpen) {
this._overlayRef.detach();
this._backdropSubscription.unsubscribe();
this._closeSubscription.unsubscribe();
this._resetMenu();
if (this.menu instanceof MdMenu) {
@@ -156,45 +208,18 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
}
}
/** Removes the menu from the DOM. */
destroyMenu(): void {
if (this._overlayRef) {
this._overlayRef.dispose();
this._overlayRef = null;
this._cleanUpSubscriptions();
}
}
/** Focuses the menu trigger. */
focus() {
this._element.nativeElement.focus();
}
/** The text direction of the containing app. */
get dir(): Direction {
return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr';
}
/**
* This method ensures that the menu closes when the overlay backdrop is clicked.
* We do not use first() here because doing so would not catch clicks from within
* the menu, and it would fail to unsubscribe properly. Instead, we unsubscribe
* explicitly when the menu is closed or destroyed.
*/
private _subscribeToBackdrop(): void {
if (this._overlayRef) {
this._backdropSubscription = this._overlayRef.backdropClick().subscribe(() => {
this.menu._emitCloseEvent();
});
}
}
/**
* This method sets the menu state to open and focuses the first item if
* the menu was opened via the keyboard.
*/
private _initMenu(): void {
this.menu.isSubmenu = this.triggersSubmenu();
this.menu.direction = this.dir;
this._setIsMenuOpen(true);
// Should only set focus if opened via the keyboard, so keyboard users can
@@ -217,6 +242,7 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
if (!this._openedByMouse) {
this.focus();
}
this._openedByMouse = false;
}
@@ -224,11 +250,15 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
private _setIsMenuOpen(isOpen: boolean): void {
this._menuOpen = isOpen;
this._menuOpen ? this.onMenuOpen.emit() : this.onMenuClose.emit();
if (this.triggersSubmenu()) {
this._menuItemInstance._highlighted = isOpen;
}
}
/**
* This method checks that a valid instance of MdMenu has been passed into
* mdMenuTriggerFor. If not, an exception is thrown.
* This method checks that a valid instance of MdMenu has been passed into
* mdMenuTriggerFor. If not, an exception is thrown.
*/
private _checkMenu() {
if (!this.menu) {
@@ -237,8 +267,8 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
}
/**
* This method creates the overlay from the provided menu's template and saves its
* OverlayRef so that it can be attached to the DOM when openMenu is called.
* This method creates the overlay from the provided menu's template and saves its
* OverlayRef so that it can be attached to the DOM when openMenu is called.
*/
private _createOverlay(): OverlayRef {
if (!this._overlayRef) {
@@ -257,9 +287,8 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
*/
private _getOverlayConfig(): OverlayState {
const overlayState = new OverlayState();
overlayState.positionStrategy = this._getPosition()
.withDirection(this.dir);
overlayState.hasBackdrop = true;
overlayState.positionStrategy = this._getPosition();
overlayState.hasBackdrop = !this.triggersSubmenu();
overlayState.backdropClass = 'cdk-overlay-transparent-backdrop';
overlayState.direction = this.dir;
overlayState.scrollStrategy = this._scrollStrategy();
@@ -285,48 +314,96 @@ export class MdMenuTrigger implements AfterViewInit, OnDestroy {
* to the trigger.
* @returns ConnectedPositionStrategy
*/
private _getPosition(): ConnectedPositionStrategy {
const [posX, fallbackX]: HorizontalConnectionPos[] =
private _getPosition(): ConnectedPositionStrategy {
let [originX, originFallbackX]: HorizontalConnectionPos[] =
this.menu.xPosition === 'before' ? ['end', 'start'] : ['start', 'end'];
const [overlayY, fallbackOverlayY]: VerticalConnectionPos[] =
let [overlayY, overlayFallbackY]: VerticalConnectionPos[] =
this.menu.yPosition === 'above' ? ['bottom', 'top'] : ['top', 'bottom'];
let originY = overlayY;
let fallbackOriginY = fallbackOverlayY;
let [originY, originFallbackY] = [overlayY, overlayFallbackY];
let [overlayX, overlayFallbackX] = [originX, originFallbackX];
let offsetY = 0;
if (!this.menu.overlapTrigger) {
if (this.triggersSubmenu()) {
// When the menu is a sub-menu, it should always align itself
// to the edges of the trigger, instead of overlapping it.
overlayFallbackX = originX = this.menu.xPosition === 'before' ? 'start' : 'end';
originFallbackX = overlayX = originX === 'end' ? 'start' : 'end';
// TODO(crisbeto): this should be a function, once the overlay supports it.
// Right now it will be wrong for the fallback positions.
offsetY = overlayY === 'bottom' ? MENU_PANEL_TOP_PADDING : -MENU_PANEL_TOP_PADDING;
} else if (!this.menu.overlapTrigger) {
originY = overlayY === 'top' ? 'bottom' : 'top';
fallbackOriginY = fallbackOverlayY === 'top' ? 'bottom' : 'top';
originFallbackY = overlayFallbackY === 'top' ? 'bottom' : 'top';
}
return this._overlay.position()
.connectedTo(this._element,
{originX: posX, originY: originY}, {overlayX: posX, overlayY: overlayY})
.connectedTo(this._element, {originX, originY}, {overlayX, overlayY})
.withDirection(this.dir)
.withOffsetY(offsetY)
.withFallbackPosition(
{originX: fallbackX, originY: originY},
{overlayX: fallbackX, overlayY: overlayY})
{originX: originFallbackX, originY},
{overlayX: overlayFallbackX, overlayY})
.withFallbackPosition(
{originX: posX, originY: fallbackOriginY},
{overlayX: posX, overlayY: fallbackOverlayY})
{originX, originY: originFallbackY},
{overlayX, overlayY: overlayFallbackY})
.withFallbackPosition(
{originX: fallbackX, originY: fallbackOriginY},
{overlayX: fallbackX, overlayY: fallbackOverlayY});
{originX: originFallbackX, originY: originFallbackY},
{overlayX: overlayFallbackX, overlayY: overlayFallbackY});
}
/** Cleans up the active subscriptions. */
private _cleanUpSubscriptions(): void {
if (this._backdropSubscription) {
this._backdropSubscription.unsubscribe();
}
if (this._positionSubscription) {
this._positionSubscription.unsubscribe();
}
[
this._closeSubscription,
this._positionSubscription,
this._hoverSubscription
]
.filter(subscription => !!subscription)
.forEach(subscription => subscription.unsubscribe());
}
/** Returns a stream that emits whenever an action that should close the menu occurs. */
private _menuClosingActions() {
const backdrop = this._overlayRef!.backdropClick();
const parentClose = this._parentMenu ? this._parentMenu.close : observableOf(null);
const hover = this._parentMenu ? RxChain.from(this._parentMenu.hover())
.call(filter, active => active !== this._menuItemInstance)
.call(filter, () => this._menuOpen)
.result() : observableOf(null);
return merge(backdrop, parentClose, hover);
}
/** Handles mouse presses on the trigger. */
_handleMousedown(event: MouseEvent): void {
if (!isFakeMousedownFromScreenReader(event)) {
this._openedByMouse = true;
}
}
/** Handles key presses on the trigger. */
_handleKeydown(event: KeyboardEvent): void {
const keyCode = event.keyCode;
if (this.triggersSubmenu() && (
(keyCode === RIGHT_ARROW && this.dir === 'ltr') ||
(keyCode === LEFT_ARROW && this.dir === 'rtl'))) {
this.openMenu();
}
}
/** Handles click events on the trigger. */
_handleClick(event: MouseEvent): void {
if (this.triggersSubmenu()) {
// Stop event propagation to avoid closing the parent menu.
event.stopPropagation();
this.openMenu();
} else {
this.toggleMenu();
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
class="mat-menu-panel"
[ngClass]="_classList"
(keydown)="_handleKeydown($event)"
(click)="_emitCloseEvent()"
(click)="close.emit('click')"
[@transformMenu]="_panelAnimationState"
(@transformMenu.done)="_onAnimationDone($event)"
role="menu">
+33 -7
View File
@@ -1,8 +1,8 @@
`<md-menu>` is a floating panel containing list of options.
`<md-menu>` is a floating panel containing list of options.
<!-- example(menu-overview) -->
By itself, the `<md-menu>` element does not render anything. The menu is attached to and opened
By itself, the `<md-menu>` element does not render anything. The menu is attached to and opened
via application of the `mdMenuTriggerFor` directive:
```html
<md-menu #appMenu="mdMenu">
@@ -16,7 +16,7 @@ via application of the `mdMenuTriggerFor` directive:
```
### Toggling the menu programmatically
The menu exposes an API to open/close programmatically. Please note that in this case, an
The menu exposes an API to open/close programmatically. Please note that in this case, an
`mdMenuTriggerFor` directive is still necessary to attach the menu to a trigger element in the DOM.
```ts
@@ -52,9 +52,10 @@ Menus support displaying `md-icon` elements before the menu item text.
### Customizing menu position
By default, the menu will display below (y-axis), after (x-axis), and overlapping its trigger. The position can be changed
using the `xPosition` (`before | after`) and `yPosition` (`above | below`) attributes.
The menu can be be forced to not overlap the trigger using `[overlapTrigger]="false"` attribute.
By default, the menu will display below (y-axis), after (x-axis), and overlapping its trigger.
The position can be changed using the `xPosition` (`before | after`) and `yPosition`
(`above | below`) attributes. The menu can be be forced to not overlap the trigger using
`[overlapTrigger]="false"` attribute.
```html
<md-menu #appMenu="mdMenu" yPosition="above">
@@ -63,7 +64,30 @@ The menu can be be forced to not overlap the trigger using `[overlapTrigger]="fa
</md-menu>
<button md-icon-button [mdMenuTriggerFor]="appMenu">
<md-icon>more_vert</md-icon>
<md-icon>more_vert</md-icon>
</button>
```
### Nested menu
Material supports the ability for an `md-menu-item` to open a sub-menu. To do so, you have to define
your root menu and sub-menus, in addition to setting the `[mdMenuTriggerFor]` on the `md-menu-item`
that should trigger the sub-menu:
```html
<md-menu #rootMenu="mdMenu">
<button md-menu-item [mdMenuTriggerFor]="subMenu">Power</button>
<button md-menu-item>System settings</button>
</md-menu>
<md-menu #subMenu="mdMenu">
<button md-menu-item>Shut down</button>
<button md-menu-item>Restart</button>
<button md-menu-item>Hibernate</button>
</md-menu>
<button md-icon-button [mdMenuTriggerFor]="rootMenu">
<md-icon>more_vert</md-icon>
</button>
```
@@ -71,4 +95,6 @@ The menu can be be forced to not overlap the trigger using `[overlapTrigger]="fa
### Keyboard interaction
- <kbd>DOWN_ARROW</kbd>: Focuses the next menu item
- <kbd>UP_ARROW</kbd>: Focuses previous menu item
- <kbd>RIGHT_ARROW</kbd>: Opens the menu item's sub-menu
- <kbd>LEFT_ARROW</kbd>: Closes the current menu, if it is a sub-menu.
- <kbd>ENTER</kbd>: Activates the focused menu item
+435 -15
View File
@@ -10,19 +10,25 @@ import {
TemplateRef,
ViewChild
} from '@angular/core';
import {
OverlayContainer,
Directionality,
Direction,
ESCAPE,
RIGHT_ARROW,
LEFT_ARROW,
} from '../core';
import {
MdMenuModule,
MdMenuTrigger,
MdMenuPanel,
MenuPositionX,
MenuPositionY,
MdMenu
MdMenu,
} from './index';
import {OverlayContainer} from '../core/overlay/overlay-container';
import {Directionality, Direction} from '../core/bidi/index';
import {MENU_PANEL_TOP_PADDING} from './menu-trigger';
import {extendObject} from '../core/util/object-extend';
import {ESCAPE} from '../core/keyboard/keycodes';
import {dispatchKeyboardEvent} from '@angular/cdk/testing';
import {dispatchKeyboardEvent, dispatchMouseEvent} from '@angular/cdk/testing';
describe('MdMenu', () => {
@@ -33,7 +39,14 @@ describe('MdMenu', () => {
dir = 'ltr';
TestBed.configureTestingModule({
imports: [MdMenuModule, NoopAnimationsModule],
declarations: [SimpleMenu, PositionedMenu, OverlapMenu, CustomMenuPanel, CustomMenu],
declarations: [
SimpleMenu,
PositionedMenu,
OverlapMenu,
CustomMenuPanel,
CustomMenu,
NestedMenu
],
providers: [
{provide: OverlayContainer, useFactory: () => {
overlayContainerElement = document.createElement('div');
@@ -147,6 +160,11 @@ describe('MdMenu', () => {
expect(role).toBe('menu', 'Expected panel to have the "menu" role.');
});
it('should not throw an error on destroy', () => {
const fixture = TestBed.createComponent(SimpleMenu);
expect(fixture.destroy.bind(fixture)).not.toThrow();
});
describe('positions', () => {
let fixture: ComponentFixture<PositionedMenu>;
let panel: HTMLElement;
@@ -491,12 +509,370 @@ describe('MdMenu', () => {
});
});
describe('destroy', () => {
it('does not throw an error on destroy', () => {
const fixture = TestBed.createComponent(SimpleMenu);
expect(fixture.destroy.bind(fixture)).not.toThrow();
describe('nested menu', () => {
let fixture: ComponentFixture<NestedMenu>;
let instance: NestedMenu;
let overlay: HTMLElement;
let compileTestComponent = () => {
fixture = TestBed.createComponent(NestedMenu);
fixture.detectChanges();
instance = fixture.componentInstance;
overlay = overlayContainerElement;
};
it('should set the `triggersSubmenu` flags on the triggers', () => {
compileTestComponent();
expect(instance.rootTrigger.triggersSubmenu()).toBe(false);
expect(instance.levelOneTrigger.triggersSubmenu()).toBe(true);
expect(instance.levelTwoTrigger.triggersSubmenu()).toBe(true);
});
it('should set the `isSubmenu` flag on the menu instances', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();
expect(instance.rootMenu.isSubmenu).toBe(false);
expect(instance.levelOneMenu.isSubmenu).toBe(true);
expect(instance.levelTwoMenu.isSubmenu).toBe(true);
});
it('should pass the layout direction the nested menus', () => {
dir = 'rtl';
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();
expect(instance.rootMenu.direction).toBe('rtl');
expect(instance.levelOneMenu.direction).toBe('rtl');
expect(instance.levelTwoMenu.direction).toBe('rtl');
});
it('should emit an event when the hover state of the menu items changes', () => {
compileTestComponent();
instance.rootTrigger.openMenu();
fixture.detectChanges();
const spy = jasmine.createSpy('hover spy');
const subscription = instance.rootMenu.hover().subscribe(spy);
const menuItems = overlay.querySelectorAll('[md-menu-item]');
dispatchMouseEvent(menuItems[0], 'mouseenter');
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(1);
dispatchMouseEvent(menuItems[1], 'mouseenter');
fixture.detectChanges();
expect(spy).toHaveBeenCalledTimes(2);
subscription.unsubscribe();
});
it('should toggle a nested menu when its trigger is hovered', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
const items = Array.from(overlay.querySelectorAll('.mat-menu-panel [md-menu-item]'));
const levelOneTrigger = overlay.querySelector('#level-one-trigger')!;
dispatchMouseEvent(levelOneTrigger, 'mouseenter');
fixture.detectChanges();
expect(levelOneTrigger.classList)
.toContain('mat-menu-item-highlighted', 'Expected the trigger to be highlighted');
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus');
dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
expect(levelOneTrigger.classList)
.not.toContain('mat-menu-item-highlighted', 'Expected the trigger to not be highlighted');
});
it('should close all the open sub-menus when the hover state is changed at the root', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
const items = Array.from(overlay.querySelectorAll('.mat-menu-panel [md-menu-item]'));
const levelOneTrigger = overlay.querySelector('#level-one-trigger')!;
dispatchMouseEvent(levelOneTrigger, 'mouseenter');
fixture.detectChanges();
const levelTwoTrigger = overlay.querySelector('#level-two-trigger')! as HTMLElement;
dispatchMouseEvent(levelTwoTrigger, 'mouseenter');
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(3, 'Expected three open menus');
dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
});
it('should open a nested menu when its trigger is clicked', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
levelOneTrigger.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus');
levelOneTrigger.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(2, 'Expected repeat clicks not to close the menu.');
});
it('should open and close a nested menu with arrow keys in ltr', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
dispatchKeyboardEvent(levelOneTrigger, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
const panels = overlay.querySelectorAll('.mat-menu-panel');
expect(panels.length).toBe(2, 'Expected two open menus');
dispatchKeyboardEvent(panels[1], 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1);
});
it('should open and close a nested menu with the arrow keys in rtl', () => {
dir = 'rtl';
fixture.destroy();
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
dispatchKeyboardEvent(levelOneTrigger, 'keydown', LEFT_ARROW);
fixture.detectChanges();
const panels = overlay.querySelectorAll('.mat-menu-panel');
expect(panels.length).toBe(2, 'Expected two open menus');
dispatchKeyboardEvent(panels[1], 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1);
});
it('should not do anything with the arrow keys for a top-level menu', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
const menu = overlay.querySelector('.mat-menu-panel')!;
dispatchKeyboardEvent(menu, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(1, 'Expected one menu to remain open');
dispatchKeyboardEvent(menu, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(1, 'Expected one menu to remain open');
});
it('should close all of the menus when the backdrop is clicked', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(3, 'Expected three open menus');
expect(overlay.querySelectorAll('.cdk-overlay-backdrop').length)
.toBe(1, 'Expected one backdrop element');
expect(overlay.querySelectorAll('.mat-menu-panel, .cdk-overlay-backdrop')[0].classList)
.toContain('cdk-overlay-backdrop', 'Expected backdrop to be beneath all of the menus');
(overlay.querySelector('.cdk-overlay-backdrop')! as HTMLElement).click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(0, 'Expected no open menus');
});
it('should shift focus between the sub-menus', () => {
compileTestComponent();
instance.rootTrigger.openMenu();
fixture.detectChanges();
expect(overlay.querySelector('.mat-menu-panel')!.contains(document.activeElement))
.toBe(true, 'Expected focus to be inside the root menu');
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel')[1].contains(document.activeElement))
.toBe(true, 'Expected focus to be inside the first nested menu');
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel')[2].contains(document.activeElement))
.toBe(true, 'Expected focus to be inside the second nested menu');
instance.levelTwoTrigger.closeMenu();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel')[1].contains(document.activeElement))
.toBe(true, 'Expected focus to be back inside the first nested menu');
instance.levelOneTrigger.closeMenu();
fixture.detectChanges();
expect(overlay.querySelector('.mat-menu-panel')!.contains(document.activeElement))
.toBe(true, 'Expected focus to be back inside the root menu');
});
it('should not shift focus to the sub-menu when it was opened by hover', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
dispatchMouseEvent(levelOneTrigger, 'mouseenter');
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel')[1].contains(document.activeElement))
.toBe(false, 'Expected focus to not be inside the nested menu');
});
it('should position the sub-menu to the right edge of the trigger in ltr', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.style.position = 'fixed';
instance.rootTriggerEl.nativeElement.style.left = '50px';
instance.rootTriggerEl.nativeElement.style.top = '50px';
instance.rootTrigger.openMenu();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
const triggerRect = overlay.querySelector('#level-one-trigger')!.getBoundingClientRect();
const panelRect = overlay.querySelectorAll('.mat-menu-panel')[1].getBoundingClientRect();
expect(Math.round(triggerRect.right)).toBe(Math.round(panelRect.left));
expect(Math.round(triggerRect.top)).toBe(Math.round(panelRect.top) + MENU_PANEL_TOP_PADDING);
});
it('should fall back to aligning to the left edge of the trigger in ltr', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.style.position = 'fixed';
instance.rootTriggerEl.nativeElement.style.right = '10px';
instance.rootTriggerEl.nativeElement.style.top = '50%';
instance.rootTrigger.openMenu();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
const triggerRect = overlay.querySelector('#level-one-trigger')!.getBoundingClientRect();
const panelRect = overlay.querySelectorAll('.mat-menu-panel')[1].getBoundingClientRect();
expect(Math.round(triggerRect.left)).toBe(Math.round(panelRect.right));
expect(Math.round(triggerRect.top)).toBe(Math.round(panelRect.top) + MENU_PANEL_TOP_PADDING);
});
it('should position the sub-menu to the left edge of the trigger in rtl', () => {
dir = 'rtl';
compileTestComponent();
instance.rootTriggerEl.nativeElement.style.position = 'fixed';
instance.rootTriggerEl.nativeElement.style.left = '50%';
instance.rootTriggerEl.nativeElement.style.top = '50%';
instance.rootTrigger.openMenu();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
const triggerRect = overlay.querySelector('#level-one-trigger')!.getBoundingClientRect();
const panelRect = overlay.querySelectorAll('.mat-menu-panel')[1].getBoundingClientRect();
expect(Math.round(triggerRect.left)).toBe(Math.round(panelRect.right));
expect(Math.round(triggerRect.top)).toBe(Math.round(panelRect.top) + MENU_PANEL_TOP_PADDING);
});
it('should fall back to aligning to the right edge of the trigger in rtl', () => {
dir = 'rtl';
compileTestComponent();
instance.rootTriggerEl.nativeElement.style.position = 'fixed';
instance.rootTriggerEl.nativeElement.style.left = '10px';
instance.rootTriggerEl.nativeElement.style.top = '50%';
instance.rootTrigger.openMenu();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
const triggerRect = overlay.querySelector('#level-one-trigger')!.getBoundingClientRect();
const panelRect = overlay.querySelectorAll('.mat-menu-panel')[1].getBoundingClientRect();
expect(Math.round(triggerRect.right)).toBe(Math.round(panelRect.left));
expect(Math.round(triggerRect.top)).toBe(Math.round(panelRect.top) + MENU_PANEL_TOP_PADDING);
});
it('should close all of the menus when an item is clicked', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();
const menus = overlay.querySelectorAll('.mat-menu-panel');
expect(menus.length).toBe(3, 'Expected three open menus');
(menus[2].querySelector('.mat-menu-item')! as HTMLElement).click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(0, 'Expected no open menus');
});
});
});
@Component({
@@ -559,17 +935,16 @@ class OverlapMenu implements TestableMenu {
exportAs: 'mdCustomMenu'
})
class CustomMenuPanel implements MdMenuPanel {
direction: Direction;
xPosition: MenuPositionX = 'after';
yPosition: MenuPositionY = 'below';
overlapTrigger: true;
overlapTrigger = true;
isSubmenu = false;
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
@Output() close = new EventEmitter<void>();
@Output() close = new EventEmitter<void | 'click' | 'keydown'>();
focusFirstItem = () => {};
setPositionClasses = () => {};
_emitCloseEvent() {
this.close.emit();
}
}
@Component({
@@ -583,3 +958,48 @@ class CustomMenuPanel implements MdMenuPanel {
class CustomMenu {
@ViewChild(MdMenuTrigger) trigger: MdMenuTrigger;
}
@Component({
template: `
<button
[mdMenuTriggerFor]="root"
#rootTrigger="mdMenuTrigger"
#rootTriggerEl>Toggle menu</button>
<md-menu #root="mdMenu">
<button md-menu-item
id="level-one-trigger"
[mdMenuTriggerFor]="levelOne"
#levelOneTrigger="mdMenuTrigger">One</button>
<button md-menu-item>Two</button>
<button md-menu-item>Three</button>
</md-menu>
<md-menu #levelOne="mdMenu">
<button md-menu-item>Four</button>
<button md-menu-item
id="level-two-trigger"
[mdMenuTriggerFor]="levelTwo"
#levelTwoTrigger="mdMenuTrigger">Five</button>
<button md-menu-item>Six</button>
</md-menu>
<md-menu #levelTwo="mdMenu">
<button md-menu-item>Seven</button>
<button md-menu-item>Eight</button>
<button md-menu-item>Nine</button>
</md-menu>
`
})
class NestedMenu {
@ViewChild('root') rootMenu: MdMenu;
@ViewChild('rootTrigger') rootTrigger: MdMenuTrigger;
@ViewChild('rootTriggerEl') rootTriggerEl: ElementRef;
@ViewChild('levelOne') levelOneMenu: MdMenu;
@ViewChild('levelOneTrigger') levelOneTrigger: MdMenuTrigger;
@ViewChild('levelTwo') levelTwoMenu: MdMenu;
@ViewChild('levelTwoTrigger') levelTwoTrigger: MdMenuTrigger;
}