Skip to content

Commit 75042e4

Browse files
authored
feat(cdk-experimental/menu): add roving tab index to menu items (#20235)
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 3ce4452 commit 75042e4

File tree

4 files changed

+145
-1
lines changed

4 files changed

+145
-1
lines changed

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

Lines changed: 104 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', () => {
@@ -884,6 +932,7 @@ describe('MenuBar', () => {
884932
function openFileMenu() {
885933
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
886934
dispatchMouseEvent(menuBarNativeItems[0], 'click');
935+
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
887936
detectChanges();
888937
}
889938

@@ -1052,6 +1101,61 @@ describe('MenuBar', () => {
10521101
expect(nativeMenus.length).toBe(0);
10531102
}
10541103
);
1104+
1105+
it(
1106+
'should not set the tabindex when hovering over menubar item and there is no open' +
1107+
' sibling menu',
1108+
() => {
1109+
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
1110+
detectChanges();
1111+
1112+
expect(menuBarNativeItems[0].tabIndex).toBe(-1);
1113+
}
1114+
);
1115+
1116+
it(
1117+
'should set the tabindex of the opened trigger to 0 and toggle tabindex' +
1118+
' when hovering between items',
1119+
() => {
1120+
openFileMenu();
1121+
1122+
expect(menuBarNativeItems[0].tabIndex).toBe(0);
1123+
1124+
dispatchMouseEvent(menuBarNativeItems[1], 'mouseenter');
1125+
detectChanges();
1126+
1127+
expect(menuBarNativeItems[0].tabIndex).toBe(-1);
1128+
expect(menuBarNativeItems[1].tabIndex).toBe(0);
1129+
1130+
dispatchMouseEvent(menuBarNativeItems[0], 'mouseenter');
1131+
detectChanges();
1132+
1133+
expect(menuBarNativeItems[0].tabIndex).toBe(0);
1134+
expect(menuBarNativeItems[1].tabIndex).toBe(-1);
1135+
}
1136+
);
1137+
1138+
it(
1139+
'should set the tabindex to 0 on the active item and reset the previous active items ' +
1140+
'to -1 when navigating down to a submenu and within it using a mouse',
1141+
() => {
1142+
openFileMenu();
1143+
expect(menuBarNativeItems[0].tabIndex).toBe(0);
1144+
1145+
dispatchMouseEvent(fileMenuNativeItems[0], 'mouseenter');
1146+
dispatchMouseEvent(menuBarNativeItems[0], 'mouseout');
1147+
detectChanges();
1148+
1149+
expect(menuBarNativeItems[0].tabIndex).toBe(-1);
1150+
expect(fileMenuNativeItems[0].tabIndex).toBe(0);
1151+
1152+
dispatchMouseEvent(fileMenuNativeItems[1], 'mouseenter');
1153+
detectChanges();
1154+
1155+
expect(fileMenuNativeItems[0].tabIndex).toBe(-1);
1156+
expect(fileMenuNativeItems[1].tabIndex).toBe(0);
1157+
}
1158+
);
10551159
});
10561160
});
10571161

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
@@ -22,6 +22,7 @@ import {CDK_MENU, Menu} from './menu-interface';
2222
selector: '[cdkMenuItemRadio]',
2323
exportAs: 'cdkMenuItemRadio',
2424
host: {
25+
'[tabindex]': '_tabindex',
2526
'type': 'button',
2627
'role': 'menuitemradio',
2728
'[attr.aria-checked]': 'checked || null',

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

Lines changed: 39 additions & 1 deletion
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

@@ -92,6 +98,38 @@ export class CdkMenuItem implements FocusableOption, FocusableElement, OnDestroy
9298
this._elementRef.nativeElement.focus();
9399
}
94100

101+
// In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order
102+
// to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we
103+
// can move this back into `host`.
104+
// tslint:disable:no-host-decorator-in-concrete
105+
@HostListener('blur')
106+
@HostListener('mouseout')
107+
/** Reset the _tabindex to -1. */
108+
_resetTabIndex() {
109+
this._tabindex = -1;
110+
}
111+
112+
// In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order
113+
// to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we
114+
// can move this back into `host`.
115+
// tslint:disable:no-host-decorator-in-concrete
116+
@HostListener('focus')
117+
@HostListener('mouseenter', ['$event'])
118+
/**
119+
* Set the tab index to 0 if not disabled and it's a focus event, or a mouse enter if this element
120+
* is not in a menu bar.
121+
*/
122+
_setTabIndex(event?: MouseEvent) {
123+
if (this.disabled) {
124+
return;
125+
}
126+
127+
// don't set the tabindex if there are no open sibling or parent menus
128+
if (!event || (event && !this._getMenuStack().isEmpty())) {
129+
this._tabindex = 0;
130+
}
131+
}
132+
95133
// In Ivy the `host` metadata will be merged, whereas in ViewEngine it is overridden. In order
96134
// to avoid double event listeners, we need to use `HostListener`. Once Ivy is the default, we
97135
// can move this back into `host`.

0 commit comments

Comments
 (0)