Skip to content

Commit b61582c

Browse files
zelliottmmalerba
authored andcommitted
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 c358975 commit b61582c

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,
@@ -281,6 +281,49 @@ describe('MatSelectionList without forms', () => {
281281
expect(selectionModel.selected.length).toBe(0);
282282
});
283283

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

@@ -680,49 +723,6 @@ describe('MatSelectionList without forms', () => {
680723
});
681724
});
682725

683-
describe('with tabindex', () => {
684-
685-
beforeEach(async(() => {
686-
TestBed.configureTestingModule({
687-
imports: [MatListModule],
688-
declarations: [
689-
SelectionListWithTabindexAttr,
690-
SelectionListWithTabindexBinding,
691-
]
692-
});
693-
694-
TestBed.compileComponents();
695-
}));
696-
697-
it('should properly handle native tabindex attribute', () => {
698-
const fixture = TestBed.createComponent(SelectionListWithTabindexAttr);
699-
const selectionList = fixture.debugElement.query(By.directive(MatSelectionList))!;
700-
701-
expect(selectionList.componentInstance.tabIndex)
702-
.toBe(5, 'Expected the selection-list tabindex to be set to the attribute value.');
703-
});
704-
705-
it('should support changing the tabIndex through binding', () => {
706-
const fixture = TestBed.createComponent(SelectionListWithTabindexBinding);
707-
const selectionList = fixture.debugElement.query(By.directive(MatSelectionList))!;
708-
709-
expect(selectionList.componentInstance.tabIndex)
710-
.toBe(0, 'Expected the tabIndex to be set to "0" by default.');
711-
712-
fixture.componentInstance.tabIndex = 3;
713-
fixture.detectChanges();
714-
715-
expect(selectionList.componentInstance.tabIndex)
716-
.toBe(3, 'Expected the tabIndex to updated through binding.');
717-
718-
fixture.componentInstance.disabled = true;
719-
fixture.detectChanges();
720-
721-
expect(selectionList.componentInstance.tabIndex)
722-
.toBe(3, 'Expected the tabIndex to be still set to "3".');
723-
});
724-
});
725-
726726
describe('with option disabled', () => {
727727
let fixture: ComponentFixture<SelectionListWithDisabledOption>;
728728
let listOptionEl: HTMLElement;
@@ -1058,6 +1058,16 @@ describe('MatSelectionList with forms', () => {
10581058
expect(listOptions.map(option => option.selected)).toEqual([true, true, true, false, false]);
10591059
}));
10601060

1061+
it('should only be in the tab order if it has options', () => {
1062+
expect(selectionListDebug.componentInstance.options.length > 0).toBe(true);
1063+
expect(selectionListDebug.nativeElement.tabIndex).toBe(0);
1064+
1065+
fixture.componentInstance.options = [];
1066+
fixture.detectChanges();
1067+
1068+
expect(selectionListDebug.nativeElement.tabIndex).toBe(-1);
1069+
});
1070+
10611071
});
10621072

10631073
describe('and formControl', () => {
@@ -1352,19 +1362,6 @@ class SelectionListWithSelectedOptionAndValue {
13521362
class SelectionListWithOnlyOneOption {
13531363
}
13541364

1355-
@Component({
1356-
template: `<mat-selection-list tabindex="5"></mat-selection-list>`
1357-
})
1358-
class SelectionListWithTabindexAttr {}
1359-
1360-
@Component({
1361-
template: `<mat-selection-list [tabIndex]="tabIndex" [disabled]="disabled"></mat-selection-list>`
1362-
})
1363-
class SelectionListWithTabindexBinding {
1364-
tabIndex: number;
1365-
disabled: boolean;
1366-
}
1367-
13681365
@Component({
13691366
template: `
13701367
<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
@@ -51,7 +51,7 @@ import {
5151
ThemePalette,
5252
} from '@angular/material/core';
5353
import {Subject} from 'rxjs';
54-
import {takeUntil} from 'rxjs/operators';
54+
import {startWith, takeUntil} from 'rxjs/operators';
5555

5656
import {MatListAvatarCssMatStyler, MatListIconCssMatStyler} from './list';
5757

@@ -97,7 +97,6 @@ export class MatSelectionListChange {
9797
'(focus)': '_handleFocus()',
9898
'(blur)': '_handleBlur()',
9999
'(click)': '_handleClick()',
100-
'tabindex': '-1',
101100
'[class.mat-list-item-disabled]': 'disabled',
102101
'[class.mat-list-item-with-avatar]': '_avatar || _icon',
103102
// Manually set the "primary" or "warn" class if the color has been explicitly
@@ -110,6 +109,7 @@ export class MatSelectionListChange {
110109
'[class.mat-warn]': 'color === "warn"',
111110
'[attr.aria-selected]': 'selected',
112111
'[attr.aria-disabled]': 'disabled',
112+
'[attr.tabindex]': '-1',
113113
},
114114
templateUrl: 'list-option.html',
115115
encapsulation: ViewEncapsulation.None,
@@ -320,12 +320,13 @@ export class MatListOption extends _MatListOptionMixinBase implements AfterConte
320320
inputs: ['disableRipple'],
321321
host: {
322322
'role': 'listbox',
323-
'[tabIndex]': 'tabIndex',
324323
'class': 'mat-selection-list mat-list-base',
324+
'(focus)': '_onFocus()',
325325
'(blur)': '_onTouched()',
326326
'(keydown)': '_keydown($event)',
327327
'aria-multiselectable': 'true',
328328
'[attr.aria-disabled]': 'disabled.toString()',
329+
'[attr.tabindex]': '_tabIndex',
329330
},
330331
template: '<ng-content></ng-content>',
331332
styleUrls: ['list.css'],
@@ -346,7 +347,10 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
346347
@Output() readonly selectionChange: EventEmitter<MatSelectionListChange> =
347348
new EventEmitter<MatSelectionListChange>();
348349

349-
/** Tabindex of the selection list. */
350+
/**
351+
* Tabindex of the selection list.
352+
* @breaking-change 11.0.0 Remove `tabIndex` input.
353+
*/
350354
@Input() tabIndex: number = 0;
351355

352356
/** Theme color of the selection list. This sets the checkbox color for all list options. */
@@ -376,6 +380,9 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
376380
/** The currently selected options. */
377381
selectedOptions: SelectionModel<MatListOption> = new SelectionModel<MatListOption>(true);
378382

383+
/** The tabindex of the selection list. */
384+
_tabIndex = -1;
385+
379386
/** View to model callback that should be called whenever the selected options change. */
380387
private _onChange: (value: any) => void = (_: any) => {};
381388

@@ -391,9 +398,11 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
391398
/** Whether the list has been destroyed. */
392399
private _isDestroyed: boolean;
393400

394-
constructor(private _element: ElementRef<HTMLElement>, @Attribute('tabindex') tabIndex: string) {
401+
constructor(private _element: ElementRef<HTMLElement>,
402+
// @breaking-change 11.0.0 Remove `tabIndex` parameter.
403+
@Attribute('tabindex') tabIndex: string,
404+
private _changeDetector: ChangeDetectorRef) {
395405
super();
396-
this.tabIndex = parseInt(tabIndex) || 0;
397406
}
398407

399408
ngAfterContentInit(): void {
@@ -409,6 +418,16 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
409418
this._setOptionsFromValues(this._value);
410419
}
411420

421+
// If the user attempts to tab out of the selection list, allow focus to escape.
422+
this._keyManager.tabOut.pipe(takeUntil(this._destroyed)).subscribe(() => {
423+
this._allowFocusEscape();
424+
});
425+
426+
// When the number of options change, update the tabindex of the selection list.
427+
this.options.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
428+
this._updateTabIndex();
429+
});
430+
412431
// Sync external changes to the model back to the options.
413432
this.selectedOptions.changed.pipe(takeUntil(this._destroyed)).subscribe(event => {
414433
if (event.added) {
@@ -536,6 +555,22 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
536555
this.selectionChange.emit(new MatSelectionListChange(this, option));
537556
}
538557

558+
/**
559+
* When the selection list is focused, we want to move focus to an option within the list. Do this
560+
* by setting the appropriate option to be active.
561+
*/
562+
_onFocus(): void {
563+
const activeIndex = this._keyManager.activeItemIndex;
564+
565+
if (!activeIndex || (activeIndex === -1)) {
566+
// If there is no active index, set focus to the first option.
567+
this._keyManager.setFirstItemActive();
568+
} else {
569+
// Otherwise, set focus to the active option.
570+
this._keyManager.setActiveItem(activeIndex);
571+
}
572+
}
573+
539574
/** Implemented as part of ControlValueAccessor. */
540575
writeValue(values: string[]): void {
541576
this._value = values;
@@ -640,6 +675,25 @@ export class MatSelectionList extends _MatSelectionListMixinBase implements CanD
640675
}
641676
}
642677

678+
/**
679+
* Removes the tabindex from the selection list and resets it back afterwards, allowing the user
680+
* to tab out of it. This prevents the list from capturing focus and redirecting it back within
681+
* the list, creating a focus trap if it user tries to tab away.
682+
*/
683+
private _allowFocusEscape() {
684+
this._tabIndex = -1;
685+
686+
setTimeout(() => {
687+
this._tabIndex = 0;
688+
this._changeDetector.markForCheck();
689+
});
690+
}
691+
692+
/** Updates the tabindex based upon if the selection list is empty. */
693+
private _updateTabIndex(): void {
694+
this._tabIndex = (this.options.length === 0) ? -1 : 0;
695+
}
696+
643697
static ngAcceptInputType_disabled: BooleanInput;
644698
static ngAcceptInputType_disableRipple: BooleanInput;
645699
}

tools/public_api_guard/material/list.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export declare class MatNavList extends _MatListMixinBase implements CanDisableR
9494
export declare class MatSelectionList extends _MatSelectionListMixinBase implements CanDisableRipple, AfterContentInit, ControlValueAccessor, OnDestroy, OnChanges {
9595
_keyManager: FocusKeyManager<MatListOption>;
9696
_onTouched: () => void;
97+
_tabIndex: number;
9798
_value: string[] | null;
9899
color: ThemePalette;
99100
compareWith: (o1: any, o2: any) => boolean;
@@ -103,9 +104,10 @@ export declare class MatSelectionList extends _MatSelectionListMixinBase impleme
103104
selectedOptions: SelectionModel<MatListOption>;
104105
readonly selectionChange: EventEmitter<MatSelectionListChange>;
105106
tabIndex: number;
106-
constructor(_element: ElementRef<HTMLElement>, tabIndex: string);
107+
constructor(_element: ElementRef<HTMLElement>, tabIndex: string, _changeDetector: ChangeDetectorRef);
107108
_emitChangeEvent(option: MatListOption): void;
108109
_keydown(event: KeyboardEvent): void;
110+
_onFocus(): void;
109111
_removeOptionFromList(option: MatListOption): MatListOption | null;
110112
_reportValueChange(): void;
111113
_setFocusedOption(option: MatListOption): void;

0 commit comments

Comments
 (0)