Skip to content

Commit f73cc97

Browse files
willshowelltinayuangao
authored andcommitted
feat(input): add custom error state matcher (#4750)
* feat(input): Add custom error state matcher * Address comments * Address comments pt. 2 * Use FormControl and only one of incompatible form options * Remove unnecesary async tests and const declarations * Add jsdoc comments to error state matchers
1 parent 9137fd9 commit f73cc97

File tree

7 files changed

+255
-8
lines changed

7 files changed

+255
-8
lines changed

src/demo-app/input/input-demo.html

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,17 @@ <h4>Inside a form</h4>
9494

9595
<button color="primary" md-raised-button>Submit</button>
9696
</form>
97+
98+
<h4>With a custom error function</h4>
99+
<md-input-container>
100+
<input mdInput
101+
placeholder="example"
102+
[(ngModel)]="errorMessageExample4"
103+
[errorStateMatcher]="customErrorStateMatcher"
104+
required>
105+
<md-error>This field is required</md-error>
106+
</md-input-container>
107+
97108
</md-card-content>
98109
</md-card>
99110

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export class InputDemo {
2424
errorMessageExample1: string;
2525
errorMessageExample2: string;
2626
errorMessageExample3: string;
27+
errorMessageExample4: string;
2728
dividerColorExample1: string;
2829
dividerColorExample2: string;
2930
dividerColorExample3: string;
@@ -44,4 +45,11 @@ export class InputDemo {
4445
this.items.push({ value: ++max });
4546
}
4647
}
48+
49+
customErrorStateMatcher(c: FormControl): boolean {
50+
const hasInteraction = c.dirty || c.touched;
51+
const isInvalid = c.invalid;
52+
53+
return !!(hasInteraction && isInvalid);
54+
}
4755
}

src/lib/core/core.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,15 @@ export {
116116
MD_PLACEHOLDER_GLOBAL_OPTIONS
117117
} from './placeholder/placeholder-options';
118118

119+
// Error
120+
export {
121+
ErrorStateMatcher,
122+
ErrorOptions,
123+
MD_ERROR_GLOBAL_OPTIONS,
124+
defaultErrorStateMatcher,
125+
showOnDirtyErrorStateMatcher
126+
} from './error/error-options';
127+
119128
@NgModule({
120129
imports: [
121130
MdLineModule,

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
import {FormControl, FormGroupDirective, Form, NgForm} from '@angular/forms';
11+
12+
/** Injection token that can be used to specify the global error options. */
13+
export const MD_ERROR_GLOBAL_OPTIONS = new InjectionToken<ErrorOptions>('md-error-global-options');
14+
15+
export type ErrorStateMatcher =
16+
(control: FormControl, form: FormGroupDirective | NgForm) => boolean;
17+
18+
export interface ErrorOptions {
19+
errorStateMatcher?: ErrorStateMatcher;
20+
}
21+
22+
/** Returns whether control is invalid and is either touched or is a part of a submitted form. */
23+
export function defaultErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) {
24+
const isSubmitted = form && form.submitted;
25+
return !!(control.invalid && (control.touched || isSubmitted));
26+
}
27+
28+
/** Returns whether control is invalid and is either dirty or is a part of a submitted form. */
29+
export function showOnDirtyErrorStateMatcher(control: FormControl,
30+
form: FormGroupDirective | NgForm) {
31+
const isSubmitted = form && form.submitted;
32+
return !!(control.invalid && (control.dirty || isSubmitted));
33+
}

src/lib/input/input-container.spec.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
getMdInputContainerPlaceholderConflictError
2424
} from './input-container-errors';
2525
import {MD_PLACEHOLDER_GLOBAL_OPTIONS} from '../core/placeholder/placeholder-options';
26+
import {MD_ERROR_GLOBAL_OPTIONS, showOnDirtyErrorStateMatcher} from '../core/error/error-options';
2627

2728
describe('MdInputContainer', function () {
2829
beforeEach(async(() => {
@@ -56,6 +57,7 @@ describe('MdInputContainer', function () {
5657
MdInputContainerWithDynamicPlaceholder,
5758
MdInputContainerWithFormControl,
5859
MdInputContainerWithFormErrorMessages,
60+
MdInputContainerWithCustomErrorStateMatcher,
5961
MdInputContainerWithFormGroupErrorMessages,
6062
MdInputContainerWithId,
6163
MdInputContainerWithPrefixAndSuffix,
@@ -749,6 +751,113 @@ describe('MdInputContainer', function () {
749751

750752
});
751753

754+
describe('custom error behavior', () => {
755+
it('should display an error message when a custom error matcher returns true', () => {
756+
let fixture = TestBed.createComponent(MdInputContainerWithCustomErrorStateMatcher);
757+
fixture.detectChanges();
758+
759+
let component = fixture.componentInstance;
760+
let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement;
761+
762+
const control = component.formGroup.get('name')!;
763+
764+
expect(control.invalid).toBe(true, 'Expected form control to be invalid');
765+
expect(containerEl.querySelectorAll('md-error').length)
766+
.toBe(0, 'Expected no error messages');
767+
768+
control.markAsTouched();
769+
fixture.detectChanges();
770+
771+
expect(containerEl.querySelectorAll('md-error').length)
772+
.toBe(0, 'Expected no error messages after being touched.');
773+
774+
component.errorState = true;
775+
fixture.detectChanges();
776+
777+
expect(containerEl.querySelectorAll('md-error').length)
778+
.toBe(1, 'Expected one error messages to have been rendered.');
779+
});
780+
781+
it('should display an error message when global error matcher returns true', () => {
782+
783+
// Global error state matcher that will always cause errors to show
784+
function globalErrorStateMatcher() {
785+
return true;
786+
}
787+
788+
TestBed.resetTestingModule();
789+
TestBed.configureTestingModule({
790+
imports: [
791+
FormsModule,
792+
MdInputModule,
793+
NoopAnimationsModule,
794+
ReactiveFormsModule,
795+
],
796+
declarations: [
797+
MdInputContainerWithFormErrorMessages
798+
],
799+
providers: [
800+
{
801+
provide: MD_ERROR_GLOBAL_OPTIONS,
802+
useValue: { errorStateMatcher: globalErrorStateMatcher } }
803+
]
804+
});
805+
806+
let fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages);
807+
808+
fixture.detectChanges();
809+
810+
let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement;
811+
let testComponent = fixture.componentInstance;
812+
813+
// Expect the control to still be untouched but the error to show due to the global setting
814+
expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control');
815+
expect(containerEl.querySelectorAll('md-error').length).toBe(1, 'Expected an error message');
816+
});
817+
818+
it('should display an error message when using showOnDirtyErrorStateMatcher', async(() => {
819+
TestBed.resetTestingModule();
820+
TestBed.configureTestingModule({
821+
imports: [
822+
FormsModule,
823+
MdInputModule,
824+
NoopAnimationsModule,
825+
ReactiveFormsModule,
826+
],
827+
declarations: [
828+
MdInputContainerWithFormErrorMessages
829+
],
830+
providers: [
831+
{
832+
provide: MD_ERROR_GLOBAL_OPTIONS,
833+
useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher }
834+
}
835+
]
836+
});
837+
838+
let fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages);
839+
fixture.detectChanges();
840+
841+
let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement;
842+
let testComponent = fixture.componentInstance;
843+
844+
expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid');
845+
expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages');
846+
847+
testComponent.formControl.markAsTouched();
848+
fixture.detectChanges();
849+
850+
expect(containerEl.querySelectorAll('md-error').length)
851+
.toBe(0, 'Expected no error messages when touched');
852+
853+
testComponent.formControl.markAsDirty();
854+
fixture.detectChanges();
855+
856+
expect(containerEl.querySelectorAll('md-error').length)
857+
.toBe(1, 'Expected one error message when dirty');
858+
}));
859+
});
860+
752861
it('should not have prefix and suffix elements when none are specified', () => {
753862
let fixture = TestBed.createComponent(MdInputContainerWithId);
754863
fixture.detectChanges();
@@ -1018,6 +1127,31 @@ class MdInputContainerWithFormErrorMessages {
10181127
renderError = true;
10191128
}
10201129

1130+
@Component({
1131+
template: `
1132+
<form [formGroup]="formGroup">
1133+
<md-input-container>
1134+
<input mdInput
1135+
formControlName="name"
1136+
[errorStateMatcher]="customErrorStateMatcher.bind(this)">
1137+
<md-hint>Please type something</md-hint>
1138+
<md-error>This field is required</md-error>
1139+
</md-input-container>
1140+
</form>
1141+
`
1142+
})
1143+
class MdInputContainerWithCustomErrorStateMatcher {
1144+
formGroup = new FormGroup({
1145+
name: new FormControl('', Validators.required)
1146+
});
1147+
1148+
errorState = false;
1149+
1150+
customErrorStateMatcher(): boolean {
1151+
return this.errorState;
1152+
}
1153+
}
1154+
10211155
@Component({
10221156
template: `
10231157
<form [formGroup]="formGroup" novalidate>

src/lib/input/input-container.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
} from '@angular/core';
3030
import {animate, state, style, transition, trigger} from '@angular/animations';
3131
import {coerceBooleanProperty, Platform} from '../core';
32-
import {FormGroupDirective, NgControl, NgForm} from '@angular/forms';
32+
import {FormGroupDirective, NgControl, NgForm, FormControl} from '@angular/forms';
3333
import {getSupportedInputTypes} from '../core/platform/features';
3434
import {
3535
getMdInputContainerDuplicatedHintError,
@@ -42,6 +42,12 @@ import {
4242
PlaceholderOptions,
4343
MD_PLACEHOLDER_GLOBAL_OPTIONS
4444
} from '../core/placeholder/placeholder-options';
45+
import {
46+
defaultErrorStateMatcher,
47+
ErrorStateMatcher,
48+
ErrorOptions,
49+
MD_ERROR_GLOBAL_OPTIONS
50+
} from '../core/error/error-options';
4551

4652
// Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError.
4753
const MD_INPUT_INVALID_TYPES = [
@@ -137,6 +143,7 @@ export class MdInputDirective {
137143
private _required = false;
138144
private _id: string;
139145
private _cachedUid: string;
146+
private _errorOptions: ErrorOptions;
140147

141148
/** Whether the element is focused or not. */
142149
focused = false;
@@ -189,6 +196,9 @@ export class MdInputDirective {
189196
}
190197
}
191198

199+
/** A function used to control when error messages are shown. */
200+
@Input() errorStateMatcher: ErrorStateMatcher;
201+
192202
/** The input element's value. */
193203
get value() { return this._elementRef.nativeElement.value; }
194204
set value(value: string) { this._elementRef.nativeElement.value = value; }
@@ -224,10 +234,14 @@ export class MdInputDirective {
224234
private _platform: Platform,
225235
@Optional() @Self() public _ngControl: NgControl,
226236
@Optional() private _parentForm: NgForm,
227-
@Optional() private _parentFormGroup: FormGroupDirective) {
237+
@Optional() private _parentFormGroup: FormGroupDirective,
238+
@Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {
228239

229240
// Force setter to be called in case id was not specified.
230241
this.id = this.id;
242+
243+
this._errorOptions = errorOptions ? errorOptions : {};
244+
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;
231245
}
232246

233247
/** Focuses the input element. */
@@ -250,12 +264,8 @@ export class MdInputDirective {
250264
/** Whether the input is in an error state. */
251265
_isErrorState(): boolean {
252266
const control = this._ngControl;
253-
const isInvalid = control && control.invalid;
254-
const isTouched = control && control.touched;
255-
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
256-
(this._parentForm && this._parentForm.submitted);
257-
258-
return !!(isInvalid && (isTouched || isSubmitted));
267+
const form = this._parentFormGroup || this._parentForm;
268+
return control && this.errorStateMatcher(control.control as FormControl, form);
259269
}
260270

261271
/** Make sure the input is a supported type. */

src/lib/input/input.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,45 @@ The underline (line under the `input` content) color can be changed by using the
107107
attribute of `md-input-container`. A value of `primary` is the default and will correspond to the
108108
theme primary color. Alternatively, `accent` or `warn` can be specified to use the theme's accent or
109109
warn color.
110+
111+
### Custom Error Matcher
112+
113+
By default, error messages are shown when the control is invalid and either the user has interacted with
114+
(touched) the element or the parent form has been submitted. If you wish to override this
115+
behavior (e.g. to show the error as soon as the invalid control is dirty or when a parent form group
116+
is invalid), you can use the `errorStateMatcher` property of the `mdInput`. To use this property,
117+
create a function in your component class that returns a boolean. A result of `true` will display
118+
the error messages.
119+
120+
```html
121+
<md-input-container>
122+
<input mdInput [(ngModel)]="myInput" required [errorStateMatcher]="myErrorStateMatcher">
123+
<md-error>This field is required</md-error>
124+
</md-input-container>
125+
```
126+
127+
```ts
128+
function myErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm): boolean {
129+
// Error when invalid control is dirty, touched, or submitted
130+
const isSubmitted = form && form.submitted;
131+
return !!(control.invalid && (control.dirty || control.touched || isSubmitted)));
132+
}
133+
```
134+
135+
A global error state matcher can be specified by setting the `MD_ERROR_GLOBAL_OPTIONS` provider. This applies
136+
to all inputs. For convenience, `showOnDirtyErrorStateMatcher` is available in order to globally cause
137+
input errors to show when the input is dirty and invalid.
138+
139+
```ts
140+
@NgModule({
141+
providers: [
142+
{provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher }}
143+
]
144+
})
145+
```
146+
147+
Here are the available global options:
148+
149+
| Name | Type | Description |
150+
| ----------------- | -------- | ----------- |
151+
| errorStateMatcher | Function | Returns a boolean specifying if the error should be shown |

0 commit comments

Comments
 (0)