Skip to content

Commit

Permalink
feat(cdk-experimental/menu): add roving tab index to menu items (#20235)
Browse files Browse the repository at this point in the history
The element under focus has a tab index of 0 while all others are set to -1. As other elements come
into focus, the previous elements tab index is reset to -1 and the newly focused element has a tab
index set to 0.
  • Loading branch information
andy9775 committed Aug 12, 2020
1 parent 3ce4452 commit 75042e4
Show file tree
Hide file tree
Showing 4 changed files with 145 additions and 1 deletion.
104 changes: 104 additions & 0 deletions src/cdk-experimental/menu/menu-bar.spec.ts
Expand Up @@ -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',
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -884,6 +932,7 @@ describe('MenuBar', () => {
function openFileMenu() {
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
dispatchMouseEvent(menuBarNativeItems[0], 'click');
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
detectChanges();
}

Expand Down Expand Up @@ -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);
}
);
});
});

Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/menu/menu-item-checkbox.ts
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions src/cdk-experimental/menu/menu-item-radio.ts
Expand Up @@ -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',
Expand Down
40 changes: 39 additions & 1 deletion src/cdk-experimental/menu/menu-item.ts
Expand Up @@ -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',
Expand All @@ -71,6 +71,12 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy
*/
@Output('cdkMenuItemTriggered') triggered: EventEmitter<void> = 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<void> = new Subject();

Expand All @@ -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`.
Expand Down

0 comments on commit 75042e4

Please sign in to comment.