mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(menu): add nested menu functionality (#5493)
This commit is contained in:
committed by
Kara
parent
8c1e80388b
commit
1e0c1fca6b
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user