Skip to content

Commit af2f9d1

Browse files
committed
feat(cdk-experimental/menu): add roving tab index to menu items
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.
1 parent 25ce323 commit af2f9d1

File tree

4 files changed

+128
-4
lines changed

4 files changed

+128
-4
lines changed

src/cdk-experimental/menu/menu-bar.spec.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,32 @@ describe('MenuBar', () => {
228228
}
229229
);
230230

231+
it('should toggle tabindex of menu bar items with left/right arrow keys', () => {
232+
focusMenuBar();
233+
234+
dispatchKeyboardEvent(nativeMenuBar, 'keydown', RIGHT_ARROW);
235+
detectChanges();
236+
expect(menuBarNativeItems[0].tabIndex).toEqual(-1);
237+
expect(menuBarNativeItems[1].tabIndex).toEqual(0);
238+
239+
dispatchKeyboardEvent(nativeMenuBar, 'keydown', RIGHT_ARROW);
240+
detectChanges();
241+
expect(menuBarNativeItems[0].tabIndex).toEqual(0);
242+
expect(menuBarNativeItems[1].tabIndex).toEqual(-1);
243+
244+
dispatchKeyboardEvent(nativeMenuBar, 'keydown', LEFT_ARROW);
245+
detectChanges();
246+
expect(menuBarNativeItems[0].tabIndex).toEqual(-1);
247+
expect(menuBarNativeItems[1].tabIndex).toEqual(0);
248+
249+
dispatchKeyboardEvent(nativeMenuBar, 'keydown', LEFT_ARROW);
250+
detectChanges();
251+
expect(menuBarNativeItems[0].tabIndex).toEqual(0);
252+
expect(menuBarNativeItems[1].tabIndex).toEqual(-1);
253+
254+
expect(nativeMenus.length).toBe(0);
255+
});
256+
231257
it(
232258
"should open the focused menu item's menu and focus the first submenu" +
233259
' item on the down key',
@@ -264,6 +290,28 @@ describe('MenuBar', () => {
264290

265291
expect(document.activeElement).toEqual(fileMenuNativeItems[0]);
266292
});
293+
294+
it(
295+
'should set the tabindex to 0 on the active item and reset the previous active items ' +
296+
'to -1 when navigating down to a submenu and within it using the arrow keys',
297+
() => {
298+
focusMenuBar();
299+
300+
expect(menuBarNativeItems[0].tabIndex).toEqual(0);
301+
302+
dispatchKeyboardEvent(menuBarNativeItems[0], 'keydown', SPACE);
303+
detectChanges();
304+
305+
expect(menuBarNativeItems[0].tabIndex).toEqual(-1);
306+
expect(fileMenuNativeItems[0].tabIndex).toEqual(0);
307+
308+
dispatchKeyboardEvent(fileMenuNativeItems[0], 'keydown', DOWN_ARROW);
309+
detectChanges();
310+
311+
expect(fileMenuNativeItems[0].tabIndex).toEqual(-1);
312+
expect(fileMenuNativeItems[1].tabIndex).toEqual(0);
313+
}
314+
);
267315
});
268316

269317
describe('for Menu', () => {
@@ -1041,6 +1089,60 @@ describe('MenuBar', () => {
10411089
expect(nativeMenus.length).toBe(0);
10421090
}
10431091
);
1092+
1093+
it(
1094+
'should not set the tabindex when hovering over menubar item and there is no open' +
1095+
' sibling menu',
1096+
() => {
1097+
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
1098+
detectChanges();
1099+
1100+
expect(menuBarNativeItems[0].tabIndex).toBe(-1);
1101+
}
1102+
);
1103+
1104+
it(
1105+
'should set the tabindex of the opened trigger to 0 and toggle sibling tabindex' +
1106+
' on hover',
1107+
() => {
1108+
openFileMenu();
1109+
1110+
expect(menuBarNativeItems[0].tabIndex).toBe(0);
1111+
1112+
dispatchMouseEvent(menuBarNativeItems[1], 'mouseenter');
1113+
detectChanges();
1114+
1115+
expect(menuBarNativeItems[0].tabIndex).toBe(-1);
1116+
expect(menuBarNativeItems[1].tabIndex).toBe(0);
1117+
1118+
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
1119+
detectChanges();
1120+
1121+
expect(menuBarNativeItems[0].tabIndex).toBe(0);
1122+
expect(menuBarNativeItems[1].tabIndex).toBe(-1);
1123+
}
1124+
);
1125+
1126+
it(
1127+
'should set the tabindex to 0 on the active item and reset the previous active items ' +
1128+
'to -1 when navigating down to a submenu and within it using a mouse',
1129+
() => {
1130+
openFileMenu();
1131+
expect(menuBarNativeItems[0].tabIndex).toBe(0);
1132+
1133+
dispatchMouseEvent(fileMenuNativeItems[0], 'mouseenter');
1134+
detectChanges();
1135+
1136+
expect(menuBarNativeItems[0].tabIndex).toBe(-1);
1137+
expect(fileMenuNativeItems[0].tabIndex).toBe(0);
1138+
1139+
dispatchMouseEvent(fileMenuNativeItems[1], 'mouseenter');
1140+
detectChanges();
1141+
1142+
expect(fileMenuNativeItems[0].tabIndex).toBe(-1);
1143+
expect(fileMenuNativeItems[1].tabIndex).toBe(0);
1144+
}
1145+
);
10441146
});
10451147
});
10461148

src/cdk-experimental/menu/menu-item-checkbox.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {CdkMenuItem} from './menu-item';
1818
selector: '[cdkMenuItemCheckbox]',
1919
exportAs: 'cdkMenuItemCheckbox',
2020
host: {
21+
'[tabindex]': '_tabindex',
2122
'type': 'button',
2223
'role': 'menuitemcheckbox',
2324
'[attr.aria-checked]': 'checked || null',

src/cdk-experimental/menu/menu-item-radio.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {CDK_MENU, Menu} from './menu-interface';
3131
selector: '[cdkMenuItemRadio]',
3232
exportAs: 'cdkMenuItemRadio',
3333
host: {
34+
'[tabindex]': '_tabindex',
3435
'type': 'button',
3536
'role': 'menuitemradio',
3637
'[attr.aria-checked]': 'checked || null',

src/cdk-experimental/menu/menu-item.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ function removeIcons(element: Element) {
4747
selector: '[cdkMenuItem]',
4848
exportAs: 'cdkMenuItem',
4949
host: {
50-
'tabindex': '-1',
50+
'[tabindex]': '_tabindex',
5151
'type': 'button',
5252
'role': 'menuitem',
5353
'class': 'cdk-menu-item',
@@ -71,6 +71,12 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy
7171
*/
7272
@Output('cdkMenuItemTriggered') triggered: EventEmitter<void> = new EventEmitter();
7373

74+
/**
75+
* The tabindex for this menu item managed internally and used for implementing roving a
76+
* tab index.
77+
*/
78+
_tabindex: 0 | -1 = -1;
79+
7480
/** Emits when the menu item is destroyed. */
7581
private readonly _destroyed: Subject<void> = new Subject();
7682

@@ -89,9 +95,20 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy
8995

9096
/** Place focus on the element. */
9197
focus() {
98+
this._tabindex = 0;
9299
this._elementRef.nativeElement.focus();
93100
}
94101

102+
// In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order
103+
// to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we
104+
// can move this back into `host`.
105+
// tslint:disable:no-host-decorator-in-concrete
106+
@HostListener('blur')
107+
/** Reset the _tabindex to -1. */
108+
_blur() {
109+
this._tabindex = -1;
110+
}
111+
95112
// In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order
96113
// to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we
97114
// can move this back into `host`.
@@ -102,9 +119,12 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy
102119
* on the cdkMenuItemTriggered emitter and close all open menus.
103120
*/
104121
trigger() {
105-
if (!this.disabled && !this.hasMenu()) {
106-
this.triggered.next();
107-
this._getMenuStack().closeAll();
122+
if (!this.disabled) {
123+
if (!this.hasMenu()) {
124+
this.triggered.next();
125+
this._getMenuStack().closeAll();
126+
}
127+
this.focus();
108128
}
109129
}
110130

0 commit comments

Comments
 (0)