Skip to content

Commit d08d8bc

Browse files
crisbetojelbourn
authored andcommitted
fix(chips): focus not being restored correctly on chip removal when inside component with animations (#12416)
Fixes the chip list losing its focus position if a chip is deleted while it's inside a component with animations. Fixes #12374.
1 parent 2349166 commit d08d8bc

File tree

2 files changed

+87
-8
lines changed

2 files changed

+87
-8
lines changed

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

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import {FocusKeyManager} from '@angular/cdk/a11y';
22
import {Directionality, Direction} from '@angular/cdk/bidi';
33
import {BACKSPACE, DELETE, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, TAB} from '@angular/cdk/keycodes';
4-
import {createKeyboardEvent, dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing';
4+
import {
5+
createKeyboardEvent,
6+
dispatchFakeEvent,
7+
dispatchKeyboardEvent,
8+
MockNgZone,
9+
} from '@angular/cdk/testing';
510
import {
611
Component,
712
DebugElement,
@@ -10,16 +15,18 @@ import {
1015
ViewChildren,
1116
Type,
1217
Provider,
18+
NgZone,
1319
} from '@angular/core';
1420
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
1521
import {FormControl, FormsModule, NgForm, ReactiveFormsModule, Validators} from '@angular/forms';
1622
import {MatFormFieldModule} from '@angular/material/form-field';
1723
import {By} from '@angular/platform-browser';
18-
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
24+
import {NoopAnimationsModule, BrowserAnimationsModule} from '@angular/platform-browser/animations';
1925
import {MatInputModule} from '../input/index';
2026
import {MatChip} from './chip';
2127
import {MatChipInputEvent} from './chip-input';
2228
import {MatChipList, MatChipsModule} from './index';
29+
import {trigger, transition, style, animate} from '@angular/animations';
2330

2431

2532
describe('MatChipList', () => {
@@ -30,6 +37,7 @@ describe('MatChipList', () => {
3037
let testComponent: StandardChipList;
3138
let chips: QueryList<any>;
3239
let manager: FocusKeyManager<MatChip>;
40+
let zone: MockNgZone;
3341

3442
describe('StandardChipList', () => {
3543
describe('basic behaviors', () => {
@@ -189,6 +197,7 @@ describe('MatChipList', () => {
189197
// Focus and blur the middle item
190198
midItem.focus();
191199
midItem._blur();
200+
zone.simulateZoneExit();
192201

193202
// Destroy the middle item
194203
testComponent.remove = 2;
@@ -197,6 +206,32 @@ describe('MatChipList', () => {
197206
// Should not have focus
198207
expect(chipListInstance._keyManager.activeItemIndex).toEqual(-1);
199208
});
209+
210+
it('should move focus to the last chip when the focused chip was deleted inside a' +
211+
'component with animations', fakeAsync(() => {
212+
fixture.destroy();
213+
TestBed.resetTestingModule();
214+
fixture = createComponent(StandardChipListWithAnimations, [], BrowserAnimationsModule);
215+
fixture.detectChanges();
216+
217+
chipListDebugElement = fixture.debugElement.query(By.directive(MatChipList));
218+
chipListNativeElement = chipListDebugElement.nativeElement;
219+
chipListInstance = chipListDebugElement.componentInstance;
220+
testComponent = fixture.debugElement.componentInstance;
221+
chips = chipListInstance.chips;
222+
223+
chips.last.focus();
224+
fixture.detectChanges();
225+
226+
expect(chipListInstance._keyManager.activeItemIndex).toBe(chips.length - 1);
227+
228+
dispatchKeyboardEvent(chips.last._elementRef.nativeElement, 'keydown', BACKSPACE);
229+
fixture.detectChanges();
230+
tick(500);
231+
232+
expect(chipListInstance._keyManager.activeItemIndex).toBe(chips.length - 1);
233+
}));
234+
200235
});
201236
});
202237

@@ -1053,18 +1088,23 @@ describe('MatChipList', () => {
10531088
});
10541089
});
10551090

1056-
function createComponent<T>(component: Type<T>, providers: Provider[] = []): ComponentFixture<T> {
1091+
function createComponent<T>(component: Type<T>, providers: Provider[] = [], animationsModule:
1092+
Type<NoopAnimationsModule> | Type<BrowserAnimationsModule> = NoopAnimationsModule):
1093+
ComponentFixture<T> {
10571094
TestBed.configureTestingModule({
10581095
imports: [
10591096
FormsModule,
10601097
ReactiveFormsModule,
10611098
MatChipsModule,
10621099
MatFormFieldModule,
10631100
MatInputModule,
1064-
NoopAnimationsModule,
1101+
animationsModule,
10651102
],
10661103
declarations: [component],
1067-
providers
1104+
providers: [
1105+
{provide: NgZone, useFactory: () => zone = new MockNgZone()},
1106+
...providers
1107+
]
10681108
}).compileComponents();
10691109

10701110
return TestBed.createComponent<T>(component);
@@ -1328,3 +1368,33 @@ class ChipListWithFormErrorMessages {
13281368
@ViewChild('form') form: NgForm;
13291369
formControl = new FormControl('', Validators.required);
13301370
}
1371+
1372+
1373+
@Component({
1374+
template: `
1375+
<mat-chip-list>
1376+
<mat-chip *ngFor="let i of numbers" (removed)="remove(i)">{{i}}</mat-chip>
1377+
</mat-chip-list>`,
1378+
animations: [
1379+
// For the case we're testing this animation doesn't
1380+
// have to be used anywhere, it just has to be defined.
1381+
trigger('dummyAnimation', [
1382+
transition(':leave', [
1383+
style({opacity: 0}),
1384+
animate('500ms', style({opacity: 1}))
1385+
])
1386+
])
1387+
]
1388+
})
1389+
class StandardChipListWithAnimations {
1390+
numbers = [0, 1, 2, 3, 4];
1391+
1392+
remove(item: number): void {
1393+
const index = this.numbers.indexOf(item);
1394+
1395+
if (index > -1) {
1396+
this.numbers.splice(index, 1);
1397+
}
1398+
}
1399+
}
1400+

src/lib/chips/chip.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
RippleTarget
3838
} from '@angular/material/core';
3939
import {Subject} from 'rxjs';
40+
import {take} from 'rxjs/operators';
4041

4142

4243
/** Represents an event fired on an individual `mat-chip`. */
@@ -218,14 +219,14 @@ export class MatChip extends _MatChipMixinBase implements FocusableOption, OnDes
218219
}
219220

220221
constructor(public _elementRef: ElementRef,
221-
ngZone: NgZone,
222+
private _ngZone: NgZone,
222223
platform: Platform,
223224
@Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) globalOptions: RippleGlobalOptions) {
224225
super(_elementRef);
225226

226227
this._addHostClassName();
227228

228-
this._chipRipple = new RippleRenderer(this, ngZone, _elementRef, platform);
229+
this._chipRipple = new RippleRenderer(this, _ngZone, _elementRef, platform);
229230
this._chipRipple.setupTriggerEvents(_elementRef.nativeElement);
230231

231232
if (globalOptions) {
@@ -359,7 +360,15 @@ export class MatChip extends _MatChipMixinBase implements FocusableOption, OnDes
359360
}
360361

361362
_blur(): void {
362-
this._hasFocus = false;
363+
// When animations are enabled, Angular may end up removing the chip from the DOM a little
364+
// earlier than usual, causing it to be blurred and throwing off the logic in the chip list
365+
// that moves focus not the next item. To work around the issue, we defer marking the chip
366+
// as not focused until the next time the zone stabilizes.
367+
this._ngZone.onStable
368+
.asObservable()
369+
.pipe(take(1))
370+
.subscribe(() => this._hasFocus = false);
371+
363372
this._onBlur.next({chip: this});
364373
}
365374
}

0 commit comments

Comments
 (0)