Skip to content

Commit

Permalink
fix(chip-list): fix error state changes in chip list (#8425)
Browse files Browse the repository at this point in the history
  • Loading branch information
tinayuangao authored Nov 28, 2017
1 parent 55a9f9a commit d2c11ca
Show file tree
Hide file tree
Showing 8 changed files with 312 additions and 109 deletions.
203 changes: 173 additions & 30 deletions src/lib/chips/chip-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {BACKSPACE, DELETE, ENTER, LEFT_ARROW, RIGHT_ARROW, SPACE, TAB} from '@an
import {createKeyboardEvent, dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing';
import {Component, DebugElement, QueryList, ViewChild, ViewChildren} from '@angular/core';
import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {FormControl, FormsModule, NgForm, ReactiveFormsModule, Validators} from '@angular/forms';
import {MatFormFieldModule} from '@angular/material/form-field';
import {By} from '@angular/platform-browser';
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
Expand Down Expand Up @@ -35,6 +35,7 @@ describe('MatChipList', () => {
NoopAnimationsModule
],
declarations: [
ChipListWithFormErrorMessages,
StandardChipList,
FormFieldChipList,
BasicChipList,
Expand Down Expand Up @@ -864,6 +865,121 @@ describe('MatChipList', () => {
});
});

describe('error messages', () => {
let errorTestComponent: ChipListWithFormErrorMessages;
let containerEl: HTMLElement;
let chipListEl: HTMLElement;

beforeEach(() => {
fixture = TestBed.createComponent(ChipListWithFormErrorMessages);
fixture.detectChanges();
errorTestComponent = fixture.componentInstance;
containerEl = fixture.debugElement.query(By.css('mat-form-field')).nativeElement;
chipListEl = fixture.debugElement.query(By.css('mat-chip-list')).nativeElement;
});

it('should not show any errors if the user has not interacted', () => {
expect(errorTestComponent.formControl.untouched)
.toBe(true, 'Expected untouched form control');
expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message');
expect(chipListEl.getAttribute('aria-invalid'))
.toBe('false', 'Expected aria-invalid to be set to "false".');
});

it('should display an error message when the chip list is touched and invalid', async(() => {
expect(errorTestComponent.formControl.invalid)
.toBe(true, 'Expected form control to be invalid');
expect(containerEl.querySelectorAll('mat-error').length)
.toBe(0, 'Expected no error message');

errorTestComponent.formControl.markAsTouched();
fixture.detectChanges();

fixture.whenStable().then(() => {
expect(containerEl.classList)
.toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.');
expect(containerEl.querySelectorAll('mat-error').length)
.toBe(1, 'Expected one error message to have been rendered.');
expect(chipListEl.getAttribute('aria-invalid'))
.toBe('true', 'Expected aria-invalid to be set to "true".');
});
}));

it('should display an error message when the parent form is submitted', fakeAsync(() => {
expect(errorTestComponent.form.submitted)
.toBe(false, 'Expected form not to have been submitted');
expect(errorTestComponent.formControl.invalid)
.toBe(true, 'Expected form control to be invalid');
expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message');

dispatchFakeEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit');
fixture.detectChanges();

fixture.whenStable().then(() => {
expect(errorTestComponent.form.submitted)
.toBe(true, 'Expected form to have been submitted');
expect(containerEl.classList)
.toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.');
expect(containerEl.querySelectorAll('mat-error').length)
.toBe(1, 'Expected one error message to have been rendered.');
expect(chipListEl.getAttribute('aria-invalid'))
.toBe('true', 'Expected aria-invalid to be set to "true".');
});
}));

it('should hide the errors and show the hints once the chip list becomes valid',
fakeAsync(() => {
errorTestComponent.formControl.markAsTouched();
fixture.detectChanges();

fixture.whenStable().then(() => {
expect(containerEl.classList)
.toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.');
expect(containerEl.querySelectorAll('mat-error').length)
.toBe(1, 'Expected one error message to have been rendered.');
expect(containerEl.querySelectorAll('mat-hint').length)
.toBe(0, 'Expected no hints to be shown.');

errorTestComponent.formControl.setValue('something');
fixture.detectChanges();

fixture.whenStable().then(() => {
expect(containerEl.classList).not.toContain('mat-form-field-invalid',
'Expected container not to have the invalid class when valid.');
expect(containerEl.querySelectorAll('mat-error').length)
.toBe(0, 'Expected no error messages when the input is valid.');
expect(containerEl.querySelectorAll('mat-hint').length)
.toBe(1, 'Expected one hint to be shown once the input is valid.');
});
});
}));

it('should set the proper role on the error messages', () => {
errorTestComponent.formControl.markAsTouched();
fixture.detectChanges();

expect(containerEl.querySelector('mat-error')!.getAttribute('role')).toBe('alert');
});

it('sets the aria-describedby to reference errors when in error state', () => {
let hintId = fixture.debugElement.query(By.css('.mat-hint')).nativeElement.getAttribute('id');
let describedBy = chipListEl.getAttribute('aria-describedby');

expect(hintId).toBeTruthy('hint should be shown');
expect(describedBy).toBe(hintId);

fixture.componentInstance.formControl.markAsTouched();
fixture.detectChanges();

let errorIds = fixture.debugElement.queryAll(By.css('.mat-error'))
.map(el => el.nativeElement.getAttribute('id')).join(' ');
describedBy = chipListEl.getAttribute('aria-describedby');

expect(errorIds).toBeTruthy('errors should be shown');
expect(describedBy).toBe(errorIds);
});
});

function setupStandardList() {
fixture = TestBed.createComponent(StandardChipList);
fixture.detectChanges();
Expand Down Expand Up @@ -940,14 +1056,14 @@ class FormFieldChipList {
})
class BasicChipList {
foods: any[] = [
{ value: 'steak-0', viewValue: 'Steak' },
{ value: 'pizza-1', viewValue: 'Pizza' },
{ value: 'tacos-2', viewValue: 'Tacos', disabled: true },
{ value: 'sandwich-3', viewValue: 'Sandwich' },
{ value: 'chips-4', viewValue: 'Chips' },
{ value: 'eggs-5', viewValue: 'Eggs' },
{ value: 'pasta-6', viewValue: 'Pasta' },
{ value: 'sushi-7', viewValue: 'Sushi' },
{value: 'steak-0', viewValue: 'Steak'},
{value: 'pizza-1', viewValue: 'Pizza'},
{value: 'tacos-2', viewValue: 'Tacos', disabled: true},
{value: 'sandwich-3', viewValue: 'Sandwich'},
{value: 'chips-4', viewValue: 'Chips'},
{value: 'eggs-5', viewValue: 'Eggs'},
{value: 'pasta-6', viewValue: 'Pasta'},
{value: 'sushi-7', viewValue: 'Sushi'},
];
control = new FormControl();
isRequired: boolean;
Expand Down Expand Up @@ -975,14 +1091,14 @@ class BasicChipList {
})
class MultiSelectionChipList {
foods: any[] = [
{ value: 'steak-0', viewValue: 'Steak' },
{ value: 'pizza-1', viewValue: 'Pizza' },
{ value: 'tacos-2', viewValue: 'Tacos', disabled: true },
{ value: 'sandwich-3', viewValue: 'Sandwich' },
{ value: 'chips-4', viewValue: 'Chips' },
{ value: 'eggs-5', viewValue: 'Eggs' },
{ value: 'pasta-6', viewValue: 'Pasta' },
{ value: 'sushi-7', viewValue: 'Sushi' },
{value: 'steak-0', viewValue: 'Steak'},
{value: 'pizza-1', viewValue: 'Pizza'},
{value: 'tacos-2', viewValue: 'Tacos', disabled: true},
{value: 'sandwich-3', viewValue: 'Sandwich'},
{value: 'chips-4', viewValue: 'Chips'},
{value: 'eggs-5', viewValue: 'Eggs'},
{value: 'pasta-6', viewValue: 'Pasta'},
{value: 'sushi-7', viewValue: 'Sushi'},
];
control = new FormControl();
isRequired: boolean;
Expand Down Expand Up @@ -1013,14 +1129,14 @@ class MultiSelectionChipList {
})
class InputChipList {
foods: any[] = [
{ value: 'steak-0', viewValue: 'Steak' },
{ value: 'pizza-1', viewValue: 'Pizza' },
{ value: 'tacos-2', viewValue: 'Tacos', disabled: true },
{ value: 'sandwich-3', viewValue: 'Sandwich' },
{ value: 'chips-4', viewValue: 'Chips' },
{ value: 'eggs-5', viewValue: 'Eggs' },
{ value: 'pasta-6', viewValue: 'Pasta' },
{ value: 'sushi-7', viewValue: 'Sushi' },
{value: 'steak-0', viewValue: 'Steak'},
{value: 'pizza-1', viewValue: 'Pizza'},
{value: 'tacos-2', viewValue: 'Tacos', disabled: true},
{value: 'sandwich-3', viewValue: 'Sandwich'},
{value: 'chips-4', viewValue: 'Chips'},
{value: 'eggs-5', viewValue: 'Eggs'},
{value: 'pasta-6', viewValue: 'Pasta'},
{value: 'sushi-7', viewValue: 'Sushi'},
];
control = new FormControl();

Expand Down Expand Up @@ -1061,8 +1177,8 @@ class InputChipList {
})
class FalsyValueChipList {
foods: any[] = [
{ value: 0, viewValue: 'Steak' },
{ value: 1, viewValue: 'Pizza' },
{value: 0, viewValue: 'Steak'},
{value: 1, viewValue: 'Pizza'},
];
control = new FormControl();
@ViewChildren(MatChip) chips: QueryList<MatChip>;
Expand All @@ -1079,9 +1195,36 @@ class FalsyValueChipList {
})
class SelectedChipList {
foods: any[] = [
{ value: 0, viewValue: 'Steak', selected: true },
{ value: 1, viewValue: 'Pizza', selected: false },
{ value: 2, viewValue: 'Pasta', selected: true },
{value: 0, viewValue: 'Steak', selected: true},
{value: 1, viewValue: 'Pizza', selected: false},
{value: 2, viewValue: 'Pasta', selected: true},
];
@ViewChildren(MatChip) chips: QueryList<MatChip>;
}

@Component({
template: `
<form #form="ngForm" novalidate>
<mat-form-field>
<mat-chip-list [formControl]="formControl">
<mat-chip *ngFor="let food of foods" [value]="food.value" [selected]="food.selected">
{{food.viewValue}}
</mat-chip>
</mat-chip-list>
<mat-hint>Please select a chip, or type to add a new chip</mat-hint>
<mat-error>Should have value</mat-error>
</mat-form-field>
</form>
`
})
class ChipListWithFormErrorMessages {
foods: any[] = [
{value: 0, viewValue: 'Steak', selected: true},
{value: 1, viewValue: 'Pizza', selected: false},
{value: 2, viewValue: 'Pasta', selected: true},
];
@ViewChildren(MatChip) chips: QueryList<MatChip>;

@ViewChild('form') form: NgForm;
formControl = new FormControl('', Validators.required);
}
57 changes: 37 additions & 20 deletions src/lib/chips/chip-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ChangeDetectorRef,
Component,
ContentChildren,
DoCheck,
ElementRef,
EventEmitter,
Input,
Expand All @@ -29,15 +30,31 @@ import {
Self,
ViewEncapsulation,
} from '@angular/core';
import {ControlValueAccessor, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
import {
ControlValueAccessor,
FormGroupDirective,
NgControl,
NgForm
} from '@angular/forms';
import {ErrorStateMatcher, mixinErrorState, CanUpdateErrorState} from '@angular/material/core';
import {MatFormFieldControl} from '@angular/material/form-field';
import {Observable} from 'rxjs/Observable';
import {merge} from 'rxjs/observable/merge';
import {Subject} from 'rxjs/Subject';
import {Subscription} from 'rxjs/Subscription';
import {MatChip, MatChipEvent, MatChipSelectionChange} from './chip';
import {MatChipInput} from './chip-input';

// Boilerplate for applying mixins to MatChipList.
/** @docs-private */
export class MatChipListBase {
constructor(public _defaultErrorStateMatcher: ErrorStateMatcher,
public _parentForm: NgForm,
public _parentFormGroup: FormGroupDirective,
public ngControl: NgControl) {}
}
export const _MatChipListMixinBase = mixinErrorState(MatChipListBase);


// Increasing integer for generating unique ids for chip-list components.
let nextUniqueId = 0;

Expand Down Expand Up @@ -78,16 +95,10 @@ export class MatChipListChange {
preserveWhitespaces: false,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class MatChipList implements MatFormFieldControl<any>, ControlValueAccessor,
AfterContentInit, OnInit, OnDestroy {
export class MatChipList extends _MatChipListMixinBase implements MatFormFieldControl<any>,
ControlValueAccessor, AfterContentInit, DoCheck, OnInit, OnDestroy, CanUpdateErrorState {
readonly controlType = 'mat-chip-list';

/**
* Stream that emits whenever the state of the input changes such that the wrapping `MatFormField`
* needs to run change detection.
*/
stateChanges = new Subject<void>();

/** When a chip is destroyed, we track the index so we can focus the appropriate next chip. */
protected _lastDestroyedIndex: number|null = null;

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

/** An object used to control when error messages are shown. */
@Input() errorStateMatcher: ErrorStateMatcher;

/** Whether the user should be allowed to select multiple chips. */
@Input()
get multiple(): boolean { return this._multiple; }
Expand Down Expand Up @@ -251,14 +265,6 @@ export class MatChipList implements MatFormFieldControl<any>, ControlValueAccess
get disabled() { return this.ngControl ? this.ngControl.disabled : this._disabled; }
set disabled(value: any) { this._disabled = coerceBooleanProperty(value); }

/** Whether the chip list is in an error state. */
get errorState(): boolean {
const isInvalid = this.ngControl && this.ngControl.invalid;
const isTouched = this.ngControl && this.ngControl.touched;
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
(this._parentForm && this._parentForm.submitted);
return !!(isInvalid && (isTouched || isSubmitted));
}

/** Orientation of the chip list. */
@Input('aria-orientation') ariaOrientation: 'horizontal' | 'vertical' = 'horizontal';
Expand Down Expand Up @@ -313,9 +319,11 @@ export class MatChipList implements MatFormFieldControl<any>, ControlValueAccess
constructor(protected _elementRef: ElementRef,
private _changeDetectorRef: ChangeDetectorRef,
@Optional() private _dir: Directionality,
@Optional() private _parentForm: NgForm,
@Optional() private _parentFormGroup: FormGroupDirective,
@Optional() _parentForm: NgForm,
@Optional() _parentFormGroup: FormGroupDirective,
_defaultErrorStateMatcher: ErrorStateMatcher,
@Optional() @Self() public ngControl: NgControl) {
super(_defaultErrorStateMatcher, _parentForm, _parentFormGroup, ngControl);
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
Expand Down Expand Up @@ -352,6 +360,15 @@ export class MatChipList implements MatFormFieldControl<any>, ControlValueAccess
this.stateChanges.next();
}

ngDoCheck() {
if (this.ngControl) {
// We need to re-evaluate this on every change detection cycle, because there are some
// error triggers that we can't subscribe to (e.g. parent form submissions). This means
// that whatever logic is in here has to be super lean or we risk destroying the performance.
this.updateErrorState();
}
}

ngOnDestroy(): void {
this._tabOutSubscription.unsubscribe();

Expand Down
4 changes: 3 additions & 1 deletion src/lib/chips/chips-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import {NgModule} from '@angular/core';
import {ErrorStateMatcher} from '@angular/material/core';
import {MatChipList} from './chip-list';
import {MatBasicChip, MatChip, MatChipRemove} from './chip';
import {MatChipInput} from './chip-input';
Expand All @@ -15,6 +16,7 @@ import {MatChipInput} from './chip-input';
@NgModule({
imports: [],
exports: [MatChipList, MatChip, MatChipInput, MatChipRemove, MatChipRemove, MatBasicChip],
declarations: [MatChipList, MatChip, MatChipInput, MatChipRemove, MatChipRemove, MatBasicChip]
declarations: [MatChipList, MatChip, MatChipInput, MatChipRemove, MatChipRemove, MatBasicChip],
providers: [ErrorStateMatcher]
})
export class MatChipsModule {}
Loading

0 comments on commit d2c11ca

Please sign in to comment.