Skip to content

Commit 1ef16ac

Browse files
crisbetovivian-hu-zz
authored andcommitted
fix(a11y): key manager preventing arrow key events with modifier keys (#13503)
* Skips handling arrow key events that have a modifier key. Previously we would prevent the default action which can get in the way of some of the native keyboard shortcuts (e.g. marking all of the text using shift + up arrow). This has come up before in #11987, however these changes expand it to the key manager. * Adds an API that allows consumers to opt into having the key manager handle some modifier keys. This is an exception for some of our components where we have custom functionality for shift + arrow key. * Fixes the `metaKey` always being set to true on our fake keyboard events. Fixes #13496.
1 parent cd6da93 commit 1ef16ac

File tree

4 files changed

+83
-15
lines changed

4 files changed

+83
-15
lines changed

src/cdk/a11y/key-manager/list-key-manager.spec.ts

+56-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {fakeAsync, tick} from '@angular/core/testing';
55
import {createKeyboardEvent} from '@angular/cdk/testing';
66
import {ActiveDescendantKeyManager} from './activedescendant-key-manager';
77
import {FocusKeyManager} from './focus-key-manager';
8-
import {ListKeyManager} from './list-key-manager';
8+
import {ListKeyManager, ListKeyManagerModifierKey} from './list-key-manager';
99
import {FocusOrigin} from '../focus-monitor/focus-monitor';
1010
import {Subject} from 'rxjs';
1111

@@ -103,6 +103,16 @@ describe('Key managers', () => {
103103
expect(spy).toHaveBeenCalled();
104104
});
105105

106+
it('should emit tabOut when the tab key is pressed with a modifier', () => {
107+
const spy = jasmine.createSpy('tabOut spy');
108+
keyManager.tabOut.pipe(take(1)).subscribe(spy);
109+
110+
Object.defineProperty(fakeKeyEvents.tab, 'shiftKey', {get: () => true});
111+
keyManager.onKeydown(fakeKeyEvents.tab);
112+
113+
expect(spy).toHaveBeenCalled();
114+
});
115+
106116
it('should emit an event whenever the active item changes', () => {
107117
const spy = jasmine.createSpy('change spy');
108118
const subscription = keyManager.change.subscribe(spy);
@@ -338,6 +348,51 @@ describe('Key managers', () => {
338348
keyManager.onKeydown(this.prevKeyEvent);
339349
expect(this.prevKeyEvent.defaultPrevented).toBe(true);
340350
});
351+
352+
it('should not do anything for arrow keys if the alt key is held down', () => {
353+
runModifierKeyTest(this, 'altKey');
354+
});
355+
356+
it('should not do anything for arrow keys if the control key is held down', () => {
357+
runModifierKeyTest(this, 'ctrlKey');
358+
});
359+
360+
it('should not do anything for arrow keys if the meta key is held down', () => {
361+
runModifierKeyTest(this, 'metaKey');
362+
});
363+
364+
it('should not do anything for arrow keys if the shift key is held down', () => {
365+
runModifierKeyTest(this, 'shiftKey');
366+
});
367+
368+
}
369+
370+
/** Runs the test that asserts that we handle modifier keys correctly. */
371+
function runModifierKeyTest(context: {
372+
nextKeyEvent: KeyboardEvent,
373+
prevKeyEvent: KeyboardEvent
374+
}, modifier: ListKeyManagerModifierKey) {
375+
const initialActiveIndex = keyManager.activeItemIndex;
376+
const spy = jasmine.createSpy('change spy');
377+
const subscription = keyManager.change.subscribe(spy);
378+
379+
expect(context.nextKeyEvent.defaultPrevented).toBe(false);
380+
expect(context.prevKeyEvent.defaultPrevented).toBe(false);
381+
382+
Object.defineProperty(context.nextKeyEvent, modifier, {get: () => true});
383+
Object.defineProperty(context.prevKeyEvent, modifier, {get: () => true});
384+
385+
keyManager.onKeydown(context.nextKeyEvent);
386+
expect(context.nextKeyEvent.defaultPrevented).toBe(false);
387+
expect(keyManager.activeItemIndex).toBe(initialActiveIndex);
388+
expect(spy).not.toHaveBeenCalled();
389+
390+
keyManager.onKeydown(context.prevKeyEvent);
391+
expect(context.prevKeyEvent.defaultPrevented).toBe(false);
392+
expect(keyManager.activeItemIndex).toBe(initialActiveIndex);
393+
expect(spy).not.toHaveBeenCalled();
394+
395+
subscription.unsubscribe();
341396
}
342397

343398
});

src/cdk/a11y/key-manager/list-key-manager.ts

+23-12
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ export interface ListKeyManagerOption {
3030
getLabel?(): string;
3131
}
3232

33+
/** Modifier keys handled by the ListKeyManager. */
34+
export type ListKeyManagerModifierKey = 'altKey' | 'ctrlKey' | 'metaKey' | 'shiftKey';
35+
3336
/**
3437
* This class manages keyboard events for selectable lists. If you pass it a query list
3538
* of items, it will set the active item correctly when arrow events occur.
@@ -42,6 +45,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
4245
private _typeaheadSubscription = Subscription.EMPTY;
4346
private _vertical = true;
4447
private _horizontal: 'ltr' | 'rtl' | null;
48+
private _allowedModifierKeys: ListKeyManagerModifierKey[] = [];
4549

4650
/**
4751
* Predicate function that can be used to check whether an item should be skipped
@@ -118,6 +122,15 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
118122
return this;
119123
}
120124

125+
/**
126+
* Modifier keys which are allowed to be held down and whose default actions will be prevented
127+
* as the user is pressing the arrow keys. Defaults to not allowing any modifier keys.
128+
*/
129+
withAllowedModifierKeys(keys: ListKeyManagerModifierKey[]): this {
130+
this._allowedModifierKeys = keys;
131+
return this;
132+
}
133+
121134
/**
122135
* Turns on typeahead mode which allows users to set the active item by typing.
123136
* @param debounceInterval Time to wait after the last keystroke before setting the active item.
@@ -188,45 +201,43 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
188201
*/
189202
onKeydown(event: KeyboardEvent): void {
190203
const keyCode = event.keyCode;
204+
const modifiers: ListKeyManagerModifierKey[] = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey'];
205+
const isModifierAllowed = modifiers.every(modifier => {
206+
return !event[modifier] || this._allowedModifierKeys.indexOf(modifier) > -1;
207+
});
191208

192209
switch (keyCode) {
193210
case TAB:
194211
this.tabOut.next();
195212
return;
196213

197214
case DOWN_ARROW:
198-
if (this._vertical) {
215+
if (this._vertical && isModifierAllowed) {
199216
this.setNextItemActive();
200217
break;
201218
} else {
202219
return;
203220
}
204221

205222
case UP_ARROW:
206-
if (this._vertical) {
223+
if (this._vertical && isModifierAllowed) {
207224
this.setPreviousItemActive();
208225
break;
209226
} else {
210227
return;
211228
}
212229

213230
case RIGHT_ARROW:
214-
if (this._horizontal === 'ltr') {
215-
this.setNextItemActive();
216-
break;
217-
} else if (this._horizontal === 'rtl') {
218-
this.setPreviousItemActive();
231+
if (this._horizontal && isModifierAllowed) {
232+
this._horizontal === 'rtl' ? this.setPreviousItemActive() : this.setNextItemActive();
219233
break;
220234
} else {
221235
return;
222236
}
223237

224238
case LEFT_ARROW:
225-
if (this._horizontal === 'ltr') {
226-
this.setPreviousItemActive();
227-
break;
228-
} else if (this._horizontal === 'rtl') {
229-
this.setNextItemActive();
239+
if (this._horizontal && isModifierAllowed) {
240+
this._horizontal === 'rtl' ? this.setNextItemActive() : this.setPreviousItemActive();
230241
break;
231242
} else {
232243
return;

src/lib/list/selection-list.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -345,7 +345,8 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
345345
.withTypeAhead()
346346
// Allow disabled items to be focusable. For accessibility reasons, there must be a way for
347347
// screenreader users, that allows reading the different options of the list.
348-
.skipPredicate(() => false);
348+
.skipPredicate(() => false)
349+
.withAllowedModifierKeys(['shiftKey']);
349350

350351
if (this._tempValues) {
351352
this._setOptionsFromValues(this._tempValues);

src/lib/select/select.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -864,7 +864,8 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit,
864864
this._keyManager = new ActiveDescendantKeyManager<MatOption>(this.options)
865865
.withTypeAhead()
866866
.withVerticalOrientation()
867-
.withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr');
867+
.withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr')
868+
.withAllowedModifierKeys(['shiftKey']);
868869

869870
this._keyManager.tabOut.pipe(takeUntil(this._destroy)).subscribe(() => {
870871
// Restore focus to the trigger before closing. Ensures that the focus

0 commit comments

Comments
 (0)