Skip to content

Commit d2c11ca

Browse files
authored
fix(chip-list): fix error state changes in chip list (#8425)
1 parent 55a9f9a commit d2c11ca

File tree

8 files changed

+312
-109
lines changed

8 files changed

+312
-109
lines changed

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

Lines changed: 173 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import {BACKSPACE, DELETE, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, TAB} from '@an
44
import {createKeyboardEvent, dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing';
55
import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core';
66
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
7-
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
7+
import {FormControl, FormsModule, NgForm, ReactiveFormsModule, Validators} from '@angular/forms';
88
import {MatFormFieldModule} from '@angular/material/form-field';
99
import {By} from '@angular/platform-browser';
1010
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
@@ -35,6 +35,7 @@ describe('MatChipList', () => {
3535
NoopAnimationsModule
3636
],
3737
declarations: [
38+
ChipListWithFormErrorMessages,
3839
StandardChipList,
3940
FormFieldChipList,
4041
BasicChipList,
@@ -864,6 +865,121 @@ describe('MatChipList', () => {
864865
});
865866
});
866867

868+
describe('error messages', () => {
869+
let errorTestComponent: ChipListWithFormErrorMessages;
870+
let containerEl: HTMLElement;
871+
let chipListEl: HTMLElement;
872+
873+
beforeEach(() => {
874+
fixture = TestBed.createComponent(ChipListWithFormErrorMessages);
875+
fixture.detectChanges();
876+
errorTestComponent = fixture.componentInstance;
877+
containerEl = fixture.debugElement.query(By.css('mat-form-field')).nativeElement;
878+
chipListEl = fixture.debugElement.query(By.css('mat-chip-list')).nativeElement;
879+
});
880+
881+
it('should not show any errors if the user has not interacted', () => {
882+
expect(errorTestComponent.formControl.untouched)
883+
.toBe(true, 'Expected untouched form control');
884+
expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message');
885+
expect(chipListEl.getAttribute('aria-invalid'))
886+
.toBe('false', 'Expected aria-invalid to be set to "false".');
887+
});
888+
889+
it('should display an error message when the chip list is touched and invalid', async(() => {
890+
expect(errorTestComponent.formControl.invalid)
891+
.toBe(true, 'Expected form control to be invalid');
892+
expect(containerEl.querySelectorAll('mat-error').length)
893+
.toBe(0, 'Expected no error message');
894+
895+
errorTestComponent.formControl.markAsTouched();
896+
fixture.detectChanges();
897+
898+
fixture.whenStable().then(() => {
899+
expect(containerEl.classList)
900+
.toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.');
901+
expect(containerEl.querySelectorAll('mat-error').length)
902+
.toBe(1, 'Expected one error message to have been rendered.');
903+
expect(chipListEl.getAttribute('aria-invalid'))
904+
.toBe('true', 'Expected aria-invalid to be set to "true".');
905+
});
906+
}));
907+
908+
it('should display an error message when the parent form is submitted', fakeAsync(() => {
909+
expect(errorTestComponent.form.submitted)
910+
.toBe(false, 'Expected form not to have been submitted');
911+
expect(errorTestComponent.formControl.invalid)
912+
.toBe(true, 'Expected form control to be invalid');
913+
expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message');
914+
915+
dispatchFakeEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit');
916+
fixture.detectChanges();
917+
918+
fixture.whenStable().then(() => {
919+
expect(errorTestComponent.form.submitted)
920+
.toBe(true, 'Expected form to have been submitted');
921+
expect(containerEl.classList)
922+
.toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.');
923+
expect(containerEl.querySelectorAll('mat-error').length)
924+
.toBe(1, 'Expected one error message to have been rendered.');
925+
expect(chipListEl.getAttribute('aria-invalid'))
926+
.toBe('true', 'Expected aria-invalid to be set to "true".');
927+
});
928+
}));
929+
930+
it('should hide the errors and show the hints once the chip list becomes valid',
931+
fakeAsync(() => {
932+
errorTestComponent.formControl.markAsTouched();
933+
fixture.detectChanges();
934+
935+
fixture.whenStable().then(() => {
936+
expect(containerEl.classList)
937+
.toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.');
938+
expect(containerEl.querySelectorAll('mat-error').length)
939+
.toBe(1, 'Expected one error message to have been rendered.');
940+
expect(containerEl.querySelectorAll('mat-hint').length)
941+
.toBe(0, 'Expected no hints to be shown.');
942+
943+
errorTestComponent.formControl.setValue('something');
944+
fixture.detectChanges();
945+
946+
fixture.whenStable().then(() => {
947+
expect(containerEl.classList).not.toContain('mat-form-field-invalid',
948+
'Expected container not to have the invalid class when valid.');
949+
expect(containerEl.querySelectorAll('mat-error').length)
950+
.toBe(0, 'Expected no error messages when the input is valid.');
951+
expect(containerEl.querySelectorAll('mat-hint').length)
952+
.toBe(1, 'Expected one hint to be shown once the input is valid.');
953+
});
954+
});
955+
}));
956+
957+
it('should set the proper role on the error messages', () => {
958+
errorTestComponent.formControl.markAsTouched();
959+
fixture.detectChanges();
960+
961+
expect(containerEl.querySelector('mat-error')!.getAttribute('role')).toBe('alert');
962+
});
963+
964+
it('sets the aria-describedby to reference errors when in error state', () => {
965+
let hintId = fixture.debugElement.query(By.css('.mat-hint')).nativeElement.getAttribute('id');
966+
let describedBy = chipListEl.getAttribute('aria-describedby');
967+
968+
expect(hintId).toBeTruthy('hint should be shown');
969+
expect(describedBy).toBe(hintId);
970+
971+
fixture.componentInstance.formControl.markAsTouched();
972+
fixture.detectChanges();
973+
974+
let errorIds = fixture.debugElement.queryAll(By.css('.mat-error'))
975+
.map(el => el.nativeElement.getAttribute('id')).join(' ');
976+
describedBy = chipListEl.getAttribute('aria-describedby');
977+
978+
expect(errorIds).toBeTruthy('errors should be shown');
979+
expect(describedBy).toBe(errorIds);
980+
});
981+
});
982+
867983
function setupStandardList() {
868984
fixture = TestBed.createComponent(StandardChipList);
869985
fixture.detectChanges();
@@ -940,14 +1056,14 @@ class FormFieldChipList {
9401056
})
9411057
class BasicChipList {
9421058
foods: any[] = [
943-
{ value: 'steak-0', viewValue: 'Steak' },
944-
{ value: 'pizza-1', viewValue: 'Pizza' },
945-
{ value: 'tacos-2', viewValue: 'Tacos', disabled: true },
946-
{ value: 'sandwich-3', viewValue: 'Sandwich' },
947-
{ value: 'chips-4', viewValue: 'Chips' },
948-
{ value: 'eggs-5', viewValue: 'Eggs' },
949-
{ value: 'pasta-6', viewValue: 'Pasta' },
950-
{ value: 'sushi-7', viewValue: 'Sushi' },
1059+
{value: 'steak-0', viewValue: 'Steak'},
1060+
{value: 'pizza-1', viewValue: 'Pizza'},
1061+
{value: 'tacos-2', viewValue: 'Tacos', disabled: true},
1062+
{value: 'sandwich-3', viewValue: 'Sandwich'},
1063+
{value: 'chips-4', viewValue: 'Chips'},
1064+
{value: 'eggs-5', viewValue: 'Eggs'},
1065+
{value: 'pasta-6', viewValue: 'Pasta'},
1066+
{value: 'sushi-7', viewValue: 'Sushi'},
9511067
];
9521068
control = new FormControl();
9531069
isRequired: boolean;
@@ -975,14 +1091,14 @@ class BasicChipList {
9751091
})
9761092
class MultiSelectionChipList {
9771093
foods: any[] = [
978-
{ value: 'steak-0', viewValue: 'Steak' },
979-
{ value: 'pizza-1', viewValue: 'Pizza' },
980-
{ value: 'tacos-2', viewValue: 'Tacos', disabled: true },
981-
{ value: 'sandwich-3', viewValue: 'Sandwich' },
982-
{ value: 'chips-4', viewValue: 'Chips' },
983-
{ value: 'eggs-5', viewValue: 'Eggs' },
984-
{ value: 'pasta-6', viewValue: 'Pasta' },
985-
{ value: 'sushi-7', viewValue: 'Sushi' },
1094+
{value: 'steak-0', viewValue: 'Steak'},
1095+
{value: 'pizza-1', viewValue: 'Pizza'},
1096+
{value: 'tacos-2', viewValue: 'Tacos', disabled: true},
1097+
{value: 'sandwich-3', viewValue: 'Sandwich'},
1098+
{value: 'chips-4', viewValue: 'Chips'},
1099+
{value: 'eggs-5', viewValue: 'Eggs'},
1100+
{value: 'pasta-6', viewValue: 'Pasta'},
1101+
{value: 'sushi-7', viewValue: 'Sushi'},
9861102
];
9871103
control = new FormControl();
9881104
isRequired: boolean;
@@ -1013,14 +1129,14 @@ class MultiSelectionChipList {
10131129
})
10141130
class InputChipList {
10151131
foods: any[] = [
1016-
{ value: 'steak-0', viewValue: 'Steak' },
1017-
{ value: 'pizza-1', viewValue: 'Pizza' },
1018-
{ value: 'tacos-2', viewValue: 'Tacos', disabled: true },
1019-
{ value: 'sandwich-3', viewValue: 'Sandwich' },
1020-
{ value: 'chips-4', viewValue: 'Chips' },
1021-
{ value: 'eggs-5', viewValue: 'Eggs' },
1022-
{ value: 'pasta-6', viewValue: 'Pasta' },
1023-
{ value: 'sushi-7', viewValue: 'Sushi' },
1132+
{value: 'steak-0', viewValue: 'Steak'},
1133+
{value: 'pizza-1', viewValue: 'Pizza'},
1134+
{value: 'tacos-2', viewValue: 'Tacos', disabled: true},
1135+
{value: 'sandwich-3', viewValue: 'Sandwich'},
1136+
{value: 'chips-4', viewValue: 'Chips'},
1137+
{value: 'eggs-5', viewValue: 'Eggs'},
1138+
{value: 'pasta-6', viewValue: 'Pasta'},
1139+
{value: 'sushi-7', viewValue: 'Sushi'},
10241140
];
10251141
control = new FormControl();
10261142

@@ -1061,8 +1177,8 @@ class InputChipList {
10611177
})
10621178
class FalsyValueChipList {
10631179
foods: any[] = [
1064-
{ value: 0, viewValue: 'Steak' },
1065-
{ value: 1, viewValue: 'Pizza' },
1180+
{value: 0, viewValue: 'Steak'},
1181+
{value: 1, viewValue: 'Pizza'},
10661182
];
10671183
control = new FormControl();
10681184
@ViewChildren(MatChip) chips: QueryList<MatChip>;
@@ -1079,9 +1195,36 @@ class FalsyValueChipList {
10791195
})
10801196
class SelectedChipList {
10811197
foods: any[] = [
1082-
{ value: 0, viewValue: 'Steak', selected: true },
1083-
{ value: 1, viewValue: 'Pizza', selected: false },
1084-
{ value: 2, viewValue: 'Pasta', selected: true },
1198+
{value: 0, viewValue: 'Steak', selected: true},
1199+
{value: 1, viewValue: 'Pizza', selected: false},
1200+
{value: 2, viewValue: 'Pasta', selected: true},
10851201
];
10861202
@ViewChildren(MatChip) chips: QueryList<MatChip>;
10871203
}
1204+
1205+
@Component({
1206+
template: `
1207+
<form #form="ngForm" novalidate>
1208+
<mat-form-field>
1209+
<mat-chip-list [formControl]="formControl">
1210+
<mat-chip *ngFor="let food of foods" [value]="food.value" [selected]="food.selected">
1211+
{{food.viewValue}}
1212+
</mat-chip>
1213+
</mat-chip-list>
1214+
<mat-hint>Please select a chip, or type to add a new chip</mat-hint>
1215+
<mat-error>Should have value</mat-error>
1216+
</mat-form-field>
1217+
</form>
1218+
`
1219+
})
1220+
class ChipListWithFormErrorMessages {
1221+
foods: any[] = [
1222+
{value: 0, viewValue: 'Steak', selected: true},
1223+
{value: 1, viewValue: 'Pizza', selected: false},
1224+
{value: 2, viewValue: 'Pasta', selected: true},
1225+
];
1226+
@ViewChildren(MatChip) chips: QueryList<MatChip>;
1227+
1228+
@ViewChild('form') form: NgForm;
1229+
formControl = new FormControl('', Validators.required);
1230+
}

src/lib/chips/chip-list.ts

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
ChangeDetectorRef,
1919
Component,
2020
ContentChildren,
21+
DoCheck,
2122
ElementRef,
2223
EventEmitter,
2324
Input,
@@ -29,15 +30,31 @@ import {
2930
Self,
3031
ViewEncapsulation,
3132
} from '@angular/core';
32-
import {ControlValueAccessor, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
33+
import {
34+
ControlValueAccessor,
35+
FormGroupDirective,
36+
NgControl,
37+
NgForm
38+
} from '@angular/forms';
39+
import {ErrorStateMatcher, mixinErrorState, CanUpdateErrorState} from '@angular/material/core';
3340
import {MatFormFieldControl} from '@angular/material/form-field';
3441
import {Observable} from 'rxjs/Observable';
3542
import {merge} from 'rxjs/observable/merge';
36-
import {Subject} from 'rxjs/Subject';
3743
import {Subscription} from 'rxjs/Subscription';
3844
import {MatChip, MatChipEvent, MatChipSelectionChange} from './chip';
3945
import {MatChipInput} from './chip-input';
4046

47+
// Boilerplate for applying mixins to MatChipList.
48+
/** @docs-private */
49+
export class MatChipListBase {
50+
constructor(public _defaultErrorStateMatcher: ErrorStateMatcher,
51+
public _parentForm: NgForm,
52+
public _parentFormGroup: FormGroupDirective,
53+
public ngControl: NgControl) {}
54+
}
55+
export const _MatChipListMixinBase = mixinErrorState(MatChipListBase);
56+
57+
4158
// Increasing integer for generating unique ids for chip-list components.
4259
let nextUniqueId = 0;
4360

@@ -78,16 +95,10 @@ export class MatChipListChange {
7895
preserveWhitespaces: false,
7996
changeDetection: ChangeDetectionStrategy.OnPush
8097
})
81-
export class MatChipList implements MatFormFieldControl<any>, ControlValueAccessor,
82-
AfterContentInit, OnInit, OnDestroy {
98+
export class MatChipList extends _MatChipListMixinBase implements MatFormFieldControl<any>,
99+
ControlValueAccessor, AfterContentInit, DoCheck, OnInit, OnDestroy, CanUpdateErrorState {
83100
readonly controlType = 'mat-chip-list';
84101

85-
/**
86-
* Stream that emits whenever the state of the input changes such that the wrapping `MatFormField`
87-
* needs to run change detection.
88-
*/
89-
stateChanges = new Subject<void>();
90-
91102
/** When a chip is destroyed, we track the index so we can focus the appropriate next chip. */
92103
protected _lastDestroyedIndex: number|null = null;
93104

@@ -173,6 +184,9 @@ export class MatChipList implements MatFormFieldControl<any>, ControlValueAccess
173184
return this.empty ? null : 'listbox';
174185
}
175186

187+
/** An object used to control when error messages are shown. */
188+
@Input() errorStateMatcher: ErrorStateMatcher;
189+
176190
/** Whether the user should be allowed to select multiple chips. */
177191
@Input()
178192
get multiple(): boolean { return this._multiple; }
@@ -251,14 +265,6 @@ export class MatChipList implements MatFormFieldControl<any>, ControlValueAccess
251265
get disabled() { return this.ngControl ? this.ngControl.disabled : this._disabled; }
252266
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }
253267

254-
/** Whether the chip list is in an error state. */
255-
get errorState(): boolean {
256-
const isInvalid = this.ngControl && this.ngControl.invalid;
257-
const isTouched = this.ngControl && this.ngControl.touched;
258-
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
259-
(this._parentForm && this._parentForm.submitted);
260-
return !!(isInvalid && (isTouched || isSubmitted));
261-
}
262268

263269
/** Orientation of the chip list. */
264270
@Input('aria-orientation') ariaOrientation: 'horizontal' | 'vertical' = 'horizontal';
@@ -313,9 +319,11 @@ export class MatChipList implements MatFormFieldControl<any>, ControlValueAccess
313319
constructor(protected _elementRef: ElementRef,
314320
private _changeDetectorRef: ChangeDetectorRef,
315321
@Optional() private _dir: Directionality,
316-
@Optional() private _parentForm: NgForm,
317-
@Optional() private _parentFormGroup: FormGroupDirective,
322+
@Optional() _parentForm: NgForm,
323+
@Optional() _parentFormGroup: FormGroupDirective,
324+
_defaultErrorStateMatcher: ErrorStateMatcher,
318325
@Optional() @Self() public ngControl: NgControl) {
326+
super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl);
319327
if (this.ngControl) {
320328
this.ngControl.valueAccessor = this;
321329
}
@@ -352,6 +360,15 @@ export class MatChipList implements MatFormFieldControl<any>, ControlValueAccess
352360
this.stateChanges.next();
353361
}
354362

363+
ngDoCheck() {
364+
if (this.ngControl) {
365+
// We need to re-evaluate this on every change detection cycle, because there are some
366+
// error triggers that we can't subscribe to (e.g. parent form submissions). This means
367+
// that whatever logic is in here has to be super lean or we risk destroying the performance.
368+
this.updateErrorState();
369+
}
370+
}
371+
355372
ngOnDestroy(): void {
356373
this._tabOutSubscription.unsubscribe();
357374

src/lib/chips/chips-module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {NgModule} from '@angular/core';
10+
import {ErrorStateMatcher} from '@angular/material/core';
1011
import {MatChipList} from './chip-list';
1112
import {MatBasicChip, MatChip, MatChipRemove} from './chip';
1213
import {MatChipInput} from './chip-input';
@@ -15,6 +16,7 @@ import {MatChipInput} from './chip-input';
1516
@NgModule({
1617
imports: [],
1718
exports: [MatChipList, MatChip, MatChipInput, MatChipRemove, MatChipRemove, MatBasicChip],
18-
declarations: [MatChipList, MatChip, MatChipInput, MatChipRemove, MatChipRemove, MatBasicChip]
19+
declarations: [MatChipList, MatChip, MatChipInput, MatChipRemove, MatChipRemove, MatBasicChip],
20+
providers: [ErrorStateMatcher]
1921
})
2022
export class MatChipsModule {}

0 commit comments

Comments
 (0)