Skip to content

Commit fd1593d

Browse files
authored
fix(material/list): Selection List element should not be focus… (#18445)
* fix(material/list): Selection list element should not take focus. Focus should move to previously active option. * Nit changes to a few comments and accept API goldens.
1 parent 52fea06 commit fd1593d

File tree

3 files changed

+117
-64
lines changed

3 files changed

+117
-64
lines changed

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

Lines changed: 54 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {DOWN_ARROW, SPACE, ENTER, UP_ARROW, HOME, END, A, D} from '@angular/cdk/keycodes';
1+
import {DOWN_ARROW, SPACE, ENTER, UP_ARROW, HOME, END, A, D, TAB} from '@angular/cdk/keycodes';
22
import {
33
createKeyboardEvent,
44
dispatchFakeEvent,
@@ -291,6 +291,49 @@ describe('MatSelectionList without forms', () => {
291291
expect(selectionModel.selected.length).toBe(0);
292292
});
293293

294+
it('should focus the first option when the list takes focus for the first time', () => {
295+
spyOn(listOptions[0].componentInstance, 'focus').and.callThrough();
296+
297+
const manager = selectionList.componentInstance._keyManager;
298+
expect(manager.activeItemIndex).toBe(-1);
299+
300+
dispatchFakeEvent(selectionList.nativeElement, 'focus');
301+
fixture.detectChanges();
302+
303+
expect(manager.activeItemIndex).toBe(0);
304+
expect(listOptions[0].componentInstance.focus).toHaveBeenCalled();
305+
});
306+
307+
it('should focus the previously focused option when the list takes focus a second time', () => {
308+
spyOn(listOptions[1].componentInstance, 'focus').and.callThrough();
309+
310+
const manager = selectionList.componentInstance._keyManager;
311+
expect(manager.activeItemIndex).toBe(-1);
312+
313+
// Focus and blur the option to move the active item index. This option is now the previously
314+
// focused option.
315+
listOptions[1].componentInstance._handleFocus();
316+
listOptions[1].componentInstance._handleBlur();
317+
318+
dispatchFakeEvent(selectionList.nativeElement, 'focus');
319+
fixture.detectChanges();
320+
321+
expect(manager.activeItemIndex).toBe(1);
322+
expect(listOptions[1].componentInstance.focus).toHaveBeenCalled();
323+
});
324+
325+
it('should allow focus to escape when tabbing away', fakeAsync(() => {
326+
selectionList.componentInstance._keyManager.onKeydown(createKeyboardEvent('keydown', TAB));
327+
328+
expect(selectionList.componentInstance._tabIndex)
329+
.toBe(-1, 'Expected tabIndex to be set to -1 temporarily.');
330+
331+
tick();
332+
333+
expect(selectionList.componentInstance._tabIndex)
334+
.toBe(0, 'Expected tabIndex to be reset back to 0');
335+
}));
336+
294337
it('should restore focus if active option is destroyed', () => {
295338
const manager = selectionList.componentInstance._keyManager;
296339

@@ -690,49 +733,6 @@ describe('MatSelectionList without forms', () => {
690733
});
691734
});
692735

693-
describe('with tabindex', () => {
694-
695-
beforeEach(async(() => {
696-
TestBed.configureTestingModule({
697-
imports: [MatListModule],
698-
declarations: [
699-
SelectionListWithTabindexAttr,
700-
SelectionListWithTabindexBinding,
701-
]
702-
});
703-
704-
TestBed.compileComponents();
705-
}));
706-
707-
it('should properly handle native tabindex attribute', () => {
708-
const fixture = TestBed.createComponent(SelectionListWithTabindexAttr);
709-
const selectionList = fixture.debugElement.query(By.directive(MatSelectionList))!;
710-
711-
expect(selectionList.componentInstance.tabIndex)
712-
.toBe(5, 'Expected the selection-list tabindex to be set to the attribute value.');
713-
});
714-
715-
it('should support changing the tabIndex through binding', () => {
716-
const fixture = TestBed.createComponent(SelectionListWithTabindexBinding);
717-
const selectionList = fixture.debugElement.query(By.directive(MatSelectionList))!;
718-
719-
expect(selectionList.componentInstance.tabIndex)
720-
.toBe(0, 'Expected the tabIndex to be set to "0" by default.');
721-
722-
fixture.componentInstance.tabIndex = 3;
723-
fixture.detectChanges();
724-
725-
expect(selectionList.componentInstance.tabIndex)
726-
.toBe(3, 'Expected the tabIndex to updated through binding.');
727-
728-
fixture.componentInstance.disabled = true;
729-
fixture.detectChanges();
730-
731-
expect(selectionList.componentInstance.tabIndex)
732-
.toBe(3, 'Expected the tabIndex to be still set to "3".');
733-
});
734-
});
735-
736736
describe('with option disabled', () => {
737737
let fixture: ComponentFixture<SelectionListWithDisabledOption>;
738738
let listOptionEl: HTMLElement;
@@ -1142,6 +1142,16 @@ describe('MatSelectionList with forms', () => {
11421142
expect(listOptions.map(option => option.selected)).toEqual([true, true, true, false, false]);
11431143
}));
11441144

1145+
it('should only be in the tab order if it has options', () => {
1146+
expect(selectionListDebug.componentInstance.options.length > 0).toBe(true);
1147+
expect(selectionListDebug.nativeElement.tabIndex).toBe(0);
1148+
1149+
fixture.componentInstance.options = [];
1150+
fixture.detectChanges();
1151+
1152+
expect(selectionListDebug.nativeElement.tabIndex).toBe(-1);
1153+
});
1154+
11451155
});
11461156

11471157
describe('and formControl', () => {
@@ -1438,19 +1448,6 @@ class SelectionListWithSelectedOptionAndValue {
14381448
class SelectionListWithOnlyOneOption {
14391449
}
14401450

1441-
@Component({
1442-
template: `<mat-selection-list tabindex="5"></mat-selection-list>`
1443-
})
1444-
class SelectionListWithTabindexAttr {}
1445-
1446-
@Component({
1447-
template: `<mat-selection-list [tabIndex]="tabIndex" [disabled]="disabled"></mat-selection-list>`
1448-
})
1449-
class SelectionListWithTabindexBinding {
1450-
tabIndex: number;
1451-
disabled: boolean;
1452-
}
1453-
14541451
@Component({
14551452
template: `
14561453
<mat-selection-list [(ngModel)]="selectedOptions" (ngModelChange)="modelChangeSpy()">

src/material/list/selection-list.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ import {
5353
} from '@angular/material/core';
5454

5555
import {Subject} from 'rxjs';
56-
import {takeUntil} from 'rxjs/operators';
56+
import {startWith, takeUntil} from 'rxjs/operators';
5757

5858
import {MatListAvatarCssMatStyler, MatListIconCssMatStyler} from './list';
5959

@@ -99,7 +99,6 @@ export class MatSelectionListChange {
9999
'(focus)': '_handleFocus()',
100100
'(blur)': '_handleBlur()',
101101
'(click)': '_handleClick()',
102-
'tabindex': '-1',
103102
'[class.mat-list-item-disabled]': 'disabled',
104103
'[class.mat-list-item-with-avatar]': '_avatar || _icon',
105104
// Manually set the "primary" or "warn" class if the color has been explicitly
@@ -113,6 +112,7 @@ export class MatSelectionListChange {
113112
'[class.mat-list-single-selected-option]': 'selected && !selectionList.multiple',
114113
'[attr.aria-selected]': 'selected',
115114
'[attr.aria-disabled]': 'disabled',
115+
'[attr.tabindex]': '-1',
116116
},
117117
templateUrl: 'list-option.html',
118118
encapsulation: ViewEncapsulation.None,
@@ -323,12 +323,13 @@ export class MatListOption extends _MatListOptionMixinBase implements AfterConte
323323
inputs: ['disableRipple'],
324324
host: {
325325
'role': 'listbox',
326-
'[tabIndex]': 'tabIndex',
327326
'class': 'mat-selection-list mat-list-base',
327+
'(focus)': '_onFocus()',
328328
'(blur)': '_onTouched()',
329329
'(keydown)': '_keydown($event)',
330330
'[attr.aria-multiselectable]': 'multiple',
331331
'[attr.aria-disabled]': 'disabled.toString()',
332+
'[attr.tabindex]': '_tabIndex',
332333
},
333334
template: '<ng-content></ng-content>',
334335
styleUrls: ['list.css'],
@@ -351,7 +352,10 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
351352
@Output() readonly selectionChange: EventEmitter<MatSelectionListChange> =
352353
new EventEmitter<MatSelectionListChange>();
353354

354-
/** Tabindex of the selection list. */
355+
/**
356+
* Tabindex of the selection list.
357+
* @breaking-change 11.0.0 Remove `tabIndex` input.
358+
*/
355359
@Input() tabIndex: number = 0;
356360

357361
/** Theme color of the selection list. This sets the checkbox color for all list options. */
@@ -398,6 +402,9 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
398402
/** The currently selected options. */
399403
selectedOptions = new SelectionModel<MatListOption>(this._multiple);
400404

405+
/** The tabindex of the selection list. */
406+
_tabIndex = -1;
407+
401408
/** View to model callback that should be called whenever the selected options change. */
402409
private _onChange: (value: any) => void = (_: any) => {};
403410

@@ -413,9 +420,11 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
413420
/** Whether the list has been destroyed. */
414421
private _isDestroyed: boolean;
415422

416-
constructor(private _element: ElementRef<HTMLElement>, @Attribute('tabindex') tabIndex: string) {
423+
constructor(private _element: ElementRef<HTMLElement>,
424+
// @breaking-change 11.0.0 Remove `tabIndex` parameter.
425+
@Attribute('tabindex') tabIndex: string,
426+
private _changeDetector: ChangeDetectorRef) {
417427
super();
418-
this.tabIndex = parseInt(tabIndex) || 0;
419428
}
420429

421430
ngAfterContentInit(): void {
@@ -433,6 +442,16 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
433442
this._setOptionsFromValues(this._value);
434443
}
435444

445+
// If the user attempts to tab out of the selection list, allow focus to escape.
446+
this._keyManager.tabOut.pipe(takeUntil(this._destroyed)).subscribe(() => {
447+
this._allowFocusEscape();
448+
});
449+
450+
// When the number of options change, update the tabindex of the selection list.
451+
this.options.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
452+
this._updateTabIndex();
453+
});
454+
436455
// Sync external changes to the model back to the options.
437456
this.selectedOptions.changed.pipe(takeUntil(this._destroyed)).subscribe(event => {
438457
if (event.added) {
@@ -560,6 +579,22 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
560579
this.selectionChange.emit(new MatSelectionListChange(this, option));
561580
}
562581

582+
/**
583+
* When the selection list is focused, we want to move focus to an option within the list. Do this
584+
* by setting the appropriate option to be active.
585+
*/
586+
_onFocus(): void {
587+
const activeIndex = this._keyManager.activeItemIndex;
588+
589+
if (!activeIndex || (activeIndex === -1)) {
590+
// If there is no active index, set focus to the first option.
591+
this._keyManager.setFirstItemActive();
592+
} else {
593+
// Otherwise, set focus to the active option.
594+
this._keyManager.setActiveItem(activeIndex);
595+
}
596+
}
597+
563598
/** Implemented as part of ControlValueAccessor. */
564599
writeValue(values: string[]): void {
565600
this._value = values;
@@ -664,6 +699,25 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
664699
}
665700
}
666701

702+
/**
703+
* Removes the tabindex from the selection list and resets it back afterwards, allowing the user
704+
* to tab out of it. This prevents the list from capturing focus and redirecting it back within
705+
* the list, creating a focus trap if it user tries to tab away.
706+
*/
707+
private _allowFocusEscape() {
708+
this._tabIndex = -1;
709+
710+
setTimeout(() => {
711+
this._tabIndex = 0;
712+
this._changeDetector.markForCheck();
713+
});
714+
}
715+
716+
/** Updates the tabindex based upon if the selection list is empty. */
717+
private _updateTabIndex(): void {
718+
this._tabIndex = (this.options.length === 0) ? -1 : 0;
719+
}
720+
667721
static ngAcceptInputType_disabled: BooleanInput;
668722
static ngAcceptInputType_disableRipple: BooleanInput;
669723
static ngAcceptInputType_multiple: BooleanInput;

tools/public_api_guard/material/list.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ export declare class MatNavList extends _MatListMixinBase implements CanDisable,
9999
export declare class MatSelectionList extends _MatSelectionListMixinBase implements CanDisableRipple, AfterContentInit, ControlValueAccessor, OnDestroy, OnChanges {
100100
_keyManager: FocusKeyManager<MatListOption>;
101101
_onTouched: () => void;
102+
_tabIndex: number;
102103
_value: string[] | null;
103104
color: ThemePalette;
104105
compareWith: (o1: any, o2: any) => boolean;
@@ -110,9 +111,10 @@ export declare class MatSelectionList extends _MatSelectionListMixinBase impleme
110111
selectedOptions: SelectionModel<MatListOption>;
111112
readonly selectionChange: EventEmitter<MatSelectionListChange>;
112113
tabIndex: number;
113-
constructor(_element: ElementRef<HTMLElement>, tabIndex: string);
114+
constructor(_element: ElementRef<HTMLElement>, tabIndex: string, _changeDetector: ChangeDetectorRef);
114115
_emitChangeEvent(option: MatListOption): void;
115116
_keydown(event: KeyboardEvent): void;
117+
_onFocus(): void;
116118
_removeOptionFromList(option: MatListOption): MatListOption | null;
117119
_reportValueChange(): void;
118120
_setFocusedOption(option: MatListOption): void;

0 commit comments

Comments
 (0)