diff --git a/e2e/components/mdc-menu-e2e.spec.ts b/e2e/components/mdc-menu-e2e.spec.ts index f827d816238d..5c2d5aa78751 100644 --- a/e2e/components/mdc-menu-e2e.spec.ts +++ b/e2e/components/mdc-menu-e2e.spec.ts @@ -1 +1,208 @@ -// TODO: copy tests from existing mat-menu, update as necessary to fix. +import {browser, by, element, ExpectedConditions, Key, protractor} from 'protractor'; +import { + expectAlignedWith, + expectFocusOn, + expectLocation, + expectToExist, + pressKeys, +} from '../util/index'; + +const presenceOf = ExpectedConditions.presenceOf; +const not = ExpectedConditions.not; + +describe('menu', () => { + const menuSelector = '.mat-mdc-menu-panel'; + const page = { + menu: () => element(by.css(menuSelector)), + start: () => element(by.id('start')), + trigger: () => element(by.id('trigger')), + triggerTwo: () => element(by.id('trigger-two')), + backdrop: () => element(by.css('.cdk-overlay-backdrop')), + items: (index: number) => element.all(by.css('[mat-menu-item]')).get(index), + textArea: () => element(by.id('text')), + beforeTrigger: () => element(by.id('before-t')), + aboveTrigger: () => element(by.id('above-t')), + combinedTrigger: () => element(by.id('combined-t')), + beforeMenu: () => element(by.css(`${menuSelector}.before`)), + aboveMenu: () => element(by.css(`${menuSelector}.above`)), + combinedMenu: () => element(by.css(`${menuSelector}.combined`)), + getResultText: () => page.textArea().getText(), + }; + + beforeEach(async () => await browser.get('/mdc-menu')); + + it('should open menu when the trigger is clicked', async () => { + await expectToExist(menuSelector, false); + await page.trigger().click(); + + await expectToExist(menuSelector); + expect(await page.menu().getText()).toEqual('One\nTwo\nThree\nFour'); + }); + + it('should close menu when menu item is clicked', async () => { + await page.trigger().click(); + await page.items(0).click(); + await expectToExist(menuSelector, false); + }); + + it('should run click handlers on regular menu items', async () => { + await page.trigger().click(); + await page.items(0).click(); + expect(await page.getResultText()).toEqual('one'); + + await page.trigger().click(); + await page.items(1).click(); + expect(await page.getResultText()).toEqual('two'); + }); + + it('should run not run click handlers on disabled menu items', async () => { + await page.trigger().click(); + await page.items(2).click(); + expect(await page.getResultText()).toEqual(''); + }); + + it('should support multiple triggers opening the same menu', async () => { + await page.triggerTwo().click(); + + expect(await page.menu().getText()).toEqual('One\nTwo\nThree\nFour'); + await expectAlignedWith(page.menu(), '#trigger-two'); + + await page.backdrop().click(); + await browser.wait(not(presenceOf(element(by.css(menuSelector))))); + await browser.wait(not(presenceOf(element(by.css('.cdk-overlay-backdrop'))))); + + await page.trigger().click(); + + expect(await page.menu().getText()).toEqual('One\nTwo\nThree\nFour'); + await expectAlignedWith(page.menu(), '#trigger'); + + await page.backdrop().click(); + + await browser.wait(not(presenceOf(element(by.css(menuSelector))))); + await browser.wait(not(presenceOf(element(by.css('.cdk-overlay-backdrop'))))); + }); + + it('should mirror classes on host to menu template in overlay', async () => { + await page.trigger().click(); + expect(await page.menu().getAttribute('class')).toContain('mat-mdc-menu-panel'); + expect(await page.menu().getAttribute('class')).toContain('custom'); + }); + + describe('keyboard events', () => { + beforeEach(async () => { + // click start button to avoid tabbing past navigation + await page.start().click(); + await pressKeys(Key.TAB); + }); + + it('should auto-focus the first item when opened with ENTER', async () => { + await pressKeys(Key.ENTER); + await expectFocusOn(page.items(0)); + }); + + it('should auto-focus the first item when opened with SPACE', async () => { + await pressKeys(Key.SPACE); + await expectFocusOn(page.items(0)); + }); + + it('should focus the first item when opened by mouse', async () => { + await page.trigger().click(); + await expectFocusOn(page.items(0)); + }); + + it('should focus subsequent items when down arrow is pressed', async () => { + await pressKeys(Key.ENTER, Key.DOWN); + await expectFocusOn(page.items(1)); + }); + + it('should focus previous items when up arrow is pressed', async () => { + await pressKeys(Key.ENTER, Key.DOWN, Key.UP); + await expectFocusOn(page.items(0)); + }); + + it('should skip disabled items using arrow keys', async () => { + await pressKeys(Key.ENTER, Key.DOWN, Key.DOWN); + await expectFocusOn(page.items(3)); + + await pressKeys(Key.UP); + await expectFocusOn(page.items(1)); + }); + + it('should close the menu when tabbing past items', async () => { + await pressKeys(Key.ENTER, Key.TAB); + await expectToExist(menuSelector, false); + + await pressKeys(Key.TAB, Key.ENTER); + await expectToExist(menuSelector); + + await pressKeys(protractor.Key.chord(Key.SHIFT, Key.TAB)); + await expectToExist(menuSelector, false); + }); + + it('should wrap back to menu when arrow keying past items', async () => { + let down = Key.DOWN; + await pressKeys(Key.ENTER, down, down, down); + await expectFocusOn(page.items(0)); + + await pressKeys(Key.UP); + await expectFocusOn(page.items(3)); + }); + + it('should focus before and after trigger when tabbing past items', async () => { + let shiftTab = protractor.Key.chord(Key.SHIFT, Key.TAB); + + await pressKeys(Key.ENTER, Key.TAB); + await expectFocusOn(page.triggerTwo()); + + // navigate back to trigger + await pressKeys(shiftTab, Key.ENTER, shiftTab); + await expectFocusOn(page.start()); + }); + + }); + + describe('position - ', () => { + + it('should default menu alignment to "after below" when not set', async () => { + await page.trigger().click(); + + // menu.x should equal trigger.x, menu.y should equal trigger.y + await expectAlignedWith(page.menu(), '#trigger'); + }); + + it('should align overlay end to origin end when x-position is "before"', async () => { + await page.beforeTrigger().click(); + + const trigger = await page.beforeTrigger().getLocation(); + + // the menu's right corner must be attached to the trigger's right corner. + // menu = 112px wide. trigger = 60px wide. 112 - 60 = 52px of menu to the left of trigger. + // trigger.x (left corner) - 52px (menu left of trigger) = expected menu.x (left corner) + // menu.y should equal trigger.y because only x position has changed. + await expectLocation(page.beforeMenu(), {x: trigger.x - 52, y: trigger.y}); + }); + + it('should align overlay bottom to origin bottom when y-position is "above"', async () => { + await page.aboveTrigger().click(); + + const trigger = await page.aboveTrigger().getLocation(); + + // the menu's bottom corner must be attached to the trigger's bottom corner. + // menu.x should equal trigger.x because only y position has changed. + // menu = 64px high. trigger = 20px high. 64 - 20 = 44px of menu extending up past trigger. + // trigger.y (top corner) - 44px (menu above trigger) = expected menu.y (top corner) + await expectLocation(page.aboveMenu(), {x: trigger.x, y: trigger.y - 44}); + }); + + it('should align menu to top left of trigger when "below" and "above"', async () => { + await page.combinedTrigger().click(); + + const trigger = await page.combinedTrigger().getLocation(); + + // trigger.x (left corner) - 52px (menu left of trigger) = expected menu.x + // trigger.y (top corner) - 44px (menu above trigger) = expected menu.y + await expectLocation(page.combinedMenu(), {x: trigger.x - 52, y: trigger.y - 44}); + }); + + }); +}); diff --git a/e2e/components/menu-e2e.spec.ts b/e2e/components/menu-e2e.spec.ts index ae4b161a0394..15cb391bcafd 100644 --- a/e2e/components/menu-e2e.spec.ts +++ b/e2e/components/menu-e2e.spec.ts @@ -13,7 +13,7 @@ const not = ExpectedConditions.not; describe('menu', () => { const menuSelector = '.mat-menu-panel'; const page = { - menu: () => element(by.css('.mat-menu-panel')), + menu: () => element(by.css(menuSelector)), start: () => element(by.id('start')), trigger: () => element(by.id('trigger')), triggerTwo: () => element(by.id('trigger-two')), @@ -23,9 +23,9 @@ describe('menu', () => { beforeTrigger: () => element(by.id('before-t')), aboveTrigger: () => element(by.id('above-t')), combinedTrigger: () => element(by.id('combined-t')), - beforeMenu: () => element(by.css('.mat-menu-panel.before')), - aboveMenu: () => element(by.css('.mat-menu-panel.above')), - combinedMenu: () => element(by.css('.mat-menu-panel.combined')), + beforeMenu: () => element(by.css(`${menuSelector}.before`)), + aboveMenu: () => element(by.css(`${menuSelector}.above`)), + combinedMenu: () => element(by.css(`${menuSelector}.combined`)), getResultText: () => page.textArea().getText(), }; diff --git a/src/dev-app/mdc-menu/mdc-menu-demo-module.ts b/src/dev-app/mdc-menu/mdc-menu-demo-module.ts index 9bdb8b839dff..68b973158320 100644 --- a/src/dev-app/mdc-menu/mdc-menu-demo-module.ts +++ b/src/dev-app/mdc-menu/mdc-menu-demo-module.ts @@ -7,14 +7,24 @@ */ import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; import {MatMenuModule} from '@angular/material-experimental/mdc-menu'; import {RouterModule} from '@angular/router'; +import {MatToolbarModule} from '@angular/material/toolbar'; +import {MatIconModule} from '@angular/material/icon'; +import {MatDividerModule} from '@angular/material/divider'; +import {MatButtonModule} from '@angular/material/button'; import {MdcMenuDemo} from './mdc-menu-demo'; @NgModule({ imports: [ + CommonModule, MatMenuModule, RouterModule.forChild([{path: '', component: MdcMenuDemo}]), + MatButtonModule, + MatToolbarModule, + MatIconModule, + MatDividerModule, ], declarations: [MdcMenuDemo], }) diff --git a/src/dev-app/mdc-menu/mdc-menu-demo.html b/src/dev-app/mdc-menu/mdc-menu-demo.html index 759ac0a673ca..7d679932eda6 100644 --- a/src/dev-app/mdc-menu/mdc-menu-demo.html +++ b/src/dev-app/mdc-menu/mdc-menu-demo.html @@ -1,2 +1,189 @@ - -Not yet implemented. +
+
+

You clicked on: {{ selected }}

+ + + + + + + + +
+
+

Menu with divider

+ + + + + + + + + + + +
+
+

Nested menu

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+

Clicking these will navigate:

+ + + + + + + {{ item.text }} + + +
+
+

+ Position x: before +

+ + + + + + + +
+
+

+ Position y: above +

+ + + + + + + +
+
+ +
+
+

overlapTrigger: true

+ + + + + + + + +
+
+

+ Position x: before, overlapTrigger: true +

+ + + + + + + +
+
+

+ Position y: above, overlapTrigger: true +

+ + + + + + + +
+
+ +
This div is for testing scrolled menus.
diff --git a/src/dev-app/mdc-menu/mdc-menu-demo.scss b/src/dev-app/mdc-menu/mdc-menu-demo.scss index 7db6e1bd7b63..a3559edd5d40 100644 --- a/src/dev-app/mdc-menu/mdc-menu-demo.scss +++ b/src/dev-app/mdc-menu/mdc-menu-demo.scss @@ -1 +1,13 @@ -// TODO: copy in demo styles from existing mat-menu demo. +.demo-menu { + display: flex; + flex-flow: row wrap; + + .demo-menu-section { + width: 300px; + margin: 20px; + } + + .demo-end-icon { + justify-content: flex-end; + } +} diff --git a/src/dev-app/mdc-menu/mdc-menu-demo.ts b/src/dev-app/mdc-menu/mdc-menu-demo.ts index 9f48fc918a41..54f76d889675 100644 --- a/src/dev-app/mdc-menu/mdc-menu-demo.ts +++ b/src/dev-app/mdc-menu/mdc-menu-demo.ts @@ -15,4 +15,19 @@ import {Component} from '@angular/core'; styleUrls: ['mdc-menu-demo.css'], }) export class MdcMenuDemo { + selected = ''; + items = [ + {text: 'Refresh'}, + {text: 'Settings'}, + {text: 'Help', disabled: true}, + {text: 'Sign Out'} + ]; + + iconItems = [ + {text: 'Redial', icon: 'dialpad'}, + {text: 'Check voicemail', icon: 'voicemail', disabled: true}, + {text: 'Disable alerts', icon: 'notifications_off'} + ]; + + select(text: string) { this.selected = text; } } diff --git a/src/e2e-app/mdc-menu/mdc-menu-e2e.html b/src/e2e-app/mdc-menu/mdc-menu-e2e.html index 5829ddf76541..3105a008a6b0 100644 --- a/src/e2e-app/mdc-menu/mdc-menu-e2e.html +++ b/src/e2e-app/mdc-menu/mdc-menu-e2e.html @@ -1 +1,48 @@ - +
+
+
{{ selected }}
+ + + + + + + + + + + + + + + + +
+ + + + + + + + + + +
+
+
+ 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 @@

    MDC checkbox

    with a label

    MDC menu

    - - -Not yet implemented. + + + + + +

    MDC radio

    diff --git a/tools/public_api_guard/material/menu.d.ts b/tools/public_api_guard/material/menu.d.ts index cb23467d9bb5..0e54f01eb832 100644 --- a/tools/public_api_guard/material/menu.d.ts +++ b/tools/public_api_guard/material/menu.d.ts @@ -1,10 +1,7 @@ -export declare const fadeInItems: AnimationTriggerMetadata; - -export declare const MAT_MENU_DEFAULT_OPTIONS: InjectionToken; - -export declare const MAT_MENU_SCROLL_STRATEGY: InjectionToken<() => ScrollStrategy>; +export declare class _MatMenu extends MatMenu { +} -export declare class MatMenu implements AfterContentInit, MatMenuPanel, OnInit, OnDestroy { +export declare class _MatMenuBase implements AfterContentInit, MatMenuPanel, OnInit, OnDestroy { _animationDone: Subject; _classList: { [key: string]: boolean; @@ -43,6 +40,20 @@ export declare class MatMenu implements AfterContentInit, MatMenuPanel; + +export declare const MAT_MENU_PANEL: InjectionToken>; + +export declare const MAT_MENU_SCROLL_STRATEGY: InjectionToken<() => ScrollStrategy>; + +export declare class MatMenu extends _MatMenuBase { +} + export declare const matMenuAnimations: { readonly transformMenu: AnimationTriggerMetadata; readonly fadeInItems: AnimationTriggerMetadata;