Skip to content

Commit a4e2e19

Browse files
committed
feat(select): add support for custom errorStateMatcher
* Allows for the `md-select` error behavior to be configured through an `@Input`, as well as globally through the same provider as `md-input-container`. * Simplifies the signature of some of the error option symbols.
1 parent 846899d commit a4e2e19

File tree

5 files changed

+95
-29
lines changed

5 files changed

+95
-29
lines changed

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

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,24 @@
77
*/
88

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

1212
/** Injection token that can be used to specify the global error options. */
1313
export const MD_ERROR_GLOBAL_OPTIONS = new InjectionToken<ErrorOptions>('md-error-global-options');
1414

1515
export type ErrorStateMatcher =
16-
(control: FormControl, form: FormGroupDirective | NgForm) => boolean;
16+
(control: NgControl | null, form: FormGroupDirective | NgForm | null) => boolean;
1717

1818
export interface ErrorOptions {
1919
errorStateMatcher?: ErrorStateMatcher;
2020
}
2121

2222
/** 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-
}
23+
export const defaultErrorStateMatcher: ErrorStateMatcher = (control, form) => {
24+
return control ? !!(control.invalid && (control.touched || (form && form.submitted))) : false;
25+
};
2726

2827
/** 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-
}
28+
export const showOnDirtyErrorStateMatcher: ErrorStateMatcher = (control, form) => {
29+
return control ? !!(control.invalid && (control.dirty || (form && form.submitted))) : false;
30+
};

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

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1194,7 +1194,7 @@ class MdInputContainerWithFormErrorMessages {
11941194
<md-input-container>
11951195
<input mdInput
11961196
formControlName="name"
1197-
[errorStateMatcher]="customErrorStateMatcher.bind(this)">
1197+
[errorStateMatcher]="customErrorStateMatcher">
11981198
<md-hint>Please type something</md-hint>
11991199
<md-error>This field is required</md-error>
12001200
</md-input-container>
@@ -1207,10 +1207,7 @@ class MdInputContainerWithCustomErrorStateMatcher {
12071207
});
12081208

12091209
errorState = false;
1210-
1211-
customErrorStateMatcher(): boolean {
1212-
return this.errorState;
1213-
}
1210+
customErrorStateMatcher = () => this.errorState;
12141211
}
12151212

12161213
@Component({

src/lib/input/input-container.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
} from '@angular/core';
3232
import {animate, state, style, transition, trigger} from '@angular/animations';
3333
import {coerceBooleanProperty, Platform} from '../core';
34-
import {FormGroupDirective, NgControl, NgForm, FormControl} from '@angular/forms';
34+
import {FormGroupDirective, NgControl, NgForm} from '@angular/forms';
3535
import {getSupportedInputTypes} from '../core/platform/features';
3636
import {
3737
getMdInputContainerDuplicatedHintError,
@@ -240,7 +240,7 @@ export class MdInputDirective implements OnChanges, OnDestroy, DoCheck {
240240

241241
// Force setter to be called in case id was not specified.
242242
this.id = this.id;
243-
this._errorOptions = errorOptions ? errorOptions : {};
243+
this._errorOptions = errorOptions || {};
244244
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;
245245
}
246246

@@ -297,9 +297,8 @@ export class MdInputDirective implements OnChanges, OnDestroy, DoCheck {
297297
/** Re-evaluates the error state. This is only relevant with @angular/forms. */
298298
private _updateErrorState() {
299299
const oldState = this._isErrorState;
300-
const control = this._ngControl;
301-
const parent = this._parentFormGroup || this._parentForm;
302-
const newState = control && this.errorStateMatcher(control.control as FormControl, parent);
300+
const newState = this.errorStateMatcher(this._ngControl,
301+
this._parentFormGroup || this._parentForm);
303302

304303
if (newState !== oldState) {
305304
this._isErrorState = newState;

src/lib/select/select.spec.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {Subject} from 'rxjs/Subject';
3131
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
3232
import {dispatchFakeEvent, dispatchKeyboardEvent, wrappedErrorMessage} from '@angular/cdk/testing';
3333
import {ScrollDispatcher} from '../core/overlay/scroll/scroll-dispatcher';
34+
import {MD_ERROR_GLOBAL_OPTIONS, ErrorOptions} from '../core/error/error-options';
3435
import {
3536
FloatPlaceholderType,
3637
MD_PLACEHOLDER_GLOBAL_OPTIONS
@@ -74,7 +75,8 @@ describe('MdSelect', () => {
7475
BasicSelectWithoutForms,
7576
BasicSelectWithoutFormsPreselected,
7677
BasicSelectWithoutFormsMultiple,
77-
SelectInsideFormGroup
78+
SelectInsideFormGroup,
79+
CustomErrorBehaviorSelect
7880
],
7981
providers: [
8082
{provide: OverlayContainer, useFactory: () => {
@@ -2660,6 +2662,46 @@ describe('MdSelect', () => {
26602662
.toBe('true', 'Expected aria-invalid to be set to true.');
26612663
});
26622664

2665+
it('should be able to override the error matching behavior via an @Input', () => {
2666+
fixture.destroy();
2667+
2668+
const customErrorFixture = TestBed.createComponent(CustomErrorBehaviorSelect);
2669+
const component = customErrorFixture.componentInstance;
2670+
const matcher = jasmine.createSpy('error state matcher').and.returnValue(true);
2671+
2672+
customErrorFixture.detectChanges();
2673+
2674+
expect(component.control.invalid).toBe(false);
2675+
expect(component.select._isErrorState()).toBe(false);
2676+
2677+
customErrorFixture.componentInstance.errorStateMatcher = matcher;
2678+
customErrorFixture.detectChanges();
2679+
2680+
expect(component.select._isErrorState()).toBe(true);
2681+
expect(matcher).toHaveBeenCalled();
2682+
});
2683+
2684+
it('should be able to override the error matching behavior via the injection token', () => {
2685+
const errorOptions: ErrorOptions = {
2686+
errorStateMatcher: jasmine.createSpy('error state matcher').and.returnValue(true)
2687+
};
2688+
2689+
fixture.destroy();
2690+
2691+
TestBed.resetTestingModule().configureTestingModule({
2692+
imports: [MdSelectModule, ReactiveFormsModule, FormsModule, NoopAnimationsModule],
2693+
declarations: [SelectInsideFormGroup],
2694+
providers: [{ provide: MD_ERROR_GLOBAL_OPTIONS, useValue: errorOptions }],
2695+
});
2696+
2697+
const errorFixture = TestBed.createComponent(SelectInsideFormGroup);
2698+
const component = errorFixture.componentInstance;
2699+
2700+
errorFixture.detectChanges();
2701+
2702+
expect(component.select._isErrorState()).toBe(true);
2703+
expect(errorOptions.errorStateMatcher).toHaveBeenCalled();
2704+
});
26632705
});
26642706

26652707
});
@@ -3132,6 +3174,7 @@ class InvalidSelectInForm {
31323174
})
31333175
class SelectInsideFormGroup {
31343176
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
3177+
@ViewChild(MdSelect) select: MdSelect;
31353178
formControl = new FormControl('', Validators.required);
31363179
formGroup = new FormGroup({
31373180
food: this.formControl
@@ -3197,3 +3240,23 @@ class BasicSelectWithoutFormsMultiple {
31973240

31983241
@ViewChild(MdSelect) select: MdSelect;
31993242
}
3243+
3244+
@Component({
3245+
template: `
3246+
<md-select placeholder="Food" [formControl]="control" [errorStateMatcher]="errorStateMatcher">
3247+
<md-option *ngFor="let food of foods" [value]="food.value">
3248+
{{ food.viewValue }}
3249+
</md-option>
3250+
</md-select>
3251+
`
3252+
})
3253+
class CustomErrorBehaviorSelect {
3254+
@ViewChild(MdSelect) select: MdSelect;
3255+
control = new FormControl();
3256+
foods: any[] = [
3257+
{ value: 'steak-0', viewValue: 'Steak' },
3258+
{ value: 'pizza-1', viewValue: 'Pizza' },
3259+
];
3260+
errorStateMatcher = () => false;
3261+
}
3262+

src/lib/select/select.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,12 @@ import {
5757
// tslint:disable-next-line:no-unused-variable
5858
import {ScrollStrategy, RepositionScrollStrategy} from '../core/overlay/scroll';
5959
import {Platform} from '@angular/cdk/platform';
60+
import {
61+
defaultErrorStateMatcher,
62+
ErrorStateMatcher,
63+
ErrorOptions,
64+
MD_ERROR_GLOBAL_OPTIONS
65+
} from '../core/error/error-options';
6066

6167
/**
6268
* The following style constants are necessary to save here in order
@@ -216,6 +222,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
216222
/** Deals with configuring placeholder options */
217223
private _placeholderOptions: PlaceholderOptions;
218224

225+
/** Options that determine how an invalid select behaves. */
226+
private _errorOptions: ErrorOptions;
227+
219228
/**
220229
* The width of the trigger. Must be saved to set the min width of the overlay panel
221230
* and the width of the selected value.
@@ -359,6 +368,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
359368
/** Input that can be used to specify the `aria-labelledby` attribute. */
360369
@Input('aria-labelledby') ariaLabelledby: string = '';
361370

371+
/** A function used to control when error messages are shown. */
372+
@Input() errorStateMatcher: ErrorStateMatcher;
373+
362374
/** Combined stream of all of the child options' change events. */
363375
get optionSelectionChanges(): Observable<MdOptionSelectionChange> {
364376
return merge(...this.options.map(option => option.onSelectionChange));
@@ -393,7 +405,8 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
393405
@Self() @Optional() public _control: NgControl,
394406
@Attribute('tabindex') tabIndex: string,
395407
@Optional() @Inject(MD_PLACEHOLDER_GLOBAL_OPTIONS) placeholderOptions: PlaceholderOptions,
396-
@Inject(MD_SELECT_SCROLL_STRATEGY) private _scrollStrategyFactory) {
408+
@Inject(MD_SELECT_SCROLL_STRATEGY) private _scrollStrategyFactory,
409+
@Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {
397410

398411
super(renderer, elementRef);
399412

@@ -404,6 +417,8 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
404417
this._tabIndex = parseInt(tabIndex) || 0;
405418
this._placeholderOptions = placeholderOptions ? placeholderOptions : {};
406419
this.floatPlaceholder = this._placeholderOptions.float || 'auto';
420+
this._errorOptions = errorOptions || {};
421+
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;
407422
}
408423

409424
ngOnInit() {
@@ -632,12 +647,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
632647

633648
/** Whether the select is in an error state. */
634649
_isErrorState(): boolean {
635-
const isInvalid = this._control && this._control.invalid;
636-
const isTouched = this._control && this._control.touched;
637-
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
638-
(this._parentForm && this._parentForm.submitted);
639-
640-
return !!(isInvalid && (isTouched || isSubmitted));
650+
return this.errorStateMatcher(this._control, this._parentFormGroup || this._parentForm);
641651
}
642652

643653
/**

0 commit comments

Comments
 (0)