Skip to content

Commit 3da390e

Browse files
devversionjelbourn
authored andcommitted
fix(chips): focus not restored properly if chip has been removed by click (#12788)
* Currently if someone tries to remove a chip by clicking the `[matChipRemove]`, the click event will bubble up to the potential `MatFormField` and cause the `onContainerClick` to be invoked. This causes the focus to be always moved to the first chip (which is not good for accessibility). * Replaces multiple subscriptions in the chip list with a `takeUntil`. Also fixes that the chip remove subscription is re-created multiple times but not cleaned up. * Simplifies and cleans up the logic to restore focus after a chip has been destroyed. PR is based on: #12416
1 parent 3b9d6c0 commit 3da390e

File tree

4 files changed

+105
-78
lines changed

4 files changed

+105
-78
lines changed

src/lib/chips/chip-list.spec.ts

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {animate, style, transition, trigger} from '@angular/animations';
12
import {FocusKeyManager} from '@angular/cdk/a11y';
23
import {Directionality, Direction} from '@angular/cdk/bidi';
34
import {
@@ -15,28 +16,28 @@ import {
1516
createKeyboardEvent,
1617
dispatchFakeEvent,
1718
dispatchKeyboardEvent,
19+
dispatchMouseEvent,
1820
MockNgZone,
1921
} from '@angular/cdk/testing';
2022
import {
2123
Component,
2224
DebugElement,
25+
NgZone,
26+
Provider,
2327
QueryList,
28+
Type,
2429
ViewChild,
2530
ViewChildren,
26-
Type,
27-
Provider,
28-
NgZone,
2931
} from '@angular/core';
3032
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
3133
import {FormControl, FormsModule, NgForm, ReactiveFormsModule, Validators} from '@angular/forms';
3234
import {MatFormFieldModule} from '@angular/material/form-field';
3335
import {By} from '@angular/platform-browser';
34-
import {NoopAnimationsModule, BrowserAnimationsModule} from '@angular/platform-browser/animations';
36+
import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations';
3537
import {MatInputModule} from '../input/index';
3638
import {MatChip} from './chip';
3739
import {MatChipInputEvent} from './chip-input';
38-
import {MatChipList, MatChipsModule} from './index';
39-
import {trigger, transition, style, animate} from '@angular/animations';
40+
import {MatChipEvent, MatChipList, MatChipRemove, MatChipsModule} from './index';
4041

4142

4243
describe('MatChipList', () => {
@@ -45,7 +46,7 @@ describe('MatChipList', () => {
4546
let chipListNativeElement: HTMLElement;
4647
let chipListInstance: MatChipList;
4748
let testComponent: StandardChipList;
48-
let chips: QueryList<any>;
49+
let chips: QueryList<MatChip>;
4950
let manager: FocusKeyManager<MatChip>;
5051
let zone: MockNgZone;
5152

@@ -168,6 +169,7 @@ describe('MatChipList', () => {
168169
});
169170

170171
describe('on chip destroy', () => {
172+
171173
it('should focus the next item', () => {
172174
let array = chips.toArray();
173175
let midItem = array[2];
@@ -183,7 +185,6 @@ describe('MatChipList', () => {
183185
expect(manager.activeItemIndex).toEqual(2);
184186
});
185187

186-
187188
it('should focus the previous item', () => {
188189
let array = chips.toArray();
189190
let lastIndex = array.length - 1;
@@ -505,6 +506,32 @@ describe('MatChipList', () => {
505506

506507
});
507508

509+
describe('with chip remove', () => {
510+
let chipList: MatChipList;
511+
let chipRemoveDebugElements: DebugElement[];
512+
513+
beforeEach(() => {
514+
fixture = createComponent(ChipListWithRemove);
515+
fixture.detectChanges();
516+
517+
chipList = fixture.debugElement.query(By.directive(MatChipList)).componentInstance;
518+
chipRemoveDebugElements = fixture.debugElement.queryAll(By.directive(MatChipRemove));
519+
chips = chipList.chips;
520+
});
521+
522+
it('should properly focus next item if chip is removed through click', () => {
523+
chips.toArray()[2].focus();
524+
525+
// Destroy the third focused chip by dispatching a bubbling click event on the
526+
// associated chip remove element.
527+
dispatchMouseEvent(chipRemoveDebugElements[2].nativeElement, 'click');
528+
fixture.detectChanges();
529+
530+
expect(chips.toArray()[2].value).not.toBe(2, 'Expected the third chip to be removed.');
531+
expect(chipList._keyManager.activeItemIndex).toBe(2);
532+
});
533+
});
534+
508535
describe('selection logic', () => {
509536
let formField: HTMLElement;
510537
let nativeChips: HTMLElement[];
@@ -1438,3 +1465,24 @@ class StandardChipListWithAnimations {
14381465
}
14391466
}
14401467

1468+
@Component({
1469+
template: `
1470+
<mat-form-field>
1471+
<mat-chip-list>
1472+
<div *ngFor="let i of chips">
1473+
<mat-chip [value]="i" (removed)="removeChip($event)">
1474+
Chip {{i + 1}}
1475+
<span matChipRemove>Remove</span>
1476+
</mat-chip>
1477+
</div>
1478+
</mat-chip-list>
1479+
</mat-form-field>
1480+
`
1481+
})
1482+
class ChipListWithRemove {
1483+
chips = [0, 1, 2, 3, 4];
1484+
1485+
removeChip(event: MatChipEvent) {
1486+
this.chips.splice(event.chip.value, 1);
1487+
}
1488+
}

src/lib/chips/chip-list.ts

Lines changed: 38 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,8 @@ import {
3232
import {ControlValueAccessor, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
3333
import {CanUpdateErrorState, ErrorStateMatcher, mixinErrorState} from '@angular/material/core';
3434
import {MatFormFieldControl} from '@angular/material/form-field';
35-
import {merge, Observable, Subscription} from 'rxjs';
36-
import {startWith} from 'rxjs/operators';
35+
import {merge, Observable, Subject, Subscription} from 'rxjs';
36+
import {startWith, takeUntil} from 'rxjs/operators';
3737
import {MatChip, MatChipEvent, MatChipSelectionChange} from './chip';
3838
import {MatChipInput} from './chip-input';
3939

@@ -102,17 +102,15 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
102102
*/
103103
readonly controlType: string = 'mat-chip-list';
104104

105-
/** When a chip is destroyed, we track the index so we can focus the appropriate next chip. */
106-
protected _lastDestroyedIndex: number|null = null;
107-
108-
/** Track which chips we're listening to for focus/destruction. */
109-
protected _chipSet: WeakMap<MatChip, boolean> = new WeakMap();
110-
111-
/** Subscription to tabbing out from the chip list. */
112-
private _tabOutSubscription = Subscription.EMPTY;
105+
/**
106+
* When a chip is destroyed, we store the index of the destroyed chip until the chips
107+
* query list notifies about the update. This is necessary because we cannot determine an
108+
* appropriate chip that should receive focus until the array of chips updated completely.
109+
*/
110+
private _lastDestroyedChipIndex: number | null = null;
113111

114-
/** Subscription to changes in the chip list. */
115-
private _changeSubscription: Subscription;
112+
/** Subject that emits when the component has been destroyed. */
113+
private _destroyed = new Subject<void>();
116114

117115
/** Subscription to focus changes in the chips. */
118116
private _chipFocusSubscription: Subscription | null;
@@ -350,13 +348,13 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
350348

351349
// Prevents the chip list from capturing focus and redirecting
352350
// it back to the first chip when the user tabs out.
353-
this._tabOutSubscription = this._keyManager.tabOut.subscribe(() => {
351+
this._keyManager.tabOut.pipe(takeUntil(this._destroyed)).subscribe(() => {
354352
this._tabIndex = -1;
355353
setTimeout(() => this._tabIndex = this._userTabIndex || 0);
356354
});
357355

358356
// When the list changes, re-subscribe
359-
this._changeSubscription = this.chips.changes.pipe(startWith(null)).subscribe(() => {
357+
this.chips.changes.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => {
360358
this._resetChips();
361359

362360
// Reset chips selected/deselected status
@@ -387,18 +385,11 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
387385
}
388386

389387
ngOnDestroy() {
390-
this._tabOutSubscription.unsubscribe();
391-
392-
if (this._changeSubscription) {
393-
this._changeSubscription.unsubscribe();
394-
}
395-
396-
if (this._chipRemoveSubscription) {
397-
this._chipRemoveSubscription.unsubscribe();
398-
}
388+
this._destroyed.next();
389+
this._destroyed.complete();
390+
this.stateChanges.complete();
399391

400392
this._dropSubscriptions();
401-
this.stateChanges.complete();
402393
}
403394

404395

@@ -507,49 +498,19 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
507498
}
508499

509500
/**
510-
* Update key manager's active item when chip is deleted.
511-
* If the deleted chip is the last chip in chip list, focus the new last chip.
512-
* Otherwise focus the next chip in the list.
513-
* Save `_lastDestroyedIndex` so we can set the correct focus.
514-
*/
515-
protected _updateKeyManager(chip: MatChip) {
516-
let chipIndex: number = this.chips.toArray().indexOf(chip);
517-
if (this._isValidIndex(chipIndex)) {
518-
if (chip._hasFocus) {
519-
// Check whether the chip is not the last item
520-
if (chipIndex < this.chips.length - 1) {
521-
this._keyManager.setActiveItem(chipIndex);
522-
} else if (chipIndex - 1 >= 0) {
523-
this._keyManager.setActiveItem(chipIndex - 1);
524-
}
525-
}
526-
if (this._keyManager.activeItemIndex === chipIndex) {
527-
this._lastDestroyedIndex = chipIndex;
528-
}
529-
}
530-
}
531-
532-
/**
533-
* Checks to see if a focus chip was recently destroyed so that we can refocus the next closest
534-
* one.
501+
* If the amount of chips changed, we need to update the key manager state and make sure
502+
* that to so that we can refocus the
503+
* next closest one.
535504
*/
536505
protected _updateFocusForDestroyedChips() {
537-
const chipsArray = this.chips.toArray();
538-
539-
if (this._lastDestroyedIndex != null && chipsArray.length > 0 && (this.focused ||
540-
(this._keyManager.activeItem && chipsArray.indexOf(this._keyManager.activeItem) === -1))) {
541-
// Check whether the destroyed chip was the last item
542-
const newFocusIndex = Math.min(this._lastDestroyedIndex, chipsArray.length - 1);
543-
this._keyManager.setActiveItem(newFocusIndex);
544-
const focusChip = this._keyManager.activeItem;
545-
// Focus the chip
546-
if (focusChip) {
547-
focusChip.focus();
548-
}
506+
if (this._lastDestroyedChipIndex == null || !this.chips.length) {
507+
return;
549508
}
550509

551-
// Reset our destroyed index
552-
this._lastDestroyedIndex = null;
510+
const newChipIndex = Math.min(this._lastDestroyedChipIndex, this.chips.length - 1);
511+
512+
this._keyManager.setActiveItem(newChipIndex);
513+
this._lastDestroyedChipIndex = null;
553514
}
554515

555516
/**
@@ -702,7 +663,6 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
702663
this._listenToChipsRemoved();
703664
}
704665

705-
706666
private _dropSubscriptions() {
707667
if (this._chipFocusSubscription) {
708668
this._chipFocusSubscription.unsubscribe();
@@ -718,6 +678,11 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
718678
this._chipSelectionSubscription.unsubscribe();
719679
this._chipSelectionSubscription = null;
720680
}
681+
682+
if (this._chipRemoveSubscription) {
683+
this._chipRemoveSubscription.unsubscribe();
684+
this._chipRemoveSubscription = null;
685+
}
721686
}
722687

723688
/** Listens to user-generated selection events on each chip. */
@@ -761,7 +726,15 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
761726

762727
private _listenToChipsRemoved(): void {
763728
this._chipRemoveSubscription = this.chipRemoveChanges.subscribe(event => {
764-
this._updateKeyManager(event.chip);
729+
const chip = event.chip;
730+
const chipIndex = this.chips.toArray().indexOf(event.chip);
731+
732+
// In case the chip that will be removed is currently focused, we temporarily store
733+
// the index in order to be able to determine an appropriate sibling chip that will
734+
// receive focus.
735+
if (this._isValidIndex(chipIndex) && chip._hasFocus) {
736+
this._lastDestroyedChipIndex = chipIndex;
737+
}
765738
});
766739
}
767740
}

src/lib/chips/chip-remove.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {async, ComponentFixture, TestBed} from '@angular/core/testing';
44
import {MatChip, MatChipsModule} from './index';
55

66
describe('Chip Remove', () => {
7-
let fixture: ComponentFixture<any>;
7+
let fixture: ComponentFixture<TestChip>;
88
let testChip: TestChip;
99
let chipDebugElement: DebugElement;
1010
let chipNativeElement: HTMLElement;

src/lib/chips/chip.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -391,17 +391,23 @@ export class MatChip extends _MatChipMixinBase implements FocusableOption, OnDes
391391
selector: '[matChipRemove]',
392392
host: {
393393
'class': 'mat-chip-remove mat-chip-trailing-icon',
394-
'(click)': '_handleClick()',
394+
'(click)': '_handleClick($event)',
395395
}
396396
})
397397
export class MatChipRemove {
398-
constructor(protected _parentChip: MatChip) {
399-
}
398+
constructor(protected _parentChip: MatChip) {}
400399

401400
/** Calls the parent chip's public `remove()` method if applicable. */
402-
_handleClick(): void {
401+
_handleClick(event: Event): void {
403402
if (this._parentChip.removable) {
404403
this._parentChip.remove();
405404
}
405+
406+
// We need to stop event propagation because otherwise the event will bubble up to the
407+
// form field and cause the `onContainerClick` method to be invoked. This method would then
408+
// reset the focused chip that has been focused after chip removal. Usually the parent
409+
// the parent click listener of the `MatChip` would prevent propagation, but it can happen
410+
// that the chip is being removed before the event bubbles up.
411+
event.stopPropagation();
406412
}
407413
}

0 commit comments

Comments
 (0)