/// /// Copyright 2015-2026 Micro Focus or one of its affiliates. /// /// Licensed under the Apache License, Version 2.0 (the "License"); /// you may not use this file except in compliance with the License. /// You may obtain a copy of the License at /// /// http://www.apache.org/licenses/LICENSE-2.0 /// /// Unless required by applicable law or agreed to in writing, software /// distributed under the License is distributed on an "AS IS" BASIS, /// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. /// See the License for the specific language governing permissions and /// limitations under the License. /// import { OverlayContainer, OverlayRef } from '@angular/cdk/overlay'; import { Component, ViewChild } from '@angular/core'; import { ComponentFixture, TestBed, inject } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { OverlayPlacementService } from '../../services/overlay-placement'; import { MenuTriggerDirective } from './menu-trigger/menu-trigger.directive'; import { MenuModule } from './menu.module'; @Component({ selector: 'app-menu-test', template: `
`, imports: [MenuModule], }) export class MenuTestComponent { alignment = 'start'; disabled: boolean = false; @ViewChild('menuTrigger', { static: true }) trigger: MenuTriggerDirective; @ViewChild('subMenuTrigger', { static: true }) subMenuTrigger: MenuTriggerDirective; // eslint-disable-next-line @typescript-eslint/no-empty-function onActivate(_: MouseEvent | KeyboardEvent): void {} // eslint-disable-next-line @typescript-eslint/no-empty-function onClick(_: MouseEvent): void {} } describe('MenuComponent', () => { let component: MenuTestComponent; let fixture: ComponentFixture; let nativeElement: HTMLElement; let triggerElement: HTMLButtonElement; let overlayContainer: OverlayContainer; let overlayContainerElement: HTMLElement; let overlayPlacement: OverlayPlacementService; beforeEach(() => { TestBed.configureTestingModule({ imports: [MenuModule, NoopAnimationsModule, MenuTestComponent], }).compileComponents(); // access the overlay container inject([OverlayContainer], (oc: OverlayContainer) => { overlayContainer = oc; overlayContainerElement = oc.getContainerElement(); })(); }); afterEach(() => { overlayContainer.ngOnDestroy(); }); beforeEach(() => { fixture = TestBed.createComponent(MenuTestComponent); component = fixture.componentInstance; nativeElement = fixture.nativeElement; triggerElement = nativeElement.querySelector('#trigger'); overlayPlacement = TestBed.inject(OverlayPlacementService); fixture.detectChanges(); }); it('should create not initially show the menus', () => { // attempt to find the .ux-menu elements expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); it('should show the menu on trigger click', () => { // perform a click on the trigger element triggerElement.click(); // run change detection fixture.detectChanges(); // only the root level menu item should be open expect(document.querySelectorAll('.ux-menu').length).toBe(1); }); it('should hide when the trigger is clicked again', async () => { // perform a click on the trigger element triggerElement.click(); // run change detection fixture.detectChanges(); // only the root level menu item should be open expect(document.querySelectorAll('.ux-menu').length).toBe(1); // perform a click on the overlay triggerElement.click(); // run change detection fixture.detectChanges(); // allow noop animation to complete await fixture.whenStable(); // no menus should be visible expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); it('should hide when the outside the menu is clicked', async () => { // perform a click on the trigger element triggerElement.click(); // run change detection fixture.detectChanges(); // only the root level menu item should be open expect(document.querySelectorAll('.ux-menu').length).toBe(1); // get the backdrop element const backdrop = overlayContainerElement.querySelector( '.cdk-overlay-backdrop' ) as HTMLDivElement; expect(backdrop).toBeTruthy(); // perform a click on the overlay backdrop.click(); // run change detection fixture.detectChanges(); // allow noop animation to complete await fixture.whenStable(); // no menus should be visible expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); it('should show the menu when the trigger is programmatically called', () => { component.trigger.openMenu(); fixture.detectChanges(); expect(document.querySelectorAll('.ux-menu').length).toBe(1); }); it('should close the menu when the trigger is programmatically called', async () => { component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(1); component.trigger.closeMenu(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); it('should toggle the menu when the trigger is programmatically called', async () => { component.trigger.toggleMenu(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(1); component.trigger.toggleMenu(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); it('should have the correct number of menu items', () => { component.trigger.openMenu(); fixture.detectChanges(); // find the menu items expect(document.querySelectorAll('button[uxmenuitem]').length).toBe(3); }); it('should display the menu divider', () => { component.trigger.openMenu(); fixture.detectChanges(); // find the menu items expect(document.querySelectorAll('ux-menu-divider').length).toBe(1); }); it('should update the aria-expanded state on the trigger', () => { expect(triggerElement.getAttribute('aria-haspopup')).toBe('true'); expect(triggerElement.getAttribute('aria-expanded')).toBe('false'); component.trigger.openMenu(); fixture.detectChanges(); expect(triggerElement.getAttribute('aria-expanded')).toBe('true'); }); it('should make only the first menu item tabbable', async () => { component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); const items = document.querySelectorAll('button[uxmenuitem]'); expect(items.item(0).getAttribute('tabindex')).toBe('0'); expect(items.item(1).getAttribute('tabindex')).toBe('-1'); expect(items.item(2).getAttribute('tabindex')).toBe('-1'); }); it('should focus the menu trigger when the menu is closed', async () => { // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // close menu by clicking on an item const items = document.querySelectorAll('button[uxmenuitem]'); items.item(0).click(); fixture.detectChanges(); await fixture.whenStable(); // the trigger element should be focused expect(document.activeElement).toBe(triggerElement); }); it('should show the submenu when the menu item is hovered', async () => { // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // dispatch a mouse enter event component.subMenuTrigger._onMouseEnter(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(2); }); it('should close all menus when backdrop is clicked', async () => { // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // dispatch a mouse enter event component.subMenuTrigger._onMouseEnter(); fixture.detectChanges(); await fixture.whenStable(); // get the backdrop element const backdrop = overlayContainerElement.querySelector( '.cdk-overlay-backdrop' ) as HTMLDivElement; expect(backdrop).toBeTruthy(); // perform a click on the overlay backdrop.click(); // run change detection fixture.detectChanges(); // allow noop animation to complete await fixture.whenStable(); // no menus should be visible expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); it('should close the menu when an item is clicked', async () => { // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // close menu by clicking on an item const items = document.querySelectorAll('button[uxmenuitem]'); items.item(0).click(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); it('should close both menus when a menu item is clicked', async () => { // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // dispatch a mouse enter event component.subMenuTrigger._onMouseEnter(); fixture.detectChanges(); await fixture.whenStable(); // close menu by clicking on an item const items = document.querySelectorAll('button[uxmenuitem]'); items.item(0).click(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); /** * Test case covering issue: https://portal.digitalsafe.net/browse/EL-3644 * * Opening a menu, expanding a submenu and selecting an item would close * the menu, hover when the top level menu is re-opened the internal hover * and focus states were not being reset correctly */ it('should correctly reset menu state when closed', async () => { component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); component.subMenuTrigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // get the child submenu item const submenuItem = document.querySelector('#submenu-item-1'); expect(submenuItem).toBeTruthy(); // perform a click submenuItem.click(); fixture.detectChanges(); await fixture.whenStable(); // the menu should now be closed check the internal menu state is correct expect(component.trigger.menu._isHovering$.value).toBeFalsy(); expect(component.trigger.menu._isFocused$.value).toBeFalsy(); expect(component.subMenuTrigger.menu._isHovering$.value).toBeFalsy(); expect(component.subMenuTrigger.menu._isFocused$.value).toBeFalsy(); }); it('should emit an activated event when clicking on menu item', async () => { component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); spyOn(component, 'onActivate'); const item1Element = overlayContainerElement.querySelector('#menu-item-1') as HTMLButtonElement; expect(item1Element).toBeTruthy(); item1Element.click(); expect(component.onActivate).toHaveBeenCalledTimes(1); }); it('should emit an activated event when pressing enter key on a menu item', async () => { component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); spyOn(component, 'onActivate'); const item1Element = overlayContainerElement.querySelector('#menu-item-1') as HTMLButtonElement; expect(item1Element).toBeTruthy(); item1Element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); expect(component.onActivate).toHaveBeenCalledTimes(1); }); it('should not emit a click or activated event when disabled is true.', async () => { component.disabled = true; fixture.detectChanges(); await fixture.whenStable(); // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // get the child submenu item const menuItem = overlayContainerElement.querySelector('#menu-item-1') as HTMLButtonElement; expect(menuItem).toBeTruthy(); spyOn(component, 'onActivate'); spyOn(component, 'onClick'); // perform a click menuItem.click(); fixture.detectChanges(); await fixture.whenStable(); expect(component.onActivate).not.toHaveBeenCalled(); expect(component.onClick).not.toHaveBeenCalled(); }); it('should not open submenu when menu item is disabled ', async () => { component.disabled = true; fixture.detectChanges(); await fixture.whenStable(); // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); component.subMenuTrigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // get the child submenu item const subItemMenu = overlayContainerElement.querySelector( '#submenu-item-1' ) as HTMLButtonElement; expect(subItemMenu).toBeFalsy(); }); it('should contain the role menu in the overlay dropdown', async () => { // perform a click on the trigger element await triggerElement.click(); // run change detection await fixture.detectChanges(); // only the root level menu item should be open expect(document.querySelectorAll('.ux-menu').length).toBe(1); // expect the ux-menu to contain the role expect(document.querySelector('.ux-menu').getAttribute('role')).toBe('menu'); }); it('should change update the alignment when the alignment input is changed', async () => { spyOn(overlayPlacement, 'updatePosition').and.callThrough(); // change the input component.alignment = 'end'; fixture.detectChanges(); await fixture.whenStable(); // expect alignment to be the default expect(await overlayPlacement.updatePosition).toHaveBeenCalledWith( jasmine.any(OverlayRef), 'bottom', 'end', undefined ); }); }); @Component({ selector: 'app-menu-trigger-test', template: ` @if (showTrigger) {
} `, imports: [MenuModule], }) export class MenuTriggerDestroyTestComponent { @ViewChild(MenuTriggerDirective, { static: false }) trigger: MenuTriggerDirective; // eslint-disable-next-line @typescript-eslint/no-empty-function onActivate(_: MouseEvent | KeyboardEvent): void {} // eslint-disable-next-line @typescript-eslint/no-empty-function closed(): void {} showTrigger: boolean = true; closeOnSelect: boolean = true; } describe('MenuTriggerDestroyTestComponent', () => { let component: MenuTriggerDestroyTestComponent; let fixture: ComponentFixture; beforeEach(() => { TestBed.configureTestingModule({ imports: [MenuModule, NoopAnimationsModule, MenuTriggerDestroyTestComponent], }).compileComponents(); }); beforeEach(() => { fixture = TestBed.createComponent(MenuTriggerDestroyTestComponent); component = fixture.componentInstance; fixture.detectChanges(); }); it('should hide the menu when the trigger element is destroyed.', async () => { component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(1); component.showTrigger = false; fixture.detectChanges(); await fixture.whenStable(); expect(component.trigger).toBeFalsy(); expect(document.querySelectorAll('.ux-menu').length).toBe(0); }); it('should not close the menu when an item is clicked when closeOnSelect is false', async () => { component.closeOnSelect = false; // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // close menu by clicking on an item const items = document.querySelectorAll('button[uxmenuitem]'); items.item(1).click(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(1); }); it('should not close the menu item when pressing enter key when closeOnSelect is false', async () => { component.closeOnSelect = false; component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); const item2Element = document.querySelector('#menu-item-2') as HTMLButtonElement; item2Element.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelectorAll('.ux-menu').length).toBe(1); }); it('should contain the aria-controls attribute when the menu is opened', async () => { expect(document.querySelector('.btn').getAttribute('aria-controls')).toBe(null); // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // get the aria-controls overlay expect(document.querySelector('.btn').getAttribute('aria-controls')).toBe('ux-menu-1-menu'); }); it('should contain the correct id for the ux-menu and the overlay', async () => { // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); expect(document.querySelector('.ux-menu').getAttribute('id')).toBe('ux-menu-1-menu'); expect(document.querySelector('ux-menu').getAttribute('id')).toBe('ux-menu-1'); }); it('should emit closed when the menu has closed', async () => { spyOn(component, 'closed'); // open menu component.trigger.openMenu(); fixture.detectChanges(); await fixture.whenStable(); // close menu by clicking on an item const items = document.querySelectorAll('button[uxmenuitem]'); items.item(1).click(); fixture.detectChanges(); await fixture.whenStable(); expect(component.closed).toHaveBeenCalledTimes(1); }); });