From de279dc740205f15ea9047c967da23d314cc6485 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 30 Sep 2017 14:44:03 +0200 Subject: [PATCH] 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. Fixes #7419. --- src/demo-app/input/input-demo.ts | 21 ++++++---- src/lib/core/error/error-options.ts | 36 +++++++--------- src/lib/input/input-module.ts | 2 + src/lib/input/input.md | 27 ++++++------ src/lib/input/input.spec.ts | 33 ++++----------- src/lib/input/input.ts | 21 +++------- src/lib/select/select-module.ts | 4 +- src/lib/select/select.spec.ts | 65 ++++++++++++++++++++++++++++- src/lib/select/select.ts | 13 +++--- src/lib/stepper/stepper-module.ts | 3 +- src/lib/stepper/stepper.ts | 31 ++++---------- 11 files changed, 143 insertions(+), 113 deletions(-) diff --git a/src/demo-app/input/input-demo.ts b/src/demo-app/input/input-demo.ts index a76d2165ed09..2f28f2b6f922 100644 --- a/src/demo-app/input/input-demo.ts +++ b/src/demo-app/input/input-demo.ts @@ -1,5 +1,6 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import {FormControl, Validators} from '@angular/forms'; +import {Component, ChangeDetectionStrategy} from '@angular/core'; +import {FormControl, NgControl, Validators} from '@angular/forms'; +import {ErrorStateMatcher} from '@angular/material'; let max = 5; @@ -52,10 +53,16 @@ export class InputDemo { } } - customErrorStateMatcher(c: FormControl): boolean { - const hasInteraction = c.dirty || c.touched; - const isInvalid = c.invalid; + customErrorStateMatcher: ErrorStateMatcher = { + isErrorState: (control: NgControl | null) => { + if (control) { + const hasInteraction = control.dirty || control.touched; + const isInvalid = control.invalid; - return !!(hasInteraction && isInvalid); - } + return !!(hasInteraction && isInvalid); + } + + return false; + } + }; } diff --git a/src/lib/core/error/error-options.ts b/src/lib/core/error/error-options.ts index fc0945955f0d..261906995c22 100644 --- a/src/lib/core/error/error-options.ts +++ b/src/lib/core/error/error-options.ts @@ -6,29 +6,21 @@ * found in the LICENSE file at https://angular.io/license */ -import {InjectionToken} from '@angular/core'; -import {FormControl, FormGroupDirective, NgForm} from '@angular/forms'; +import {Injectable} from '@angular/core'; +import {FormGroupDirective, NgForm, NgControl} from '@angular/forms'; -/** Injection token that can be used to specify the global error options. */ -export const MAT_ERROR_GLOBAL_OPTIONS = - new InjectionToken('mat-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)); +/** Error state matcher that matches when a control is invalid and dirty. */ +@Injectable() +export class ShowOnDirtyErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean { + return !!(control && control.invalid && (control.dirty || (form && form.submitted))); + } } -/** 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)); +/** Provider that defines how form controls behave with regards to displaying error messages. */ +@Injectable() +export class ErrorStateMatcher { + isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean { + return !!(control && control.invalid && (control.touched || (form && form.submitted))); + } } diff --git a/src/lib/input/input-module.ts b/src/lib/input/input-module.ts index 57efdfb87d9e..3271ab7f0fc2 100644 --- a/src/lib/input/input-module.ts +++ b/src/lib/input/input-module.ts @@ -12,6 +12,7 @@ import {NgModule} from '@angular/core'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatTextareaAutosize} from './autosize'; import {MatInput} from './input'; +import {ErrorStateMatcher} from '@angular/material/core'; @NgModule({ @@ -31,5 +32,6 @@ import {MatInput} from './input'; MatInput, MatTextareaAutosize, ], + providers: [ErrorStateMatcher], }) export class MatInputModule {} diff --git a/src/lib/input/input.md b/src/lib/input/input.md index c2cd28a0bc6c..c86da66c654f 100644 --- a/src/lib/input/input.md +++ b/src/lib/input/input.md @@ -111,12 +111,12 @@ 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 +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 `matInput`. To use this property, -create a function in your component class that returns a boolean. A result of `true` will display -the error messages. +create an `ErrorStateMatcher` object in your component class that has a `isErrorState` function which +returns a boolean. A result of `true` will display the error messages. ```html @@ -126,25 +126,26 @@ the error messages. ``` ```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)); +class MyErrorStateMatcher implements ErrorStateMatcher { + isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean { + // Error when invalid control is dirty, touched, or submitted + const isSubmitted = form && form.submitted; + return !!(control && control.invalid && (control.dirty || control.touched || isSubmitted))); + } } ``` -A global error state matcher can be specified by setting the `MAT_ERROR_GLOBAL_OPTIONS` provider. This applies -to all inputs. For convenience, `showOnDirtyErrorStateMatcher` is available in order to globally set -input errors to show when the input is dirty and invalid. +A global error state matcher can be specified by setting the `ErrorStateMatcher` 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: MAT_ERROR_GLOBAL_OPTIONS, useValue: {errorStateMatcher: showOnDirtyErrorStateMatcher}} + {provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher} ] }) ``` - Here are the available global options: | Name | Type | Description | diff --git a/src/lib/input/input.spec.ts b/src/lib/input/input.spec.ts index b3ba3a3ded67..5f161743e271 100644 --- a/src/lib/input/input.spec.ts +++ b/src/lib/input/input.spec.ts @@ -12,9 +12,9 @@ import { Validators, } from '@angular/forms'; import { - MAT_ERROR_GLOBAL_OPTIONS, MAT_PLACEHOLDER_GLOBAL_OPTIONS, - showOnDirtyErrorStateMatcher, + ShowOnDirtyErrorStateMatcher, + ErrorStateMatcher, } from '@angular/material/core'; import { getMatFormFieldDuplicatedHintError, @@ -926,12 +926,6 @@ describe('MatInput with forms', () => { }); 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: [ @@ -944,11 +938,7 @@ describe('MatInput with forms', () => { declarations: [ MatInputWithFormErrorMessages ], - providers: [ - { - provide: MAT_ERROR_GLOBAL_OPTIONS, - useValue: { errorStateMatcher: globalErrorStateMatcher } } - ] + providers: [{provide: ErrorStateMatcher, useValue: {isErrorState: () => true}}] }); let fixture = TestBed.createComponent(MatInputWithFormErrorMessages); @@ -963,7 +953,7 @@ describe('MatInput with forms', () => { expect(containerEl.querySelectorAll('mat-error').length).toBe(1, 'Expected an error message'); }); - it('should display an error message when using showOnDirtyErrorStateMatcher', async(() => { + it('should display an error message when using ShowOnDirtyErrorStateMatcher', async(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ imports: [ @@ -976,12 +966,7 @@ describe('MatInput with forms', () => { declarations: [ MatInputWithFormErrorMessages ], - providers: [ - { - provide: MAT_ERROR_GLOBAL_OPTIONS, - useValue: { errorStateMatcher: showOnDirtyErrorStateMatcher } - } - ] + providers: [{provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher}] }); let fixture = TestBed.createComponent(MatInputWithFormErrorMessages); @@ -1298,7 +1283,7 @@ class MatInputWithFormErrorMessages { + [errorStateMatcher]="customErrorStateMatcher"> Please type something This field is required @@ -1312,9 +1297,9 @@ class MatInputWithCustomErrorStateMatcher { errorState = false; - customErrorStateMatcher(): boolean { - return this.errorState; - } + customErrorStateMatcher = { + isErrorState: () => this.errorState + }; } @Component({ diff --git a/src/lib/input/input.ts b/src/lib/input/input.ts index c1681a97c2bf..ffef43633493 100644 --- a/src/lib/input/input.ts +++ b/src/lib/input/input.ts @@ -10,7 +10,6 @@ import { Directive, DoCheck, ElementRef, - Inject, Input, OnChanges, OnDestroy, @@ -19,15 +18,10 @@ import { Self, } from '@angular/core'; import {coerceBooleanProperty} from '@angular/cdk/coercion'; -import {FormControl, FormGroupDirective, NgControl, NgForm} from '@angular/forms'; +import {FormGroupDirective, NgControl, NgForm} from '@angular/forms'; import {Platform, getSupportedInputTypes} from '@angular/cdk/platform'; import {getMatInputUnsupportedTypeError} from './input-errors'; -import { - defaultErrorStateMatcher, - ErrorOptions, - ErrorStateMatcher, - MAT_ERROR_GLOBAL_OPTIONS -} from '@angular/material/core'; +import {ErrorStateMatcher} from '@angular/material/core'; import {Subject} from 'rxjs/Subject'; import {MatFormFieldControl} from '@angular/material/form-field'; @@ -74,7 +68,6 @@ export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy, protected _required = false; protected _id: string; protected _uid = `mat-input-${nextUniqueId++}`; - protected _errorOptions: ErrorOptions; protected _previousNativeValue = this.value; private _readonly = false; @@ -129,7 +122,7 @@ export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy, } } - /** A function used to control when error messages are shown. */ + /** An object used to control when error messages are shown. */ @Input() errorStateMatcher: ErrorStateMatcher; /** The input element's value. */ @@ -162,12 +155,10 @@ export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy, @Optional() @Self() public ngControl: NgControl, @Optional() protected _parentForm: NgForm, @Optional() protected _parentFormGroup: FormGroupDirective, - @Optional() @Inject(MAT_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) { + private _defaultErrorStateMatcher: ErrorStateMatcher) { // 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; // On some versions of iOS the caret gets stuck in the wrong place when holding down the delete // key. In order to get around this we need to "jiggle" the caret loose. Since this bug only @@ -230,9 +221,9 @@ export class MatInput implements MatFormFieldControl, OnChanges, OnDestroy, /** Re-evaluates the error state. This is only relevant with @angular/forms. */ protected _updateErrorState() { const oldState = this.errorState; - const ngControl = this.ngControl; const parent = this._parentFormGroup || this._parentForm; - const newState = ngControl && this.errorStateMatcher(ngControl.control as FormControl, parent); + const matcher = this.errorStateMatcher || this._defaultErrorStateMatcher; + const newState = matcher.isErrorState(this.ngControl, parent); if (newState !== oldState) { this.errorState = newState; diff --git a/src/lib/select/select-module.ts b/src/lib/select/select-module.ts index 99b154bfc2e0..b80787eb730b 100644 --- a/src/lib/select/select-module.ts +++ b/src/lib/select/select-module.ts @@ -5,12 +5,12 @@ * 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 {NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; import {MatSelect, MatSelectTrigger, MAT_SELECT_SCROLL_STRATEGY_PROVIDER} from './select'; import {MatCommonModule, MatOptionModule} from '@angular/material/core'; import {OverlayModule} from '@angular/cdk/overlay'; +import {ErrorStateMatcher} from '@angular/material/core'; @NgModule({ @@ -22,6 +22,6 @@ import {OverlayModule} from '@angular/cdk/overlay'; ], exports: [MatSelect, MatSelectTrigger, MatOptionModule, MatCommonModule], declarations: [MatSelect, MatSelectTrigger], - providers: [MAT_SELECT_SCROLL_STRATEGY_PROVIDER] + providers: [MAT_SELECT_SCROLL_STRATEGY_PROVIDER, ErrorStateMatcher] }) export class MatSelectModule {} diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index 56769e54ef4b..005379383d62 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -28,7 +28,8 @@ import { extendObject, FloatPlaceholderType, MAT_PLACEHOLDER_GLOBAL_OPTIONS, - MatOption + MatOption, + ErrorStateMatcher, } from '@angular/material/core'; import {MatFormFieldModule} from '@angular/material/form-field'; import {By} from '@angular/platform-browser'; @@ -91,6 +92,7 @@ describe('MatSelect', () => { FalsyValueSelect, SelectInsideFormGroup, NgModelCompareWithSelect, + CustomErrorBehaviorSelect, ], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -2831,6 +2833,47 @@ describe('MatSelect', () => { expect(select.getAttribute('aria-invalid')) .toBe('true', 'Expected aria-invalid to be set to true.'); }); + + it('should be able to override the error matching behavior via an @Input', () => { + fixture.destroy(); + + const customErrorFixture = TestBed.createComponent(CustomErrorBehaviorSelect); + const component = customErrorFixture.componentInstance; + const matcher = jasmine.createSpy('error state matcher').and.returnValue(true); + + customErrorFixture.detectChanges(); + + expect(component.control.invalid).toBe(false); + expect(component.select.errorState).toBe(false); + + customErrorFixture.componentInstance.errorStateMatcher = { isErrorState: matcher }; + customErrorFixture.detectChanges(); + + expect(component.select.errorState).toBe(true); + expect(matcher).toHaveBeenCalled(); + }); + + it('should be able to override the error matching behavior via the injection token', () => { + const errorStateMatcher: ErrorStateMatcher = { + isErrorState: jasmine.createSpy('error state matcher').and.returnValue(true) + }; + + fixture.destroy(); + + TestBed.resetTestingModule().configureTestingModule({ + imports: [MatSelectModule, ReactiveFormsModule, FormsModule, NoopAnimationsModule], + declarations: [SelectInsideFormGroup], + providers: [{ provide: ErrorStateMatcher, useValue: errorStateMatcher }], + }); + + const errorFixture = TestBed.createComponent(SelectInsideFormGroup); + const component = errorFixture.componentInstance; + + errorFixture.detectChanges(); + + expect(component.select.errorState).toBe(true); + expect(errorStateMatcher.isErrorState).toHaveBeenCalled(); + }); }); describe('compareWith behavior', () => { @@ -3411,6 +3454,7 @@ class InvalidSelectInForm { }) class SelectInsideFormGroup { @ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective; + @ViewChild(MatSelect) select: MatSelect; formControl = new FormControl('', Validators.required); formGroup = new FormGroup({ food: this.formControl @@ -3545,3 +3589,22 @@ class NgModelCompareWithSelect { this.selectedFood = extendObject({}, newValue); } } + +@Component({ + template: ` + + + {{ food.viewValue }} + + + ` +}) +class CustomErrorBehaviorSelect { + @ViewChild(MatSelect) select: MatSelect; + control = new FormControl(); + foods: any[] = [ + { value: 'steak-0', viewValue: 'Steak' }, + { value: 'pizza-1', viewValue: 'Pizza' }, + ]; + errorStateMatcher: ErrorStateMatcher; +} diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 1a5038a9b5f7..4ce78bfa8edc 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -54,6 +54,7 @@ import { MatOptionSelectionChange, mixinDisabled, mixinTabIndex, + ErrorStateMatcher, } from '@angular/material/core'; import {MatFormField, MatFormFieldControl} from '@angular/material/form-field'; import {Observable} from 'rxjs/Observable'; @@ -373,6 +374,9 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, /** Input that can be used to specify the `aria-labelledby` attribute. */ @Input('aria-labelledby') ariaLabelledby: string; + /** An object used to control when error messages are shown. */ + @Input() errorStateMatcher: ErrorStateMatcher; + /** Unique id of the element. */ @Input() get id() { return this._id; } @@ -407,6 +411,7 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, private _viewportRuler: ViewportRuler, private _changeDetectorRef: ChangeDetectorRef, private _ngZone: NgZone, + private _defaultErrorStateMatcher: ErrorStateMatcher, renderer: Renderer2, elementRef: ElementRef, @Optional() private _dir: Directionality, @@ -656,12 +661,10 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, /** Whether the select 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); + const parent = this._parentFormGroup || this._parentForm; + const matcher = this.errorStateMatcher || this._defaultErrorStateMatcher; - return !!(isInvalid && (isTouched || isSubmitted)); + return matcher.isErrorState(this.ngControl, parent); } /** diff --git a/src/lib/stepper/stepper-module.ts b/src/lib/stepper/stepper-module.ts index 02f8b4f65867..065de311d5a2 100644 --- a/src/lib/stepper/stepper-module.ts +++ b/src/lib/stepper/stepper-module.ts @@ -12,7 +12,7 @@ import {CdkStepperModule} from '@angular/cdk/stepper'; import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; import {MatButtonModule} from '@angular/material/button'; -import {MatCommonModule, MatRippleModule} from '@angular/material/core'; +import {MatCommonModule, MatRippleModule, ErrorStateMatcher} from '@angular/material/core'; import {MatIconModule} from '@angular/material/icon'; import {MatStepHeader} from './step-header'; import {MatStepLabel} from './step-label'; @@ -44,5 +44,6 @@ import {MatStepperNext, MatStepperPrevious} from './stepper-button'; ], declarations: [MatHorizontalStepper, MatVerticalStepper, MatStep, MatStepLabel, MatStepper, MatStepperNext, MatStepperPrevious, MatStepHeader], + providers: [ErrorStateMatcher], }) export class MatStepperModule {} diff --git a/src/lib/stepper/stepper.ts b/src/lib/stepper/stepper.ts index 2cf8a8feecd5..b7331142f7e7 100644 --- a/src/lib/stepper/stepper.ts +++ b/src/lib/stepper/stepper.ts @@ -16,19 +16,13 @@ import { ElementRef, forwardRef, Inject, - Optional, QueryList, SkipSelf, ViewChildren, ViewEncapsulation, } from '@angular/core'; -import {FormControl, FormGroupDirective, NgForm} from '@angular/forms'; -import { - defaultErrorStateMatcher, - ErrorOptions, - ErrorStateMatcher, - MAT_ERROR_GLOBAL_OPTIONS, -} from '@angular/material/core'; +import {NgControl, FormGroupDirective, NgForm} from '@angular/forms'; +import {ErrorStateMatcher} from '@angular/material/core'; import {MatStepHeader} from './step-header'; import {MatStepLabel} from './step-label'; @@ -40,36 +34,27 @@ export const _MatStepper = CdkStepper; moduleId: module.id, selector: 'mat-step', templateUrl: 'step.html', - providers: [{provide: MAT_ERROR_GLOBAL_OPTIONS, useExisting: MatStep}], + providers: [{provide: ErrorStateMatcher, useExisting: MatStep}], encapsulation: ViewEncapsulation.None, preserveWhitespaces: false, }) -export class MatStep extends _MatStep implements ErrorOptions { +export class MatStep extends _MatStep implements ErrorStateMatcher { /** Content for step label given by . */ @ContentChild(MatStepLabel) stepLabel: MatStepLabel; - /** Original ErrorStateMatcher that checks the validity of form control. */ - private _originalErrorStateMatcher: ErrorStateMatcher; - constructor(@Inject(forwardRef(() => MatStepper)) stepper: MatStepper, - @Optional() @SkipSelf() @Inject(MAT_ERROR_GLOBAL_OPTIONS) - errorOptions: ErrorOptions) { + @SkipSelf() private _errorStateMatcher: ErrorStateMatcher) { super(stepper); - if (errorOptions && errorOptions.errorStateMatcher) { - this._originalErrorStateMatcher = errorOptions.errorStateMatcher; - } else { - this._originalErrorStateMatcher = defaultErrorStateMatcher; - } } /** Custom error state matcher that additionally checks for validity of interacted form. */ - errorStateMatcher = (control: FormControl, form: FormGroupDirective | NgForm) => { - let originalErrorState = this._originalErrorStateMatcher(control, form); + isErrorState(control: NgControl | null, form: FormGroupDirective | NgForm | null): boolean { + const originalErrorState = this._errorStateMatcher.isErrorState(control, form); // Custom error state checks for the validity of form that is not submitted or touched // since user can trigger a form change by calling for another step without directly // interacting with the current form. - let customErrorState = control.invalid && this.interacted; + const customErrorState = !!(control && control.invalid && this.interacted); return originalErrorState || customErrorState; }