diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index e1ab003378ad..a75b17788de9 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -94,6 +94,17 @@

Inside a form

+ +

With a custom error function

+ + + This field is required + + diff --git a/src/demo-app/input/input-demo.ts b/src/demo-app/input/input-demo.ts index 746475b443f2..675c10acd995 100644 --- a/src/demo-app/input/input-demo.ts +++ b/src/demo-app/input/input-demo.ts @@ -24,6 +24,7 @@ export class InputDemo { errorMessageExample1: string; errorMessageExample2: string; errorMessageExample3: string; + errorMessageExample4: string; dividerColorExample1: string; dividerColorExample2: string; dividerColorExample3: string; @@ -44,4 +45,11 @@ export class InputDemo { this.items.push({ value: ++max }); } } + + customErrorStateMatcher(c: FormControl): boolean { + const hasInteraction = c.dirty || c.touched; + const isInvalid = c.invalid; + + return !!(hasInteraction && isInvalid); + } } diff --git a/src/lib/core/core.ts b/src/lib/core/core.ts index 28035d5d6493..e49d5bc83b47 100644 --- a/src/lib/core/core.ts +++ b/src/lib/core/core.ts @@ -116,6 +116,15 @@ export { MD_PLACEHOLDER_GLOBAL_OPTIONS } from './placeholder/placeholder-options'; +// Error +export { + ErrorStateMatcher, + ErrorOptions, + MD_ERROR_GLOBAL_OPTIONS, + defaultErrorStateMatcher, + showOnDirtyErrorStateMatcher +} from './error/error-options'; + @NgModule({ imports: [ MdLineModule, diff --git a/src/lib/core/error/error-options.ts b/src/lib/core/error/error-options.ts new file mode 100644 index 000000000000..ec1cd71e42ea --- /dev/null +++ b/src/lib/core/error/error-options.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {InjectionToken} from '@angular/core'; +import {FormControl, FormGroupDirective, Form, NgForm} from '@angular/forms'; + +/** Injection token that can be used to specify the global error options. */ +export const MD_ERROR_GLOBAL_OPTIONS = new InjectionToken('md-error-global-options'); + +export type ErrorStateMatcher = + (control: FormControl, form: FormGroupDirective | NgForm) => boolean; + +export interface ErrorOptions { + errorStateMatcher?: ErrorStateMatcher; +} + +/** Returns whether control is invalid and is either touched or is a part of a submitted form. */ +export function defaultErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm) { + const isSubmitted = form && form.submitted; + return !!(control.invalid && (control.touched || isSubmitted)); +} + +/** Returns whether control is invalid and is either dirty or is a part of a submitted form. */ +export function showOnDirtyErrorStateMatcher(control: FormControl, + form: FormGroupDirective | NgForm) { + const isSubmitted = form && form.submitted; + return !!(control.invalid && (control.dirty || isSubmitted)); +} diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index cd10fb53dbe3..089d3b259958 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -23,6 +23,7 @@ import { getMdInputContainerPlaceholderConflictError } from './input-container-errors'; import {MD_PLACEHOLDER_GLOBAL_OPTIONS} from '../core/placeholder/placeholder-options'; +import {MD_ERROR_GLOBAL_OPTIONS, showOnDirtyErrorStateMatcher} from '../core/error/error-options'; describe('MdInputContainer', function () { beforeEach(async(() => { @@ -56,6 +57,7 @@ describe('MdInputContainer', function () { MdInputContainerWithDynamicPlaceholder, MdInputContainerWithFormControl, MdInputContainerWithFormErrorMessages, + MdInputContainerWithCustomErrorStateMatcher, MdInputContainerWithFormGroupErrorMessages, MdInputContainerWithId, MdInputContainerWithPrefixAndSuffix, @@ -749,6 +751,113 @@ describe('MdInputContainer', function () { }); + describe('custom error behavior', () => { + it('should display an error message when a custom error matcher returns true', () => { + let fixture = TestBed.createComponent(MdInputContainerWithCustomErrorStateMatcher); + fixture.detectChanges(); + + let component = fixture.componentInstance; + let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement; + + const control = component.formGroup.get('name')!; + + expect(control.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages'); + + control.markAsTouched(); + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages after being touched.'); + + component.errorState = true; + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error messages to have been rendered.'); + }); + + it('should display an error message when global error matcher returns true', () => { + + // Global error state matcher that will always cause errors to show + function globalErrorStateMatcher() { + return true; + } + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MdInputModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [ + MdInputContainerWithFormErrorMessages + ], + providers: [ + { + provide: MD_ERROR_GLOBAL_OPTIONS, + useValue: { errorStateMatcher: globalErrorStateMatcher } } + ] + }); + + let fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); + + fixture.detectChanges(); + + let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement; + let testComponent = fixture.componentInstance; + + // Expect the control to still be untouched but the error to show due to the global setting + expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control'); + expect(containerEl.querySelectorAll('md-error').length).toBe(1, 'Expected an error message'); + }); + + it('should display an error message when using showOnDirtyErrorStateMatcher', async(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MdInputModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [ + MdInputContainerWithFormErrorMessages + ], + providers: [ + { + provide: MD_ERROR_GLOBAL_OPTIONS, + useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher } + } + ] + }); + + let fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); + fixture.detectChanges(); + + let containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement; + let testComponent = fixture.componentInstance; + + expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages when touched'); + + testComponent.formControl.markAsDirty(); + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error message when dirty'); + })); + }); + it('should not have prefix and suffix elements when none are specified', () => { let fixture = TestBed.createComponent(MdInputContainerWithId); fixture.detectChanges(); @@ -1018,6 +1127,31 @@ class MdInputContainerWithFormErrorMessages { renderError = true; } +@Component({ + template: ` +
+ + + Please type something + This field is required + +
+ ` +}) +class MdInputContainerWithCustomErrorStateMatcher { + formGroup = new FormGroup({ + name: new FormControl('', Validators.required) + }); + + errorState = false; + + customErrorStateMatcher(): boolean { + return this.errorState; + } +} + @Component({ template: `
diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index f614a1de78dc..07fea22ced03 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -29,7 +29,7 @@ import { } from '@angular/core'; import {animate, state, style, transition, trigger} from '@angular/animations'; import {coerceBooleanProperty, Platform} from '../core'; -import {FormGroupDirective, NgControl, NgForm} from '@angular/forms'; +import {FormGroupDirective, NgControl, NgForm, FormControl} from '@angular/forms'; import {getSupportedInputTypes} from '../core/platform/features'; import { getMdInputContainerDuplicatedHintError, @@ -42,6 +42,12 @@ import { PlaceholderOptions, MD_PLACEHOLDER_GLOBAL_OPTIONS } from '../core/placeholder/placeholder-options'; +import { + defaultErrorStateMatcher, + ErrorStateMatcher, + ErrorOptions, + MD_ERROR_GLOBAL_OPTIONS +} from '../core/error/error-options'; // Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError. const MD_INPUT_INVALID_TYPES = [ @@ -137,6 +143,7 @@ export class MdInputDirective { private _required = false; private _id: string; private _cachedUid: string; + private _errorOptions: ErrorOptions; /** Whether the element is focused or not. */ focused = false; @@ -189,6 +196,9 @@ export class MdInputDirective { } } + /** A function used to control when error messages are shown. */ + @Input() errorStateMatcher: ErrorStateMatcher; + /** The input element's value. */ get value() { return this._elementRef.nativeElement.value; } set value(value: string) { this._elementRef.nativeElement.value = value; } @@ -224,10 +234,14 @@ export class MdInputDirective { private _platform: Platform, @Optional() @Self() public _ngControl: NgControl, @Optional() private _parentForm: NgForm, - @Optional() private _parentFormGroup: FormGroupDirective) { + @Optional() private _parentFormGroup: FormGroupDirective, + @Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) { // Force setter to be called in case id was not specified. this.id = this.id; + + this._errorOptions = errorOptions ? errorOptions : {}; + this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher; } /** Focuses the input element. */ @@ -250,12 +264,8 @@ export class MdInputDirective { /** Whether the input is in an error state. */ _isErrorState(): boolean { const control = this._ngControl; - const isInvalid = control && control.invalid; - const isTouched = control && control.touched; - const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) || - (this._parentForm && this._parentForm.submitted); - - return !!(isInvalid && (isTouched || isSubmitted)); + const form = this._parentFormGroup || this._parentForm; + return control && this.errorStateMatcher(control.control as FormControl, form); } /** Make sure the input is a supported type. */ diff --git a/src/lib/input/input.md b/src/lib/input/input.md index 47dca6836bad..36e4bfe0e805 100644 --- a/src/lib/input/input.md +++ b/src/lib/input/input.md @@ -107,3 +107,45 @@ The underline (line under the `input` content) color can be changed by using the attribute of `md-input-container`. A value of `primary` is the default and will correspond to the theme primary color. Alternatively, `accent` or `warn` can be specified to use the theme's accent or warn color. + +### Custom Error Matcher + +By default, error messages are shown when the control is invalid and either the user has interacted with +(touched) the element or the parent form has been submitted. If you wish to override this +behavior (e.g. to show the error as soon as the invalid control is dirty or when a parent form group +is invalid), you can use the `errorStateMatcher` property of the `mdInput`. To use this property, +create a function in your component class that returns a boolean. A result of `true` will display +the error messages. + +```html + + + This field is required + +``` + +```ts +function myErrorStateMatcher(control: FormControl, form: FormGroupDirective | NgForm): boolean { + // Error when invalid control is dirty, touched, or submitted + const isSubmitted = form && form.submitted; + return !!(control.invalid && (control.dirty || control.touched || isSubmitted))); +} +``` + +A global error state matcher can be specified by setting the `MD_ERROR_GLOBAL_OPTIONS` provider. This applies +to all inputs. For convenience, `showOnDirtyErrorStateMatcher` is available in order to globally cause +input errors to show when the input is dirty and invalid. + +```ts +@NgModule({ + providers: [ + {provide: MD_ERROR_GLOBAL_OPTIONS, useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher }} + ] +}) +``` + +Here are the available global options: + +| Name | Type | Description | +| ----------------- | -------- | ----------- | +| errorStateMatcher | Function | Returns a boolean specifying if the error should be shown |