ipsos-components
Version:
Material Design components for Angular
1,319 lines (1,031 loc) • 53.3 kB
text/typescript
import {async, ComponentFixture, fakeAsync, 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,
TemplateRef,
ViewChild,
ViewChildren,
QueryList,
} from '@angular/core';
import {Direction, Directionality} from '@angular/cdk/bidi';
import {OverlayContainer, Overlay} from '@angular/cdk/overlay';
import {ESCAPE, LEFT_ARROW, RIGHT_ARROW} from '@angular/cdk/keycodes';
import {
MAT_MENU_DEFAULT_OPTIONS,
MatMenu,
MatMenuModule,
MatMenuPanel,
MatMenuTrigger,
MenuPositionX,
MenuPositionY,
MatMenuItem,
} from './index';
import {MENU_PANEL_TOP_PADDING, MAT_MENU_SCROLL_STRATEGY} from './menu-trigger';
import {MatRipple} from '@angular/material/core';
import {
dispatchKeyboardEvent,
dispatchMouseEvent,
dispatchEvent,
createKeyboardEvent,
createMouseEvent,
dispatchFakeEvent,
} from '@angular/cdk/testing';
import {Subject} from 'rxjs/Subject';
import {ScrollDispatcher} from '@angular/cdk/scrolling';
describe('MatMenu', () => {
let overlayContainer: OverlayContainer;
let overlayContainerElement: HTMLElement;
let dir: Direction;
beforeEach(async(() => {
dir = 'ltr';
TestBed.configureTestingModule({
imports: [MatMenuModule, NoopAnimationsModule],
declarations: [
SimpleMenu,
PositionedMenu,
OverlapMenu,
CustomMenuPanel,
CustomMenu,
NestedMenu,
NestedMenuCustomElevation,
NestedMenuRepeater,
FakeIcon
],
providers: [
{provide: Directionality, useFactory: () => ({value: dir})}
]
});
TestBed.compileComponents();
inject([OverlayContainer], (oc: OverlayContainer) => {
overlayContainer = oc;
overlayContainerElement = oc.getContainerElement();
})();
}));
afterEach(() => {
overlayContainer.ngOnDestroy();
});
it('should open the menu as an idempotent operation', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
expect(overlayContainerElement.textContent).toBe('');
expect(() => {
fixture.componentInstance.trigger.openMenu();
fixture.componentInstance.trigger.openMenu();
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 = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
const backdrop = <HTMLElement>overlayContainerElement.querySelector('.cdk-overlay-backdrop');
backdrop.click();
fixture.detectChanges();
tick(500);
expect(overlayContainerElement.textContent).toBe('');
}));
it('should restore focus to the trigger when the menu was opened by keyboard', fakeAsync(() => {
const fixture = TestBed.createComponent(SimpleMenu);
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-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 mouse', fakeAsync(() => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
dispatchFakeEvent(triggerEl, 'mousedown');
triggerEl.click();
fixture.detectChanges();
expect(overlayContainerElement.querySelector('.mat-menu-panel')).toBeTruthy();
fixture.componentInstance.trigger.closeMenu();
fixture.detectChanges();
tick(500);
expect(document.activeElement).toBe(triggerEl);
}));
it('should close the menu when pressing ESCAPE', fakeAsync(() => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
const panel = overlayContainerElement.querySelector('.mat-menu-panel')!;
const event = createKeyboardEvent('keydown', ESCAPE);
const stopPropagationSpy = spyOn(event, 'stopPropagation').and.callThrough();
dispatchEvent(panel, event);
fixture.detectChanges();
tick(500);
expect(overlayContainerElement.textContent).toBe('');
expect(stopPropagationSpy).toHaveBeenCalled();
}));
it('should open a custom menu', () => {
const fixture = TestBed.createComponent(CustomMenu);
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', () => {
dir = 'rtl';
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
const overlayPane = overlayContainerElement.querySelector('.cdk-overlay-pane')!;
expect(overlayPane.getAttribute('dir')).toEqual('rtl');
});
it('should transfer any custom classes from the host to the overlay', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
const menuEl = fixture.debugElement.query(By.css('mat-menu')).nativeElement;
const panel = overlayContainerElement.querySelector('.mat-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');
});
it('should set the "menu" role on the overlay panel', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
const menuPanel = overlayContainerElement.querySelector('.mat-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 not throw an error on destroy', () => {
const fixture = TestBed.createComponent(SimpleMenu);
expect(fixture.destroy.bind(fixture)).not.toThrow();
});
it('should be able to extract the menu item text', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
expect(fixture.componentInstance.items.first.getLabel()).toBe('Item');
});
it('should filter out non-text nodes when figuring out the label', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
expect(fixture.componentInstance.items.last.getLabel()).toBe('Item with an icon');
});
it('should focus the menu panel root node when it was opened by mouse', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
const triggerEl = fixture.componentInstance.triggerEl.nativeElement;
dispatchFakeEvent(triggerEl, 'mousedown');
triggerEl.click();
fixture.detectChanges();
const panel = overlayContainerElement.querySelector('.mat-menu-panel')!;
expect(panel).toBeTruthy('Expected the panel to be rendered.');
expect(document.activeElement).toBe(panel, 'Expected the panel to be focused.');
});
it('should close the menu when using the CloseScrollStrategy', fakeAsync(() => {
const scrolledSubject = new Subject();
TestBed
.resetTestingModule()
.configureTestingModule({
imports: [MatMenuModule, NoopAnimationsModule],
declarations: [SimpleMenu, FakeIcon],
providers: [
{
provide: ScrollDispatcher,
useFactory: () => ({scrolled: () => scrolledSubject})
},
{
provide: MAT_MENU_SCROLL_STRATEGY,
deps: [Overlay],
useFactory: (overlay: Overlay) => () => overlay.scrollStrategies.close()
}
]
});
const fixture = TestBed.createComponent(SimpleMenu);
const trigger = fixture.componentInstance.trigger;
fixture.detectChanges();
trigger.openMenu();
fixture.detectChanges();
expect(trigger.menuOpen).toBe(true);
scrolledSubject.next();
tick(500);
expect(trigger.menuOpen).toBe(false);
}));
describe('positions', () => {
let fixture: ComponentFixture<PositionedMenu>;
let panel: HTMLElement;
beforeEach(() => {
fixture = TestBed.createComponent(PositionedMenu);
fixture.detectChanges();
const 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';
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
panel = overlayContainerElement.querySelector('.mat-menu-panel') as HTMLElement;
});
it('should append mat-menu-before if the x position is changed', () => {
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', () => {
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', () => {
fixture.destroy();
let newFixture = TestBed.createComponent(SimpleMenu);
newFixture.detectChanges();
newFixture.componentInstance.trigger.openMenu();
newFixture.detectChanges();
panel = overlayContainerElement.querySelector('.mat-menu-panel') as HTMLElement;
expect(panel.classList).toContain('mat-menu-below');
expect(panel.classList).toContain('mat-menu-after');
});
});
describe('fallback positions', () => {
it('should fall back to "before" mode if "after" mode would not fit on screen', () => {
const fixture = TestBed.createComponent(SimpleMenu);
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 = '-50px';
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.top),
`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 = TestBed.createComponent(SimpleMenu);
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();
// In "above" position, the bottom edges of the overlay and the origin are aligned.
// To find the overlay top, subtract the menu height from the origin's bottom edge.
const expectedTop = triggerRect.bottom - overlayRect.height;
expect(Math.floor(overlayRect.top))
.toBe(Math.floor(expectedTop),
`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 = TestBed.createComponent(SimpleMenu);
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 = '-50px';
trigger.style.bottom = '65px';
fixture.componentInstance.trigger.openMenu();
fixture.detectChanges();
const overlayPane = getOverlayPane();
const triggerRect = trigger.getBoundingClientRect();
const overlayRect = overlayPane.getBoundingClientRect();
const expectedLeft = triggerRect.right - overlayRect.width;
const expectedTop = triggerRect.bottom - overlayRect.height;
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.top))
.toBe(Math.floor(expectedTop),
`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 = TestBed.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.top),
`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<T extends TestableMenu> {
readonly fixture: ComponentFixture<T>;
readonly trigger: HTMLElement;
constructor(ctor: {new(): T; }, inputs: {[key: string]: any} = {}) {
this.fixture = TestBed.createComponent(ctor);
Object.keys(inputs).forEach(key => this.fixture.componentInstance[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.overlayPane.getBoundingClientRect();
}
get triggerRect() {
return this.trigger.getBoundingClientRect();
}
get menuPanel() {
return overlayContainerElement.querySelector('.mat-menu-panel');
}
private get overlayPane() {
return overlayContainerElement.querySelector('.cdk-overlay-pane') as HTMLElement;
}
}
let subject: OverlapSubject<OverlapMenu>;
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 = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
const item = fixture.debugElement.query(By.css('.mat-menu-item'));
const ripple = item.query(By.css('.mat-ripple')).injector.get<MatRipple>(MatRipple);
expect(ripple.disabled).toBe(false);
});
it('should disable ripples on disabled items', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
// The second menu item in the `SimpleMenu` component is disabled.
const items = fixture.debugElement.queryAll(By.css('.mat-menu-item'));
const ripple = items[1].query(By.css('.mat-ripple')).injector.get<MatRipple>(MatRipple);
expect(ripple.disabled).toBe(true);
});
it('should disable ripples if disableRipple is set', () => {
const fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
// The third menu item in the `SimpleMenu` component has ripples disabled.
const items = fixture.debugElement.queryAll(By.css('.mat-menu-item'));
const ripple = items[2].query(By.css('.mat-ripple')).injector.get<MatRipple>(MatRipple);
expect(ripple.disabled).toBe(true);
});
});
describe('close event', () => {
let fixture: ComponentFixture<SimpleMenu>;
beforeEach(() => {
fixture = TestBed.createComponent(SimpleMenu);
fixture.detectChanges();
fixture.componentInstance.trigger.openMenu();
});
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-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<NestedMenu>;
let instance: NestedMenu;
let overlay: HTMLElement;
let compileTestComponent = () => {
fixture = TestBed.createComponent(NestedMenu);
fixture.detectChanges();
instance = fixture.componentInstance;
overlay = overlayContainerElement;
};
it('should set the `triggersSubmenu` flags on the triggers', () => {
compileTestComponent();
expect(instance.rootTrigger.triggersSubmenu()).toBe(false);
expect(instance.levelOneTrigger.triggersSubmenu()).toBe(true);
expect(instance.levelTwoTrigger.triggersSubmenu()).toBe(true);
});
it('should set the `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', () => {
dir = 'rtl';
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();
expect(instance.rootMenu.direction).toBe('rtl');
expect(instance.levelOneMenu.direction).toBe('rtl');
expect(instance.levelTwoMenu.direction).toBe('rtl');
});
it('should emit an event when the hover state of the menu items changes', () => {
compileTestComponent();
instance.rootTrigger.openMenu();
fixture.detectChanges();
const spy = jasmine.createSpy('hover spy');
const subscription = instance.rootMenu._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-menu-panel').length).toBe(1, 'Expected one open menu');
const items = Array.from(overlay.querySelectorAll('.mat-menu-panel [mat-menu-item]'));
const levelOneTrigger = overlay.querySelector('#level-one-trigger')!;
dispatchMouseEvent(levelOneTrigger, 'mouseenter');
fixture.detectChanges();
expect(levelOneTrigger.classList)
.toContain('mat-menu-item-highlighted', 'Expected the trigger to be highlighted');
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus');
dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
fixture.detectChanges();
tick(500);
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
expect(levelOneTrigger.classList)
.not.toContain('mat-menu-item-highlighted', 'Expected the trigger to not be highlighted');
}));
it('should close all the open sub-menus when the hover state is changed at the root',
fakeAsync(() => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
const items = Array.from(overlay.querySelectorAll('.mat-menu-panel [mat-menu-item]'));
const levelOneTrigger = overlay.querySelector('#level-one-trigger')!;
dispatchMouseEvent(levelOneTrigger, 'mouseenter');
fixture.detectChanges();
const levelTwoTrigger = overlay.querySelector('#level-two-trigger')! as HTMLElement;
dispatchMouseEvent(levelTwoTrigger, 'mouseenter');
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(3, 'Expected three open menus');
dispatchMouseEvent(items[items.indexOf(levelOneTrigger) + 1], 'mouseenter');
fixture.detectChanges();
tick(500);
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(1, 'Expected one open menu');
}));
it('should open a nested menu when its trigger is clicked', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
levelOneTrigger.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus');
levelOneTrigger.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(2, 'Expected repeat clicks not to close the menu.');
});
it('should open and close a nested menu with arrow keys in ltr', fakeAsync(() => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
dispatchKeyboardEvent(levelOneTrigger, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
const panels = overlay.querySelectorAll('.mat-menu-panel');
expect(panels.length).toBe(2, 'Expected two open menus');
dispatchKeyboardEvent(panels[1], 'keydown', LEFT_ARROW);
fixture.detectChanges();
tick(500);
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1);
}));
it('should open and close a nested menu with the arrow keys in rtl', fakeAsync(() => {
dir = 'rtl';
fixture.destroy();
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
const levelOneTrigger = overlay.querySelector('#level-one-trigger')! as HTMLElement;
dispatchKeyboardEvent(levelOneTrigger, 'keydown', LEFT_ARROW);
fixture.detectChanges();
const panels = overlay.querySelectorAll('.mat-menu-panel');
expect(panels.length).toBe(2, 'Expected two open menus');
dispatchKeyboardEvent(panels[1], 'keydown', RIGHT_ARROW);
fixture.detectChanges();
tick(500);
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1);
}));
it('should not do anything with the arrow keys for a top-level menu', () => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
const menu = overlay.querySelector('.mat-menu-panel')!;
dispatchKeyboardEvent(menu, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(1, 'Expected one menu to remain open');
dispatchKeyboardEvent(menu, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(1, 'Expected one menu to remain open');
});
it('should close all of the menus when the backdrop is clicked', fakeAsync(() => {
compileTestComponent();
instance.rootTriggerEl.nativeElement.click();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length)
.toBe(3, 'Expected three open menus');
expect(overlay.querySelectorAll('.cdk-overlay-backdrop').length)
.toBe(1, 'Expected one backdrop element');
expect(overlay.querySelectorAll('.mat-menu-panel, .cdk-overlay-backdrop')[0].classList)
.toContain('cdk-overlay-backdrop', 'Expected backdrop to be beneath all of the menus');
(overlay.querySelector('.cdk-overlay-backdrop')! as HTMLElement).click();
fixture.detectChanges();
tick(500);
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(0, 'Expected no open menus');
}));
it('should shift focus between the sub-menus', () => {
compileTestComponent();
instance.rootTrigger.openMenu();
fixture.detectChanges();
expect(overlay.querySelector('.mat-menu-panel')!.contains(document.activeElement))
.toBe(true, 'Expected focus to be inside the root menu');
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel')[1].contains(document.activeElement))
.toBe(true, 'Expected focus to be inside the first nested menu');
instance.levelTwoTrigger.openMenu();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel')[2].contains(document.activeElement))
.toBe(true, 'Expected focus to be inside the second nested menu');
instance.levelTwoTrigger.closeMenu();
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel')[1].contains(document.activeElement))
.toBe(true, 'Expected focus to be back inside the first nested menu');
instance.levelOneTrigger.closeMenu();
fixture.detectChanges();
expect(overlay.querySelector('.mat-menu-panel')!.contains(document.activeElement))
.toBe(true, 'Expected focus to be back inside the root menu');
});
it('should 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', () => {
dir = 'rtl';
compileTestComponent();
instance.rootTriggerEl.nativeElement.style.position = 'fixed';
instance.rootTriggerEl.nativeElement.style.left = '50%';
instance.rootTriggerEl.nativeElement.style.top = '50%';
instance.rootTrigger.openMenu();
fixture.detectChanges();
instance.levelOneTrigger.openMenu();
fixture.detectChanges();
const triggerRect = overlay.querySelector('#level-one-trigger')!.getBoundingClientRect();
const panelRect = overlay.querySelectorAll('.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(() => {
dir = 'rtl';
compileTestComponent();
instance.rootTriggerEl.nativeElement.style.position = 'fixed';
instance.rootTriggerEl.nativeElement.style.left = '10px';
instance.rootTriggerEl.nativeElement.style.top = '50%';
instance.rootTrigger.openMenu();
fixture.detectChanges();
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-menu-panel');
expect(menus.length).toBe(3, 'Expected three open menus');
(menus[2].querySelector('.mat-menu-item')! as HTMLElement).click();
fixture.detectChanges();
tick(500);
expect(overlay.querySelectorAll('.mat-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-menu-item-submenu-trigger');
expect(menuItems[1].classList).not.toContain('mat-menu-item-submenu-trigger');
});
it('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-menu-panel');
expect(menus[0].classList)
.toContain('mat-elevation-z2', 'Expected root menu to have base elevation.');
expect(menus[1].classList)
.toContain('mat-elevation-z3', 'Expected first sub-menu to have base elevation + 1.');
expect(menus[2].classList)
.toContain('mat-elevation-z4', 'Expected second sub-menu to have base elevation + 2.');
});
it('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-menu-panel')[2];
expect(lastMenu.classList)
.toContain('mat-elevation-z4', '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-menu-panel').length)
.toBe(0, 'Expected no open menus');
instance.alternateTrigger.openMenu();
fixture.detectChanges();
lastMenu = overlay.querySelector('.mat-menu-panel') as HTMLElement;
expect(lastMenu.classList)
.not.toContain('mat-elevation-z4', 'Expected menu not to maintain old elevation.');
expect(lastMenu.classList)
.toContain('mat-elevation-z2', 'Expected menu to have the proper updated elevation.');
}));
it('should not increase the elevation if the user specified a custom one', () => {
const elevationFixture = TestBed.createComponent(NestedMenuCustomElevation);
elevationFixture.detectChanges();
elevationFixture.componentInstance.rootTrigger.openMenu();
elevationFixture.detectChanges();
elevationFixture.componentInstance.levelOneTrigger.openMenu();
elevationFixture.detectChanges();
const menuClasses = overlayContainerElement.querySelectorAll('.mat-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-menu-panel');
expect(menus.length).toBe(3, 'Expected three open menus');
instance.rootTrigger.closeMenu();
fixture.detectChanges();
tick(500);
expect(overlay.querySelectorAll('.mat-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();
expect(overlay.querySelectorAll('.mat-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();
expect(lazyTrigger.classList)
.toContain('mat-menu-item-highlighted', 'Expected the trigger to be highlighted');
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus');
}));
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 = TestBed.createComponent(NestedMenuRepeater);
overlay = overlayContainerElement;
expect(() => repeaterFixture.detectChanges()).not.toThrow();
repeaterFixture.componentInstance.rootTriggerEl.nativeElement.click();
repeaterFixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(1, 'Expected one open menu');
dispatchMouseEvent(overlay.querySelector('.level-one-trigger')!, 'mouseenter');
repeaterFixture.detectChanges();
expect(overlay.querySelectorAll('.mat-menu-panel').length).toBe(2, 'Expected two open menus');
}));
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-menu-panel [mat-menu-item]'));
const levelOneTrigger = overlay.querySelector('#level-one-trigger')!;
dispatchMouseEvent(levelOneTrigger, 'mouseenter');
fixture.detectChanges();
expect(overlay.querySelectorAll('.mat-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(async(() => {
TestBed.configureTestingModule({
imports: [MatMenuModule, NoopAnimationsModule],
declarations: [SimpleMenu, FakeIcon],
providers: [{
provide: MAT_MENU_DEFAULT_OPTIONS,
useValue: {overlapTrigger: false, 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(false);
expect(menu.xPosition).toBe('before');
expect(menu.yPosition).toBe('above');
});
});
@Component({
template: `
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
<mat-menu class="custom-one custom-two" #menu="matMenu" (closed)="closeCallback($event)">
<button mat-menu-item> Item </button>
<button mat-menu-item disabled> Disabled </button>
<button mat-menu-item disableRipple>
<fake-icon>unicorn</fake-icon>
Item with an icon
</button>
</mat-menu>
`
})
class SimpleMenu {
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
@ViewChild('triggerEl') triggerEl: ElementRef;
@ViewChild(MatMenu) menu: MatMenu;
@ViewChildren(MatMenuItem) items: QueryList<MatMenuItem>;
closeCallback = jasmine.createSpy('menu closed callback');
}
@Component({
template: `
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
<mat-menu [xPosition]="xPosition" [yPosition]="yPosition" #menu="matMenu">
<button mat-menu-item> Positioned Content </button>
</mat-menu>
`
})
class PositionedMenu {
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
@ViewChild('triggerEl') triggerEl: ElementRef;
xPosition: MenuPositionX = 'before';
yPosition: MenuPositionY = 'above';
}
interface TestableMenu {
trigger: MatMenuTrigger;
triggerEl: ElementRef;
}
@Component({
template: `
<button [matMenuTriggerFor]="menu" #triggerEl>Toggle menu</button>
<mat-menu [overlapTrigger]="overlapTrigger" #menu="matMenu">
<button mat-menu-item> Not overlapped Content </button>
</mat-menu>
`
})
class OverlapMenu implements TestableMenu {
@Input() overlapTrigger: boolean;
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
@ViewChild('triggerEl') triggerEl: ElementRef;
}
@Component({
selector: 'custom-menu',
template: `
<ng-template>
Custom Menu header
<ng-content></ng-content>
</ng-template>
`,
exportAs: 'matCustomMenu'
})
class CustomMenuPanel implements MatMenuPanel {
direction: Direction;
xPosition: MenuPositionX = 'after';
yPosition: MenuPositionY = 'below';
overlapTrigger = true;
parentMenu: MatMenuPanel;
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
@Output() close = new EventEmitter<void | 'click' | 'keydown'>();
focusFirstItem = () => {};
resetActiveItem = () => {};
setPositionClasses = () => {};
}
@Component({
template: `
<button [matMenuTriggerFor]="menu">Toggle menu</button>
<custom-menu #menu="matCustomMenu">
<button mat-menu-item> Custom Content </button>
</custom-menu>
`
})
class CustomMenu {
@ViewChild(MatMenuTrigger) trigger: MatMenuTrigger;
}
@Component({
template: `
<button
[matMenuTriggerFor]="root"
#rootTrigger="matMenuTrigger"
#rootTriggerEl>Toggle menu</button>
<button
[matMenuTriggerFor]="levelTwo"
#alternateTrigger="matMenuTrigger">Toggle alternate menu</button>
<mat-menu #root="matMenu" (close)="rootCloseCallback($event)">
<button mat-menu-item
id="level-one-trigger"