Skip to content

Commit dfeaa7f

Browse files
committed
feat(select): add support for custom error state matcher
* Allows for the select's error state matcher to be overwritten through an `@Input`. * Switches `MatSelect` over to use the same global provider for its error state as `MatInput`. **Note:** This is a resubmit of #6147 that works with our latest setup and excludes a few changes.
1 parent 3c6f7a2 commit dfeaa7f

File tree

11 files changed

+143
-109
lines changed

11 files changed

+143
-109
lines changed

src/demo-app/input/input-demo.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { Component, ChangeDetectionStrategy } from '@angular/core';
2-
import {FormControl, Validators} from '@angular/forms';
1+
import {Component, ChangeDetectionStrategy} from '@angular/core';
2+
import {FormControl, NgControl, FormGroupDirective, NgForm, Validators} from '@angular/forms';
3+
import {ErrorStateMatcher} from '@angular/material';
34

45

56
let max = 5;
@@ -52,10 +53,16 @@ export class InputDemo {
5253
}
5354
}
5455

55-
customErrorStateMatcher(c: FormControl): boolean {
56-
const hasInteraction = c.dirty || c.touched;
57-
const isInvalid = c.invalid;
56+
customErrorStateMatcher: ErrorStateMatcher = {
57+
isErrorState: (control: NgControl | null) => {
58+
if (control) {
59+
const hasInteraction = control.dirty || control.touched;
60+
const isInvalid = control.invalid;
5861

59-
return !!(hasInteraction && isInvalid);
60-
}
62+
return !!(hasInteraction && isInvalid);
63+
}
64+
65+
return false;
66+
}
67+
};
6168
}

src/lib/core/error/error-options.ts

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,21 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {InjectionToken} from '@angular/core';
10-
import {FormControl, FormGroupDirective, NgForm} from '@angular/forms';
9+
import {Injectable} from '@angular/core';
10+
import {FormGroupDirective, NgForm, NgControl} from '@angular/forms';
1111

12-
/** Injection token that can be used to specify the global error options. */
13-
export const MAT_ERROR_GLOBAL_OPTIONS =
14-
new InjectionToken<ErrorOptions>('mat-error-global-options');
15-
16-
export type ErrorStateMatcher =
17-
(control: FormControl, form: FormGroupDirective | NgForm) => boolean;
18-
19-
export interface ErrorOptions {
20-
errorStateMatcher?: ErrorStateMatcher;
21-
}
22-
23-
/** Returns whether control is invalid and is either touched or is a part of a submitted form. */
24-
export function defaultErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) {
25-
const isSubmitted = form && form.submitted;
26-
return !!(control.invalid && (control.touched || isSubmitted));
12+
/** Error state matcher that matches when a control is invalid and dirty. */
13+
@Injectable()
14+
export class ShowOnDirtyErrorStateMatcher implements ErrorStateMatcher {
15+
isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean {
16+
return control ? !!(control.invalid && (control.dirty || (form && form.submitted))) : false;
17+
}
2718
}
2819

29-
/** Returns whether control is invalid and is either dirty or is a part of a submitted form. */
30-
export function showOnDirtyErrorStateMatcher(control: FormControl,
31-
form: FormGroupDirective | NgForm) {
32-
const isSubmitted = form && form.submitted;
33-
return !!(control.invalid && (control.dirty || isSubmitted));
20+
/** Provider that defines how form controls behave with regards to displaying error messages. */
21+
@Injectable()
22+
export class ErrorStateMatcher {
23+
isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean {
24+
return control ? !!(control.invalid && (control.touched || (form && form.submitted))) : false;
25+
}
3426
}

src/lib/input/input-module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {NgModule} from '@angular/core';
1212
import {MatFormFieldModule} from '@angular/material/form-field';
1313
import {MatTextareaAutosize} from './autosize';
1414
import {MatInput} from './input';
15+
import {ErrorStateMatcher} from '@angular/material/core';
1516

1617

1718
@NgModule({
@@ -31,5 +32,6 @@ import {MatInput} from './input';
3132
MatInput,
3233
MatTextareaAutosize,
3334
],
35+
providers: [ErrorStateMatcher],
3436
})
3537
export class MatInputModule {}

src/lib/input/input.md

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -111,12 +111,12 @@ warn color.
111111

112112
### Custom Error Matcher
113113

114-
By default, error messages are shown when the control is invalid and either the user has interacted with
115-
(touched) the element or the parent form has been submitted. If you wish to override this
114+
By default, error messages are shown when the control is invalid and either the user has interacted
115+
with (touched) the element or the parent form has been submitted. If you wish to override this
116116
behavior (e.g. to show the error as soon as the invalid control is dirty or when a parent form group
117117
is invalid), you can use the `errorStateMatcher` property of the `matInput`. To use this property,
118-
create a function in your component class that returns a boolean. A result of `true` will display
119-
the error messages.
118+
create an `ErrorStateMatcher` object in your component class that has a `isErrorState` function which
119+
returns a boolean. A result of `true` will display the error messages.
120120

121121
```html
122122
<mat-form-field>
@@ -126,25 +126,26 @@ the error messages.
126126
```
127127

128128
```ts
129-
function myErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm): boolean {
130-
// Error when invalid control is dirty, touched, or submitted
131-
const isSubmitted = form && form.submitted;
132-
return !!(control.invalid && (control.dirty || control.touched || isSubmitted));
129+
class MyErrorStateMatcher implements ErrorStateMatcher {
130+
isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean {
131+
// Error when invalid control is dirty, touched, or submitted
132+
const isSubmitted = form && form.submitted;
133+
return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted)));
134+
}
133135
}
134136
```
135137

136-
A global error state matcher can be specified by setting the `MAT_ERROR_GLOBAL_OPTIONS` provider. This applies
137-
to all inputs. For convenience, `showOnDirtyErrorStateMatcher` is available in order to globally set
138-
input errors to show when the input is dirty and invalid.
138+
A global error state matcher can be specified by setting the `ErrorStateMatcher` provider. This
139+
applies to all inputs. For convenience, `ShowOnDirtyErrorStateMatcher` is available in order to
140+
globally cause input errors to show when the input is dirty and invalid.
139141

140142
```ts
141143
@NgModule({
142144
providers: [
143-
{provide: MAT_ERROR_GLOBAL_OPTIONS, useValue: {errorStateMatcher: showOnDirtyErrorStateMatcher}}
145+
{provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher}
144146
]
145147
})
146148
```
147-
148149
Here are the available global options:
149150

150151
| Name | Type | Description |

src/lib/input/input.spec.ts

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@ import {
1212
Validators,
1313
} from '@angular/forms';
1414
import {
15-
MAT_ERROR_GLOBAL_OPTIONS,
1615
MAT_PLACEHOLDER_GLOBAL_OPTIONS,
17-
showOnDirtyErrorStateMatcher,
16+
ShowOnDirtyErrorStateMatcher,
17+
ErrorStateMatcher,
1818
} from '@angular/material/core';
1919
import {
2020
getMatFormFieldDuplicatedHintError,
@@ -926,12 +926,6 @@ describe('MatInput with forms', () => {
926926
});
927927

928928
it('should display an error message when global error matcher returns true', () => {
929-
930-
// Global error state matcher that will always cause errors to show
931-
function globalErrorStateMatcher() {
932-
return true;
933-
}
934-
935929
TestBed.resetTestingModule();
936930
TestBed.configureTestingModule({
937931
imports: [
@@ -944,11 +938,7 @@ describe('MatInput with forms', () => {
944938
declarations: [
945939
MatInputWithFormErrorMessages
946940
],
947-
providers: [
948-
{
949-
provide: MAT_ERROR_GLOBAL_OPTIONS,
950-
useValue: { errorStateMatcher: globalErrorStateMatcher } }
951-
]
941+
providers: [{provide: ErrorStateMatcher, useValue: {isErrorState: () => true}}]
952942
});
953943

954944
let fixture = TestBed.createComponent(MatInputWithFormErrorMessages);
@@ -963,7 +953,7 @@ describe('MatInput with forms', () => {
963953
expect(containerEl.querySelectorAll('mat-error').length).toBe(1, 'Expected an error message');
964954
});
965955

966-
it('should display an error message when using showOnDirtyErrorStateMatcher', async(() => {
956+
it('should display an error message when using ShowOnDirtyErrorStateMatcher', async(() => {
967957
TestBed.resetTestingModule();
968958
TestBed.configureTestingModule({
969959
imports: [
@@ -976,12 +966,7 @@ describe('MatInput with forms', () => {
976966
declarations: [
977967
MatInputWithFormErrorMessages
978968
],
979-
providers: [
980-
{
981-
provide: MAT_ERROR_GLOBAL_OPTIONS,
982-
useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher }
983-
}
984-
]
969+
providers: [{provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher}]
985970
});
986971

987972
let fixture = TestBed.createComponent(MatInputWithFormErrorMessages);
@@ -1298,7 +1283,7 @@ class MatInputWithFormErrorMessages {
12981283
<mat-form-field>
12991284
<input matInput
13001285
formControlName="name"
1301-
[errorStateMatcher]="customErrorStateMatcher.bind(this)">
1286+
[errorStateMatcher]="customErrorStateMatcher">
13021287
<mat-hint>Please type something</mat-hint>
13031288
<mat-error>This field is required</mat-error>
13041289
</mat-form-field>
@@ -1312,9 +1297,9 @@ class MatInputWithCustomErrorStateMatcher {
13121297

13131298
errorState = false;
13141299

1315-
customErrorStateMatcher(): boolean {
1316-
return this.errorState;
1317-
}
1300+
customErrorStateMatcher = {
1301+
isErrorState: () => this.errorState
1302+
};
13181303
}
13191304

13201305
@Component({

src/lib/input/input.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,7 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion';
2222
import {FormControl, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
2323
import {Platform, getSupportedInputTypes} from '@angular/cdk/platform';
2424
import {getMatInputUnsupportedTypeError} from './input-errors';
25-
import {
26-
defaultErrorStateMatcher,
27-
ErrorOptions,
28-
ErrorStateMatcher,
29-
MAT_ERROR_GLOBAL_OPTIONS
30-
} from '@angular/material/core';
25+
import {ErrorStateMatcher} from '@angular/material/core';
3126
import {Subject} from 'rxjs/Subject';
3227
import {MatFormFieldControl} from '@angular/material/form-field';
3328

@@ -74,7 +69,6 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
7469
protected _required = false;
7570
protected _id: string;
7671
protected _uid = `mat-input-${nextUniqueId++}`;
77-
protected _errorOptions: ErrorOptions;
7872
protected _previousNativeValue = this.value;
7973
private _readonly = false;
8074

@@ -129,7 +123,7 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
129123
}
130124
}
131125

132-
/** A function used to control when error messages are shown. */
126+
/** An object used to control when error messages are shown. */
133127
@Input() errorStateMatcher: ErrorStateMatcher;
134128

135129
/** The input element's value. */
@@ -162,12 +156,10 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
162156
@Optional() @Self() public ngControl: NgControl,
163157
@Optional() protected _parentForm: NgForm,
164158
@Optional() protected _parentFormGroup: FormGroupDirective,
165-
@Optional() @Inject(MAT_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {
159+
private _defaultErrorStateMatcher: ErrorStateMatcher) {
166160

167161
// Force setter to be called in case id was not specified.
168162
this.id = this.id;
169-
this._errorOptions = errorOptions ? errorOptions : {};
170-
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;
171163

172164
// On some versions of iOS the caret gets stuck in the wrong place when holding down the delete
173165
// key. In order to get around this we need to "jiggle" the caret loose. Since this bug only
@@ -232,7 +224,8 @@ export class MatInput implements MatFormFieldControl<any>, OnChanges, OnDestroy,
232224
const oldState = this.errorState;
233225
const ngControl = this.ngControl;
234226
const parent = this._parentFormGroup || this._parentForm;
235-
const newState = ngControl && this.errorStateMatcher(ngControl.control as FormControl, parent);
227+
const matcher = this.errorStateMatcher || this._defaultErrorStateMatcher;
228+
const newState = ngControl && matcher.isErrorState(ngControl, parent);
236229

237230
if (newState !== oldState) {
238231
this.errorState = newState;

src/lib/select/select-module.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
* Use of this source code is governed by an MIT-style license that can be
66
* found in the LICENSE file at https://angular.io/license
77
*/
8-
98
import {NgModule} from '@angular/core';
109
import {CommonModule} from '@angular/common';
1110
import {MatSelect, MatSelectTrigger, MAT_SELECT_SCROLL_STRATEGY_PROVIDER} from './select';
1211
import {MatCommonModule, MatOptionModule} from '@angular/material/core';
1312
import {OverlayModule} from '@angular/cdk/overlay';
13+
import {ErrorStateMatcher} from '@angular/material/core';
1414

1515

1616
@NgModule({
@@ -22,6 +22,6 @@ import {OverlayModule} from '@angular/cdk/overlay';
2222
],
2323
exports: [MatSelect, MatSelectTrigger, MatOptionModule, MatCommonModule],
2424
declarations: [MatSelect, MatSelectTrigger],
25-
providers: [MAT_SELECT_SCROLL_STRATEGY_PROVIDER]
25+
providers: [MAT_SELECT_SCROLL_STRATEGY_PROVIDER, ErrorStateMatcher]
2626
})
2727
export class MatSelectModule {}

src/lib/select/select.spec.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ import {
2828
extendObject,
2929
FloatPlaceholderType,
3030
MAT_PLACEHOLDER_GLOBAL_OPTIONS,
31-
MatOption
31+
MatOption,
32+
ErrorStateMatcher,
3233
} from '@angular/material/core';
3334
import {MatFormFieldModule} from '@angular/material/form-field';
3435
import {By} from '@angular/platform-browser';
@@ -91,6 +92,7 @@ describe('MatSelect', () => {
9192
FalsyValueSelect,
9293
SelectInsideFormGroup,
9394
NgModelCompareWithSelect,
95+
CustomErrorBehaviorSelect,
9496
],
9597
providers: [
9698
{provide: OverlayContainer, useFactory: () => {
@@ -2831,6 +2833,47 @@ describe('MatSelect', () => {
28312833
expect(select.getAttribute('aria-invalid'))
28322834
.toBe('true', 'Expected aria-invalid to be set to true.');
28332835
});
2836+
2837+
it('should be able to override the error matching behavior via an @Input', () => {
2838+
fixture.destroy();
2839+
2840+
const customErrorFixture = TestBed.createComponent(CustomErrorBehaviorSelect);
2841+
const component = customErrorFixture.componentInstance;
2842+
const matcher = jasmine.createSpy('error state matcher').and.returnValue(true);
2843+
2844+
customErrorFixture.detectChanges();
2845+
2846+
expect(component.control.invalid).toBe(false);
2847+
expect(component.select.errorState).toBe(false);
2848+
2849+
customErrorFixture.componentInstance.errorStateMatcher = { isErrorState: matcher };
2850+
customErrorFixture.detectChanges();
2851+
2852+
expect(component.select.errorState).toBe(true);
2853+
expect(matcher).toHaveBeenCalled();
2854+
});
2855+
2856+
it('should be able to override the error matching behavior via the injection token', () => {
2857+
const errorStateMatcher: ErrorStateMatcher = {
2858+
isErrorState: jasmine.createSpy('error state matcher').and.returnValue(true)
2859+
};
2860+
2861+
fixture.destroy();
2862+
2863+
TestBed.resetTestingModule().configureTestingModule({
2864+
imports: [MatSelectModule, ReactiveFormsModule, FormsModule, NoopAnimationsModule],
2865+
declarations: [SelectInsideFormGroup],
2866+
providers: [{ provide: ErrorStateMatcher, useValue: errorStateMatcher }],
2867+
});
2868+
2869+
const errorFixture = TestBed.createComponent(SelectInsideFormGroup);
2870+
const component = errorFixture.componentInstance;
2871+
2872+
errorFixture.detectChanges();
2873+
2874+
expect(component.select.errorState).toBe(true);
2875+
expect(errorStateMatcher.isErrorState).toHaveBeenCalled();
2876+
});
28342877
});
28352878

28362879
describe('compareWith behavior', () => {
@@ -3411,6 +3454,7 @@ class InvalidSelectInForm {
34113454
})
34123455
class SelectInsideFormGroup {
34133456
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
3457+
@ViewChild(MatSelect) select: MatSelect;
34143458
formControl = new FormControl('', Validators.required);
34153459
formGroup = new FormGroup({
34163460
food: this.formControl
@@ -3545,3 +3589,22 @@ class NgModelCompareWithSelect {
35453589
this.selectedFood = extendObject({}, newValue);
35463590
}
35473591
}
3592+
3593+
@Component({
3594+
template: `
3595+
<mat-select placeholder="Food" [formControl]="control" [errorStateMatcher]="errorStateMatcher">
3596+
<mat-option *ngFor="let food of foods" [value]="food.value">
3597+
{{ food.viewValue }}
3598+
</mat-option>
3599+
</mat-select>
3600+
`
3601+
})
3602+
class CustomErrorBehaviorSelect {
3603+
@ViewChild(MatSelect) select: MatSelect;
3604+
control = new FormControl();
3605+
foods: any[] = [
3606+
{ value: 'steak-0', viewValue: 'Steak' },
3607+
{ value: 'pizza-1', viewValue: 'Pizza' },
3608+
];
3609+
errorStateMatcher: ErrorStateMatcher;
3610+
}

0 commit comments

Comments
 (0)