Skip to content

Commit 4997c85

Browse files
devversionjelbourn
authored andcommitted
fix(chips): focus not restored properly if chip has been removed by click
* 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 89d16b2 commit 4997c85

File tree

4 files changed

+106
-79
lines changed

4 files changed

+106
-79
lines changed

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

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
1+
import {animate, style, transition, trigger} from '@angular/animations';
12
import {FocusKeyManager} from '@angular/cdk/a11y';
2-
import {Directionality, Direction} from '@angular/cdk/bidi';
3+
import {Direction, Directionality} from '@angular/cdk/bidi';
34
import {BACKSPACE, DELETE, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, TAB} from '@angular/cdk/keycodes';
45
import {
56
createKeyboardEvent,
67
dispatchFakeEvent,
78
dispatchKeyboardEvent,
9+
dispatchMouseEvent,
810
MockNgZone,
911
} from '@angular/cdk/testing';
1012
import {
1113
Component,
1214
DebugElement,
15+
NgZone,
16+
Provider,
1317
QueryList,
18+
Type,
1419
ViewChild,
1520
ViewChildren,
16-
Type,
17-
Provider,
18-
NgZone,
1921
} from '@angular/core';
2022
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
2123
import {FormControl, FormsModule, NgForm, ReactiveFormsModule, Validators} from '@angular/forms';
2224
import {MatFormFieldModule} from '@angular/material/form-field';
2325
import {By} from '@angular/platform-browser';
24-
import {NoopAnimationsModule, BrowserAnimationsModule} from '@angular/platform-browser/animations';
26+
import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations';
2527
import {MatInputModule} from '../input/index';
2628
import {MatChip} from './chip';
2729
import {MatChipInputEvent} from './chip-input';
28-
import {MatChipList, MatChipsModule} from './index';
29-
import {trigger, transition, style, animate} from '@angular/animations';
30+
import {MatChipEvent, MatChipList, MatChipRemove, MatChipsModule} from './index';
3031

3132

3233
describe('MatChipList', () => {
@@ -35,7 +36,7 @@ describe('MatChipList', () => {
3536
let chipListNativeElement: HTMLElement;
3637
let chipListInstance: MatChipList;
3738
let testComponent: StandardChipList;
38-
let chips: QueryList<any>;
39+
let chips: QueryList<MatChip>;
3940
let manager: FocusKeyManager<MatChip>;
4041
let zone: MockNgZone;
4142

@@ -158,6 +159,7 @@ describe('MatChipList', () => {
158159
});
159160

160161
describe('on chip destroy', () => {
162+
161163
it('should focus the next item', () => {
162164
let array = chips.toArray();
163165
let midItem = array[2];
@@ -173,7 +175,6 @@ describe('MatChipList', () => {
173175
expect(manager.activeItemIndex).toEqual(2);
174176
});
175177

176-
177178
it('should focus the previous item', () => {
178179
let array = chips.toArray();
179180
let lastIndex = array.length - 1;
@@ -465,6 +466,32 @@ describe('MatChipList', () => {
465466

466467
});
467468

469+
describe('with chip remove', () => {
470+
let chipList: MatChipList;
471+
let chipRemoveDebugElements: DebugElement[];
472+
473+
beforeEach(() => {
474+
fixture = createComponent(ChipListWithRemove);
475+
fixture.detectChanges();
476+
477+
chipList = fixture.debugElement.query(By.directive(MatChipList)).componentInstance;
478+
chipRemoveDebugElements = fixture.debugElement.queryAll(By.directive(MatChipRemove));
479+
chips = chipList.chips;
480+
});
481+
482+
it('should properly focus next item if chip is removed through click', () => {
483+
chips.toArray()[2].focus();
484+
485+
// Destroy the third focused chip by dispatching a bubbling click event on the
486+
// associated chip remove element.
487+
dispatchMouseEvent(chipRemoveDebugElements[2].nativeElement, 'click');
488+
fixture.detectChanges();
489+
490+
expect(chips.toArray()[2].value).not.toBe(2, 'Expected the third chip to be removed.');
491+
expect(chipList._keyManager.activeItemIndex).toBe(2);
492+
});
493+
});
494+
468495
describe('selection logic', () => {
469496
let formField: HTMLElement;
470497
let nativeChips: HTMLElement[];
@@ -1398,3 +1425,24 @@ class StandardChipListWithAnimations {
13981425
}
13991426
}
14001427

1428+
@Component({
1429+
template: `
1430+
<mat-form-field>
1431+
<mat-chip-list>
1432+
<div *ngFor="let i of chips">
1433+
<mat-chip [value]="i" (removed)="removeChip($event)">
1434+
Chip {{i + 1}}
1435+
<span matChipRemove>Remove</span>
1436+
</mat-chip>
1437+
</div>
1438+
</mat-chip-list>
1439+
</mat-form-field>
1440+
`
1441+
})
1442+
class ChipListWithRemove {
1443+
chips = [0, 1, 2, 3, 4];
1444+
1445+
removeChip(event: MatChipEvent) {
1446+
this.chips.splice(event.chip.value, 1);
1447+
}
1448+
}

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

@@ -498,49 +489,19 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
498489
}
499490

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

542-
// Reset our destroyed index
543-
this._lastDestroyedIndex = null;
501+
const newChipIndex = Math.min(this._lastDestroyedChipIndex, this.chips.length - 1);
502+
503+
this._keyManager.setActiveItem(newChipIndex);
504+
this._lastDestroyedChipIndex = null;
544505
}
545506

546507
/**
@@ -693,7 +654,6 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
693654
this._listenToChipsRemoved();
694655
}
695656

696-
697657
private _dropSubscriptions() {
698658
if (this._chipFocusSubscription) {
699659
this._chipFocusSubscription.unsubscribe();
@@ -709,6 +669,11 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
709669
this._chipSelectionSubscription.unsubscribe();
710670
this._chipSelectionSubscription = null;
711671
}
672+
673+
if (this._chipRemoveSubscription) {
674+
this._chipRemoveSubscription.unsubscribe();
675+
this._chipRemoveSubscription = null;
676+
}
712677
}
713678

714679
/** Listens to user-generated selection events on each chip. */
@@ -752,7 +717,15 @@ export class MatChipList extends _MatChipListMixinBase implements MatFormFieldCo
752717

753718
private _listenToChipsRemoved(): void {
754719
this._chipRemoveSubscription = this.chipRemoveChanges.subscribe(event => {
755-
this._updateKeyManager(event.chip);
720+
const chip = event.chip;
721+
const chipIndex = this.chips.toArray().indexOf(event.chip);
722+
723+
// In case the chip that will be removed is currently focused, we temporarily store
724+
// the index in order to be able to determine an appropriate sibling chip that will
725+
// receive focus.
726+
if (this._isValidIndex(chipIndex) && chip._hasFocus) {
727+
this._lastDestroyedChipIndex = chipIndex;
728+
}
756729
});
757730
}
758731
}

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)