Skip to content

Commit 51fce51

Browse files
devversiontinayuangao
authored andcommitted
fix(selection-list): improve accessibility of selection list (#10137)
* Since the selection list is still a `list` that contains some content, there should be a way for screenreader users, to navigate through the options (even if disabled). With this change, if the list is disabled, it's still possible to walk through the options. * Adds a new functionality to the `ListKeyManager` that allows the developer to control the items that can't be focused. `skipPredicate`. (e.g. for the selection list we want to make sure that users can navigate using the arrow keys to disabled items as well) * Testing: Fixes that by default all fake events bubble up the DOM. This works in most of the cases, but it's wrong to always bubble events. e.g. `focus` doesn't bubble. Fixes #9995
1 parent e42f0bc commit 51fce51

File tree

8 files changed

+81
-51
lines changed

8 files changed

+81
-51
lines changed

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@ import {Subject} from 'rxjs/Subject';
1111

1212

1313
class FakeFocusable {
14-
constructor(private _label = '') { }
14+
/** Whether the item is disabled or not. */
1515
disabled = false;
16+
/** Test property that can be used to test the `skipPredicate` functionality. */
17+
skipItem = false;
18+
constructor(private _label = '') {}
1619
focus(_focusOrigin?: FocusOrigin) {}
1720
getLabel() { return this._label; }
1821
}
@@ -502,6 +505,31 @@ describe('Key managers', () => {
502505
});
503506
});
504507

508+
describe('skip predicate', () => {
509+
510+
it('should skip disabled items by default', () => {
511+
itemList.items[1].disabled = true;
512+
513+
expect(keyManager.activeItemIndex).toBe(0);
514+
515+
keyManager.onKeydown(fakeKeyEvents.downArrow);
516+
517+
expect(keyManager.activeItemIndex).toBe(2);
518+
});
519+
520+
it('should be able to skip items with a custom predicate', () => {
521+
keyManager.skipPredicate(item => item.skipItem);
522+
523+
itemList.items[1].skipItem = true;
524+
525+
expect(keyManager.activeItemIndex).toBe(0);
526+
527+
keyManager.onKeydown(fakeKeyEvents.downArrow);
528+
529+
expect(keyManager.activeItemIndex).toBe(2);
530+
});
531+
});
532+
505533
describe('typeahead mode', () => {
506534
const debounceInterval = 300;
507535

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

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
4747
private _vertical = true;
4848
private _horizontal: 'ltr' | 'rtl' | null;
4949

50+
/**
51+
* Predicate function that can be used to check whether an item should be skipped
52+
* by the key manager. By default, disabled items are skipped.
53+
*/
54+
private _skipPredicateFn = (item: T) => item.disabled;
55+
5056
// Buffer for the letters that the user has pressed when the typeahead option is turned on.
5157
private _pressedLetters: string[] = [];
5258

@@ -72,6 +78,16 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
7278
/** Stream that emits whenever the active item of the list manager changes. */
7379
change = new Subject<number>();
7480

81+
/**
82+
* Sets the predicate function that determines which items should be skipped by the
83+
* list key manager.
84+
* @param predicate Function that determines whether the given item should be skipped.
85+
*/
86+
skipPredicate(predicate: (item: T) => boolean): this {
87+
this._skipPredicateFn = predicate;
88+
return this;
89+
}
90+
7591
/**
7692
* Turns on wrapping mode, which ensures that the active item will wrap to
7793
* the other end of list when there are no more items in the given direction.
@@ -128,7 +144,9 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
128144
const index = (this._activeItemIndex + i) % items.length;
129145
const item = items[index];
130146

131-
if (!item.disabled && item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0) {
147+
if (!this._skipPredicateFn(item) &&
148+
item.getLabel!().toUpperCase().trim().indexOf(inputString) === 0) {
149+
132150
this.setActiveItem(index);
133151
break;
134152
}
@@ -282,7 +300,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
282300
const index = (this._activeItemIndex + (delta * i) + items.length) % items.length;
283301
const item = items[index];
284302

285-
if (!item.disabled) {
303+
if (!this._skipPredicateFn(item)) {
286304
this.setActiveItem(index);
287305
return;
288306
}
@@ -309,7 +327,7 @@ export class ListKeyManager<T extends ListKeyManagerOption> {
309327
return;
310328
}
311329

312-
while (items[index].disabled) {
330+
while (this._skipPredicateFn(items[index])) {
313331
index += fallbackDelta;
314332

315333
if (!items[index]) {

src/cdk/testing/event-objects.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function createKeyboardEvent(type: string, keyCode: number, target?: Elem
7474
}
7575

7676
/** Creates a fake event object with any desired event type. */
77-
export function createFakeEvent(type: string, canBubble = true, cancelable = true) {
77+
export function createFakeEvent(type: string, canBubble = false, cancelable = true) {
7878
const event = document.createEvent('Event');
7979
event.initEvent(type, canBubble, cancelable);
8080
return event;

src/lib/list/_list-theme.scss

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,8 @@
2626
background-color: mat-color($background, disabled-list-option);
2727
}
2828

29+
.mat-list-option,
2930
.mat-nav-list .mat-list-item {
30-
outline: none;
31-
32-
&:hover, &.mat-list-item-focus {
33-
background: mat-color($background, 'hover');
34-
}
35-
}
36-
37-
.mat-list-option {
38-
outline: none;
39-
4031
&:hover, &.mat-list-item-focus {
4132
background: mat-color($background, 'hover');
4233
}

src/lib/list/list-option.html

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
<div class="mat-list-item-content"
2-
[class.mat-list-item-content-reverse]="checkboxPosition == 'after'"
3-
[class.mat-list-item-disabled]="disabled">
2+
[class.mat-list-item-content-reverse]="checkboxPosition == 'after'">
43

54
<div mat-ripple
65
class="mat-list-item-ripple"

src/lib/list/list.scss

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -240,24 +240,19 @@ $mat-list-item-inset-divider-offset: 72px;
240240
}
241241
}
242242

243-
244243
.mat-nav-list {
245244
a {
246245
text-decoration: none;
247246
color: inherit;
248247
}
249248

250-
.mat-list-item-content {
249+
.mat-list-item {
251250
cursor: pointer;
252-
253-
&:hover, &.mat-list-item-focus {
254-
outline: none;
255-
}
251+
outline: none;
256252
}
257253
}
258254

259-
.mat-list-option {
260-
&:not(.mat-list-item-disabled) {
261-
cursor: pointer;
262-
}
255+
.mat-list-option:not(.mat-list-item-disabled) {
256+
cursor: pointer;
257+
outline: none;
263258
}

src/lib/list/selection-list.spec.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -251,30 +251,25 @@ describe('MatSelectionList without forms', () => {
251251
});
252252

253253
it('should focus next item when press DOWN ARROW', () => {
254-
let testListItem = listOptions[2].nativeElement as HTMLElement;
255-
let DOWN_EVENT: KeyboardEvent =
256-
createKeyboardEvent('keydown', DOWN_ARROW, testListItem);
257-
let manager = selectionList.componentInstance._keyManager;
254+
const manager = selectionList.componentInstance._keyManager;
258255

259256
dispatchFakeEvent(listOptions[2].nativeElement, 'focus');
260257
expect(manager.activeItemIndex).toEqual(2);
261258

262-
selectionList.componentInstance._keydown(DOWN_EVENT);
263-
259+
selectionList.componentInstance._keydown(createKeyboardEvent('keydown', DOWN_ARROW));
264260
fixture.detectChanges();
265261

266262
expect(manager.activeItemIndex).toEqual(3);
267263
});
268264

269-
it('should focus the first non-disabled item when pressing HOME', () => {
265+
it('should be able to focus the first item when pressing HOME', () => {
270266
const manager = selectionList.componentInstance._keyManager;
271267
expect(manager.activeItemIndex).toBe(-1);
272268

273269
const event = dispatchKeyboardEvent(selectionList.nativeElement, 'keydown', HOME);
274270
fixture.detectChanges();
275271

276-
// Note that the first item is disabled so we expect the second one to be focused.
277-
expect(manager.activeItemIndex).toBe(1);
272+
expect(manager.activeItemIndex).toBe(0);
278273
expect(event.defaultPrevented).toBe(true);
279274
});
280275

@@ -425,7 +420,7 @@ describe('MatSelectionList without forms', () => {
425420
fixture.detectChanges();
426421

427422
expect(selectionList.componentInstance.tabIndex)
428-
.toBe(-1, 'Expected the tabIndex to be set to "-1" if selection list is disabled.');
423+
.toBe(3, 'Expected the tabIndex to be still set to "3".');
429424
});
430425
});
431426

src/lib/list/selection-list.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -32,21 +32,18 @@ import {
3232
import {
3333
CanDisable,
3434
CanDisableRipple,
35-
HasTabIndex,
3635
MatLine,
3736
MatLineSetter,
3837
mixinDisabled,
3938
mixinDisableRipple,
40-
mixinTabIndex,
4139
} from '@angular/material/core';
4240
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
4341
import {Subscription} from 'rxjs/Subscription';
4442

4543

4644
/** @docs-private */
4745
export class MatSelectionListBase {}
48-
export const _MatSelectionListMixinBase =
49-
mixinTabIndex(mixinDisableRipple(mixinDisabled(MatSelectionListBase)));
46+
export const _MatSelectionListMixinBase = mixinDisableRipple(mixinDisabled(MatSelectionListBase));
5047

5148
/** @docs-private */
5249
export class MatListOptionBase {}
@@ -299,7 +296,7 @@ export class MatListOption extends _MatListOptionMixinBase
299296
changeDetection: ChangeDetectionStrategy.OnPush
300297
})
301298
export class MatSelectionList extends _MatSelectionListMixinBase implements FocusableOption,
302-
CanDisable, CanDisableRipple, HasTabIndex, AfterContentInit, ControlValueAccessor, OnDestroy {
299+
CanDisable, CanDisableRipple, AfterContentInit, ControlValueAccessor, OnDestroy {
303300

304301
/** The FocusKeyManager which handles focus. */
305302
_keyManager: FocusKeyManager<MatListOption>;
@@ -311,6 +308,9 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
311308
@Output() readonly selectionChange: EventEmitter<MatSelectionListChange> =
312309
new EventEmitter<MatSelectionListChange>();
313310

311+
/** Tabindex of the selection list. */
312+
@Input() tabIndex: number = 0;
313+
314314
/** The currently selected options. */
315315
selectedOptions: SelectionModel<MatListOption> = new SelectionModel<MatListOption>(true);
316316

@@ -332,7 +332,12 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
332332
}
333333

334334
ngAfterContentInit(): void {
335-
this._keyManager = new FocusKeyManager<MatListOption>(this.options).withWrap().withTypeAhead();
335+
this._keyManager = new FocusKeyManager<MatListOption>(this.options)
336+
.withWrap()
337+
.withTypeAhead()
338+
// Allow disabled items to be focusable. For accessibility reasons, there must be a way for
339+
// screenreader users, that allows reading the different options of the list.
340+
.skipPredicate(() => false);
336341

337342
if (this._tempValues) {
338343
this._setOptionsFromValues(this._tempValues);
@@ -359,7 +364,7 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
359364
this._modelChanges.unsubscribe();
360365
}
361366

362-
/** Focus the selection-list. */
367+
/** Focuses the last active list option. */
363368
focus() {
364369
this._element.nativeElement.focus();
365370
}
@@ -397,16 +402,15 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements Focu
397402

398403
/** Passes relevant key presses to our key manager. */
399404
_keydown(event: KeyboardEvent) {
400-
if (this.disabled) {
401-
return;
402-
}
403-
404405
switch (event.keyCode) {
405406
case SPACE:
406407
case ENTER:
407-
this._toggleSelectOnFocusedOption();
408-
// Always prevent space from scrolling the page since the list has focus
409-
event.preventDefault();
408+
if (!this.disabled) {
409+
this._toggleSelectOnFocusedOption();
410+
411+
// Always prevent space from scrolling the page since the list has focus
412+
event.preventDefault();
413+
}
410414
break;
411415
case HOME:
412416
case END:

0 commit comments

Comments
 (0)