Skip to content

Commit 3ff8d87

Browse files
committed
feat(input): Add custom error state matcher
1 parent 9acb152 commit 3ff8d87

File tree

7 files changed

+243
-2
lines changed

7 files changed

+243
-2
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: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import {Component} from '@angular/core';
2-
import {FormControl, Validators} from '@angular/forms';
2+
import {FormControl, Validators, NgControl} from '@angular/forms';
33

44

55
let max = 5;
@@ -23,6 +23,7 @@ export class InputDemo {
2323
errorMessageExample1: string;
2424
errorMessageExample2: string;
2525
errorMessageExample3: string;
26+
errorMessageExample4: string;
2627
dividerColorExample1: string;
2728
dividerColorExample2: string;
2829
dividerColorExample3: string;
@@ -43,4 +44,11 @@ export class InputDemo {
4344
this.items.push({ value: ++max });
4445
}
4546
}
47+
48+
customErrorStateMatcher(c: NgControl): boolean {
49+
const isDirty = c.dirty;
50+
const isInvalid = c.invalid;
51+
52+
return isDirty && isInvalid;
53+
}
4654
}

src/lib/core/core.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ export {
118118
MD_PLACEHOLDER_GLOBAL_OPTIONS
119119
} from './placeholder/placeholder-options';
120120

121+
// Error
122+
export {
123+
ErrorStateMatcherType,
124+
ErrorOptions,
125+
MD_ERROR_GLOBAL_OPTIONS
126+
} from './error/error-options';
127+
121128
@NgModule({
122129
imports: [
123130
MdLineModule,

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {InjectionToken} from '@angular/core';
2+
import {NgControl, FormGroupDirective, NgForm} from '@angular/forms';
3+
4+
/** Injection token that can be used to specify the global error options. */
5+
export const MD_ERROR_GLOBAL_OPTIONS =
6+
new InjectionToken<() => boolean>('md-error-global-options');
7+
8+
export type ErrorStateMatcherType =
9+
(control: NgControl, parentFormGroup: FormGroupDirective, parentForm: NgForm) => boolean;
10+
11+
export interface ErrorOptions {
12+
errorStateMatcher?: ErrorStateMatcherType;
13+
showOnDirty?: boolean;
14+
}

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

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
FormGroupDirective,
77
FormsModule,
88
NgForm,
9+
NgControl,
910
ReactiveFormsModule,
1011
Validators
1112
} from '@angular/forms';
@@ -23,6 +24,7 @@ import {
2324
getMdInputContainerPlaceholderConflictError
2425
} from './input-container-errors';
2526
import {MD_PLACEHOLDER_GLOBAL_OPTIONS} from '../core/placeholder/placeholder-options';
27+
import {MD_ERROR_GLOBAL_OPTIONS} from '../core/error/error-options';
2628

2729
describe('MdInputContainer', function () {
2830
beforeEach(async(() => {
@@ -56,6 +58,7 @@ describe('MdInputContainer', function () {
5658
MdInputContainerWithDynamicPlaceholder,
5759
MdInputContainerWithFormControl,
5860
MdInputContainerWithFormErrorMessages,
61+
MdInputContainerWithCustomErrorStateMatcher,
5962
MdInputContainerWithFormGroupErrorMessages,
6063
MdInputContainerWithId,
6164
MdInputContainerWithPrefixAndSuffix,
@@ -705,6 +708,116 @@ describe('MdInputContainer', function () {
705708
});
706709
}));
707710

711+
it('should display an error message when a custom error matcher returns true', async(() => {
712+
fixture.destroy();
713+
714+
let customFixture = TestBed.createComponent(MdInputContainerWithCustomErrorStateMatcher);
715+
let component: MdInputContainerWithCustomErrorStateMatcher;
716+
717+
customFixture.detectChanges();
718+
component = customFixture.componentInstance;
719+
containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement;
720+
721+
expect(component.formControl.invalid).toBe(true, 'Expected form control to be invalid');
722+
expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages');
723+
724+
component.formControl.markAsTouched();
725+
customFixture.detectChanges();
726+
727+
customFixture.whenStable().then(() => {
728+
expect(containerEl.querySelectorAll('md-error').length)
729+
.toBe(0, 'Expected no error messages after being touched.');
730+
731+
component.errorState = true;
732+
customFixture.detectChanges();
733+
734+
customFixture.whenStable().then(() => {
735+
expect(containerEl.querySelectorAll('md-error').length)
736+
.toBe(1, 'Expected one error messages to have been rendered.');
737+
});
738+
});
739+
}));
740+
741+
it('should display an error message when global error matcher returns true', () => {
742+
743+
// Global error state matcher that will always cause errors to show
744+
function globalErrorStateMatcher() {
745+
return true;
746+
}
747+
748+
TestBed.resetTestingModule();
749+
TestBed.configureTestingModule({
750+
imports: [
751+
FormsModule,
752+
MdInputModule,
753+
NoopAnimationsModule,
754+
ReactiveFormsModule,
755+
],
756+
declarations: [
757+
MdInputContainerWithFormErrorMessages
758+
],
759+
providers: [
760+
{
761+
provide: MD_ERROR_GLOBAL_OPTIONS,
762+
useValue: { errorStateMatcher: globalErrorStateMatcher } }
763+
]
764+
});
765+
766+
let customFixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages);
767+
customFixture.detectChanges();
768+
769+
containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement;
770+
testComponent = customFixture.componentInstance;
771+
772+
// Expect the control to still be untouched but the error to show due to the global setting
773+
expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control');
774+
expect(containerEl.querySelectorAll('md-error').length).toBe(1, 'Expected an error message');
775+
});
776+
777+
it('should display an error message when global setting shows errors on dirty', async() => {
778+
TestBed.resetTestingModule();
779+
TestBed.configureTestingModule({
780+
imports: [
781+
FormsModule,
782+
MdInputModule,
783+
NoopAnimationsModule,
784+
ReactiveFormsModule,
785+
],
786+
declarations: [
787+
MdInputContainerWithFormErrorMessages
788+
],
789+
providers: [
790+
{ provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { showOnDirty: true } }
791+
]
792+
});
793+
794+
let customFixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages);
795+
customFixture.detectChanges();
796+
797+
containerEl = customFixture.debugElement.query(By.css('md-input-container')).nativeElement;
798+
testComponent = customFixture.componentInstance;
799+
800+
expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid');
801+
expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages');
802+
803+
testComponent.formControl.markAsTouched();
804+
customFixture.detectChanges();
805+
806+
customFixture.whenStable().then(() => {
807+
expect(containerEl.querySelectorAll('md-error').length)
808+
.toBe(0, 'Expected no error messages when touched');
809+
810+
testComponent.formControl.markAsDirty();
811+
customFixture.detectChanges();
812+
813+
customFixture.whenStable().then(() => {
814+
expect(containerEl.querySelectorAll('md-error').length)
815+
.toBe(1, 'Expected one error message when dirty');
816+
});
817+
});
818+
819+
});
820+
708821
it('should hide the errors and show the hints once the input becomes valid', async(() => {
709822
testComponent.formControl.markAsTouched();
710823
fixture.detectChanges();
@@ -1018,6 +1131,29 @@ class MdInputContainerWithFormErrorMessages {
10181131
renderError = true;
10191132
}
10201133

1134+
@Component({
1135+
template: `
1136+
<form #form="ngForm" novalidate>
1137+
<md-input-container>
1138+
<input mdInput
1139+
[formControl]="formControl"
1140+
[errorStateMatcher]="customErrorStateMatcher.bind(this)">
1141+
<md-hint>Please type something</md-hint>
1142+
<md-error>This field is required</md-error>
1143+
</md-input-container>
1144+
</form>
1145+
`
1146+
})
1147+
class MdInputContainerWithCustomErrorStateMatcher {
1148+
@ViewChild('form') form: NgForm;
1149+
formControl = new FormControl('', Validators.required);
1150+
errorState = false;
1151+
1152+
customErrorStateMatcher(): boolean {
1153+
return this.errorState;
1154+
}
1155+
}
1156+
10211157
@Component({
10221158
template: `
10231159
<form [formGroup]="formGroup" novalidate>

src/lib/input/input-container.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ import {
4242
PlaceholderOptions,
4343
MD_PLACEHOLDER_GLOBAL_OPTIONS
4444
} from '../core/placeholder/placeholder-options';
45+
import {
46+
ErrorStateMatcherType,
47+
ErrorOptions,
48+
MD_ERROR_GLOBAL_OPTIONS
49+
} from '../core/error/error-options';
4550

4651
// Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError.
4752
const MD_INPUT_INVALID_TYPES = [
@@ -137,6 +142,7 @@ export class MdInputDirective {
137142
private _required = false;
138143
private _id: string;
139144
private _cachedUid: string;
145+
private _errorOptions: ErrorOptions;
140146

141147
/** Whether the element is focused or not. */
142148
focused = false;
@@ -189,6 +195,9 @@ export class MdInputDirective {
189195
}
190196
}
191197

198+
/** A function used to control when error messages are shown. */
199+
@Input() errorStateMatcher: ErrorStateMatcherType;
200+
192201
/** The input element's value. */
193202
get value() { return this._elementRef.nativeElement.value; }
194203
set value(value: string) { this._elementRef.nativeElement.value = value; }
@@ -224,10 +233,14 @@ export class MdInputDirective {
224233
private _platform: Platform,
225234
@Optional() @Self() public _ngControl: NgControl,
226235
@Optional() private _parentForm: NgForm,
227-
@Optional() private _parentFormGroup: FormGroupDirective) {
236+
@Optional() private _parentFormGroup: FormGroupDirective,
237+
@Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {
228238

229239
// Force setter to be called in case id was not specified.
230240
this.id = this.id;
241+
242+
this._errorOptions = errorOptions ? errorOptions : {};
243+
this.errorStateMatcher = this._errorOptions.errorStateMatcher || undefined;
231244
}
232245

233246
/** Focuses the input element. */
@@ -250,11 +263,23 @@ export class MdInputDirective {
250263
/** Whether the input is in an error state. */
251264
_isErrorState(): boolean {
252265
const control = this._ngControl;
266+
return this.errorStateMatcher
267+
? this.errorStateMatcher(control, this._parentFormGroup, this._parentForm)
268+
: this._defaultErrorStateMatcher(control);
269+
}
270+
271+
/** Default error state calculation */
272+
private _defaultErrorStateMatcher(control: NgControl): boolean {
253273
const isInvalid = control && control.invalid;
254274
const isTouched = control && control.touched;
275+
const isDirty = control && control.dirty;
255276
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
256277
(this._parentForm && this._parentForm.submitted);
257278

279+
if (this._errorOptions.showOnDirty) {
280+
return !!(isInvalid && (isDirty || isSubmitted));
281+
}
282+
258283
return !!(isInvalid && (isTouched || isSubmitted));
259284
}
260285

src/lib/input/input.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,43 @@ 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 the user has interacted with
114+
(touched) the element or the parent form has been submitted. If you wish to customize this
115+
behavior (e.g. to show the error as soon as the invalid control is dirty), you can use the
116+
`errorStateMatcher` property of the `mdInput`. To use this property, create a function in
117+
your component class that returns a boolean. A result of `true` will display the error messages.
118+
119+
```html
120+
<md-input-container>
121+
<input mdInput [(ngModel)]="myInput" required [errorStateMatcher]="myErrorStateMatcher">
122+
<md-error>This field is required</md-error>
123+
</md-input-container>
124+
```
125+
126+
```ts
127+
function myErrorStateMatcher(control: NgControl, parentFg: FormGroupDirective, parentForm: NgForm): boolean {
128+
return control.invalid && control.dirty;
129+
}
130+
```
131+
132+
A global error state matcher can be specified by setting the `MD_ERROR_GLOBAL_OPTIONS` provider. This applies
133+
to all inputs.
134+
135+
```ts
136+
@NgModule({
137+
providers: [
138+
{provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { errorStateMatcher: myErrorStateMatcher }}
139+
]
140+
})
141+
```
142+
143+
Here are the available global options:
144+
145+
146+
| Name | Type | Description |
147+
| ----------------- | -------- | ----------- |
148+
| errorStateMatcher | Function | Returns a boolean specifying if the error should be shown |
149+
| showOnDirty | boolean | If true, the error will show when the control is dirty, not touched. |P

0 commit comments

Comments
 (0)