diff --git a/src/cdk-experimental/menu/BUILD.bazel b/src/cdk-experimental/menu/BUILD.bazel index 703bc2c3b721..f7d8f4d69871 100644 --- a/src/cdk-experimental/menu/BUILD.bazel +++ b/src/cdk-experimental/menu/BUILD.bazel @@ -10,9 +10,11 @@ ng_module( ), module_name = "@angular/cdk-experimental/menu", deps = [ + "//src/cdk/a11y", "//src/cdk/bidi", "//src/cdk/coercion", "//src/cdk/collections", + "//src/cdk/keycodes", "//src/cdk/overlay", "@npm//@angular/core", "@npm//rxjs", @@ -28,6 +30,8 @@ ng_test_library( deps = [ ":menu", "//src/cdk/collections", + "//src/cdk/keycodes", + "//src/cdk/testing/private", "@npm//@angular/platform-browser", ], ) diff --git a/src/cdk-experimental/menu/menu-bar.spec.ts b/src/cdk-experimental/menu/menu-bar.spec.ts index c53db165f037..d950e9624393 100644 --- a/src/cdk-experimental/menu/menu-bar.spec.ts +++ b/src/cdk-experimental/menu/menu-bar.spec.ts @@ -1,9 +1,39 @@ -import {ComponentFixture, TestBed, async} from '@angular/core/testing'; -import {Component} from '@angular/core'; +import {ComponentFixture, TestBed, async, fakeAsync, tick} from '@angular/core/testing'; +import { + Component, + ViewChild, + ElementRef, + ViewChildren, + QueryList, + EventEmitter, +} from '@angular/core'; import {By} from '@angular/platform-browser'; +import { + TAB, + RIGHT_ARROW, + LEFT_ARROW, + DOWN_ARROW, + UP_ARROW, + SPACE, + HOME, + END, + E, + D, + ESCAPE, + S, + H, +} from '@angular/cdk/keycodes'; +import { + dispatchKeyboardEvent, + createKeyboardEvent, + dispatchEvent, +} from '@angular/cdk/testing/private'; import {CdkMenuBar} from './menu-bar'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItemRadio} from './menu-item-radio'; +import {CdkMenu} from './menu'; +import {CdkMenuItem} from './menu-item'; +import {CdkMenuItemCheckbox} from './menu-item-checkbox'; describe('MenuBar', () => { describe('as radio group', () => { @@ -67,6 +97,623 @@ describe('MenuBar', () => { expect(spy).toHaveBeenCalledWith(menuItems[0]); }); }); + + describe('Keyboard handling', () => { + describe('(with ltr layout)', () => { + let fixture: ComponentFixture; + let nativeMenuBar: HTMLElement; + let nativeMenus: HTMLElement[]; + let menuBarNativeItems: HTMLButtonElement[]; + let fileMenuNativeItems: HTMLButtonElement[]; + + function grabElementsForTesting() { + nativeMenuBar = fixture.componentInstance.nativeMenuBar.nativeElement; + + nativeMenus = fixture.componentInstance.nativeMenus.map(e => e.nativeElement); + + menuBarNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(0, 2); // menu bar has the first 2 menu items + + fileMenuNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(2, 5); // file menu has the next 3 menu items + } + + /** Run change detection and extract then set the rendered elements. */ + function detectChanges() { + fixture.detectChanges(); + grabElementsForTesting(); + } + + /** Set focus to the MenuBar and run change detection. */ + function focusMenuBar() { + dispatchKeyboardEvent(document, 'keydown', TAB); + nativeMenuBar.focus(); + + detectChanges(); + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [MultiMenuWithSubmenu], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MultiMenuWithSubmenu); + detectChanges(); + }); + + describe('for MenuBar', () => { + it('should focus the first menu item when the menubar gets tabbed in', () => { + focusMenuBar(); + + expect(document.activeElement).toEqual(menuBarNativeItems[0]); + }); + + it('should toggle the last/first menu item on end/home key press', () => { + focusMenuBar(); + dispatchKeyboardEvent(nativeMenuBar, 'keydown', END); + detectChanges(); + + expect(document.activeElement).toEqual(menuBarNativeItems[menuBarNativeItems.length - 1]); + + focusMenuBar(); + dispatchKeyboardEvent(nativeMenuBar, 'keydown', HOME); + detectChanges(); + + expect(document.activeElement).toEqual(menuBarNativeItems[0]); + }); + + it('should not focus the last item when pressing end with modifier', () => { + focusMenuBar(); + + const event = createKeyboardEvent('keydown', END, '', undefined, {control: true}); + dispatchEvent(nativeMenuBar, event); + detectChanges(); + + expect(document.activeElement).toEqual(menuBarNativeItems[0]); + }); + + it('should not focus the first item when pressing home with modifier', () => { + focusMenuBar(); + dispatchKeyboardEvent(nativeMenuBar, 'keydown', END); + detectChanges(); + + let event = createKeyboardEvent('keydown', HOME, '', undefined, {control: true}); + dispatchEvent(nativeMenuBar, event); + detectChanges(); + + expect(document.activeElement).toEqual(menuBarNativeItems[menuBarNativeItems.length - 1]); + }); + + it('should focus the edit MenuItem on E, D character keys', fakeAsync(() => { + focusMenuBar(); + dispatchKeyboardEvent(nativeMenuBar, 'keydown', E); + dispatchKeyboardEvent(nativeMenuBar, 'keydown', D); + tick(500); + detectChanges(); + + expect(document.activeElement).toEqual(menuBarNativeItems[1]); + })); + + it( + 'should toggle and wrap when cycling the right/left arrow keys on menu bar ' + + 'without toggling menus', + () => { + focusMenuBar(); + + dispatchKeyboardEvent(nativeMenuBar, 'keydown', RIGHT_ARROW); + detectChanges(); + expect(document.activeElement).toEqual(menuBarNativeItems[1]); + + dispatchKeyboardEvent(nativeMenuBar, 'keydown', RIGHT_ARROW); + detectChanges(); + expect(document.activeElement).toEqual(menuBarNativeItems[0]); + + dispatchKeyboardEvent(nativeMenuBar, 'keydown', LEFT_ARROW); + detectChanges(); + expect(document.activeElement).toEqual(menuBarNativeItems[1]); + + dispatchKeyboardEvent(nativeMenuBar, 'keydown', LEFT_ARROW); + detectChanges(); + expect(document.activeElement).toEqual(menuBarNativeItems[0]); + + expect(nativeMenus.length).toBe(0); + } + ); + + it( + "should open the focused menu item's menu and focus the first submenu" + + ' item on the down key', + () => { + focusMenuBar(); + + dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', DOWN_ARROW); + detectChanges(); + + expect(document.activeElement).toEqual(fileMenuNativeItems[0]); + } + ); + + it( + "should open the focused menu item's menu and focus the last submenu" + + ' item on the up key', + () => { + focusMenuBar(); + + dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', UP_ARROW); + detectChanges(); + + expect(document.activeElement).toEqual( + fileMenuNativeItems[fileMenuNativeItems.length - 1] + ); + } + ); + + it('should open the focused menu items menu and focus first submenu item on space', () => { + focusMenuBar(); + + dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', SPACE); + detectChanges(); + + expect(document.activeElement).toEqual(fileMenuNativeItems[0]); + }); + }); + + describe('for Menu', () => { + function openFileMenu() { + focusMenuBar(); + dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', SPACE); + detectChanges(); + } + + function openShareMenu() { + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(document.activeElement!, 'keydown', RIGHT_ARROW); + detectChanges(); + } + + it('should open the submenu with focus on item with menu on right arrow press', () => { + openFileMenu(); + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(document.activeElement!, 'keydown', RIGHT_ARROW); + detectChanges(); + + expect(nativeMenus.length).withContext('menu bar, menu and submenu').toBe(2); + expect(nativeMenus[0].id).toBe('file_menu'); + expect(nativeMenus[1].id).toBe('share_menu'); + }); + + it('should cycle focus on down/up arrow without toggling menus', () => { + openFileMenu(); + expect(document.activeElement).toEqual(fileMenuNativeItems[0]); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + expect(document.activeElement).toEqual(fileMenuNativeItems[1]); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + expect(document.activeElement).toEqual(fileMenuNativeItems[2]); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', UP_ARROW); + expect(document.activeElement).toEqual(fileMenuNativeItems[1]); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', UP_ARROW); + expect(document.activeElement).toEqual(fileMenuNativeItems[0]); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', UP_ARROW); + expect(document.activeElement).toEqual(fileMenuNativeItems[2]); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + expect(document.activeElement).toEqual(fileMenuNativeItems[0]); + + expect(nativeMenus.length).toBe(1); + }); + + it('should focus the first/last item on home/end keys', () => { + openFileMenu(); + expect(document.activeElement).toEqual(fileMenuNativeItems[0]); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', END); + expect(document.activeElement).toEqual( + fileMenuNativeItems[fileMenuNativeItems.length - 1] + ); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', HOME); + expect(document.activeElement).toEqual(fileMenuNativeItems[0]); + }); + + it('should not focus the last item when pressing end with modifier', () => { + openFileMenu(); + + const event = createKeyboardEvent('keydown', END, '', undefined, {control: true}); + dispatchEvent(nativeMenus[0], event); + detectChanges(); + + expect(document.activeElement).toEqual(fileMenuNativeItems[0]); + }); + + it('should not focus the first item when pressing home with modifier', () => { + openFileMenu(); + dispatchKeyboardEvent(nativeMenus[0], 'keydown', END); + detectChanges(); + + const event = createKeyboardEvent('keydown', HOME, '', undefined, {control: true}); + dispatchEvent(nativeMenus[0], event); + detectChanges(); + + expect(document.activeElement).toEqual( + fileMenuNativeItems[fileMenuNativeItems.length - 1] + ); + }); + + it( + 'should call user defined function and close out menus on space key on a non-trigger ' + + 'menu item', + () => { + openFileMenu(); + openShareMenu(); + const spy = jasmine.createSpy('user defined callback spy'); + fixture.componentInstance.clickEmitter.subscribe(spy); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', SPACE); + detectChanges(); + + expect(nativeMenus.length).toBe(0); + expect(spy).toHaveBeenCalledTimes(1); + } + ); + + it('should close the submenu on left arrow and place focus back on its trigger', () => { + openFileMenu(); + openShareMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', LEFT_ARROW); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('file_menu'); + expect(document.activeElement).toEqual(fileMenuNativeItems[1]); + }); + + it( + 'should close menu tree, focus next menu bar item and open its menu on right arrow ' + + "when currently focused item doesn't trigger a menu", + () => { + openFileMenu(); + openShareMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', RIGHT_ARROW); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('edit_menu'); + expect(document.activeElement).toEqual(menuBarNativeItems[1]); + } + ); + + it('should close first level menu and focus previous menubar item on left arrow', () => { + openFileMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', LEFT_ARROW); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('edit_menu'); + expect(document.activeElement).toEqual(menuBarNativeItems[1]); + }); + + it('should close the open submenu and focus its trigger on escape press', () => { + openFileMenu(); + openShareMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', ESCAPE); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('file_menu'); + expect(document.activeElement) + .withContext('re-focus trigger') + .toEqual(fileMenuNativeItems[1]); + }); + + it('should not close submenu and focus parent on escape with modifier', () => { + openFileMenu(); + openShareMenu(); + const event = createKeyboardEvent('keydown', ESCAPE, '', undefined, {control: true}); + + dispatchEvent(nativeMenus[1], event); + detectChanges(); + + expect(nativeMenus.length).withContext('menu bar, file menu, share menu').toBe(2); + expect(nativeMenus[0].id).toBe('file_menu'); + expect(nativeMenus[1].id).toBe('share_menu'); + }); + + it('should close out all menus on tab', () => { + openFileMenu(); + openShareMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', TAB); + detectChanges(); + + expect(nativeMenus.length).toBe(0); + }); + + it('should focus share MenuItem on S, H character key press', fakeAsync(() => { + openFileMenu(); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', S); + dispatchKeyboardEvent(nativeMenus[0], 'keydown', H); + tick(500); + detectChanges(); + + expect(document.activeElement).toEqual(fileMenuNativeItems[1]); + })); + }); + }); + + describe('(with rtl layout)', () => { + let fixture: ComponentFixture; + let nativeMenuBar: HTMLElement; + let nativeMenus: HTMLElement[]; + let menuBarNativeItems: HTMLButtonElement[]; + let fileMenuNativeItems: HTMLButtonElement[]; + + function grabElementsForTesting() { + nativeMenuBar = fixture.componentInstance.nativeMenuBar.nativeElement; + + nativeMenus = fixture.componentInstance.nativeMenus.map(e => e.nativeElement); + + menuBarNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(0, 2); // menu bar has the first 2 menu items + + fileMenuNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(2, 5); // file menu has the next 3 menu items + } + + /** Run change detection and extract then set the rendered elements. */ + function detectChanges() { + fixture.detectChanges(); + grabElementsForTesting(); + } + + /** Place focus on the MenuBar and run change detection. */ + function focusMenuBar() { + dispatchKeyboardEvent(document, 'keydown', TAB); + nativeMenuBar.focus(); + + detectChanges(); + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [MultiMenuWithSubmenu], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MultiMenuWithSubmenu); + detectChanges(); + }); + + beforeAll(() => { + document.dir = 'rtl'; + }); + + afterAll(() => { + document.dir = 'ltr'; + }); + + describe('for Menu', () => { + function openFileMenu() { + focusMenuBar(); + dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', SPACE); + detectChanges(); + } + + function openShareMenu() { + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(document.activeElement!, 'keydown', LEFT_ARROW); + detectChanges(); + } + + it('should open the submenu for menu item with menu on left arrow', () => { + openFileMenu(); + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(document.activeElement!, 'keydown', LEFT_ARROW); + detectChanges(); + + expect(nativeMenus.length).withContext('menu and submenu').toBe(2); + expect(nativeMenus[0].id).toBe('file_menu'); + expect(nativeMenus[1].id).toBe('share_menu'); + }); + + it('should close the submenu and focus its trigger on right arrow', () => { + openFileMenu(); + openShareMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', RIGHT_ARROW); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('file_menu'); + expect(document.activeElement).toEqual(fileMenuNativeItems[1]); + }); + + it( + 'should close menu tree, focus next menu bar item and open its menu on left arrow when ' + + "focused item doesn't have a menu", + () => { + openFileMenu(); + openShareMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', LEFT_ARROW); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('edit_menu'); + expect(document.activeElement).toEqual(menuBarNativeItems[1]); + } + ); + + it( + 'should close first level menu and focus the previous menubar item on right' + + ' arrow press', + () => { + openFileMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', RIGHT_ARROW); + detectChanges(); + + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('edit_menu'); + expect(document.activeElement).toEqual(menuBarNativeItems[1]); + } + ); + }); + }); + + describe('with menuitemcheckbox components', () => { + let fixture: ComponentFixture; + let nativeMenuBar: HTMLElement; + let nativeMenus: HTMLElement[]; + let menuBarNativeItems: HTMLButtonElement[]; + let fontMenuItems: CdkMenuItemCheckbox[]; + + function grabElementsForTesting() { + nativeMenuBar = fixture.componentInstance.nativeMenuBar.nativeElement; + + nativeMenus = fixture.componentInstance.nativeMenus.map(e => e.nativeElement); + + menuBarNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(0, 2); // menu bar has the first 2 menu items + + fontMenuItems = fixture.componentInstance.checkboxItems.toArray(); + } + + /** Run change detection and extract then set the rendered elements. */ + function detectChanges() { + fixture.detectChanges(); + grabElementsForTesting(); + } + + /** Place focus on the menu bar and run change detection. */ + function focusMenuBar() { + dispatchKeyboardEvent(document, 'keydown', TAB); + nativeMenuBar.focus(); + + detectChanges(); + } + + function openFontMenu() { + focusMenuBar(); + dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', SPACE); + detectChanges(); + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [MenuWithCheckboxes], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MenuWithCheckboxes); + detectChanges(); + }); + + it( + 'should set the checked state on the focused checkbox on space key and keep the' + + ' menu open', + () => { + openFontMenu(); + + dispatchKeyboardEvent(document.activeElement!, 'keydown', SPACE); + detectChanges(); + + expect(fontMenuItems[0].checked).toBeTrue(); + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('font_menu'); + } + ); + }); + + describe('with menuitemradio components', () => { + let fixture: ComponentFixture; + let nativeMenuBar: HTMLElement; + let nativeMenus: HTMLElement[]; + let menuBarNativeItems: HTMLButtonElement[]; + let fontMenuItems: CdkMenuItemRadio[]; + + function grabElementsForTesting() { + nativeMenuBar = fixture.componentInstance.nativeMenuBar.nativeElement; + + nativeMenus = fixture.componentInstance.nativeMenus.map(e => e.nativeElement); + + menuBarNativeItems = fixture.componentInstance.nativeItems + .map(e => e.nativeElement) + .slice(0, 1); // menu bar only has a single item + + fontMenuItems = fixture.componentInstance.radioItems.toArray(); + } + + /** run change detection and, extract and set the rendered elements. */ + function detectChanges() { + fixture.detectChanges(); + grabElementsForTesting(); + } + + /** set focus the the MenuBar and run change detection. */ + function focusMenuBar() { + dispatchKeyboardEvent(document, 'keydown', TAB); + nativeMenuBar.focus(); + + detectChanges(); + } + + function openFontMenu() { + focusMenuBar(); + dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', SPACE); + detectChanges(); + } + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [MenuWithRadioButtons], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(MenuWithRadioButtons); + detectChanges(); + }); + + it( + 'should set the checked state on the active radio button on space key and keep the' + + ' menu open', + () => { + openFontMenu(); + + dispatchKeyboardEvent(nativeMenus[0], 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(document.activeElement!, 'keydown', SPACE); + detectChanges(); + + expect(fontMenuItems[1].checked).toBeTrue(); + expect(nativeMenus.length).toBe(1); + expect(nativeMenus[0].id).toBe('text_menu'); + } + ); + }); + }); }); @Component({ @@ -86,3 +733,96 @@ describe('MenuBar', () => { `, }) class MenuBarRadioGroup {} + +@Component({ + template: ` +
+ + + +
+ + + +
+
+ + +
+ + +
+
+ + +
+ + +
+
+
+ `, +}) +class MultiMenuWithSubmenu { + clickEmitter = new EventEmitter(); + @ViewChild(CdkMenuBar, {read: ElementRef}) nativeMenuBar: ElementRef; + + @ViewChildren(CdkMenu, {read: ElementRef}) nativeMenus: QueryList; + + @ViewChildren(CdkMenuItem, {read: ElementRef}) nativeItems: QueryList; +} + +@Component({ + template: ` +
+ + + +
+ + +
+
+
+ `, +}) +class MenuWithCheckboxes { + @ViewChild(CdkMenuBar, {read: ElementRef}) nativeMenuBar: ElementRef; + + @ViewChildren(CdkMenu, {read: ElementRef}) nativeMenus: QueryList; + + @ViewChildren(CdkMenuItem, {read: ElementRef}) nativeItems: QueryList; + + @ViewChildren(CdkMenuItemCheckbox) checkboxItems: QueryList; +} + +@Component({ + template: ` +
+ + + +
+ + +
+
+
+ `, +}) +class MenuWithRadioButtons { + @ViewChild(CdkMenuBar, {read: ElementRef}) nativeMenuBar: ElementRef; + + @ViewChildren(CdkMenu, {read: ElementRef}) nativeMenus: QueryList; + + @ViewChildren(CdkMenuItem, {read: ElementRef}) nativeItems: QueryList; + + @ViewChildren(CdkMenuItemRadio) radioItems: QueryList; +} diff --git a/src/cdk-experimental/menu/menu-bar.ts b/src/cdk-experimental/menu/menu-bar.ts index 35e7c30da209..3428055443d0 100644 --- a/src/cdk-experimental/menu/menu-bar.ts +++ b/src/cdk-experimental/menu/menu-bar.ts @@ -6,9 +6,24 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, Input} from '@angular/core'; +import { + Directive, + Input, + ContentChildren, + QueryList, + AfterContentInit, + OnDestroy, + Optional, +} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; +import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; +import {LEFT_ARROW, RIGHT_ARROW, UP_ARROW, DOWN_ARROW, ESCAPE, TAB} from '@angular/cdk/keycodes'; import {CdkMenuGroup} from './menu-group'; import {CDK_MENU, Menu} from './menu-interface'; +import {CdkMenuItem} from './menu-item'; +import {MenuStack, MenuStackItem, FocusNext} from './menu-stack'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; /** * Directive applied to an element which configures it as a MenuBar by setting the appropriate @@ -20,18 +35,181 @@ import {CDK_MENU, Menu} from './menu-interface'; selector: '[cdkMenuBar]', exportAs: 'cdkMenuBar', host: { + '(keydown)': '_handleKeyEvent($event)', + '(focus)': 'focusFirstItem()', 'role': 'menubar', + 'tabindex': '0', '[attr.aria-orientation]': 'orientation', }, providers: [ {provide: CdkMenuGroup, useExisting: CdkMenuBar}, {provide: CDK_MENU, useExisting: CdkMenuBar}, + {provide: MenuStack, useClass: MenuStack}, ], }) -export class CdkMenuBar extends CdkMenuGroup implements Menu { +export class CdkMenuBar extends CdkMenuGroup implements Menu, AfterContentInit, OnDestroy { /** * Sets the aria-orientation attribute and determines where menus will be opened. * Does not affect styling/layout. */ @Input('cdkMenuBarOrientation') orientation: 'horizontal' | 'vertical' = 'horizontal'; + + /** Handles keyboard events for the MenuBar. */ + private _keyManager: FocusKeyManager; + + /** Emits when the MenuBar is destroyed. */ + private readonly _destroyed: Subject = new Subject(); + + /** All child MenuItem elements nested in this MenuBar. */ + @ContentChildren(CdkMenuItem, {descendants: true}) + private readonly _allItems: QueryList; + + constructor(readonly _menuStack: MenuStack, @Optional() private readonly _dir?: Directionality) { + super(); + } + + ngAfterContentInit() { + super.ngAfterContentInit(); + + this._setKeyManager(); + this._subscribeToMenuStack(); + } + + /** Place focus on the first MenuItem in the menu and set the focus origin. */ + focusFirstItem(focusOrigin: FocusOrigin = 'program') { + this._keyManager.setFocusOrigin(focusOrigin); + this._keyManager.setFirstItemActive(); + } + + /** Place focus on the last MenuItem in the menu and set the focus origin. */ + focusLastItem(focusOrigin: FocusOrigin = 'program') { + this._keyManager.setFocusOrigin(focusOrigin); + this._keyManager.setLastItemActive(); + } + + /** + * Handle keyboard events, specifically changing the focused element and/or toggling the active + * items menu. + * @param event the KeyboardEvent to handle. + */ + _handleKeyEvent(event: KeyboardEvent) { + const keyManager = this._keyManager; + switch (event.keyCode) { + case UP_ARROW: + case DOWN_ARROW: + case LEFT_ARROW: + case RIGHT_ARROW: + const horizontalArrows = event.keyCode === LEFT_ARROW || event.keyCode === RIGHT_ARROW; + // For a horizontal menu if the left/right keys were clicked, or a vertical menu if the + // up/down keys were clicked: if the current menu is open, close it then focus and open the + // next menu. + if ( + (this._isHorizontal() && horizontalArrows) || + (!this._isHorizontal() && !horizontalArrows) + ) { + event.preventDefault(); + + const prevIsOpen = keyManager.activeItem?.isMenuOpen(); + keyManager.activeItem?.getMenuTrigger()?.closeMenu(); + + keyManager.setFocusOrigin('keyboard'); + keyManager.onKeydown(event); + if (prevIsOpen) { + keyManager.activeItem?.getMenuTrigger()?.openMenu(); + } + } + break; + + case ESCAPE: + event.preventDefault(); + keyManager.activeItem?.getMenuTrigger()?.closeMenu(); + break; + + case TAB: + keyManager.activeItem?.getMenuTrigger()?.closeMenu(); + break; + + default: + keyManager.onKeydown(event); + } + } + + /** Setup the FocusKeyManager with the correct orientation for the menu bar. */ + private _setKeyManager() { + this._keyManager = new FocusKeyManager(this._allItems) + .withWrap() + .withTypeAhead() + .withHomeAndEnd(); + + if (this._isHorizontal()) { + this._keyManager.withHorizontalOrientation(this._dir?.value || 'ltr'); + } else { + this._keyManager.withVerticalOrientation(); + } + } + + /** Subscribe to the MenuStack close and empty observables. */ + private _subscribeToMenuStack() { + this._menuStack.close + .pipe(takeUntil(this._destroyed)) + .subscribe((item: MenuStackItem) => this._closeOpenMenu(item)); + + this._menuStack.empty + .pipe(takeUntil(this._destroyed)) + .subscribe((event: FocusNext) => this._toggleOpenMenu(event)); + } + + /** + * Close the open menu if the current active item opened the requested MenuStackItem. + * @param item the MenuStackItem requested to be closed. + */ + private _closeOpenMenu(item: MenuStackItem) { + const keyManager = this._keyManager; + if (item === keyManager.activeItem?.getMenu()) { + keyManager.activeItem.getMenuTrigger()?.closeMenu(); + keyManager.setFocusOrigin('keyboard'); + keyManager.setActiveItem(keyManager.activeItem); + } + } + + /** + * Set focus to either the current, previous or next item based on the FocusNext event, then + * open the previous or next item. + */ + private _toggleOpenMenu(event: FocusNext) { + const keyManager = this._keyManager; + switch (event) { + case FocusNext.nextItem: + keyManager.setFocusOrigin('keyboard'); + keyManager.setNextItemActive(); + keyManager.activeItem?.getMenuTrigger()?.openMenu(); + break; + + case FocusNext.previousItem: + keyManager.setFocusOrigin('keyboard'); + keyManager.setPreviousItemActive(); + keyManager.activeItem?.getMenuTrigger()?.openMenu(); + break; + + case FocusNext.currentItem: + if (keyManager.activeItem) { + keyManager.setFocusOrigin('keyboard'); + keyManager.setActiveItem(keyManager.activeItem); + } + break; + } + } + + /** + * @return true if the menu bar is configured to be horizontal. + */ + private _isHorizontal() { + return this.orientation === 'horizontal'; + } + + ngOnDestroy() { + super.ngOnDestroy(); + this._destroyed.next(); + this._destroyed.complete(); + } } diff --git a/src/cdk-experimental/menu/menu-group.spec.ts b/src/cdk-experimental/menu/menu-group.spec.ts index 82e906167f28..0071e4b78acf 100644 --- a/src/cdk-experimental/menu/menu-group.spec.ts +++ b/src/cdk-experimental/menu/menu-group.spec.ts @@ -1,10 +1,13 @@ -import {Component} from '@angular/core'; +import {Component, ViewChild} from '@angular/core'; import {ComponentFixture, TestBed, async} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {CdkMenuModule} from './menu-module'; import {CdkMenuGroup} from './menu-group'; import {CdkMenuItemCheckbox} from './menu-item-checkbox'; import {CdkMenuItemRadio} from './menu-item-radio'; +import {CdkMenuPanel} from './menu-panel'; +import {MenuStack} from './menu-stack'; +import {CdkMenuItem} from './menu-item'; describe('MenuGroup', () => { describe('with MenuItems as checkbox', () => { @@ -20,6 +23,10 @@ describe('MenuGroup', () => { fixture = TestBed.createComponent(CheckboxMenu); fixture.detectChanges(); + fixture.componentInstance.panel._menuStack = new MenuStack(); + fixture.componentInstance.trigger.getMenuTrigger()?.toggle(); + fixture.detectChanges(); + menuItems = fixture.debugElement .queryAll(By.directive(CdkMenuItemCheckbox)) .map(e => e.injector.get(CdkMenuItemCheckbox)); @@ -45,6 +52,10 @@ describe('MenuGroup', () => { fixture = TestBed.createComponent(MenuWithMultipleRadioGroups); fixture.detectChanges(); + fixture.componentInstance.panel._menuStack = new MenuStack(); + fixture.componentInstance.trigger.getMenuTrigger()?.toggle(); + fixture.detectChanges(); + menuItems = fixture.debugElement .queryAll(By.directive(CdkMenuItemRadio)) .map(e => e.injector.get(CdkMenuItemRadio)); @@ -87,6 +98,10 @@ describe('MenuGroup', () => { fixture = TestBed.createComponent(MenuWithMenuItemsAndRadioGroups); fixture.detectChanges(); + fixture.componentInstance.panel._menuStack = new MenuStack(); + fixture.componentInstance.trigger.getMenuTrigger()?.toggle(); + fixture.detectChanges(); + menuItems = fixture.debugElement .queryAll(By.directive(CdkMenuItemRadio)) .map(element => element.injector.get(CdkMenuItemRadio)); @@ -103,10 +118,10 @@ describe('MenuGroup', () => { menuItems[0].trigger(); - expect(spies[1]).toHaveBeenCalledTimes(1); - expect(spies[1]).toHaveBeenCalledWith(menuItems[0]); - expect(spies[2]).not.toHaveBeenCalled(); + expect(spies[2]).toHaveBeenCalledTimes(1); + expect(spies[2]).toHaveBeenCalledWith(menuItems[0]); expect(spies[3]).not.toHaveBeenCalled(); + expect(spies[4]).not.toHaveBeenCalled(); }); it('should not emit with click on disabled button', () => { @@ -127,7 +142,10 @@ describe('MenuGroup', () => { @Component({ template: ` - +
+ +
+
    • @@ -145,14 +163,19 @@ describe('MenuGroup', () => {
    - `, }) -class CheckboxMenu {} +class CheckboxMenu { + @ViewChild(CdkMenuItem) readonly trigger: CdkMenuItem; + @ViewChild(CdkMenuPanel) readonly panel: CdkMenuPanel; +} @Component({ template: ` - +
    + +
    +
      • @@ -184,14 +207,19 @@ class CheckboxMenu {}
      - `, }) -class MenuWithMultipleRadioGroups {} +class MenuWithMultipleRadioGroups { + @ViewChild(CdkMenuItem) readonly trigger: CdkMenuItem; + @ViewChild(CdkMenuPanel) readonly panel: CdkMenuPanel; +} @Component({ template: ` - +
      + +
      +
        • @@ -222,7 +250,9 @@ class MenuWithMultipleRadioGroups {}
        - `, }) -class MenuWithMenuItemsAndRadioGroups {} +class MenuWithMenuItemsAndRadioGroups { + @ViewChild(CdkMenuItem) readonly trigger: CdkMenuItem; + @ViewChild(CdkMenuPanel) readonly panel: CdkMenuPanel; +} diff --git a/src/cdk-experimental/menu/menu-interface.ts b/src/cdk-experimental/menu/menu-interface.ts index 4fc3bb70caf6..afcaa8bf0e16 100644 --- a/src/cdk-experimental/menu/menu-interface.ts +++ b/src/cdk-experimental/menu/menu-interface.ts @@ -7,12 +7,20 @@ */ import {InjectionToken} from '@angular/core'; +import {MenuStackItem} from './menu-stack'; +import {FocusOrigin} from '@angular/cdk/a11y'; /** Injection token used to return classes implementing the Menu interface */ export const CDK_MENU = new InjectionToken('cdk-menu'); /** Interface which specifies Menu operations and used to break circular dependency issues */ -export interface Menu { +export interface Menu extends MenuStackItem { /** The orientation of the menu */ orientation: 'horizontal' | 'vertical'; + + /** Place focus on the first MenuItem in the menu. */ + focusFirstItem(focusOrigin: FocusOrigin): void; + + /** Place focus on the last MenuItem in the menu. */ + focusLastItem(focusOrigin: FocusOrigin): void; } diff --git a/src/cdk-experimental/menu/menu-item-checkbox.spec.ts b/src/cdk-experimental/menu/menu-item-checkbox.spec.ts index 4078993f97fa..61adcd4614c3 100644 --- a/src/cdk-experimental/menu/menu-item-checkbox.spec.ts +++ b/src/cdk-experimental/menu/menu-item-checkbox.spec.ts @@ -3,6 +3,8 @@ import {ComponentFixture, TestBed, async} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItemCheckbox} from './menu-item-checkbox'; +import {CDK_MENU} from './menu-interface'; +import {CdkMenu} from './menu'; describe('MenuItemCheckbox', () => { let fixture: ComponentFixture; @@ -13,6 +15,7 @@ describe('MenuItemCheckbox', () => { TestBed.configureTestingModule({ imports: [CdkMenuModule], declarations: [SingleCheckboxButton], + providers: [{provide: CDK_MENU, useClass: CdkMenu}], }).compileComponents(); })); diff --git a/src/cdk-experimental/menu/menu-item-checkbox.ts b/src/cdk-experimental/menu/menu-item-checkbox.ts index 0644e1fd2625..050b7e3774bc 100644 --- a/src/cdk-experimental/menu/menu-item-checkbox.ts +++ b/src/cdk-experimental/menu/menu-item-checkbox.ts @@ -8,6 +8,7 @@ import {Directive} from '@angular/core'; import {CdkMenuItemSelectable} from './menu-item-selectable'; +import {CdkMenuItem} from './menu-item'; /** * A directive providing behavior for the "menuitemcheckbox" ARIA role, which behaves similarly to a @@ -23,7 +24,10 @@ import {CdkMenuItemSelectable} from './menu-item-selectable'; '[attr.aria-checked]': 'checked || null', '[attr.aria-disabled]': 'disabled || null', }, - providers: [{provide: CdkMenuItemSelectable, useExisting: CdkMenuItemCheckbox}], + providers: [ + {provide: CdkMenuItemSelectable, useExisting: CdkMenuItemCheckbox}, + {provide: CdkMenuItem, useExisting: CdkMenuItemSelectable}, + ], }) export class CdkMenuItemCheckbox extends CdkMenuItemSelectable { trigger() { diff --git a/src/cdk-experimental/menu/menu-item-radio.spec.ts b/src/cdk-experimental/menu/menu-item-radio.spec.ts index 355ba2b9c20b..45ec53aeed60 100644 --- a/src/cdk-experimental/menu/menu-item-radio.spec.ts +++ b/src/cdk-experimental/menu/menu-item-radio.spec.ts @@ -4,6 +4,8 @@ import {By} from '@angular/platform-browser'; import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItemRadio} from './menu-item-radio'; +import {CDK_MENU} from './menu-interface'; +import {CdkMenu} from './menu'; describe('MenuItemRadio', () => { let fixture: ComponentFixture; @@ -16,7 +18,10 @@ describe('MenuItemRadio', () => { TestBed.configureTestingModule({ imports: [CdkMenuModule], declarations: [SimpleRadioButton], - providers: [{provide: UniqueSelectionDispatcher, useValue: selectionDispatcher}], + providers: [ + {provide: UniqueSelectionDispatcher, useValue: selectionDispatcher}, + {provide: CDK_MENU, useClass: CdkMenu}, + ], }).compileComponents(); })); diff --git a/src/cdk-experimental/menu/menu-item-radio.ts b/src/cdk-experimental/menu/menu-item-radio.ts index 7a0e4d4cbe76..df802aba1b7f 100644 --- a/src/cdk-experimental/menu/menu-item-radio.ts +++ b/src/cdk-experimental/menu/menu-item-radio.ts @@ -6,8 +6,12 @@ * found in the LICENSE file at https://angular.io/license */ import {UniqueSelectionDispatcher} from '@angular/cdk/collections'; -import {Directive, OnDestroy} from '@angular/core'; +import {Directive, OnDestroy, ElementRef, Self, Optional, Inject} from '@angular/core'; +import {Directionality} from '@angular/cdk/bidi'; import {CdkMenuItemSelectable} from './menu-item-selectable'; +import {CdkMenuItem} from './menu-item'; +import {CdkMenuItemTrigger} from './menu-item-trigger'; +import {CDK_MENU, Menu} from './menu-interface'; /** * A directive providing behavior for the the "menuitemradio" ARIA role, which behaves similarly to @@ -24,14 +28,24 @@ import {CdkMenuItemSelectable} from './menu-item-selectable'; '[attr.aria-checked]': 'checked || null', '[attr.aria-disabled]': 'disabled || null', }, - providers: [{provide: CdkMenuItemSelectable, useExisting: CdkMenuItemRadio}], + providers: [ + {provide: CdkMenuItemSelectable, useExisting: CdkMenuItemRadio}, + {provide: CdkMenuItem, useExisting: CdkMenuItemSelectable}, + ], }) export class CdkMenuItemRadio extends CdkMenuItemSelectable implements OnDestroy { /** Function to unregister the selection dispatcher */ private _removeDispatcherListener: () => void; - constructor(private readonly _selectionDispatcher: UniqueSelectionDispatcher) { - super(); + constructor( + private readonly _selectionDispatcher: UniqueSelectionDispatcher, + element: ElementRef, + @Inject(CDK_MENU) parentMenu: Menu, + @Optional() dir?: Directionality, + /** Reference to the CdkMenuItemTrigger directive if one is added to the same element */ + @Self() @Optional() menuTrigger?: CdkMenuItemTrigger + ) { + super(element, parentMenu, dir, menuTrigger); this._registerDispatcherListener(); } diff --git a/src/cdk-experimental/menu/menu-item-trigger.spec.ts b/src/cdk-experimental/menu/menu-item-trigger.spec.ts index e4688b8abb06..879ad7f53314 100644 --- a/src/cdk-experimental/menu/menu-item-trigger.spec.ts +++ b/src/cdk-experimental/menu/menu-item-trigger.spec.ts @@ -117,7 +117,7 @@ describe('MenuItemTrigger', () => { detectChanges(); expect(menus.length).toEqual(1); - expect(menus[0] as Menu).toEqual(triggers[0]._menuPanel!._menu!); + expect(menus[0] as Menu).toEqual(triggers[0].getMenu()!); }); it('should not open the menu when menu item disabled', () => { @@ -175,7 +175,7 @@ describe('MenuItemTrigger', () => { detectChanges(); expect(menus.length).withContext('first level menu should stay open').toEqual(1); - expect(triggers[0]._menuPanel!._menu).toEqual(menus[0]); + expect(triggers[0].getMenu()).toEqual(menus[0]); }); it('should emit request to open event on menu open', () => { diff --git a/src/cdk-experimental/menu/menu-item-trigger.ts b/src/cdk-experimental/menu/menu-item-trigger.ts index 9120af0cd040..3004261ba7ca 100644 --- a/src/cdk-experimental/menu/menu-item-trigger.ts +++ b/src/cdk-experimental/menu/menu-item-trigger.ts @@ -15,6 +15,7 @@ import { ViewContainerRef, Inject, OnDestroy, + Optional, } from '@angular/core'; import {Directionality} from '@angular/cdk/bidi'; import {TemplatePortal} from '@angular/cdk/portal'; @@ -25,8 +26,10 @@ import { ConnectedPosition, FlexibleConnectedPositionStrategy, } from '@angular/cdk/overlay'; +import {SPACE, ENTER, RIGHT_ARROW, LEFT_ARROW, DOWN_ARROW, UP_ARROW} from '@angular/cdk/keycodes'; import {CdkMenuPanel} from './menu-panel'; import {Menu, CDK_MENU} from './menu-interface'; +import {FocusNext} from './menu-stack'; /** * A directive to be combined with CdkMenuItem which opens the Menu it is bound to. If the @@ -41,13 +44,28 @@ import {Menu, CDK_MENU} from './menu-interface'; selector: '[cdkMenuItem][cdkMenuTriggerFor]', exportAs: 'cdkMenuTriggerFor', host: { + '(keydown)': '_toggleOnKeydown($event)', + 'tabindex': '-1', 'aria-haspopup': 'menu', '[attr.aria-expanded]': 'isMenuOpen()', }, }) export class CdkMenuItemTrigger implements OnDestroy { /** Template reference variable to the menu this trigger opens */ - @Input('cdkMenuTriggerFor') _menuPanel?: CdkMenuPanel; + @Input('cdkMenuTriggerFor') + get menuPanel(): CdkMenuPanel | undefined { + return this._menuPanel; + } + set menuPanel(panel: CdkMenuPanel | undefined) { + this._menuPanel = panel; + + if (this._menuPanel) { + this._menuPanel._menuStack = this._getMenuStack(); + } + } + + /** Reference to the MenuPanel this trigger toggles. */ + private _menuPanel?: CdkMenuPanel; /** Emits when the attached menu is requested to open */ @Output('cdkMenuOpened') readonly opened: EventEmitter = new EventEmitter(); @@ -65,20 +83,39 @@ export class CdkMenuItemTrigger implements OnDestroy { private readonly _elementRef: ElementRef, protected readonly _viewContainerRef: ViewContainerRef, private readonly _overlay: Overlay, - private readonly _directionality: Directionality, - @Inject(CDK_MENU) private readonly _parentMenu: Menu + @Inject(CDK_MENU) private readonly _parentMenu: Menu, + @Optional() private readonly _directionality?: Directionality ) {} /** Open/close the attached menu if the trigger has been configured with one */ toggle() { if (this.hasMenu()) { - this.isMenuOpen() ? this._closeMenu() : this._openMenu(); + this.isMenuOpen() ? this.closeMenu() : this.openMenu(); + } + } + + /** Open the attached menu. */ + openMenu() { + if (!this.isMenuOpen()) { + this.opened.next(); + + this._overlayRef = this._overlayRef || this._overlay.create(this._getOverlayConfig()); + this._overlayRef.attach(this._getPortal()); + } + } + + /** Close the opened menu. */ + closeMenu() { + if (this.isMenuOpen()) { + this.closed.next(); + + this._overlayRef!.detach(); } } /** Return true if the trigger has an attached menu */ hasMenu() { - return !!this._menuPanel; + return !!this.menuPanel; } /** Whether the menu this button is a trigger for is open */ @@ -86,20 +123,63 @@ export class CdkMenuItemTrigger implements OnDestroy { return this._overlayRef ? this._overlayRef.hasAttached() : false; } - /** Open the attached menu */ - private _openMenu() { - this.opened.next(); - - this._overlayRef = this._overlay.create(this._getOverlayConfig()); - this._overlayRef.attach(this._getPortal()); + /** + * Get a reference to the rendered Menu if the Menu is open and it is visible in the DOM. + * @return the menu if it is open, otherwise undefined. + */ + getMenu(): Menu | undefined { + return this.menuPanel?._menu; } - /** Close the opened menu */ - private _closeMenu() { - if (this.isMenuOpen()) { - this.closed.next(); + /** + * Handles keyboard events for the menu item, specifically opening/closing the attached menu and + * focusing the appropriate submenu item. + * @param event the keyboard event to handle + */ + _toggleOnKeydown(event: KeyboardEvent) { + const keyCode = event.keyCode; + switch (keyCode) { + case SPACE: + case ENTER: + event.preventDefault(); + this.toggle(); + this.menuPanel?._menu?.focusFirstItem('keyboard'); + break; - this._overlayRef!.detach(); + case RIGHT_ARROW: + if (this._isParentVertical()) { + event.preventDefault(); + if (this._directionality?.value === 'rtl') { + this._getMenuStack().closeLatest(FocusNext.currentItem); + } else { + this.openMenu(); + this.menuPanel?._menu?.focusFirstItem('keyboard'); + } + } + break; + + case LEFT_ARROW: + if (this._isParentVertical()) { + event.preventDefault(); + if (this._directionality?.value === 'rtl') { + this.openMenu(); + this.menuPanel?._menu?.focusFirstItem('keyboard'); + } else { + this._getMenuStack().closeLatest(FocusNext.currentItem); + } + } + break; + + case DOWN_ARROW: + case UP_ARROW: + if (!this._isParentVertical()) { + event.preventDefault(); + this.openMenu(); + keyCode === DOWN_ARROW + ? this.menuPanel?._menu?.focusFirstItem('keyboard') + : this.menuPanel?._menu?.focusLastItem('keyboard'); + } + break; } } @@ -143,15 +223,29 @@ export class CdkMenuItemTrigger implements OnDestroy { * content to change dynamically and be reflected in the application. */ private _getPortal() { - if (!this._panelContent || this._panelContent.templateRef !== this._menuPanel?._templateRef) { - this._panelContent = new TemplatePortal( - this._menuPanel!._templateRef, - this._viewContainerRef - ); + const hasMenuContentChanged = this.menuPanel?._templateRef !== this._panelContent?.templateRef; + if (this.menuPanel && (!this._panelContent || hasMenuContentChanged)) { + this._panelContent = new TemplatePortal(this.menuPanel._templateRef, this._viewContainerRef); } + return this._panelContent; } + /** + * @return true if if the enclosing parent menu is configured in a vertical orientation. + */ + private _isParentVertical() { + return this._parentMenu.orientation === 'vertical'; + } + + /** Get the menu stack from the parent. */ + private _getMenuStack() { + // We use a function since at the construction of the MenuItemTrigger the parent Menu won't have + // its menu stack set. Therefore we need to reference the menu stack from the parent each time + // we want to use it. + return this._parentMenu._menuStack; + } + ngOnDestroy() { this._destroyOverlay(); } diff --git a/src/cdk-experimental/menu/menu-item.spec.ts b/src/cdk-experimental/menu/menu-item.spec.ts index 49431bca87fa..4dd02953ec34 100644 --- a/src/cdk-experimental/menu/menu-item.spec.ts +++ b/src/cdk-experimental/menu/menu-item.spec.ts @@ -3,56 +3,85 @@ import {ComponentFixture, TestBed, async} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItem} from './menu-item'; +import {CDK_MENU} from './menu-interface'; +import {CdkMenu} from './menu'; describe('MenuItem', () => { - let fixture: ComponentFixture; - let button: CdkMenuItem; - let nativeButton: HTMLButtonElement; - - beforeEach(async(() => { - TestBed.configureTestingModule({ - imports: [CdkMenuModule], - declarations: [SingleMenuItem], - }).compileComponents(); - })); - - beforeEach(() => { - fixture = TestBed.createComponent(SingleMenuItem); - fixture.detectChanges(); - - button = fixture.debugElement.query(By.directive(CdkMenuItem)).injector.get(CdkMenuItem); - nativeButton = fixture.debugElement.query(By.directive(CdkMenuItem)).nativeElement; - }); + describe('with no complex inner elements', () => { + let fixture: ComponentFixture; + let menuItem: CdkMenuItem; + let nativeButton: HTMLButtonElement; - it('should have the menuitem role', () => { - expect(nativeButton.getAttribute('role')).toBe('menuitem'); - }); + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [SingleMenuItem], + providers: [{provide: CDK_MENU, useClass: CdkMenu}], + }).compileComponents(); + })); - it('should coerce the disabled property', () => { - (button as any).disabled = ''; - expect(button.disabled).toBeTrue(); - }); + beforeEach(() => { + fixture = TestBed.createComponent(SingleMenuItem); + fixture.detectChanges(); - it('should toggle the aria disabled attribute', () => { - expect(nativeButton.getAttribute('aria-disabled')).toBeNull(); + menuItem = fixture.debugElement.query(By.directive(CdkMenuItem)).injector.get(CdkMenuItem); + nativeButton = fixture.debugElement.query(By.directive(CdkMenuItem)).nativeElement; + }); - button.disabled = true; - fixture.detectChanges(); + it('should have the menuitem role', () => { + expect(nativeButton.getAttribute('role')).toBe('menuitem'); + }); - expect(nativeButton.getAttribute('aria-disabled')).toBe('true'); + it('should coerce the disabled property', () => { + (menuItem as any).disabled = ''; + expect(menuItem.disabled).toBeTrue(); + }); - button.disabled = false; - fixture.detectChanges(); + it('should toggle the aria disabled attribute', () => { + expect(nativeButton.hasAttribute('aria-disabled')).toBeFalse(); - expect(nativeButton.getAttribute('aria-disabled')).toBeNull(); - }); + menuItem.disabled = true; + fixture.detectChanges(); + + expect(nativeButton.getAttribute('aria-disabled')).toBe('true'); + + menuItem.disabled = false; + fixture.detectChanges(); + + expect(nativeButton.hasAttribute('aria-disabled')).toBeFalse(); + }); + + it('should be a button type', () => { + expect(nativeButton.getAttribute('type')).toBe('button'); + }); - it('should be a button type', () => { - expect(nativeButton.getAttribute('type')).toBe('button'); + it('should not have a menu', () => { + expect(menuItem.hasMenu()).toBeFalse(); + }); }); - it('should not have a menu', () => { - expect(button.hasMenu()).toBeFalse(); + describe('with complex inner elements', () => { + let fixture: ComponentFixture; + let menuItem: CdkMenuItem; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [CdkMenuModule], + declarations: [ComplexMenuItem], + providers: [{provide: CDK_MENU, useClass: CdkMenu}], + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ComplexMenuItem); + fixture.detectChanges(); + + menuItem = fixture.debugElement.query(By.directive(CdkMenuItem)).injector.get(CdkMenuItem); + }); + + it('should be able to extract the label text, with text nested in bold tag', () => { + expect(menuItem.getLabel()).toBe('Click me!'); + }); }); }); @@ -60,3 +89,8 @@ describe('MenuItem', () => { template: ``, }) class SingleMenuItem {} + +@Component({ + template: ` `, +}) +class ComplexMenuItem {} diff --git a/src/cdk-experimental/menu/menu-item.ts b/src/cdk-experimental/menu/menu-item.ts index f00eff8667f5..1a15fb08d961 100644 --- a/src/cdk-experimental/menu/menu-item.ts +++ b/src/cdk-experimental/menu/menu-item.ts @@ -6,9 +6,24 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, Input, Optional, Self} from '@angular/core'; +import { + Directive, + Input, + Optional, + Self, + ElementRef, + Output, + EventEmitter, + Inject, + HostListener, +} from '@angular/core'; import {coerceBooleanProperty, BooleanInput} from '@angular/cdk/coercion'; +import {FocusableOption} from '@angular/cdk/a11y'; +import {SPACE, ENTER, RIGHT_ARROW, LEFT_ARROW} from '@angular/cdk/keycodes'; +import {Directionality} from '@angular/cdk/bidi'; import {CdkMenuItemTrigger} from './menu-item-trigger'; +import {Menu, CDK_MENU} from './menu-interface'; +import {FocusNext} from './menu-stack'; /** * Directive which provides the ability for an element to be focused and navigated to using the @@ -19,12 +34,13 @@ import {CdkMenuItemTrigger} from './menu-item-trigger'; selector: '[cdkMenuItem]', exportAs: 'cdkMenuItem', host: { + 'tabindex': '-1', 'type': 'button', 'role': 'menuitem', '[attr.aria-disabled]': 'disabled || null', }, }) -export class CdkMenuItem { +export class CdkMenuItem implements FocusableOption { /** Whether the CdkMenuItem is disabled - defaults to false */ @Input() get disabled(): boolean { @@ -35,21 +51,115 @@ export class CdkMenuItem { } private _disabled = false; + /** + * If this MenuItem is a regular MenuItem, outputs when it is triggered by a keyboard or mouse + * event. + */ + @Output('cdkMenuItemTriggered') triggered: EventEmitter = new EventEmitter(); + constructor( + private readonly _elementRef: ElementRef, + @Inject(CDK_MENU) private readonly _parentMenu: Menu, + @Optional() private readonly _dir?: Directionality, /** Reference to the CdkMenuItemTrigger directive if one is added to the same element */ @Self() @Optional() private readonly _menuTrigger?: CdkMenuItemTrigger ) {} - /** Open the menu if one is attached */ + /** Place focus on the element. */ + focus() { + this._elementRef.nativeElement.focus(); + } + + /** + * If the menu item is not disabled and the element does not have a menu trigger attached, emit + * on the cdkMenuItemTriggered emitter and close all open menus. + */ trigger() { - if (!this.disabled && this.hasMenu()) { - this._menuTrigger!.toggle(); + if (!this.disabled && !this.hasMenu()) { + this.triggered.next(); + this._getMenuStack().closeAll(); } } /** Whether the menu item opens a menu. */ hasMenu() { - return !!this._menuTrigger && this._menuTrigger.hasMenu(); + return !!this._menuTrigger?.hasMenu(); + } + + /** Return true if this MenuItem has an attached menu and it is open. */ + isMenuOpen() { + return !!this._menuTrigger?.isMenuOpen(); + } + + /** + * Get a reference to the rendered Menu if the Menu is open and it is visible in the DOM. + * @return the menu if it is open, otherwise undefined. + */ + getMenu(): Menu | undefined { + return this._menuTrigger?.getMenu(); + } + + /** Get the MenuItemTrigger associated with this element. */ + getMenuTrigger(): CdkMenuItemTrigger | undefined { + return this._menuTrigger; + } + + /** Get the label for this element which is required by the FocusableOption interface. */ + getLabel(): string { + // TODO(andy): implement a more robust algorithm for determining nested text + return this._elementRef.nativeElement.textContent || ''; + } + + // In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order + // to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we + // can move this back into `host`. + // tslint:disable:no-host-decorator-in-concrete + @HostListener('keydown', ['$event']) + /** + * Handles keyboard events for the menu item, specifically either triggering the user defined + * callback or opening/closing the current menu based on whether the left or right arrow key was + * pressed. + * @param event the keyboard event to handle + */ + _onKeydown(event: KeyboardEvent) { + switch (event.keyCode) { + case SPACE: + case ENTER: + event.preventDefault(); + this.trigger(); + break; + + case RIGHT_ARROW: + if (this._isParentVertical() && !this.hasMenu()) { + event.preventDefault(); + this._dir?.value === 'rtl' + ? this._getMenuStack().closeLatest(FocusNext.previousItem) + : this._getMenuStack().closeAll(FocusNext.nextItem); + } + break; + + case LEFT_ARROW: + if (this._isParentVertical() && !this.hasMenu()) { + event.preventDefault(); + this._dir?.value === 'rtl' + ? this._getMenuStack().closeAll(FocusNext.nextItem) + : this._getMenuStack().closeLatest(FocusNext.previousItem); + } + break; + } + } + + /** Return true if the enclosing parent menu is configured in a horizontal orientation. */ + private _isParentVertical() { + return this._parentMenu.orientation === 'vertical'; + } + + /** Get the MenuStack from the parent menu. */ + private _getMenuStack() { + // We use a function since at the construction of the MenuItemTrigger the parent Menu won't have + // its menu stack set. Therefore we need to reference the menu stack from the parent each time + // we want to use it. + return this._parentMenu._menuStack; } static ngAcceptInputType_disabled: BooleanInput; diff --git a/src/cdk-experimental/menu/menu-panel.ts b/src/cdk-experimental/menu/menu-panel.ts index 21d4d07f6da3..f22b880f1069 100644 --- a/src/cdk-experimental/menu/menu-panel.ts +++ b/src/cdk-experimental/menu/menu-panel.ts @@ -8,6 +8,7 @@ import {Directive, TemplateRef} from '@angular/core'; import {Menu} from './menu-interface'; +import {MenuStack} from './menu-stack'; /** * Directive applied to an ng-template which wraps a CdkMenu and provides a reference to the @@ -18,6 +19,9 @@ export class CdkMenuPanel { /** Reference to the child menu component */ _menu?: Menu; + /** Keep track of open Menus. */ + _menuStack: MenuStack; + constructor(readonly _templateRef: TemplateRef) {} /** @@ -26,5 +30,11 @@ export class CdkMenuPanel { */ _registerMenu(child: Menu) { this._menu = child; + + // The ideal solution would be to affect the CdkMenuPanel injector from the CdkMenuTrigger and + // inject the menu stack reference into the child menu and menu items, however this isn't + // possible at this time. + this._menu._menuStack = this._menuStack; + this._menuStack.push(child); } } diff --git a/src/cdk-experimental/menu/menu-stack.ts b/src/cdk-experimental/menu/menu-stack.ts new file mode 100644 index 000000000000..4cdb285fd591 --- /dev/null +++ b/src/cdk-experimental/menu/menu-stack.ts @@ -0,0 +1,89 @@ +/** + * @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 {Subject, Observable} from 'rxjs'; + +/** Events to emit as specified by the caller once the MenuStack is empty. */ +export const enum FocusNext { + nextItem, + previousItem, + currentItem, +} + +/** + * Interface for the elements tracked in the MenuStack. + */ +export interface MenuStackItem { + /** A reference to the previous Menus MenuStack instance. */ + _menuStack: MenuStack; +} + +/** + * MenuStack allows subscribers to listen for close events (when a MenuStackItem is popped off + * of the stack) in order to perform closing actions. Upon the MenuStack being empty it emits + * from the `empty` observable specifying the next focus action which the listener should perform + * as requested by the closer. + */ +export class MenuStack { + /** All MenuStackItems tracked by this MenuStack. */ + private readonly _elements: MenuStackItem[] = []; + + /** Emits the element which was popped off of the stack when requested by a closer. */ + private readonly _close: Subject = new Subject(); + + /** Emits once the MenuStack has become empty after popping off elements. */ + private readonly _empty: Subject = new Subject(); + + /** Observable which emits the MenuStackItem which has been requested to close. */ + readonly close: Observable = this._close.asObservable(); + + /** + * Observable which emits when the MenuStack is empty after popping off the last element. It + * emits a FocusNext event which specifies the action the closer has requested the listener + * perform. + */ + readonly empty: Observable = this._empty.asObservable(); + + /** @param menu the MenuStackItem to put on the stack. */ + push(menu: MenuStackItem) { + this._elements.push(menu); + } + + /** + * Pop off the top most MenuStackItem and emit it on the close observable. + * @param focusNext the event to emit on the `empty` observable if the method call resulted in an + * empty stack. Does not emit if the stack was initially empty. + */ + closeLatest(focusNext?: FocusNext) { + const menuStackItem = this._elements.pop(); + if (menuStackItem) { + this._close.next(menuStackItem); + if (this._elements.length === 0) { + this._empty.next(focusNext); + } + } + } + + /** + * Pop off all MenuStackItems and emit each one on the `close` observable one by one. + * @param focusNext the event to emit on the `empty` observable once the stack is emptied. Does + * not emit if the stack was initially empty. + */ + closeAll(focusNext?: FocusNext) { + if (this._elements.length) { + while (this._elements.length) { + const menuStackItem = this._elements.pop(); + if (menuStackItem) { + this._close.next(menuStackItem); + } + } + + this._empty.next(focusNext); + } + } +} diff --git a/src/cdk-experimental/menu/menu.spec.ts b/src/cdk-experimental/menu/menu.spec.ts index 6763b2a7519d..0369a845b93b 100644 --- a/src/cdk-experimental/menu/menu.spec.ts +++ b/src/cdk-experimental/menu/menu.spec.ts @@ -1,9 +1,12 @@ import {ComponentFixture, TestBed, async} from '@angular/core/testing'; -import {Component} from '@angular/core'; +import {Component, ViewChild} from '@angular/core'; import {By} from '@angular/platform-browser'; import {CdkMenu} from './menu'; import {CdkMenuModule} from './menu-module'; import {CdkMenuItemCheckbox} from './menu-item-checkbox'; +import {CdkMenuItem} from './menu-item'; +import {CdkMenuPanel} from './menu-panel'; +import {MenuStack} from './menu-stack'; describe('Menu', () => { describe('as checkbox group', () => { @@ -19,6 +22,10 @@ describe('Menu', () => { fixture = TestBed.createComponent(MenuCheckboxGroup); fixture.detectChanges(); + fixture.componentInstance.panel._menuStack = new MenuStack(); + fixture.componentInstance.trigger.getMenuTrigger()?.toggle(); + fixture.detectChanges(); + menuItems = fixture.debugElement .queryAll(By.directive(CdkMenuItemCheckbox)) .map(element => element.injector.get(CdkMenuItemCheckbox)); @@ -51,6 +58,10 @@ describe('Menu', () => { fixture = TestBed.createComponent(MenuCheckboxGroup); fixture.detectChanges(); + fixture.componentInstance.panel._menuStack = new MenuStack(); + fixture.componentInstance.trigger.getMenuTrigger()?.toggle(); + fixture.detectChanges(); + menuItems = fixture.debugElement .queryAll(By.directive(CdkMenuItemCheckbox)) .map(element => element.injector.get(CdkMenuItemCheckbox)); @@ -81,6 +92,10 @@ describe('Menu', () => { fixture = TestBed.createComponent(MenuWithNestedGroup); fixture.detectChanges(); + fixture.componentInstance.panel._menuStack = new MenuStack(); + fixture.componentInstance.trigger.getMenuTrigger()?.toggle(); + fixture.detectChanges(); + menu = fixture.debugElement.query(By.directive(CdkMenu)).injector.get(CdkMenu); menuItems = fixture.debugElement @@ -120,6 +135,10 @@ describe('Menu', () => { fixture = TestBed.createComponent(MenuWithConditionalGroup); fixture.detectChanges(); + fixture.componentInstance.panel._menuStack = new MenuStack(); + fixture.componentInstance.trigger.getMenuTrigger()?.toggle(); + fixture.detectChanges(); + menu = fixture.debugElement.query(By.directive(CdkMenu)).injector.get(CdkMenu); menuItems = getMenuItems(); })); @@ -143,7 +162,10 @@ describe('Menu', () => { @Component({ template: ` - +
        + +
        +
        - `, }) -class MenuCheckboxGroup {} +class MenuCheckboxGroup { + @ViewChild(CdkMenuItem) readonly trigger: CdkMenuItem; + @ViewChild(CdkMenuPanel) readonly panel: CdkMenuPanel; +} @Component({ template: ` - +
        + +
        +
          • @@ -173,14 +200,19 @@ class MenuCheckboxGroup {}
          - `, }) -class MenuWithNestedGroup {} +class MenuWithNestedGroup { + @ViewChild(CdkMenuItem) readonly trigger: CdkMenuItem; + @ViewChild(CdkMenuPanel) readonly panel: CdkMenuPanel; +} @Component({ template: ` - +
          + +
          +
          • @@ -190,9 +222,10 @@ class MenuWithNestedGroup {}
          - `, }) class MenuWithConditionalGroup { renderInnerGroup = false; + @ViewChild(CdkMenuItem) readonly trigger: CdkMenuItem; + @ViewChild(CdkMenuPanel) readonly panel: CdkMenuPanel; } diff --git a/src/cdk-experimental/menu/menu.ts b/src/cdk-experimental/menu/menu.ts index 28d44a88e233..a41aeb2e2a89 100644 --- a/src/cdk-experimental/menu/menu.ts +++ b/src/cdk-experimental/menu/menu.ts @@ -16,12 +16,26 @@ import { AfterContentInit, OnDestroy, Optional, + OnInit, } from '@angular/core'; -import {take} from 'rxjs/operators'; +import {FocusKeyManager, FocusOrigin} from '@angular/cdk/a11y'; +import { + LEFT_ARROW, + RIGHT_ARROW, + UP_ARROW, + DOWN_ARROW, + ESCAPE, + TAB, + hasModifierKey, +} from '@angular/cdk/keycodes'; +import {Directionality} from '@angular/cdk/bidi'; +import {take, takeUntil} from 'rxjs/operators'; import {CdkMenuGroup} from './menu-group'; import {CdkMenuPanel} from './menu-panel'; import {Menu, CDK_MENU} from './menu-interface'; import {throwMissingMenuPanelError} from './menu-errors'; +import {CdkMenuItem} from './menu-item'; +import {MenuStack, MenuStackItem, FocusNext} from './menu-stack'; /** * Directive which configures the element as a Menu which should contain child elements marked as @@ -34,6 +48,7 @@ import {throwMissingMenuPanelError} from './menu-errors'; selector: '[cdkMenu]', exportAs: 'cdkMenu', host: { + '(keydown)': '_handleKeyEvent($event)', 'role': 'menu', '[attr.aria-orientation]': 'orientation', }, @@ -42,7 +57,7 @@ import {throwMissingMenuPanelError} from './menu-errors'; {provide: CDK_MENU, useExisting: CdkMenu}, ], }) -export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnDestroy { +export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnInit, OnDestroy { /** * Sets the aria-orientation attribute and determines where menus will be opened. * Does not affect styling/layout. @@ -52,10 +67,20 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnD /** Event emitted when the menu is closed. */ @Output() readonly closed: EventEmitter = new EventEmitter(); + /** Track the Menus making up the open menu stack. */ + _menuStack: MenuStack; + + /** Handles keyboard events for the menu. */ + private _keyManager: FocusKeyManager; + /** List of nested CdkMenuGroup elements */ @ContentChildren(CdkMenuGroup, {descendants: true}) private readonly _nestedGroups: QueryList; + /** All child MenuItem elements nested in this Menu. */ + @ContentChildren(CdkMenuItem, {descendants: true}) + private readonly _allItems: QueryList; + /** * A reference to the enclosing parent menu panel. * @@ -65,15 +90,73 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnD */ @Input('cdkMenuPanel') private readonly _explicitPanel?: CdkMenuPanel; - constructor(@Optional() private readonly _menuPanel?: CdkMenuPanel) { + constructor( + @Optional() private readonly _dir?: Directionality, + @Optional() private readonly _menuPanel?: CdkMenuPanel + ) { super(); } + ngOnInit() { + this._registerWithParentPanel(); + } + ngAfterContentInit() { super.ngAfterContentInit(); this._completeChangeEmitter(); - this._registerWithParentPanel(); + this._setKeyManager(); + this._subscribeToMenuStack(); + } + + /** Place focus on the first MenuItem in the menu and set the focus origin. */ + focusFirstItem(focusOrigin: FocusOrigin = 'program') { + this._keyManager.setFocusOrigin(focusOrigin); + this._keyManager.setFirstItemActive(); + } + + /** Place focus on the last MenuItem in the menu and set the focus origin. */ + focusLastItem(focusOrigin: FocusOrigin = 'program') { + this._keyManager.setFocusOrigin(focusOrigin); + this._keyManager.setLastItemActive(); + } + + /** Handle keyboard events for the Menu. */ + _handleKeyEvent(event: KeyboardEvent) { + const keyManager = this._keyManager; + switch (event.keyCode) { + case LEFT_ARROW: + case RIGHT_ARROW: + if (this._isHorizontal()) { + event.preventDefault(); + keyManager.setFocusOrigin('keyboard'); + keyManager.onKeydown(event); + } + break; + + case UP_ARROW: + case DOWN_ARROW: + if (!this._isHorizontal()) { + event.preventDefault(); + keyManager.setFocusOrigin('keyboard'); + keyManager.onKeydown(event); + } + break; + + case ESCAPE: + if (!hasModifierKey(event)) { + event.preventDefault(); + this._menuStack.closeLatest(FocusNext.currentItem); + } + break; + + case TAB: + this._menuStack.closeAll(); + break; + + default: + keyManager.onKeydown(event); + } } /** Register this menu with its enclosing parent menu panel */ @@ -115,6 +198,72 @@ export class CdkMenu extends CdkMenuGroup implements Menu, AfterContentInit, OnD return this._nestedGroups.length > 0 && !(this._nestedGroups.first instanceof CdkMenu); } + /** Setup the FocusKeyManager with the correct orientation for the menu. */ + private _setKeyManager() { + this._keyManager = new FocusKeyManager(this._allItems) + .withWrap() + .withTypeAhead() + .withHomeAndEnd(); + + if (this._isHorizontal()) { + this._keyManager.withHorizontalOrientation(this._dir?.value || 'ltr'); + } else { + this._keyManager.withVerticalOrientation(); + } + } + + /** Subscribe to the MenuStack close and empty observables. */ + private _subscribeToMenuStack() { + this._menuStack.close + .pipe(takeUntil(this.closed)) + .subscribe((item: MenuStackItem) => this._closeOpenMenu(item)); + + this._menuStack.empty + .pipe(takeUntil(this.closed)) + .subscribe((event: FocusNext) => this._toggleMenuFocus(event)); + } + + /** + * Close the open menu if the current active item opened the requested MenuStackItem. + * @param item the MenuStackItem requested to be closed. + */ + private _closeOpenMenu(item: MenuStackItem) { + const keyManager = this._keyManager; + if (item === keyManager.activeItem?.getMenu()) { + keyManager.activeItem.getMenuTrigger()?.closeMenu(); + keyManager.setFocusOrigin('keyboard'); + keyManager.setActiveItem(keyManager.activeItem); + } + } + + /** Set focus the either the current, previous or next item based on the FocusNext event. */ + private _toggleMenuFocus(event: FocusNext) { + const keyManager = this._keyManager; + switch (event) { + case FocusNext.nextItem: + keyManager.setFocusOrigin('keyboard'); + keyManager.setNextItemActive(); + break; + + case FocusNext.previousItem: + keyManager.setFocusOrigin('keyboard'); + keyManager.setPreviousItemActive(); + break; + + case FocusNext.currentItem: + if (keyManager.activeItem) { + keyManager.setFocusOrigin('keyboard'); + keyManager.setActiveItem(keyManager.activeItem); + } + break; + } + } + + /** Return true if this menu has been configured in a horizontal orientation. */ + private _isHorizontal() { + return this.orientation === 'horizontal'; + } + ngOnDestroy() { this._emitClosedEvent(); }