+
diff --git a/src/e2e-app/mdc-menu/mdc-menu-e2e.ts b/src/e2e-app/mdc-menu/mdc-menu-e2e.ts
index e73f166edd42..931b06657b52 100644
--- a/src/e2e-app/mdc-menu/mdc-menu-e2e.ts
+++ b/src/e2e-app/mdc-menu/mdc-menu-e2e.ts
@@ -12,7 +12,17 @@ import {Component} from '@angular/core';
moduleId: module.id,
selector: 'mdc-menu-e2e',
templateUrl: 'mdc-menu-e2e.html',
+ styles: [`
+ #before-t, #above-t, #combined-t {
+ width: 60px;
+ height: 20px;
+ }
+
+ .bottom-row {
+ margin-top: 5px;
+ }
+ `]
})
export class MdcMenuE2e {
- // TODO: copy implementation from existing mat-menu e2e page.
+ selected: string = '';
}
diff --git a/src/material-experimental/mdc-menu/BUILD.bazel b/src/material-experimental/mdc-menu/BUILD.bazel
index f0c47e99ca22..f68e00d69e55 100644
--- a/src/material-experimental/mdc-menu/BUILD.bazel
+++ b/src/material-experimental/mdc-menu/BUILD.bazel
@@ -10,6 +10,9 @@ ng_module(
module_name = "@angular/material-experimental/mdc-menu",
assets = [":menu_scss"] + glob(["**/*.html"]),
deps = [
+ "@npm//@angular/animations",
+ "@npm//@angular/common",
+ "@npm//@angular/core",
"@npm//material-components-web",
] + CDK_TARGETS + MATERIAL_TARGETS,
)
@@ -20,10 +23,19 @@ sass_library(
deps = [
"//src/material/core:core_scss_lib",
"//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib",
+ "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib",
],
)
sass_binary(
name = "menu_scss",
src = "menu.scss",
+ include_paths = [
+ "external/npm/node_modules",
+ ],
+ deps = [
+ ":menu_scss_lib",
+ "//src/material/core:all_themes",
+ "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib",
+ ]
)
diff --git a/src/material-experimental/mdc-menu/README.md b/src/material-experimental/mdc-menu/README.md
index a5df4cddc582..02aac8490c01 100644
--- a/src/material-experimental/mdc-menu/README.md
+++ b/src/material-experimental/mdc-menu/README.md
@@ -1 +1,95 @@
-This is a placeholder for the MDC-based implementation of menu.
+This is prototype of an alternate version of `` built on top of
+[MDC Web](https://github.com/material-components/material-components-web). It demonstrates how
+Angular Material could use MDC Web under the hood while still exposing the same API Angular users as
+the existing ``. This component is experimental and should not be used in production.
+
+## How to use
+Assuming your application is already up and running using Angular Material, you can add this
+component by following these steps:
+
+1. Install Angular Material Experimental & MDC WEB:
+
+ ```bash
+ npm i material-components-web @angular/material-experimental
+ ```
+
+2. In your `angular.json`, make sure `node_modules/` is listed as a Sass include path. This is
+ needed for the Sass compiler to be able to find the MDC Web Sass files.
+
+ ```json
+ ...
+ "styles": [
+ "src/styles.scss"
+ ],
+ "stylePreprocessorOptions": {
+ "includePaths": [
+ "node_modules/"
+ ]
+ },
+ ...
+ ```
+
+3. Import the experimental `MatMenuModule` and add it to the module that declares your
+ component:
+
+ ```ts
+ import {MatMenuModule} from '@angular/material-experimental/mdc-menu';
+
+ @NgModule({
+ declarations: [MyComponent],
+ imports: [MatMenuModule],
+ })
+ export class MyModule {}
+ ```
+
+4. Add use `` in your component's template, just like you would the normal
+ ``:
+
+ ```html
+
+
+
+
+
+ ```
+
+5. Add the theme and typography mixins to your Sass. (There is currently no pre-built CSS option for
+ the experimental ``):
+
+ ```scss
+ @import '~@angular/material/theming';
+ @import '~@angular/material-experimental/mdc-menu';
+
+ $my-primary: mat-palette($mat-indigo);
+ $my-accent: mat-palette($mat-pink, A200, A100, A400);
+ $my-theme: mat-light-theme($my-primary, $my-accent);
+
+ @include mat-menu-theme-mdc($my-theme);
+ @include mat-menu-typography-mdc();
+ ```
+
+## API differences
+The experimental menu API closely matches the
+[API of the standard menu](https://material.angular.io/components/menu/api).
+`@angular/material-experimental/mdc-menu` exports symbols with the same name and public interface
+as all of the symbols found under `@angular/material/menu`, except for the following
+differences:
+
+* The experimental `MatMenu` does not support increasing the elevation of a sub-menu, based on its depth.
+
+## Replacing the standard menu in an existing app
+Because the experimental API mirrors the API for the standard menu, it can easily be swapped in
+by just changing the import paths. There is currently no schematic for this, but you can run the
+following string replace across your TypeScript files:
+
+```bash
+grep -lr --include="*.ts" --exclude-dir="node_modules" \
+ --exclude="*.d.ts" "['\"]@angular/material/menu['\"]" | xargs sed -i \
+ "s/['\"]@angular\/material\/menu['\"]/'@angular\/material-experimental\/mdc-menu'/g"
+```
+
+CSS styles and tests that depend on implementation details of mat-menu (such as getting elements
+from the template by class name) will need to be manually updated.
+
+There are some small visual differences between this menu and the standard mat-menu. This
+menu has a different font size and elevation `box-shadow`.
diff --git a/src/material-experimental/mdc-menu/_mdc-menu.scss b/src/material-experimental/mdc-menu/_mdc-menu.scss
index 6a6a09c3d0d3..f186d9e737d6 100644
--- a/src/material-experimental/mdc-menu/_mdc-menu.scss
+++ b/src/material-experimental/mdc-menu/_mdc-menu.scss
@@ -1,18 +1,60 @@
+@import '@material/menu-surface/mixins';
+@import '@material/menu-surface/variables';
+@import '@material/list/mixins';
+@import '@material/list/variables';
+@import '@material/theme/functions';
@import '../mdc-helpers/mdc-helpers';
@mixin mat-menu-theme-mdc($theme) {
@include mat-using-mdc-theme($theme) {
- // TODO: MDC theme styles here.
+ @include mdc-menu-surface-core-styles($mat-theme-styles-query);
+ @include mdc-list-without-ripple($mat-theme-styles-query);
+
+ // MDC doesn't appear to have disabled styling for menu
+ // items so we have to grey them out ourselves.
+ .mat-mdc-menu-item[disabled] {
+ &, &::after {
+ @include mdc-theme-prop(color, text-disabled-on-background);
+ }
+ }
+
+ // Since we're creating `mat-icon` and the submenu trigger
+ // chevron ourselves, we have to handle the color as well.
+ .mat-mdc-menu-item .mat-icon-no-color,
+ .mat-mdc-menu-item-submenu-trigger::after {
+ @include mdc-theme-prop(color, text-icon-on-background);
+ }
+
+ // MDC's hover and focus styles are tied to their ripples which we aren't using.
+ .mat-mdc-menu-item:hover,
+ .mat-mdc-menu-item.cdk-program-focused,
+ .mat-mdc-menu-item.cdk-keyboard-focused,
+ .mat-mdc-menu-item-highlighted {
+ &:not([disabled]) {
+ $color: $mdc-theme-on-surface;
+ background: rgba($color, mdc-states-opacity($color, hover));
+ }
+ }
}
}
@mixin mat-menu-typography-mdc($config) {
@if config {
@include mat-using-mdc-typography($config) {
- // TODO: MDC typography styles here.
+ @include mdc-menu-surface-core-styles($mat-typography-styles-query);
+
+ .mat-mdc-menu-content {
+ // Note that we include this private mixin, because the public
+ // one adds a bunch of styles that we aren't using for the menu.
+ @include mdc-list-base_($mat-typography-styles-query);
+ }
}
}
@else {
- // TODO: MDC typography styles here.
+ @include mdc-menu-surface-core-styles($mat-typography-styles-query);
+
+ .mat-mdc-menu-content {
+ @include mdc-list-base_($mat-typography-styles-query);
+ }
}
}
diff --git a/src/material-experimental/mdc-menu/menu-item.html b/src/material-experimental/mdc-menu/menu-item.html
new file mode 100644
index 000000000000..3092e914580e
--- /dev/null
+++ b/src/material-experimental/mdc-menu/menu-item.html
@@ -0,0 +1,5 @@
+
+
+
diff --git a/src/material-experimental/mdc-menu/menu-item.ts b/src/material-experimental/mdc-menu/menu-item.ts
new file mode 100644
index 000000000000..35bde6c0a4d5
--- /dev/null
+++ b/src/material-experimental/mdc-menu/menu-item.ts
@@ -0,0 +1,37 @@
+/**
+ * @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 {Component, ChangeDetectionStrategy, ViewEncapsulation} from '@angular/core';
+import {MatMenuItem as BaseMatMenuItem} from '@angular/material/menu';
+
+/**
+ * This directive is intended to be used inside an mat-menu tag.
+ * It exists mostly to set the role attribute.
+ */
+@Component({
+ moduleId: module.id,
+ selector: '[mat-menu-item]',
+ exportAs: 'matMenuItem',
+ inputs: ['disabled', 'disableRipple'],
+ host: {
+ '[attr.role]': 'role',
+ 'class': 'mat-mdc-menu-item',
+ '[class.mat-mdc-menu-item-highlighted]': '_highlighted',
+ '[class.mat-mdc-menu-item-submenu-trigger]': '_triggersSubmenu',
+ '[attr.tabindex]': '_getTabIndex()',
+ '[attr.aria-disabled]': 'disabled',
+ '[attr.disabled]': 'disabled || null',
+ },
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ templateUrl: 'menu-item.html',
+ providers: [
+ {provide: BaseMatMenuItem, useExisting: MatMenuItem},
+ ]
+})
+export class MatMenuItem extends BaseMatMenuItem {}
diff --git a/src/material-experimental/mdc-menu/menu.html b/src/material-experimental/mdc-menu/menu.html
index 9c75ddb6f477..df113c78caa1 100644
--- a/src/material-experimental/mdc-menu/menu.html
+++ b/src/material-experimental/mdc-menu/menu.html
@@ -1 +1,16 @@
-
+
+
+
+
+
+
+
diff --git a/src/material-experimental/mdc-menu/menu.scss b/src/material-experimental/mdc-menu/menu.scss
index f61450904c9a..97cba2d5390f 100644
--- a/src/material-experimental/mdc-menu/menu.scss
+++ b/src/material-experimental/mdc-menu/menu.scss
@@ -1 +1,68 @@
-// TODO: MDC core styles here.
+@import '@material/menu-surface/mixins';
+@import '@material/list/mixins';
+@import '@material/list/variables';
+@import '../../material/core/style/menu-common';
+@import '../../material/core/style/button-common';
+
+@include mdc-menu-surface-core-styles($query: structure);
+
+.mat-mdc-menu-content {
+ // Note that we include this private mixin, because the public
+ // one adds a bunch of styles that we aren't using for the menu.
+ @include mdc-list-base_($query: structure);
+}
+
+.mat-mdc-menu-panel {
+ // Include Material's base menu panel styling which contain the `min-width`, `max-width` and some
+ // styling to make scrolling smoother. MDC doesn't include the `min-width` and `max-width`, even
+ // though they're explicitly defined in spec.
+ @include mat-menu-base;
+
+ // The CDK positioning uses flexbox to anchor the element, whereas MDC uses `position: absolute`.
+ position: static;
+}
+
+.mat-mdc-menu-item {
+ // Note that we include this private mixin, because the public
+ // one adds a bunch of styles that we aren't using for the menu.
+ @include mdc-list-item-base_;
+
+ // MDC's menu items are `
` nodes which don't need resets, however ours
+ // can be anything, including buttons, so we need to do the reset ourselves.
+ @include mat-button-reset;
+ cursor: pointer;
+ width: 100%;
+ text-align: left;
+ box-sizing: border-box;
+ color: inherit;
+ font-size: inherit;
+ background: none;
+
+ &[disabled] {
+ cursor: default;
+ }
+
+ .mat-icon {
+ margin-right: $mdc-list-side-padding;
+ }
+
+ [dir='rtl'] & {
+ text-align: right;
+
+ .mat-icon {
+ margin-right: 0;
+ margin-left: $mdc-list-side-padding;
+ }
+ }
+}
+
+// Renders out a chevron on menu items that trigger a sub-menu.
+.mat-mdc-menu-item-submenu-trigger {
+ @include mat-menu-item-submenu-trigger($mdc-list-side-padding);
+}
+
+// Increase specificity because ripple styles are part of the `mat-core` mixin and can
+// potentially overwrite the absolute position of the container.
+.mat-mdc-menu-item .mat-mdc-menu-ripple {
+ @include mat-menu-item-ripple;
+}
diff --git a/src/material-experimental/mdc-menu/menu.spec.ts b/src/material-experimental/mdc-menu/menu.spec.ts
index f827d816238d..d6985647b3d9 100644
--- a/src/material-experimental/mdc-menu/menu.spec.ts
+++ b/src/material-experimental/mdc-menu/menu.spec.ts
@@ -1 +1,2345 @@
-// TODO: copy tests from existing mat-menu, update as necessary to fix.
+import {ComponentFixture, fakeAsync, flush, inject, TestBed, tick} from '@angular/core/testing';
+import {By} from '@angular/platform-browser';
+import {NoopAnimationsModule} from '@angular/platform-browser/animations';
+import {
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ Output,
+ NgZone,
+ TemplateRef,
+ ViewChild,
+ ViewChildren,
+ QueryList,
+ Type,
+ Provider,
+} from '@angular/core';
+import {Direction, Directionality} from '@angular/cdk/bidi';
+import {OverlayContainer, Overlay} from '@angular/cdk/overlay';
+import {ESCAPE, LEFT_ARROW, RIGHT_ARROW, DOWN_ARROW, TAB, HOME, END} from '@angular/cdk/keycodes';
+import {MatMenu, MatMenuModule, MatMenuItem} from './index';
+import {MatRipple} from '@angular/material/core';
+import {
+ dispatchKeyboardEvent,
+ dispatchMouseEvent,
+ dispatchEvent,
+ createKeyboardEvent,
+ createMouseEvent,
+ dispatchFakeEvent,
+ patchElementFocus,
+ MockNgZone,
+} from '@angular/cdk/testing';
+import {Subject} from 'rxjs';
+import {ScrollDispatcher} from '@angular/cdk/scrolling';
+import {FocusMonitor} from '@angular/cdk/a11y';
+import {
+ MAT_MENU_SCROLL_STRATEGY,
+ MatMenuTrigger,
+ MenuPositionX,
+ MenuPositionY,
+ MatMenuPanel,
+ MAT_MENU_DEFAULT_OPTIONS
+} from './public-api';
+
+const MENU_PANEL_TOP_PADDING = 8;
+
+describe('MatMenu', () => {
+ let overlayContainer: OverlayContainer;
+ let overlayContainerElement: HTMLElement;
+ let focusMonitor: FocusMonitor;
+
+ function createComponent(component: Type,
+ providers: Provider[] = [],
+ declarations: any[] = []): ComponentFixture {
+ TestBed.configureTestingModule({
+ imports: [MatMenuModule, NoopAnimationsModule],
+ declarations: [component, ...declarations],
+ providers
+ }).compileComponents();
+
+ inject([OverlayContainer, FocusMonitor], (oc: OverlayContainer, fm: FocusMonitor) => {
+ overlayContainer = oc;
+ overlayContainerElement = oc.getContainerElement();
+ focusMonitor = fm;
+ })();
+
+ return TestBed.createComponent(component);
+ }
+
+ afterEach(inject([OverlayContainer], (currentOverlayContainer: OverlayContainer) => {
+ // Since we're resetting the testing module in some of the tests,
+ // we can potentially have multiple overlay containers.
+ currentOverlayContainer.ngOnDestroy();
+ overlayContainer.ngOnDestroy();
+ }));
+
+ it('should open the menu as an idempotent operation', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ expect(overlayContainerElement.textContent).toBe('');
+ expect(() => {
+ fixture.componentInstance.trigger.openMenu();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.textContent).toContain('Item');
+ expect(overlayContainerElement.textContent).toContain('Disabled');
+ }).not.toThrowError();
+ });
+
+ it('should close the menu when a click occurs outside the menu', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+
+ const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop');
+ backdrop.click();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlayContainerElement.textContent).toBe('');
+ }));
+
+ it('should be able to remove the backdrop', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.menu.hasBackdrop = false;
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeFalsy();
+ }));
+
+ it('should be able to remove the backdrop on repeat openings', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ // Start off with a backdrop.
+ expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeTruthy();
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ // Change `hasBackdrop` after the first open.
+ fixture.componentInstance.menu.hasBackdrop = false;
+ fixture.detectChanges();
+
+ // Reopen the menu.
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlayContainerElement.querySelector('.cdk-overlay-backdrop')).toBeFalsy();
+ }));
+
+ it('should restore focus to the trigger when the menu was opened by keyboard', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ // A click without a mousedown before it is considered a keyboard open.
+ triggerEl.click();
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.mat-mdc-menu-panel')).toBeTruthy();
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(document.activeElement).toBe(triggerEl);
+ }));
+
+ it('should not restore focus to the trigger if focus restoration is disabled', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ fixture.componentInstance.restoreFocus = false;
+ fixture.detectChanges();
+
+ // A click without a mousedown before it is considered a keyboard open.
+ triggerEl.click();
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.mat-mdc-menu-panel')).toBeTruthy();
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(document.activeElement).not.toBe(triggerEl);
+ }));
+
+ it('should be able to set a custom class on the backdrop', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+
+ fixture.componentInstance.backdropClass = 'custom-backdrop';
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ const backdrop = overlayContainerElement.querySelector('.cdk-overlay-backdrop');
+
+ expect(backdrop.classList).toContain('custom-backdrop');
+ }));
+
+ it('should restore focus to the root trigger when the menu was opened by mouse', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+ dispatchFakeEvent(triggerEl, 'mousedown');
+ triggerEl.click();
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.mat-mdc-menu-panel')).toBeTruthy();
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(document.activeElement).toBe(triggerEl);
+ }));
+
+ it('should restore focus to the root trigger when the menu was opened by touch', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+ dispatchFakeEvent(triggerEl, 'touchstart');
+ triggerEl.click();
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.querySelector('.mat-mdc-menu-panel')).toBeTruthy();
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ flush();
+
+ expect(document.activeElement).toBe(triggerEl);
+ }));
+
+ it('should scroll the panel to the top on open, when it is scrollable', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ // Add 50 items to make the menu scrollable
+ fixture.componentInstance.extraItems = new Array(50).fill('Hello there');
+ fixture.detectChanges();
+
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+ dispatchFakeEvent(triggerEl, 'mousedown');
+ triggerEl.click();
+ fixture.detectChanges();
+
+ // Flush due to the additional tick that is necessary for the FocusMonitor.
+ flush();
+
+ expect(overlayContainerElement.querySelector('.mat-mdc-menu-panel')!.scrollTop).toBe(0);
+ }));
+
+ it('should set the proper focus origin when restoring focus after opening by keyboard',
+ fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ patchElementFocus(triggerEl);
+ focusMonitor.monitor(triggerEl, false);
+ triggerEl.click(); // A click without a mousedown before it is considered a keyboard open.
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(triggerEl.classList).toContain('cdk-program-focused');
+ focusMonitor.stopMonitoring(triggerEl);
+ }));
+
+ it('should set the proper focus origin when restoring focus after opening by mouse',
+ fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ dispatchMouseEvent(triggerEl, 'mousedown');
+ triggerEl.click();
+ fixture.detectChanges();
+ patchElementFocus(triggerEl);
+ focusMonitor.monitor(triggerEl, false);
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(triggerEl.classList).toContain('cdk-mouse-focused');
+ focusMonitor.stopMonitoring(triggerEl);
+ }));
+
+ it('should set proper focus origin when right clicking on trigger, before opening by keyboard',
+ fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ patchElementFocus(triggerEl);
+ focusMonitor.monitor(triggerEl, false);
+
+ // Trigger a fake right click.
+ dispatchEvent(triggerEl, createMouseEvent('mousedown', 50, 100, 2));
+
+ // A click without a left button mousedown before it is considered a keyboard open.
+ triggerEl.click();
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(triggerEl.classList).toContain('cdk-program-focused');
+ focusMonitor.stopMonitoring(triggerEl);
+ }));
+
+ it('should set the proper focus origin when restoring focus after opening by touch',
+ fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ dispatchMouseEvent(triggerEl, 'touchstart');
+ triggerEl.click();
+ fixture.detectChanges();
+ patchElementFocus(triggerEl);
+ focusMonitor.monitor(triggerEl, false);
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+ flush();
+
+ expect(triggerEl.classList).toContain('cdk-touch-focused');
+ focusMonitor.stopMonitoring(triggerEl);
+ }));
+
+ it('should close the menu when pressing ESCAPE', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
+ const event = createKeyboardEvent('keydown', ESCAPE);
+
+ dispatchEvent(panel, event);
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlayContainerElement.textContent).toBe('');
+ }));
+
+ it('should open a custom menu', () => {
+ const fixture = createComponent(CustomMenu, [], [CustomMenuPanel]);
+ fixture.detectChanges();
+ expect(overlayContainerElement.textContent).toBe('');
+ expect(() => {
+ fixture.componentInstance.trigger.openMenu();
+ fixture.componentInstance.trigger.openMenu();
+
+ expect(overlayContainerElement.textContent).toContain('Custom Menu header');
+ expect(overlayContainerElement.textContent).toContain('Custom Content');
+ }).not.toThrowError();
+ });
+
+ it('should set the panel direction based on the trigger direction', () => {
+ const fixture = createComponent(SimpleMenu, [{
+ provide: Directionality, useFactory: () => ({value: 'rtl'})}
+ ], [FakeIcon]);
+
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const boundingBox =
+ overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!;
+ expect(boundingBox.getAttribute('dir')).toEqual('rtl');
+ });
+
+ it('should update the panel direction if the trigger direction changes', () => {
+ const dirProvider = {value: 'rtl'};
+ const fixture = createComponent(SimpleMenu, [{
+ provide: Directionality, useFactory: () => dirProvider}
+ ], [FakeIcon]);
+
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ let boundingBox =
+ overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!;
+ expect(boundingBox.getAttribute('dir')).toEqual('rtl');
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+
+ dirProvider.value = 'ltr';
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ boundingBox =
+ overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!;
+ expect(boundingBox.getAttribute('dir')).toEqual('ltr');
+ });
+
+ it('should transfer any custom classes from the host to the overlay', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+
+ fixture.componentInstance.panelClass = 'custom-one custom-two';
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const menuEl = fixture.debugElement.query(By.css('mat-menu')).nativeElement;
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
+
+ expect(menuEl.classList).not.toContain('custom-one');
+ expect(menuEl.classList).not.toContain('custom-two');
+
+ expect(panel.classList).toContain('custom-one');
+ expect(panel.classList).toContain('custom-two');
+ });
+
+ // TODO(crisbeto): disabled until we've mapped our elevation to MDC's.
+ // tslint:disable-next-line:ban
+ xit('should not remove mat-elevation class from overlay when panelClass is changed', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+
+ fixture.componentInstance.panelClass = 'custom-one';
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
+
+ expect(panel.classList).toContain('custom-one');
+ expect(panel.classList).toContain('mat-elevation-z4');
+
+ fixture.componentInstance.panelClass = 'custom-two';
+ fixture.detectChanges();
+
+ expect(panel.classList).not.toContain('custom-one');
+ expect(panel.classList).toContain('custom-two');
+ expect(panel.classList)
+ .toContain('mat-elevation-z4', 'Expected mat-elevation-z4 not to be removed');
+ });
+
+ it('should set the "menu" role on the overlay panel', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const menuPanel = overlayContainerElement.querySelector('.mat-mdc-menu-panel');
+
+ expect(menuPanel).toBeTruthy('Expected to find a menu panel.');
+
+ const role = menuPanel ? menuPanel.getAttribute('role') : '';
+ expect(role).toBe('menu', 'Expected panel to have the "menu" role.');
+ });
+
+ it('should set the "menuitem" role on the items by default', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const items = Array.from(overlayContainerElement.querySelectorAll('.mat-mdc-menu-item'));
+
+ expect(items.length).toBeGreaterThan(0);
+ expect(items.every(item => item.getAttribute('role') === 'menuitem')).toBe(true);
+ });
+
+ it('should be able to set an alternate role on the menu items', () => {
+ const fixture = createComponent(MenuWithCheckboxItems);
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const items = Array.from(overlayContainerElement.querySelectorAll('.mat-mdc-menu-item'));
+
+ expect(items.length).toBeGreaterThan(0);
+ expect(items.every(item => item.getAttribute('role') === 'menuitemcheckbox')).toBe(true);
+ });
+
+ it('should not throw an error on destroy', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ expect(fixture.destroy.bind(fixture)).not.toThrow();
+ });
+
+ it('should be able to extract the menu item text', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ expect(fixture.componentInstance.items.first.getLabel()).toBe('Item');
+ });
+
+ it('should filter out non-text nodes when figuring out the label', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ expect(fixture.componentInstance.items.last.getLabel()).toBe('Item with an icon');
+ });
+
+ it('should set the proper focus origin when opening by mouse', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ spyOn(fixture.componentInstance.items.first, 'focus').and.callThrough();
+
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ dispatchMouseEvent(triggerEl, 'mousedown');
+ triggerEl.click();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(fixture.componentInstance.items.first.focus).toHaveBeenCalledWith('mouse');
+ }));
+
+ it('should set the proper focus origin when opening by touch', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ spyOn(fixture.componentInstance.items.first, 'focus').and.callThrough();
+
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ dispatchMouseEvent(triggerEl, 'touchstart');
+ triggerEl.click();
+ fixture.detectChanges();
+ flush();
+
+ expect(fixture.componentInstance.items.first.focus).toHaveBeenCalledWith('touch');
+ }));
+
+ it('should close the menu when using the CloseScrollStrategy', fakeAsync(() => {
+ const scrolledSubject = new Subject();
+ const fixture = createComponent(SimpleMenu, [
+ {provide: ScrollDispatcher, useFactory: () => ({scrolled: () => scrolledSubject})},
+ {
+ provide: MAT_MENU_SCROLL_STRATEGY,
+ deps: [Overlay],
+ useFactory: (overlay: Overlay) => () => overlay.scrollStrategies.close()
+ }
+ ], [FakeIcon]);
+ fixture.detectChanges();
+ const trigger = fixture.componentInstance.trigger;
+
+ trigger.openMenu();
+ fixture.detectChanges();
+
+ expect(trigger.menuOpen).toBe(true);
+
+ scrolledSubject.next();
+ tick(500);
+
+ expect(trigger.menuOpen).toBe(false);
+ }));
+
+ it('should switch to keyboard focus when using the keyboard after opening using the mouse',
+ fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+
+ fixture.detectChanges();
+ fixture.componentInstance.triggerEl.nativeElement.click();
+ fixture.detectChanges();
+
+ const panel = document.querySelector('.mat-mdc-menu-panel')! as HTMLElement;
+ const items: HTMLElement[] =
+ Array.from(panel.querySelectorAll('.mat-mdc-menu-panel [mat-menu-item]'));
+
+ items.forEach(item => patchElementFocus(item));
+
+ tick(500);
+ tick();
+ fixture.detectChanges();
+ expect(items.some(item => item.classList.contains('cdk-keyboard-focused'))).toBe(false);
+
+ dispatchKeyboardEvent(panel, 'keydown', DOWN_ARROW);
+ fixture.detectChanges();
+
+ // Flush due to the additional tick that is necessary for the FocusMonitor.
+ flush();
+
+ // We skip to the third item, because the second one is disabled.
+ expect(items[2].classList).toContain('cdk-focused');
+ expect(items[2].classList).toContain('cdk-keyboard-focused');
+ }));
+
+ it('should toggle the aria-expanded attribute on the trigger', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
+
+ expect(triggerEl.hasAttribute('aria-expanded')).toBe(false);
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ expect(triggerEl.getAttribute('aria-expanded')).toBe('true');
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+
+ expect(triggerEl.hasAttribute('aria-expanded')).toBe(false);
+ });
+
+ it('should throw the correct error if the menu is not defined after init', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.menu = null!;
+ fixture.detectChanges();
+
+ expect(() => {
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ }).toThrowError(/must pass in an mat-menu instance/);
+ });
+
+ it('should be able to swap out a menu after the first time it is opened', fakeAsync(() => {
+ const fixture = createComponent(DynamicPanelMenu);
+ fixture.detectChanges();
+ expect(overlayContainerElement.textContent).toBe('');
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.textContent).toContain('One');
+ expect(overlayContainerElement.textContent).not.toContain('Two');
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.textContent).toBe('');
+
+ fixture.componentInstance.trigger.menu = fixture.componentInstance.secondMenu;
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.textContent).not.toContain('One');
+ expect(overlayContainerElement.textContent).toContain('Two');
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(overlayContainerElement.textContent).toBe('');
+ }));
+
+ it('should focus the first item when pressing home', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
+ const items = Array.from(panel.querySelectorAll('.mat-mdc-menu-item')) as HTMLElement[];
+ items.forEach(patchElementFocus);
+
+ // Focus the last item since focus starts from the first one.
+ items[items.length - 1].focus();
+ fixture.detectChanges();
+
+ spyOn(items[0], 'focus').and.callThrough();
+
+ const event = dispatchKeyboardEvent(panel, 'keydown', HOME);
+ fixture.detectChanges();
+
+ expect(items[0].focus).toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(true);
+ flush();
+ }));
+
+ it('should not focus the first item when pressing home with a modifier key', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
+ const items = Array.from(panel.querySelectorAll('.mat-mdc-menu-item')) as HTMLElement[];
+ items.forEach(patchElementFocus);
+
+ // Focus the last item since focus starts from the first one.
+ items[items.length - 1].focus();
+ fixture.detectChanges();
+
+ spyOn(items[0], 'focus').and.callThrough();
+
+ const event = createKeyboardEvent('keydown', HOME);
+ Object.defineProperty(event, 'altKey', {get: () => true});
+
+ dispatchEvent(panel, event);
+ fixture.detectChanges();
+
+ expect(items[0].focus).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ flush();
+ }));
+
+ it('should focus the last item when pressing end', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
+ const items = Array.from(panel.querySelectorAll('.mat-mdc-menu-item')) as HTMLElement[];
+ items.forEach(patchElementFocus);
+
+ spyOn(items[items.length - 1], 'focus').and.callThrough();
+
+ const event = dispatchKeyboardEvent(panel, 'keydown', END);
+ fixture.detectChanges();
+
+ expect(items[items.length - 1].focus).toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(true);
+ flush();
+ }));
+
+ it('should not focus the last item when pressing end with a modifier key', fakeAsync(() => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
+ const items = Array.from(panel.querySelectorAll('.mat-mdc-menu-item')) as HTMLElement[];
+ items.forEach(patchElementFocus);
+
+ spyOn(items[items.length - 1], 'focus').and.callThrough();
+
+ const event = createKeyboardEvent('keydown', END);
+ Object.defineProperty(event, 'altKey', {get: () => true});
+
+ dispatchEvent(panel, event);
+ fixture.detectChanges();
+
+ expect(items[items.length - 1].focus).not.toHaveBeenCalled();
+ expect(event.defaultPrevented).toBe(false);
+ flush();
+ }));
+
+ describe('lazy rendering', () => {
+ it('should be able to render the menu content lazily', fakeAsync(() => {
+ const fixture = createComponent(SimpleLazyMenu);
+
+ fixture.detectChanges();
+ fixture.componentInstance.triggerEl.nativeElement.click();
+ fixture.detectChanges();
+ tick(500);
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel')!;
+
+ expect(panel).toBeTruthy('Expected panel to be defined');
+ expect(panel.textContent).toContain('Another item', 'Expected panel to have correct content');
+ expect(fixture.componentInstance.trigger.menuOpen).toBe(true, 'Expected menu to be open');
+ }));
+
+ it('should detach the lazy content when the menu is closed', fakeAsync(() => {
+ const fixture = createComponent(SimpleLazyMenu);
+
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(fixture.componentInstance.items.length).toBeGreaterThan(0);
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.items.length).toBe(0);
+ }));
+
+ it('should wait for the close animation to finish before considering the panel as closed',
+ fakeAsync(() => {
+ const fixture = createComponent(SimpleLazyMenu);
+ fixture.detectChanges();
+ const trigger = fixture.componentInstance.trigger;
+
+ expect(trigger.menuOpen).toBe(false, 'Expected menu to start off closed');
+
+ trigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(trigger.menuOpen).toBe(true, 'Expected menu to be open');
+
+ trigger.closeMenu();
+ fixture.detectChanges();
+
+ expect(trigger.menuOpen)
+ .toBe(true, 'Expected menu to be considered open while the close animation is running');
+ tick(500);
+ fixture.detectChanges();
+
+ expect(trigger.menuOpen).toBe(false, 'Expected menu to be closed');
+ }));
+
+ it('should focus the first menu item when opening a lazy menu via keyboard', fakeAsync(() => {
+ let zone: MockNgZone;
+ let fixture = createComponent(SimpleLazyMenu, [{
+ provide: NgZone, useFactory: () => zone = new MockNgZone()
+ }]);
+
+ fixture.detectChanges();
+
+ // A click without a mousedown before it is considered a keyboard open.
+ fixture.componentInstance.triggerEl.nativeElement.click();
+ fixture.detectChanges();
+ tick(500);
+ zone!.simulateZoneExit();
+
+ // Flush due to the additional tick that is necessary for the FocusMonitor.
+ flush();
+
+ const item = document.querySelector('.mat-mdc-menu-panel [mat-menu-item]')!;
+
+ expect(document.activeElement).toBe(item, 'Expected first item to be focused');
+ }));
+
+ it('should be able to open the same menu with a different context', fakeAsync(() => {
+ const fixture = createComponent(LazyMenuWithContext);
+
+ fixture.detectChanges();
+ fixture.componentInstance.triggerOne.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ let item = overlayContainerElement.querySelector('.mat-mdc-menu-panel [mat-menu-item]')!;
+
+ expect(item.textContent!.trim()).toBe('one');
+
+ fixture.componentInstance.triggerOne.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ fixture.componentInstance.triggerTwo.openMenu();
+ fixture.detectChanges();
+ tick(500);
+ item = overlayContainerElement.querySelector('.mat-mdc-menu-panel [mat-menu-item]')!;
+
+ expect(item.textContent!.trim()).toBe('two');
+ }));
+ });
+
+ describe('positions', () => {
+ let fixture: ComponentFixture;
+ let trigger: HTMLElement;
+
+ beforeEach(() => {
+ fixture = createComponent(PositionedMenu);
+ fixture.detectChanges();
+
+ trigger = fixture.componentInstance.triggerEl.nativeElement;
+
+ // Push trigger to the bottom edge of viewport,so it has space to open "above"
+ trigger.style.position = 'fixed';
+ trigger.style.top = '600px';
+
+ // Push trigger to the right, so it has space to open "before"
+ trigger.style.left = '100px';
+ });
+
+ it('should append mat-menu-before if the x position is changed', () => {
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel') as HTMLElement;
+
+ expect(panel.classList).toContain('mat-menu-before');
+ expect(panel.classList).not.toContain('mat-menu-after');
+
+ fixture.componentInstance.xPosition = 'after';
+ fixture.detectChanges();
+
+ expect(panel.classList).toContain('mat-menu-after');
+ expect(panel.classList).not.toContain('mat-menu-before');
+ });
+
+ it('should append mat-menu-above if the y position is changed', () => {
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel') as HTMLElement;
+
+ expect(panel.classList).toContain('mat-menu-above');
+ expect(panel.classList).not.toContain('mat-menu-below');
+
+ fixture.componentInstance.yPosition = 'below';
+ fixture.detectChanges();
+
+ expect(panel.classList).toContain('mat-menu-below');
+ expect(panel.classList).not.toContain('mat-menu-above');
+ });
+
+ it('should default to the "below" and "after" positions', () => {
+ overlayContainer.ngOnDestroy();
+ fixture.destroy();
+ TestBed.resetTestingModule();
+
+ const newFixture = createComponent(SimpleMenu, [], [FakeIcon]);
+
+ newFixture.detectChanges();
+ newFixture.componentInstance.trigger.openMenu();
+ newFixture.detectChanges();
+ const panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel') as HTMLElement;
+
+ expect(panel.classList).toContain('mat-menu-below');
+ expect(panel.classList).toContain('mat-menu-after');
+ });
+
+ it('should be able to update the position after the first open', () => {
+ trigger.style.position = 'fixed';
+ trigger.style.top = '200px';
+
+ fixture.componentInstance.yPosition = 'above';
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ let panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel') as HTMLElement;
+
+ expect(Math.floor(panel.getBoundingClientRect().bottom))
+ .toBe(Math.floor(trigger.getBoundingClientRect().top), 'Expected menu to open above');
+
+ fixture.componentInstance.trigger.closeMenu();
+ fixture.detectChanges();
+
+ fixture.componentInstance.yPosition = 'below';
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ panel = overlayContainerElement.querySelector('.mat-mdc-menu-panel') as HTMLElement;
+
+ expect(Math.floor(panel.getBoundingClientRect().top))
+ .toBe(Math.floor(trigger.getBoundingClientRect().bottom), 'Expected menu to open below');
+ });
+
+ });
+
+ describe('fallback positions', () => {
+
+ it('should fall back to "before" mode if "after" mode would not fit on screen', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const trigger = fixture.componentInstance.triggerEl.nativeElement;
+
+ // Push trigger to the right side of viewport, so it doesn't have space to open
+ // in its default "after" position on the right side.
+ trigger.style.position = 'fixed';
+ trigger.style.right = '0';
+ trigger.style.top = '200px';
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ const overlayPane = getOverlayPane();
+ const triggerRect = trigger.getBoundingClientRect();
+ const overlayRect = overlayPane.getBoundingClientRect();
+
+ // In "before" position, the right sides of the overlay and the origin are aligned.
+ // To find the overlay left, subtract the menu width from the origin's right side.
+ const expectedLeft = triggerRect.right - overlayRect.width;
+ expect(Math.floor(overlayRect.left))
+ .toBe(Math.floor(expectedLeft),
+ `Expected menu to open in "before" position if "after" position wouldn't fit.`);
+
+ // The y-position of the overlay should be unaffected, as it can already fit vertically
+ expect(Math.floor(overlayRect.top))
+ .toBe(Math.floor(triggerRect.bottom),
+ `Expected menu top position to be unchanged if it can fit in the viewport.`);
+ });
+
+ it('should fall back to "above" mode if "below" mode would not fit on screen', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const trigger = fixture.componentInstance.triggerEl.nativeElement;
+
+ // Push trigger to the bottom part of viewport, so it doesn't have space to open
+ // in its default "below" position below the trigger.
+ trigger.style.position = 'fixed';
+ trigger.style.bottom = '65px';
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ const overlayPane = getOverlayPane();
+ const triggerRect = trigger.getBoundingClientRect();
+ const overlayRect = overlayPane.getBoundingClientRect();
+
+ expect(Math.floor(overlayRect.bottom))
+ .toBe(Math.floor(triggerRect.top),
+ `Expected menu to open in "above" position if "below" position wouldn't fit.`);
+
+ // The x-position of the overlay should be unaffected, as it can already fit horizontally
+ expect(Math.floor(overlayRect.left))
+ .toBe(Math.floor(triggerRect.left),
+ `Expected menu x position to be unchanged if it can fit in the viewport.`);
+ });
+
+ it('should re-position menu on both axes if both defaults would not fit', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ const trigger = fixture.componentInstance.triggerEl.nativeElement;
+
+ // push trigger to the bottom, right part of viewport, so it doesn't have space to open
+ // in its default "after below" position.
+ trigger.style.position = 'fixed';
+ trigger.style.right = '0';
+ trigger.style.bottom = '0';
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ const overlayPane = getOverlayPane();
+ const triggerRect = trigger.getBoundingClientRect();
+ const overlayRect = overlayPane.getBoundingClientRect();
+
+ const expectedLeft = triggerRect.right - overlayRect.width;
+
+ expect(Math.floor(overlayRect.left))
+ .toBe(Math.floor(expectedLeft),
+ `Expected menu to open in "before" position if "after" position wouldn't fit.`);
+
+ expect(Math.floor(overlayRect.bottom))
+ .toBe(Math.floor(triggerRect.top),
+ `Expected menu to open in "above" position if "below" position wouldn't fit.`);
+ });
+
+ it('should re-position a menu with custom position set', () => {
+ const fixture = createComponent(PositionedMenu);
+ fixture.detectChanges();
+ const trigger = fixture.componentInstance.triggerEl.nativeElement;
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ const overlayPane = getOverlayPane();
+ const triggerRect = trigger.getBoundingClientRect();
+ const overlayRect = overlayPane.getBoundingClientRect();
+
+ // As designated "before" position won't fit on screen, the menu should fall back
+ // to "after" mode, where the left sides of the overlay and trigger are aligned.
+ expect(Math.floor(overlayRect.left))
+ .toBe(Math.floor(triggerRect.left),
+ `Expected menu to open in "after" position if "before" position wouldn't fit.`);
+
+ // As designated "above" position won't fit on screen, the menu should fall back
+ // to "below" mode, where the top edges of the overlay and trigger are aligned.
+ expect(Math.floor(overlayRect.top))
+ .toBe(Math.floor(triggerRect.bottom),
+ `Expected menu to open in "below" position if "above" position wouldn't fit.`);
+ });
+
+ function getOverlayPane(): HTMLElement {
+ return overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement;
+ }
+ });
+
+ describe('overlapping trigger', () => {
+ /**
+ * This test class is used to create components containing a menu.
+ * It provides helpers to reposition the trigger, open the menu,
+ * and access the trigger and overlay positions.
+ * Additionally it can take any inputs for the menu wrapper component.
+ *
+ * Basic usage:
+ * const subject = new OverlapSubject(MyComponent);
+ * subject.openMenu();
+ */
+ class OverlapSubject {
+ readonly fixture: ComponentFixture;
+ readonly trigger: HTMLElement;
+
+ constructor(ctor: {new(): T; }, inputs: {[key: string]: any} = {}) {
+ this.fixture = createComponent(ctor);
+ Object.keys(inputs)
+ .forEach(key => (this.fixture.componentInstance as any)[key] = inputs[key]);
+ this.fixture.detectChanges();
+ this.trigger = this.fixture.componentInstance.triggerEl.nativeElement;
+ }
+
+ openMenu() {
+ this.fixture.componentInstance.trigger.openMenu();
+ this.fixture.detectChanges();
+ }
+
+ get overlayRect() {
+ return this.getOverlayPane().getBoundingClientRect();
+ }
+
+ get triggerRect() {
+ return this.trigger.getBoundingClientRect();
+ }
+
+ get menuPanel() {
+ return overlayContainerElement.querySelector('.mat-mdc-menu-panel');
+ }
+
+ private getOverlayPane() {
+ return overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement;
+ }
+ }
+
+ let subject: OverlapSubject;
+ describe('explicitly overlapping', () => {
+ beforeEach(() => {
+ subject = new OverlapSubject(OverlapMenu, {overlapTrigger: true});
+ });
+
+ it('positions the overlay below the trigger', () => {
+ subject.openMenu();
+
+ // Since the menu is overlaying the trigger, the overlay top should be the trigger top.
+ expect(Math.floor(subject.overlayRect.top))
+ .toBe(Math.floor(subject.triggerRect.top),
+ `Expected menu to open in default "below" position.`);
+ });
+ });
+
+ describe('not overlapping', () => {
+ beforeEach(() => {
+ subject = new OverlapSubject(OverlapMenu, {overlapTrigger: false});
+ });
+
+ it('positions the overlay below the trigger', () => {
+ subject.openMenu();
+
+ // Since the menu is below the trigger, the overlay top should be the trigger bottom.
+ expect(Math.floor(subject.overlayRect.top))
+ .toBe(Math.floor(subject.triggerRect.bottom),
+ `Expected menu to open directly below the trigger.`);
+ });
+
+ it('supports above position fall back', () => {
+ // Push trigger to the bottom part of viewport, so it doesn't have space to open
+ // in its default "below" position below the trigger.
+ subject.trigger.style.position = 'fixed';
+ subject.trigger.style.bottom = '0';
+ subject.openMenu();
+
+ // Since the menu is above the trigger, the overlay bottom should be the trigger top.
+ expect(Math.floor(subject.overlayRect.bottom))
+ .toBe(Math.floor(subject.triggerRect.top),
+ `Expected menu to open in "above" position if "below" position wouldn't fit.`);
+ });
+
+ it('repositions the origin to be below, so the menu opens from the trigger', () => {
+ subject.openMenu();
+ subject.fixture.detectChanges();
+
+ expect(subject.menuPanel!.classList).toContain('mat-menu-below');
+ expect(subject.menuPanel!.classList).not.toContain('mat-menu-above');
+ });
+ });
+ });
+
+ describe('animations', () => {
+ it('should enable ripples on items by default', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const item = fixture.debugElement.query(By.css('.mat-mdc-menu-item'));
+ const ripple = item.query(By.css('.mat-ripple')).injector.get(MatRipple);
+
+ expect(ripple.disabled).toBe(false);
+ });
+
+ it('should disable ripples on disabled items', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ const items = fixture.debugElement.queryAll(By.css('.mat-mdc-menu-item'));
+ const ripple = items[1].query(By.css('.mat-ripple')).injector.get(MatRipple);
+
+ expect(ripple.disabled).toBe(true);
+ });
+
+ it('should disable ripples if disableRipple is set', () => {
+ const fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+
+ // The third menu item in the `SimpleMenu` component has ripples disabled.
+ const items = fixture.debugElement.queryAll(By.css('.mat-mdc-menu-item'));
+ const ripple = items[2].query(By.css('.mat-ripple')).injector.get(MatRipple);
+
+ expect(ripple.disabled).toBe(true);
+ });
+ });
+
+ describe('close event', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(() => {
+ fixture = createComponent(SimpleMenu, [], [FakeIcon]);
+ fixture.detectChanges();
+ fixture.componentInstance.trigger.openMenu();
+ fixture.detectChanges();
+ });
+
+ it('should emit an event when a menu item is clicked', () => {
+ const menuItem = overlayContainerElement.querySelector('[mat-menu-item]') as HTMLElement;
+
+ menuItem.click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.closeCallback).toHaveBeenCalledWith('click');
+ expect(fixture.componentInstance.closeCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit a close event when the backdrop is clicked', () => {
+ const backdrop = overlayContainerElement
+ .querySelector('.cdk-overlay-backdrop') as HTMLElement;
+
+ backdrop.click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.closeCallback).toHaveBeenCalledWith(undefined);
+ expect(fixture.componentInstance.closeCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit an event when pressing ESCAPE', () => {
+ const menu = overlayContainerElement.querySelector('.mat-mdc-menu-panel') as HTMLElement;
+
+ dispatchKeyboardEvent(menu, 'keydown', ESCAPE);
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.closeCallback).toHaveBeenCalledWith('keydown');
+ expect(fixture.componentInstance.closeCallback).toHaveBeenCalledTimes(1);
+ });
+
+ it('should complete the callback when the menu is destroyed', () => {
+ const emitCallback = jasmine.createSpy('emit callback');
+ const completeCallback = jasmine.createSpy('complete callback');
+
+ fixture.componentInstance.menu.closed.subscribe(emitCallback, null, completeCallback);
+ fixture.destroy();
+
+ expect(emitCallback).toHaveBeenCalledWith(undefined);
+ expect(emitCallback).toHaveBeenCalledTimes(1);
+ expect(completeCallback).toHaveBeenCalled();
+ });
+ });
+
+ describe('nested menu', () => {
+ let fixture: ComponentFixture;
+ let instance: NestedMenu;
+ let overlay: HTMLElement;
+ let compileTestComponent = (direction: Direction = 'ltr') => {
+ fixture = createComponent(NestedMenu, [{
+ provide: Directionality, useFactory: () => ({value: direction})
+ }]);
+
+ 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 `parentMenu` on the sub-menu instances', () => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelTwoTrigger.openMenu();
+ fixture.detectChanges();
+
+ expect(instance.rootMenu.parentMenu).toBeFalsy();
+ expect(instance.levelOneMenu.parentMenu).toBe(instance.rootMenu);
+ expect(instance.levelTwoMenu.parentMenu).toBe(instance.levelOneMenu);
+ });
+
+ it('should pass the layout direction the nested menus', () => {
+ compileTestComponent('rtl');
+ 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._hovered().subscribe(spy);
+ const menuItems = overlay.querySelectorAll('[mat-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', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+
+ const items = Array.from(overlay.querySelectorAll('.mat-mdc-menu-panel [mat-menu-item]'));
+ const levelOneTrigger = overlay.querySelector('#level-one-trigger')!;
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick();
+ fixture.detectChanges();
+
+ expect(levelOneTrigger.classList)
+ .toContain('mat-mdc-menu-item-highlighted', 'Expected the trigger to be highlighted');
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+
+ dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+ expect(levelOneTrigger.classList)
+ .not.toContain('mat-mdc-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',
+ fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+
+ const items = Array.from(overlay.querySelectorAll('.mat-mdc-menu-panel [mat-menu-item]'));
+ const levelOneTrigger = overlay.querySelector('#level-one-trigger')!;
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick();
+
+ const levelTwoTrigger = overlay.querySelector('#level-two-trigger')! as HTMLElement;
+ dispatchMouseEvent(levelTwoTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick();
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(3, 'Expected three open menus');
+
+ dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+ }));
+
+ it('should close submenu when hovering over disabled sibling item', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+ tick(500);
+
+ const items = fixture.debugElement.queryAll(By.directive(MatMenuItem));
+
+ dispatchFakeEvent(items[0].nativeElement, 'mouseenter');
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+
+ items[1].componentInstance.disabled = true;
+ fixture.detectChanges();
+
+ // Invoke the handler directly since the fake events are flaky on disabled elements.
+ items[1].componentInstance._handleMouseEnter();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+ }));
+
+ it('should not open submenu when hovering over disabled trigger', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+
+ const item = fixture.debugElement.query(By.directive(MatMenuItem));
+
+ item.componentInstance.disabled = true;
+ fixture.detectChanges();
+
+ // Invoke the handler directly since the fake events are flaky on disabled elements.
+ item.componentInstance._handleMouseEnter();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected to remain at 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-mdc-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-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+
+ levelOneTrigger.click();
+ fixture.detectChanges();
+ expect(overlay.querySelectorAll('.mat-mdc-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', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+ expect(overlay.querySelectorAll('.mat-mdc-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-mdc-menu-panel');
+
+ expect(panels.length).toBe(2, 'Expected two open menus');
+ dispatchKeyboardEvent(panels[1], 'keydown', LEFT_ARROW);
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length).toBe(1);
+ }));
+
+ it('should open and close a nested menu with the arrow keys in rtl', fakeAsync(() => {
+ compileTestComponent('rtl');
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+ expect(overlay.querySelectorAll('.mat-mdc-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-mdc-menu-panel');
+
+ expect(panels.length).toBe(2, 'Expected two open menus');
+ dispatchKeyboardEvent(panels[1], 'keydown', RIGHT_ARROW);
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-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-mdc-menu-panel')!;
+
+ dispatchKeyboardEvent(menu, 'keydown', RIGHT_ARROW);
+ fixture.detectChanges();
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one menu to remain open');
+
+ dispatchKeyboardEvent(menu, 'keydown', LEFT_ARROW);
+ fixture.detectChanges();
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one menu to remain open');
+ });
+
+ it('should close all of the menus when the backdrop is clicked', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelTwoTrigger.openMenu();
+ fixture.detectChanges();
+
+ expect(overlay.querySelectorAll('.mat-mdc-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-mdc-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();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-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-mdc-menu-panel')!.contains(document.activeElement))
+ .toBe(true, 'Expected focus to be inside the root menu');
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+
+ expect(overlay.querySelectorAll('.mat-mdc-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-mdc-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-mdc-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-mdc-menu-panel')!.contains(document.activeElement))
+ .toBe(true, 'Expected focus to be back inside the root 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('.cdk-overlay-pane')[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('.cdk-overlay-pane')[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', () => {
+ compileTestComponent('rtl');
+ 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('.cdk-overlay-pane')[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', fakeAsync(() => {
+ compileTestComponent('rtl');
+ instance.rootTriggerEl.nativeElement.style.position = 'fixed';
+ instance.rootTriggerEl.nativeElement.style.left = '10px';
+ instance.rootTriggerEl.nativeElement.style.top = '50%';
+ instance.rootTrigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ const triggerRect = overlay.querySelector('#level-one-trigger')!.getBoundingClientRect();
+ const panelRect = overlay.querySelectorAll('.cdk-overlay-pane')[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', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelTwoTrigger.openMenu();
+ fixture.detectChanges();
+
+ const menus = overlay.querySelectorAll('.mat-mdc-menu-panel');
+
+ expect(menus.length).toBe(3, 'Expected three open menus');
+
+ (menus[2].querySelector('.mat-mdc-menu-item')! as HTMLElement).click();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(0, 'Expected no open menus');
+ }));
+
+ it('should close all of the menus when the user tabs away', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelTwoTrigger.openMenu();
+ fixture.detectChanges();
+
+ const menus = overlay.querySelectorAll('.mat-mdc-menu-panel');
+
+ expect(menus.length).toBe(3, 'Expected three open menus');
+
+ dispatchKeyboardEvent(menus[menus.length - 1], 'keydown', TAB);
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(0, 'Expected no open menus');
+ }));
+
+ it('should set a class on the menu items that trigger a sub-menu', () => {
+ compileTestComponent();
+ instance.rootTrigger.openMenu();
+ fixture.detectChanges();
+
+ const menuItems = overlay.querySelectorAll('[mat-menu-item]');
+
+ expect(menuItems[0].classList).toContain('mat-mdc-menu-item-submenu-trigger');
+ expect(menuItems[1].classList).not.toContain('mat-mdc-menu-item-submenu-trigger');
+ });
+
+ // TODO(crisbeto): disabled until we've mapped our elevation to MDC's.
+ // tslint:disable-next-line:ban
+ xit('should increase the sub-menu elevation based on its depth', () => {
+ compileTestComponent();
+ instance.rootTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelTwoTrigger.openMenu();
+ fixture.detectChanges();
+
+ const menus = overlay.querySelectorAll('.mat-mdc-menu-panel');
+
+ expect(menus[0].classList)
+ .toContain('mat-elevation-z4', 'Expected root menu to have base elevation.');
+ expect(menus[1].classList)
+ .toContain('mat-elevation-z5', 'Expected first sub-menu to have base elevation + 1.');
+ expect(menus[2].classList)
+ .toContain('mat-elevation-z6', 'Expected second sub-menu to have base elevation + 2.');
+ });
+
+ // TODO(crisbeto): disabled until we've mapped our elevation to MDC's.
+ // tslint:disable-next-line:ban
+ xit('should update the elevation when the same menu is opened at a different depth',
+ fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelTwoTrigger.openMenu();
+ fixture.detectChanges();
+
+ let lastMenu = overlay.querySelectorAll('.mat-mdc-menu-panel')[2];
+
+ expect(lastMenu.classList)
+ .toContain('mat-elevation-z6', 'Expected menu to have the base elevation plus two.');
+
+ (overlay.querySelector('.cdk-overlay-backdrop')! as HTMLElement).click();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(0, 'Expected no open menus');
+
+ instance.alternateTrigger.openMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ lastMenu = overlay.querySelector('.mat-mdc-menu-panel') as HTMLElement;
+
+ expect(lastMenu.classList)
+ .not.toContain('mat-elevation-z6', 'Expected menu not to maintain old elevation.');
+ expect(lastMenu.classList)
+ .toContain('mat-elevation-z4', 'Expected menu to have the proper updated elevation.');
+ }));
+
+ // TODO(crisbeto): disabled until we've mapped our elevation to MDC's.
+ // tslint:disable-next-line:ban
+ xit('should not increase the elevation if the user specified a custom one', () => {
+ const elevationFixture = createComponent(NestedMenuCustomElevation);
+
+ elevationFixture.detectChanges();
+ elevationFixture.componentInstance.rootTrigger.openMenu();
+ elevationFixture.detectChanges();
+
+ elevationFixture.componentInstance.levelOneTrigger.openMenu();
+ elevationFixture.detectChanges();
+
+ const menuClasses =
+ overlayContainerElement.querySelectorAll('.mat-mdc-menu-panel')[1].classList;
+
+ expect(menuClasses)
+ .toContain('mat-elevation-z24', 'Expected user elevation to be maintained');
+ expect(menuClasses)
+ .not.toContain('mat-elevation-z3', 'Expected no stacked elevation.');
+ });
+
+ it('should close all of the menus when the root is closed programmatically', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelOneTrigger.openMenu();
+ fixture.detectChanges();
+
+ instance.levelTwoTrigger.openMenu();
+ fixture.detectChanges();
+
+ const menus = overlay.querySelectorAll('.mat-mdc-menu-panel');
+
+ expect(menus.length).toBe(3, 'Expected three open menus');
+
+ instance.rootTrigger.closeMenu();
+ fixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(0, 'Expected no open menus');
+ }));
+
+ it('should toggle a nested menu when its trigger is added after init', fakeAsync(() => {
+ compileTestComponent();
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+ tick(500);
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+
+ instance.showLazy = true;
+ fixture.detectChanges();
+
+ const lazyTrigger = overlay.querySelector('#lazy-trigger')!;
+
+ dispatchMouseEvent(lazyTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick(500);
+ fixture.detectChanges();
+
+ expect(lazyTrigger.classList)
+ .toContain('mat-mdc-menu-item-highlighted', 'Expected the trigger to be highlighted');
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+ }));
+
+ it('should prevent the default mousedown action if the menu item opens a sub-menu', () => {
+ compileTestComponent();
+ instance.rootTrigger.openMenu();
+ fixture.detectChanges();
+
+ const event = createMouseEvent('mousedown');
+
+ Object.defineProperty(event, 'buttons', {get: () => 1});
+ event.preventDefault = jasmine.createSpy('preventDefault spy');
+
+ dispatchMouseEvent(overlay.querySelector('[mat-menu-item]')!, 'mousedown', 0, 0, event);
+ expect(event.preventDefault).toHaveBeenCalled();
+ });
+
+ it('should handle the items being rendered in a repeater', fakeAsync(() => {
+ const repeaterFixture = createComponent(NestedMenuRepeater);
+ overlay = overlayContainerElement;
+
+ expect(() => repeaterFixture.detectChanges()).not.toThrow();
+
+ repeaterFixture.componentInstance.rootTriggerEl.nativeElement.click();
+ repeaterFixture.detectChanges();
+ tick(500);
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+
+ dispatchMouseEvent(overlay.querySelector('.level-one-trigger')!, 'mouseenter');
+ repeaterFixture.detectChanges();
+ tick(500);
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+ }));
+
+ it('should be able to trigger the same nested menu from different triggers', fakeAsync(() => {
+ const repeaterFixture = createComponent(NestedMenuRepeater);
+ overlay = overlayContainerElement;
+
+ repeaterFixture.detectChanges();
+ repeaterFixture.componentInstance.rootTriggerEl.nativeElement.click();
+ repeaterFixture.detectChanges();
+ tick(500);
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+
+ const triggers = overlay.querySelectorAll('.level-one-trigger');
+
+ dispatchMouseEvent(triggers[0], 'mouseenter');
+ repeaterFixture.detectChanges();
+ tick(500);
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+
+ dispatchMouseEvent(triggers[1], 'mouseenter');
+ repeaterFixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+ }));
+
+ it('should close the initial menu if the user moves away while animating', fakeAsync(() => {
+ const repeaterFixture = createComponent(NestedMenuRepeater);
+ overlay = overlayContainerElement;
+
+ repeaterFixture.detectChanges();
+ repeaterFixture.componentInstance.rootTriggerEl.nativeElement.click();
+ repeaterFixture.detectChanges();
+ tick(500);
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+
+ const triggers = overlay.querySelectorAll('.level-one-trigger');
+
+ dispatchMouseEvent(triggers[0], 'mouseenter');
+ repeaterFixture.detectChanges();
+ tick(100);
+ dispatchMouseEvent(triggers[1], 'mouseenter');
+ repeaterFixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+ }));
+
+ it('should be able to open a submenu through an item that is not a direct descendant ' +
+ 'of the panel', fakeAsync(() => {
+ const nestedFixture = createComponent(SubmenuDeclaredInsideParentMenu);
+ overlay = overlayContainerElement;
+
+ nestedFixture.detectChanges();
+ nestedFixture.componentInstance.rootTriggerEl.nativeElement.click();
+ nestedFixture.detectChanges();
+ tick(500);
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+
+ dispatchMouseEvent(overlay.querySelector('.level-one-trigger')!, 'mouseenter');
+ nestedFixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+ }));
+
+ it('should not close when hovering over a menu item inside a sub-menu panel that is declared' +
+ 'inside the root menu', fakeAsync(() => {
+ const nestedFixture = createComponent(SubmenuDeclaredInsideParentMenu);
+ overlay = overlayContainerElement;
+
+ nestedFixture.detectChanges();
+ nestedFixture.componentInstance.rootTriggerEl.nativeElement.click();
+ nestedFixture.detectChanges();
+ tick(500);
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(1, 'Expected one open menu');
+
+ dispatchMouseEvent(overlay.querySelector('.level-one-trigger')!, 'mouseenter');
+ nestedFixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+
+ dispatchMouseEvent(overlay.querySelector('.level-two-item')!, 'mouseenter');
+ nestedFixture.detectChanges();
+ tick(500);
+
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus to remain');
+ }));
+
+ it('should not re-focus a child menu trigger when hovering another trigger', fakeAsync(() => {
+ compileTestComponent();
+
+ dispatchFakeEvent(instance.rootTriggerEl.nativeElement, 'mousedown');
+ instance.rootTriggerEl.nativeElement.click();
+ fixture.detectChanges();
+
+ const items = Array.from(overlay.querySelectorAll('.mat-mdc-menu-panel [mat-menu-item]'));
+ const levelOneTrigger = overlay.querySelector('#level-one-trigger')!;
+
+ dispatchMouseEvent(levelOneTrigger, 'mouseenter');
+ fixture.detectChanges();
+ tick();
+ expect(overlay.querySelectorAll('.mat-mdc-menu-panel').length)
+ .toBe(2, 'Expected two open menus');
+
+ dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
+ fixture.detectChanges();
+ tick(500);
+
+ expect(document.activeElement)
+ .not.toBe(levelOneTrigger, 'Expected focus not to be returned to the initial trigger.');
+ }));
+
+ });
+
+});
+
+describe('MatMenu default overrides', () => {
+ beforeEach(fakeAsync(() => {
+ TestBed.configureTestingModule({
+ imports: [MatMenuModule, NoopAnimationsModule],
+ declarations: [SimpleMenu, FakeIcon],
+ providers: [{
+ provide: MAT_MENU_DEFAULT_OPTIONS,
+ useValue: {overlapTrigger: true, xPosition: 'before', yPosition: 'above'},
+ }],
+ }).compileComponents();
+ }));
+
+ it('should allow for the default menu options to be overridden', () => {
+ const fixture = TestBed.createComponent(SimpleMenu);
+ fixture.detectChanges();
+ const menu = fixture.componentInstance.menu;
+
+ expect(menu.overlapTrigger).toBe(true);
+ expect(menu.xPosition).toBe('before');
+ expect(menu.yPosition).toBe('above');
+ });
+});
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+ `
+})
+class SimpleMenu {
+ @ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
+ @ViewChild('triggerEl', {static: false}) triggerEl: ElementRef;
+ @ViewChild(MatMenu, {static: false}) menu: MatMenu;
+ @ViewChildren(MatMenuItem) items: QueryList;
+ extraItems: string[] = [];
+ closeCallback = jasmine.createSpy('menu closed callback');
+ backdropClass: string;
+ panelClass: string;
+ restoreFocus = true;
+}
+
+@Component({
+ template: `
+
+
+
+
+ `
+})
+class PositionedMenu {
+ @ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
+ @ViewChild('triggerEl', {static: false}) triggerEl: ElementRef;
+ xPosition: MenuPositionX = 'before';
+ yPosition: MenuPositionY = 'above';
+}
+
+interface TestableMenu {
+ trigger: MatMenuTrigger;
+ triggerEl: ElementRef;
+}
+@Component({
+ template: `
+
+
+
+
+ `
+})
+class OverlapMenu implements TestableMenu {
+ @Input() overlapTrigger: boolean;
+ @ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
+ @ViewChild('triggerEl', {static: false}) triggerEl: ElementRef;
+}
+
+@Component({
+ selector: 'custom-menu',
+ template: `
+
+ Custom Menu header
+
+
+ `,
+ exportAs: 'matCustomMenu'
+})
+class CustomMenuPanel implements MatMenuPanel {
+ direction: Direction;
+ xPosition: MenuPositionX = 'after';
+ yPosition: MenuPositionY = 'below';
+ overlapTrigger = true;
+ parentMenu: MatMenuPanel;
+
+ @ViewChild(TemplateRef, {static: false}) templateRef: TemplateRef;
+ @Output() close = new EventEmitter();
+ focusFirstItem = () => {};
+ resetActiveItem = () => {};
+ setPositionClasses = () => {};
+}
+
+@Component({
+ template: `
+
+
+
+
+ `
+})
+class CustomMenu {
+ @ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
+}
+
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+})
+class NestedMenu {
+ @ViewChild('root', {static: false}) rootMenu: MatMenu;
+ @ViewChild('rootTrigger', {static: false}) rootTrigger: MatMenuTrigger;
+ @ViewChild('rootTriggerEl', {static: false}) rootTriggerEl: ElementRef;
+ @ViewChild('alternateTrigger', {static: false}) alternateTrigger: MatMenuTrigger;
+ readonly rootCloseCallback = jasmine.createSpy('root menu closed callback');
+
+ @ViewChild('levelOne', {static: false}) levelOneMenu: MatMenu;
+ @ViewChild('levelOneTrigger', {static: false}) levelOneTrigger: MatMenuTrigger;
+ readonly levelOneCloseCallback = jasmine.createSpy('level one menu closed callback');
+
+ @ViewChild('levelTwo', {static: false}) levelTwoMenu: MatMenu;
+ @ViewChild('levelTwoTrigger', {static: false}) levelTwoTrigger: MatMenuTrigger;
+ readonly levelTwoCloseCallback = jasmine.createSpy('level one menu closed callback');
+
+ @ViewChild('lazy', {static: false}) lazyMenu: MatMenu;
+ @ViewChild('lazyTrigger', {static: false}) lazyTrigger: MatMenuTrigger;
+ showLazy = false;
+}
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+
+ `
+})
+class NestedMenuCustomElevation {
+ @ViewChild('rootTrigger', {static: false}) rootTrigger: MatMenuTrigger;
+ @ViewChild('levelOneTrigger', {static: false}) levelOneTrigger: MatMenuTrigger;
+}
+
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+
+ `
+})
+class NestedMenuRepeater {
+ @ViewChild('rootTriggerEl', {static: false}) rootTriggerEl: ElementRef;
+ @ViewChild('levelOneTrigger', {static: false}) levelOneTrigger: MatMenuTrigger;
+
+ items = ['one', 'two', 'three'];
+}
+
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+
+ `
+})
+class SubmenuDeclaredInsideParentMenu {
+ @ViewChild('rootTriggerEl', {static: false}) rootTriggerEl: ElementRef;
+}
+
+
+@Component({
+ selector: 'fake-icon',
+ template: ''
+})
+class FakeIcon {}
+
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+ `
+})
+class SimpleLazyMenu {
+ @ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
+ @ViewChild('triggerEl', {static: false}) triggerEl: ElementRef;
+ @ViewChildren(MatMenuItem) items: QueryList;
+}
+
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+
+ `
+})
+class LazyMenuWithContext {
+ @ViewChild('triggerOne', {static: false}) triggerOne: MatMenuTrigger;
+ @ViewChild('triggerTwo', {static: false}) triggerTwo: MatMenuTrigger;
+}
+
+
+
+@Component({
+ template: `
+
+
+
+
+
+
+
+
+ `
+})
+class DynamicPanelMenu {
+ @ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
+ @ViewChild('one', {static: false}) firstMenu: MatMenu;
+ @ViewChild('two', {static: false}) secondMenu: MatMenu;
+}
+
+
+@Component({
+ template: `
+
+
+
+
+
+
+ `
+})
+class MenuWithCheckboxItems {
+ @ViewChild(MatMenuTrigger, {static: false}) trigger: MatMenuTrigger;
+}
diff --git a/src/material-experimental/mdc-menu/menu.ts b/src/material-experimental/mdc-menu/menu.ts
index 03efb7cf6752..bea56f5b04eb 100644
--- a/src/material-experimental/mdc-menu/menu.ts
+++ b/src/material-experimental/mdc-menu/menu.ts
@@ -6,20 +6,52 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {ChangeDetectionStrategy, Component, ViewEncapsulation} from '@angular/core';
+import {ChangeDetectionStrategy, Component, ViewEncapsulation, Provider} from '@angular/core';
+import {Overlay, ScrollStrategy} from '@angular/cdk/overlay';
+import {
+ MatMenu as BaseMatMenu,
+ MAT_MENU_PANEL,
+ matMenuAnimations,
+ MAT_MENU_SCROLL_STRATEGY,
+} from '@angular/material/menu';
+
+/** @docs-private */
+export function MAT_MENU_SCROLL_STRATEGY_FACTORY(overlay: Overlay): () => ScrollStrategy {
+ return () => overlay.scrollStrategies.reposition();
+}
+
+/** @docs-private */
+export const MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER: Provider = {
+ provide: MAT_MENU_SCROLL_STRATEGY,
+ deps: [Overlay],
+ useFactory: MAT_MENU_SCROLL_STRATEGY_FACTORY,
+};
@Component({
moduleId: module.id,
selector: 'mat-menu',
templateUrl: 'menu.html',
styleUrls: ['menu.css'],
- host: {
- 'class': 'mat-mdc-menu',
- },
- exportAs: 'matMenu',
- encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ exportAs: 'matMenu',
+ animations: [
+ matMenuAnimations.transformMenu,
+ matMenuAnimations.fadeInItems
+ ],
+ providers: [
+ {provide: MAT_MENU_PANEL, useExisting: MatMenu},
+ {provide: BaseMatMenu, useExisting: MatMenu},
+ ]
})
-export class MatMenu {
- // TODO: set up MDC foundation class.
+export class MatMenu extends BaseMatMenu {
+ setElevation(_depth: number) {
+ // TODO(crisbeto): MDC's styles come with elevation already and we haven't mapped our mixins
+ // to theirs. Disable the elevation stacking for now until everything has been mapped.
+ // The following unit tests should be re-enabled:
+ // - should not remove mat-elevation class from overlay when panelClass is changed
+ // - should increase the sub-menu elevation based on its depth
+ // - should update the elevation when the same menu is opened at a different depth
+ // - should not increase the elevation if the user specified a custom one
+ }
}
diff --git a/src/material-experimental/mdc-menu/module.ts b/src/material-experimental/mdc-menu/module.ts
index 51a4055b8c42..16a4eeb5e072 100644
--- a/src/material-experimental/mdc-menu/module.ts
+++ b/src/material-experimental/mdc-menu/module.ts
@@ -8,13 +8,22 @@
import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
-import {MatCommonModule} from '@angular/material/core';
-import {MatMenu} from './menu';
+import {MatCommonModule, MatRippleModule} from '@angular/material/core';
+import {OverlayModule} from '@angular/cdk/overlay';
+import {_MatMenuDirectivesModule} from '@angular/material/menu';
+import {MatMenu, MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER} from './menu';
+import {MatMenuItem} from './menu-item';
@NgModule({
- imports: [MatCommonModule, CommonModule],
- exports: [MatMenu, MatCommonModule],
- declarations: [MatMenu],
+ imports: [
+ CommonModule,
+ MatRippleModule,
+ MatCommonModule,
+ OverlayModule,
+ _MatMenuDirectivesModule
+ ],
+ exports: [MatMenu, MatMenuItem, _MatMenuDirectivesModule],
+ declarations: [MatMenu, MatMenuItem],
+ providers: [MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER]
})
-export class MatMenuModule {
-}
+export class MatMenuModule {}
diff --git a/src/material-experimental/mdc-menu/public-api.ts b/src/material-experimental/mdc-menu/public-api.ts
index 6b6e6d26a47a..61565ff43c65 100644
--- a/src/material-experimental/mdc-menu/public-api.ts
+++ b/src/material-experimental/mdc-menu/public-api.ts
@@ -6,5 +6,22 @@
* found in the LICENSE file at https://angular.io/license
*/
-export * from './menu';
+export {MatMenu} from './menu';
+export {MatMenuItem} from './menu-item';
export * from './module';
+
+export {
+ _MatMenuDirectivesModule,
+ fadeInItems,
+ MAT_MENU_DEFAULT_OPTIONS,
+ MAT_MENU_PANEL,
+ MAT_MENU_SCROLL_STRATEGY,
+ matMenuAnimations,
+ MatMenuContent,
+ MatMenuDefaultOptions,
+ MatMenuPanel,
+ MatMenuTrigger,
+ MenuPositionX,
+ MenuPositionY,
+ transformMenu,
+} from '@angular/material/menu';
diff --git a/src/material/core/style/_menu-common.scss b/src/material/core/style/_menu-common.scss
index a7159051fbc0..5fabe9374078 100644
--- a/src/material/core/style/_menu-common.scss
+++ b/src/material/core/style/_menu-common.scss
@@ -1,5 +1,6 @@
@import './variables';
@import './list-common';
+@import './layout-common';
@import './vendor-prefixes';
/** The mixins below are shared between mat-menu and mat-select */
@@ -57,3 +58,46 @@ $mat-menu-icon-margin: 16px !default;
}
}
}
+
+@mixin mat-menu-item-submenu-trigger($side-padding, $triangle-height: 10px) {
+ // Increase the side padding to prevent the indicator from overlapping the text.
+ padding-right: $side-padding * 2;
+
+ // Renders a triangle to indicate that the menu item will open a sub-menu.
+ &::after {
+ $size: $triangle-height / 2;
+
+ width: 0;
+ height: 0;
+ border-style: solid;
+ border-width: $size 0 $size $size;
+ border-color: transparent transparent transparent currentColor;
+ content: '';
+ display: inline-block;
+ position: absolute;
+ top: 50%;
+ right: $side-padding;
+ transform: translateY(-50%);
+ }
+
+ [dir='rtl'] & {
+ padding-right: $side-padding;
+ padding-left: $side-padding * 2;
+
+ &::after {
+ right: auto;
+ left: $side-padding;
+ transform: rotateY(180deg) translateY(-50%);
+ }
+ }
+}
+
+@mixin mat-menu-item-ripple() {
+ @include mat-fill;
+
+ // Prevent any pointer events on the ripple container for a menu item. The ripple container is
+ // not the trigger element for the ripples and can be therefore disabled like that. Disabling
+ // the pointer events ensures that there is no `click` event that can bubble up and cause
+ // the menu panel to close.
+ pointer-events: none;
+}
diff --git a/src/material/menu/menu-animations.ts b/src/material/menu/menu-animations.ts
index 79dd1b194fc1..dcae4e5a8f31 100644
--- a/src/material/menu/menu-animations.ts
+++ b/src/material/menu/menu-animations.ts
@@ -41,7 +41,9 @@ export const matMenuAnimations: {
transform: 'scale(0.8)'
})),
transition('void => enter', group([
- query('.mat-menu-content', animate('100ms linear', style({opacity: 1}))),
+ query('.mat-menu-content, .mat-mdc-menu-content', animate('100ms linear', style({
+ opacity: 1
+ }))),
animate('120ms cubic-bezier(0, 0, 0.2, 1)', style({transform: 'scale(1)'})),
])),
transition('* => void', animate('100ms 25ms linear', style({opacity: 0})))
diff --git a/src/material/menu/menu-module.ts b/src/material/menu/menu-module.ts
index d0e65b5c7f13..92b8447bfefc 100644
--- a/src/material/menu/menu-module.ts
+++ b/src/material/menu/menu-module.ts
@@ -11,13 +11,24 @@ import {CommonModule} from '@angular/common';
import {NgModule} from '@angular/core';
import {MatCommonModule, MatRippleModule} from '@angular/material/core';
import {MatMenuContent} from './menu-content';
-import {MatMenu} from './menu';
+import {_MatMenu} from './menu';
import {MatMenuItem} from './menu-item';
import {
MatMenuTrigger,
MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER,
} from './menu-trigger';
+/**
+ * Used by both the current `MatMenuModule` and the MDC `MatMenuModule`
+ * to declare the menu-related directives.
+ */
+@NgModule({
+ exports: [MatMenuTrigger, MatMenuContent, MatCommonModule],
+ declarations: [MatMenuTrigger, MatMenuContent],
+ providers: [MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER]
+})
+// tslint:disable-next-line:class-name
+export class _MatMenuDirectivesModule {}
@NgModule({
imports: [
@@ -25,9 +36,10 @@ import {
MatCommonModule,
MatRippleModule,
OverlayModule,
+ _MatMenuDirectivesModule,
],
- exports: [MatMenu, MatMenuItem, MatMenuTrigger, MatMenuContent, MatCommonModule],
- declarations: [MatMenu, MatMenuItem, MatMenuTrigger, MatMenuContent],
+ exports: [_MatMenu, MatMenuItem, _MatMenuDirectivesModule],
+ declarations: [_MatMenu, MatMenuItem],
providers: [MAT_MENU_SCROLL_STRATEGY_FACTORY_PROVIDER]
})
export class MatMenuModule {}
diff --git a/src/material/menu/menu-trigger.ts b/src/material/menu/menu-trigger.ts
index 9c655e22cbd8..3db167d01fe9 100644
--- a/src/material/menu/menu-trigger.ts
+++ b/src/material/menu/menu-trigger.ts
@@ -407,7 +407,7 @@ export class MatMenuTrigger implements AfterContentInit, OnDestroy {
positionStrategy: this._overlay.position()
.flexibleConnectedTo(this._element)
.withLockedPosition()
- .withTransformOriginOn('.mat-menu-panel'),
+ .withTransformOriginOn('.mat-menu-panel, .mat-mdc-menu-panel'),
backdropClass: this.menu.backdropClass || 'cdk-overlay-transparent-backdrop',
scrollStrategy: this._scrollStrategy(),
direction: this._dir
diff --git a/src/material/menu/menu.scss b/src/material/menu/menu.scss
index 3dbc18fd1cee..64504b6476c1 100644
--- a/src/material/menu/menu.scss
+++ b/src/material/menu/menu.scss
@@ -1,6 +1,5 @@
// TODO(kara): update vars for desktop when MD team responds
@import '../core/style/button-common';
-@import '../core/style/layout-common';
@import '../core/style/menu-common';
@import '../../cdk/a11y/a11y';
@@ -51,36 +50,7 @@ $mat-menu-submenu-indicator-size: 10px !default;
}
.mat-menu-item-submenu-trigger {
- // Increase the side padding to prevent the indicator from overlapping the text.
- padding-right: $mat-menu-side-padding * 2;
-
- // Renders a triangle to indicate that the menu item will open a sub-menu.
- &::after {
- $size: $mat-menu-submenu-indicator-size / 2;
-
- width: 0;
- height: 0;
- border-style: solid;
- border-width: $size 0 $size $size;
- border-color: transparent transparent transparent currentColor;
- content: '';
- display: inline-block;
- position: absolute;
- top: 50%;
- right: $mat-menu-side-padding;
- transform: translateY(-50%);
- }
-
- [dir='rtl'] & {
- padding-right: $mat-menu-side-padding;
- padding-left: $mat-menu-side-padding * 2;
-
- &::after {
- right: auto;
- left: $mat-menu-side-padding;
- transform: rotateY(180deg) translateY(-50%);
- }
- }
+ @include mat-menu-item-submenu-trigger($mat-menu-side-padding);
}
button.mat-menu-item {
@@ -90,11 +60,5 @@ button.mat-menu-item {
// Increase specificity because ripple styles are part of the `mat-core` mixin and can
// potentially overwrite the absolute position of the container.
.mat-menu-item .mat-menu-ripple {
- @include mat-fill;
-
- // Prevent any pointer events on the ripple container for a menu item. The ripple container is
- // not the trigger element for the ripples and can be therefore disabled like that. Disabling
- // the pointer events ensures that there is no `click` event that can bubble up and cause
- // the menu panel to close.
- pointer-events: none;
+ @include mat-menu-item-ripple;
}
diff --git a/src/material/menu/menu.ts b/src/material/menu/menu.ts
index 0b07bfe943a0..00bd044ebb35 100644
--- a/src/material/menu/menu.ts
+++ b/src/material/menu/menu.ts
@@ -89,24 +89,10 @@ export function MAT_MENU_DEFAULT_OPTIONS_FACTORY(): MatMenuDefaultOptions {
*/
const MAT_MENU_BASE_ELEVATION = 4;
-
-@Component({
- moduleId: module.id,
- selector: 'mat-menu',
- templateUrl: 'menu.html',
- styleUrls: ['menu.css'],
- changeDetection: ChangeDetectionStrategy.OnPush,
- encapsulation: ViewEncapsulation.None,
- exportAs: 'matMenu',
- animations: [
- matMenuAnimations.transformMenu,
- matMenuAnimations.fadeInItems
- ],
- providers: [
- {provide: MAT_MENU_PANEL, useExisting: MatMenu}
- ]
-})
-export class MatMenu implements AfterContentInit, MatMenuPanel, OnInit, OnDestroy {
+/** Base class with all of the `MatMenu` functionality. */
+// tslint:disable-next-line:class-name
+export class _MatMenuBase implements AfterContentInit, MatMenuPanel, OnInit,
+ OnDestroy {
private _keyManager: FocusKeyManager;
private _xPosition: MenuPositionX = this._defaultOptions.xPosition;
private _yPosition: MenuPositionY = this._defaultOptions.yPosition;
@@ -426,3 +412,37 @@ export class MatMenu implements AfterContentInit, MatMenuPanel, OnI
}
}
}
+
+export class MatMenu extends _MatMenuBase {}
+
+// Note on the weird inheritance setup: we need three classes, because the MDC-based menu has to
+// extend `MatMenu`, however keeping a reference to it will cause the inlined template and styles
+// to be retained as well. The MDC menu also has to provide itself as a `MatMenu` in order for
+// queries and DI to work correctly, while still not referencing the actual menu class.
+// Class responsibility is split up as follows:
+// * _MatMenuBase - provides all the functionality without any of the Angular metadata.
+// * MatMenu - keeps the same name symbol name as the current menu and
+// is used as a provider for DI and query purposes.
+// * _MatMenu - the actual menu component implementation with the Angular metadata that should
+// be tree shaken away for MDC.
+
+@Component({
+ moduleId: module.id,
+ selector: 'mat-menu',
+ templateUrl: 'menu.html',
+ styleUrls: ['menu.css'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ exportAs: 'matMenu',
+ animations: [
+ matMenuAnimations.transformMenu,
+ matMenuAnimations.fadeInItems
+ ],
+ providers: [
+ {provide: MAT_MENU_PANEL, useExisting: MatMenu},
+ {provide: MatMenu, useExisting: _MatMenu}
+ ]
+})
+// tslint:disable-next-line:class-name
+export class _MatMenu extends MatMenu {
+}
diff --git a/src/material/menu/public-api.ts b/src/material/menu/public-api.ts
index 92feabe3ce8f..0b37698c0a31 100644
--- a/src/material/menu/public-api.ts
+++ b/src/material/menu/public-api.ts
@@ -6,10 +6,16 @@
* found in the LICENSE file at https://angular.io/license
*/
-export {MatMenu, MatMenuDefaultOptions, MAT_MENU_DEFAULT_OPTIONS} from './menu';
+export {
+ MatMenu,
+ MatMenuDefaultOptions,
+ MAT_MENU_DEFAULT_OPTIONS,
+ _MatMenu,
+ _MatMenuBase,
+} from './menu';
export {MatMenuItem} from './menu-item';
export {MatMenuTrigger, MAT_MENU_SCROLL_STRATEGY} from './menu-trigger';
-export {MatMenuPanel} from './menu-panel';
+export {MatMenuPanel, MAT_MENU_PANEL} from './menu-panel';
export * from './menu-module';
export * from './menu-animations';
export * from './menu-content';
diff --git a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html
index e9ae9654d6f0..42ccf0b3fb84 100644
--- a/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html
+++ b/src/universal-app/kitchen-sink-mdc/kitchen-sink-mdc.html
@@ -17,9 +17,12 @@