Skip to content

Commit

Permalink
feat(cdk-experimental/menu): add roving tab index to menu items
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 7, 2020
1 parent 25ce323 commit af2f9d1
Show file tree
Hide file tree
Showing 4 changed files with 128 additions and 4 deletions.
102 changes: 102 additions & 0 deletions src/cdk-experimental/menu/menu-bar.spec.ts
Original file line number Diff line number Diff line change
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 @@ -1041,6 +1089,60 @@ 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 sibling tabindex' +
' on hover',
() => {
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');
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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
28 changes: 24 additions & 4 deletions src/cdk-experimental/menu/menu-item.ts
Original file line number Diff line number Diff line change
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 @@ -89,9 +95,20 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy

/** Place focus on the element. */
focus() {
this._tabindex = 0;
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')
/** Reset the _tabindex to -1. */
_blur() {
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`.
Expand All @@ -102,9 +119,12 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy
* on the cdkMenuItemTriggered emitter and close all open menus.
*/
trigger() {
if (!this.disabled && !this.hasMenu()) {
this.triggered.next();
this._getMenuStack().closeAll();
if (!this.disabled) {
if (!this.hasMenu()) {
this.triggered.next();
this._getMenuStack().closeAll();
}
this.focus();
}
}

Expand Down

0 comments on commit af2f9d1

Please sign in to comment.