ipsos-components
Version:
Material Design components for Angular
448 lines (382 loc) • 14.9 kB
text/typescript
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {isFakeMousedownFromScreenReader} from '@angular/cdk/a11y';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
import {
ConnectedPositionStrategy,
HorizontalConnectionPos,
Overlay,
OverlayRef,
OverlayConfig,
RepositionScrollStrategy,
ScrollStrategy,
VerticalConnectionPos,
} from '@angular/cdk/overlay';
import {TemplatePortal} from '@angular/cdk/portal';
import {filter} from 'rxjs/operators/filter';
import {
AfterContentInit,
Directive,
ElementRef,
EventEmitter,
Inject,
InjectionToken,
Input,
OnDestroy,
Optional,
Output,
Self,
ViewContainerRef,
} from '@angular/core';
import {merge} from 'rxjs/observable/merge';
import {of as observableOf} from 'rxjs/observable/of';
import {Subscription} from 'rxjs/Subscription';
import {MatMenu} from './menu-directive';
import {throwMatMenuMissingError} from './menu-errors';
import {MatMenuItem} from './menu-item';
import {MatMenuPanel} from './menu-panel';
import {MenuPositionX, MenuPositionY} from './menu-positions';
/** Injection token that determines the scroll handling while the menu is open. */
export const MAT_MENU_SCROLL_STRATEGY =
new InjectionToken<() => ScrollStrategy>('mat-menu-scroll-strategy');
/** @docs-private */
export function MAT_MENU_SCROLL_STRATEGY_PROVIDER_FACTORY(overlay: Overlay):
() => RepositionScrollStrategy {
return () => overlay.scrollStrategies.reposition();
}
/** @docs-private */
export const MAT_MENU_SCROLL_STRATEGY_PROVIDER = {
provide: MAT_MENU_SCROLL_STRATEGY,
deps: [Overlay],
useFactory: MAT_MENU_SCROLL_STRATEGY_PROVIDER_FACTORY,
};
// 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 mat-menu tag. It is
* responsible for toggling the display of the provided menu instance.
*/
export class MatMenuTrigger implements AfterContentInit, OnDestroy {
private _portal: TemplatePortal<any>;
private _overlayRef: OverlayRef | null = null;
private _menuOpen: boolean = false;
private _closeSubscription = Subscription.EMPTY;
private _positionSubscription = Subscription.EMPTY;
private _hoverSubscription = Subscription.EMPTY;
// 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;
/** @deprecated */
get _deprecatedMatMenuTriggerFor(): MatMenuPanel {
return this.menu;
}
set _deprecatedMatMenuTriggerFor(v: MatMenuPanel) {
this.menu = v;
}
/** References the menu instance that the trigger is associated with. */
menu: MatMenuPanel;
/** Event emitted when the associated menu is opened. */
menuOpened = new EventEmitter<void>();
/**
* Event emitted when the associated menu is opened.
* @deprecated Switch to `menuOpened` instead
*/
onMenuOpen = this.menuOpened;
/** Event emitted when the associated menu is closed. */
menuClosed = new EventEmitter<void>();
/**
* Event emitted when the associated menu is closed.
* @deprecated Switch to `menuClosed` instead
*/
onMenuClose = this.menuClosed;
constructor(private _overlay: Overlay,
private _element: ElementRef,
private _viewContainerRef: ViewContainerRef,
private _scrollStrategy,
private _parentMenu: MatMenu,
private _menuItemInstance: MatMenuItem,
private _dir: Directionality) {
if (_menuItemInstance) {
_menuItemInstance._triggersSubmenu = this.triggersSubmenu();
}
}
ngAfterContentInit() {
this._checkMenu();
this.menu.close.subscribe(reason => {
this._destroyMenu();
// If a click closed the menu, we should close the entire chain of nested menus.
if (reason === 'click' && this._parentMenu) {
this._parentMenu.closed.emit(reason);
}
});
if (this.triggersSubmenu()) {
// Subscribe to changes in the hovered item in order to toggle the panel.
this._hoverSubscription = this._parentMenu._hovered()
.pipe(filter(active => active === this._menuItemInstance))
.subscribe(() => {
this._openedByMouse = true;
this.openMenu();
});
}
}
ngOnDestroy() {
if (this._overlayRef) {
this._overlayRef.dispose();
this._overlayRef = null;
}
this._cleanUpSubscriptions();
}
/** Whether the menu is open. */
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 {
return this._menuOpen ? this.closeMenu() : this.openMenu();
}
/** Opens the menu. */
openMenu(): void {
if (!this._menuOpen) {
this._createOverlay().attach(this._portal);
this._closeSubscription = this._menuClosingActions().subscribe(() => this.closeMenu());
this._initMenu();
if (this.menu instanceof MatMenu) {
this.menu._startAnimation();
}
}
}
/** Closes the menu. */
closeMenu(): void {
this.menu.close.emit();
}
/** Focuses the menu trigger. */
focus() {
this._element.nativeElement.focus();
}
/** Closes the menu and does the necessary cleanup. */
private _destroyMenu() {
if (this._overlayRef && this.menuOpen) {
this._resetMenu();
this._closeSubscription.unsubscribe();
this._overlayRef.detach();
if (this.menu instanceof MatMenu) {
this.menu._resetAnimation();
}
}
}
/**
* 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.parentMenu = this.triggersSubmenu() ? this._parentMenu : undefined;
this.menu.direction = this.dir;
this._setMenuElevation();
this._setIsMenuOpen(true);
// If the menu was opened by mouse, we focus the root node, which allows for the keyboard
// interactions to work. Otherwise, if the menu was opened by keyboard, we focus the first item.
if (this._openedByMouse) {
let rootNode = this._overlayRef!.overlayElement.firstElementChild as HTMLElement;
if (rootNode) {
this.menu.resetActiveItem();
rootNode.focus();
}
} else {
this.menu.focusFirstItem();
}
}
/** Updates the menu elevation based on the amount of parent menus that it has. */
private _setMenuElevation(): void {
if (this.menu.setElevation) {
let depth = 0;
let parentMenu = this.menu.parentMenu;
while (parentMenu) {
depth++;
parentMenu = parentMenu.parentMenu;
}
this.menu.setElevation(depth);
}
}
/**
* This method resets the menu when it's closed, most importantly restoring
* focus to the menu trigger if the menu was opened via the keyboard.
*/
private _resetMenu(): void {
this._setIsMenuOpen(false);
// We should reset focus if the user is navigating using a keyboard or
// if we have a top-level trigger which might cause focus to be lost
// when clicking on the backdrop.
if (!this._openedByMouse || !this.triggersSubmenu()) {
this.focus();
}
this._openedByMouse = false;
}
// set state rather than toggle to support triggers sharing a menu
private _setIsMenuOpen(isOpen: boolean): void {
this._menuOpen = isOpen;
this._menuOpen ? this.menuOpened.emit() : this.menuClosed.emit();
if (this.triggersSubmenu()) {
this._menuItemInstance._highlighted = isOpen;
}
}
/**
* This method checks that a valid instance of MatMenu has been passed into
* matMenuTriggerFor. If not, an exception is thrown.
*/
private _checkMenu() {
if (!this.menu) {
throwMatMenuMissingError();
}
}
/**
* 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) {
this._portal = new TemplatePortal(this.menu.templateRef, this._viewContainerRef);
const config = this._getOverlayConfig();
this._subscribeToPositions(config.positionStrategy as ConnectedPositionStrategy);
this._overlayRef = this._overlay.create(config);
}
return this._overlayRef;
}
/**
* This method builds the configuration object needed to create the overlay, the OverlayState.
* @returns OverlayConfig
*/
private _getOverlayConfig(): OverlayConfig {
return new OverlayConfig({
positionStrategy: this._getPosition(),
hasBackdrop: !this.triggersSubmenu(),
backdropClass: 'cdk-overlay-transparent-backdrop',
direction: this.dir,
scrollStrategy: this._scrollStrategy()
});
}
/**
* Listens to changes in the position of the overlay and sets the correct classes
* on the menu based on the new position. This ensures the animation origin is always
* correct, even if a fallback position is used for the overlay.
*/
private _subscribeToPositions(position: ConnectedPositionStrategy): void {
this._positionSubscription = position.onPositionChange.subscribe(change => {
const posX: MenuPositionX = change.connectionPair.overlayX === 'start' ? 'after' : 'before';
const posY: MenuPositionY = change.connectionPair.overlayY === 'top' ? 'below' : 'above';
this.menu.setPositionClasses(posX, posY);
});
}
/**
* This method builds the position strategy for the overlay, so the menu is properly connected
* to the trigger.
* @returns ConnectedPositionStrategy
*/
private _getPosition(): ConnectedPositionStrategy {
let [originX, originFallbackX]: HorizontalConnectionPos[] =
this.menu.xPosition === 'before' ? ['end', 'start'] : ['start', 'end'];
let [overlayY, overlayFallbackY]: VerticalConnectionPos[] =
this.menu.yPosition === 'above' ? ['bottom', 'top'] : ['top', 'bottom'];
let [originY, originFallbackY] = [overlayY, overlayFallbackY];
let [overlayX, overlayFallbackX] = [originX, originFallbackX];
let offsetY = 0;
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';
offsetY = overlayY === 'bottom' ? MENU_PANEL_TOP_PADDING : -MENU_PANEL_TOP_PADDING;
} else if (!this.menu.overlapTrigger) {
originY = overlayY === 'top' ? 'bottom' : 'top';
originFallbackY = overlayFallbackY === 'top' ? 'bottom' : 'top';
}
return this._overlay.position()
.connectedTo(this._element, {originX, originY}, {overlayX, overlayY})
.withDirection(this.dir)
.withOffsetY(offsetY)
.withFallbackPosition(
{originX: originFallbackX, originY},
{overlayX: overlayFallbackX, overlayY})
.withFallbackPosition(
{originX, originY: originFallbackY},
{overlayX, overlayY: overlayFallbackY},
undefined, -offsetY)
.withFallbackPosition(
{originX: originFallbackX, originY: originFallbackY},
{overlayX: overlayFallbackX, overlayY: overlayFallbackY},
undefined, -offsetY);
}
/** Cleans up the active subscriptions. */
private _cleanUpSubscriptions(): void {
this._closeSubscription.unsubscribe();
this._positionSubscription.unsubscribe();
this._hoverSubscription.unsubscribe();
}
/** Returns a stream that emits whenever an action that should close the menu occurs. */
private _menuClosingActions() {
const backdrop = this._overlayRef!.backdropClick();
const detachments = this._overlayRef!.detachments();
const parentClose = this._parentMenu ? this._parentMenu.close : observableOf();
const hover = this._parentMenu ? this._parentMenu._hovered().pipe(
filter(active => active !== this._menuItemInstance),
filter(() => this._menuOpen)
) : observableOf();
return merge(backdrop, parentClose, hover, detachments);
}
/** Handles mouse presses on the trigger. */
_handleMousedown(event: MouseEvent): void {
if (!isFakeMousedownFromScreenReader(event)) {
this._openedByMouse = true;
// Since clicking on the trigger won't close the menu if it opens a sub-menu,
// we should prevent focus from moving onto it via click to avoid the
// highlight from lingering on the menu item.
if (this.triggersSubmenu()) {
event.preventDefault();
}
}
}
/** 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();
}
}
}