diff --git a/src/cdk-experimental/menu/menu-bar.spec.ts b/src/cdk-experimental/menu/menu-bar.spec.ts index b2ef13ac7063..9ab1d68e1530 100644 --- a/src/cdk-experimental/menu/menu-bar.spec.ts +++ b/src/cdk-experimental/menu/menu-bar.spec.ts @@ -228,6 +228,32 @@ describe('MenuBar', () => { } ); + it('should toggle tabindex of menu bar items with left/right arrow keys', () => { + focusMenuBar(); + + dispatchKeyboardEvent(nativeMenuBar, 'keydown', RIGHT_ARROW); + detectChanges(); + expect(menuBarNativeItems[0].tabIndex).toEqual(-1); + expect(menuBarNativeItems[1].tabIndex).toEqual(0); + + dispatchKeyboardEvent(nativeMenuBar, 'keydown', RIGHT_ARROW); + detectChanges(); + expect(menuBarNativeItems[0].tabIndex).toEqual(0); + expect(menuBarNativeItems[1].tabIndex).toEqual(-1); + + dispatchKeyboardEvent(nativeMenuBar, 'keydown', LEFT_ARROW); + detectChanges(); + expect(menuBarNativeItems[0].tabIndex).toEqual(-1); + expect(menuBarNativeItems[1].tabIndex).toEqual(0); + + dispatchKeyboardEvent(nativeMenuBar, 'keydown', LEFT_ARROW); + detectChanges(); + expect(menuBarNativeItems[0].tabIndex).toEqual(0); + expect(menuBarNativeItems[1].tabIndex).toEqual(-1); + + expect(nativeMenus.length).toBe(0); + }); + it( "should open the focused menu item's menu and focus the first submenu" + ' item on the down key', @@ -264,6 +290,28 @@ describe('MenuBar', () => { expect(document.activeElement).toEqual(fileMenuNativeItems[0]); }); + + it( + 'should set the tabindex to 0 on the active item and reset the previous active items ' + + 'to -1 when navigating down to a submenu and within it using the arrow keys', + () => { + focusMenuBar(); + + expect(menuBarNativeItems[0].tabIndex).toEqual(0); + + dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', SPACE); + detectChanges(); + + expect(menuBarNativeItems[0].tabIndex).toEqual(-1); + expect(fileMenuNativeItems[0].tabIndex).toEqual(0); + + dispatchKeyboardEvent(fileMenuNativeItems[0], 'keydown', DOWN_ARROW); + detectChanges(); + + expect(fileMenuNativeItems[0].tabIndex).toEqual(-1); + expect(fileMenuNativeItems[1].tabIndex).toEqual(0); + } + ); }); describe('for Menu', () => { @@ -884,6 +932,7 @@ describe('MenuBar', () => { function openFileMenu() { dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); dispatchMouseEvent(menuBarNativeItems[0], 'click'); + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); detectChanges(); } @@ -1052,6 +1101,61 @@ describe('MenuBar', () => { expect(nativeMenus.length).toBe(0); } ); + + it( + 'should not set the tabindex when hovering over menubar item and there is no open' + + ' sibling menu', + () => { + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); + detectChanges(); + + expect(menuBarNativeItems[0].tabIndex).toBe(-1); + } + ); + + it( + 'should set the tabindex of the opened trigger to 0 and toggle tabindex' + + ' when hovering between items', + () => { + openFileMenu(); + + expect(menuBarNativeItems[0].tabIndex).toBe(0); + + dispatchMouseEvent(menuBarNativeItems[1], 'mouseenter'); + detectChanges(); + + expect(menuBarNativeItems[0].tabIndex).toBe(-1); + expect(menuBarNativeItems[1].tabIndex).toBe(0); + + dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter'); + detectChanges(); + + expect(menuBarNativeItems[0].tabIndex).toBe(0); + expect(menuBarNativeItems[1].tabIndex).toBe(-1); + } + ); + + it( + 'should set the tabindex to 0 on the active item and reset the previous active items ' + + 'to -1 when navigating down to a submenu and within it using a mouse', + () => { + openFileMenu(); + expect(menuBarNativeItems[0].tabIndex).toBe(0); + + dispatchMouseEvent(fileMenuNativeItems[0], 'mouseenter'); + dispatchMouseEvent(menuBarNativeItems[0], 'mouseout'); + detectChanges(); + + expect(menuBarNativeItems[0].tabIndex).toBe(-1); + expect(fileMenuNativeItems[0].tabIndex).toBe(0); + + dispatchMouseEvent(fileMenuNativeItems[1], 'mouseenter'); + detectChanges(); + + expect(fileMenuNativeItems[0].tabIndex).toBe(-1); + expect(fileMenuNativeItems[1].tabIndex).toBe(0); + } + ); }); }); diff --git a/src/cdk-experimental/menu/menu-item-checkbox.ts b/src/cdk-experimental/menu/menu-item-checkbox.ts index 6903eb1648cc..fb17857c0e79 100644 --- a/src/cdk-experimental/menu/menu-item-checkbox.ts +++ b/src/cdk-experimental/menu/menu-item-checkbox.ts @@ -18,6 +18,7 @@ import {CdkMenuItem} from './menu-item'; selector: '[cdkMenuItemCheckbox]', exportAs: 'cdkMenuItemCheckbox', host: { + '[tabindex]': '_tabindex', 'type': 'button', 'role': 'menuitemcheckbox', '[attr.aria-checked]': 'checked || null', diff --git a/src/cdk-experimental/menu/menu-item-radio.ts b/src/cdk-experimental/menu/menu-item-radio.ts index 65c57b84c628..adc5ecc4f242 100644 --- a/src/cdk-experimental/menu/menu-item-radio.ts +++ b/src/cdk-experimental/menu/menu-item-radio.ts @@ -22,6 +22,7 @@ import {CDK_MENU, Menu} from './menu-interface'; selector: '[cdkMenuItemRadio]', exportAs: 'cdkMenuItemRadio', host: { + '[tabindex]': '_tabindex', 'type': 'button', 'role': 'menuitemradio', '[attr.aria-checked]': 'checked || null', diff --git a/src/cdk-experimental/menu/menu-item.ts b/src/cdk-experimental/menu/menu-item.ts index 9d1b953343b9..76e12a982153 100644 --- a/src/cdk-experimental/menu/menu-item.ts +++ b/src/cdk-experimental/menu/menu-item.ts @@ -47,7 +47,7 @@ function removeIcons(element: Element) { selector: '[cdkMenuItem]', exportAs: 'cdkMenuItem', host: { - 'tabindex': '-1', + '[tabindex]': '_tabindex', 'type': 'button', 'role': 'menuitem', 'class': 'cdk-menu-item', @@ -71,6 +71,12 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy */ @Output('cdkMenuItemTriggered') triggered: EventEmitter = new EventEmitter(); + /** + * The tabindex for this menu item managed internally and used for implementing roving a + * tab index. + */ + _tabindex: 0 | -1 = -1; + /** Emits when the menu item is destroyed. */ private readonly _destroyed: Subject = new Subject(); @@ -92,6 +98,38 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy this._elementRef.nativeElement.focus(); } + // 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('blur') + @HostListener('mouseout') + /** Reset the _tabindex to -1. */ + _resetTabIndex() { + this._tabindex = -1; + } + + // 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('focus') + @HostListener('mouseenter', ['$event']) + /** + * Set the tab index to 0 if not disabled and it's a focus event, or a mouse enter if this element + * is not in a menu bar. + */ + _setTabIndex(event?: MouseEvent) { + if (this.disabled) { + return; + } + + // don't set the tabindex if there are no open sibling or parent menus + if (!event || (event && !this._getMenuStack().isEmpty())) { + this._tabindex = 0; + } + } + // 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`.