Skip to content

Commit 86de634

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 0850981 commit 86de634

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
@@ -1247,7 +1247,7 @@ class MdInputContainerWithFormErrorMessages {
12471247
<md-input-container>
12481248
<input mdInput
12491249
formControlName="name"
1250-
[errorStateMatcher]="customErrorStateMatcher.bind(this)">
1250+
[errorStateMatcher]="customErrorStateMatcher">
12511251
<md-hint>Please type something</md-hint>
12521252
<md-error>This field is required</md-error>
12531253
</md-input-container>
@@ -1260,10 +1260,7 @@ class MdInputContainerWithCustomErrorStateMatcher {
12601260
});
12611261

12621262
errorState = false;
1263-
1264-
customErrorStateMatcher(): boolean {
1265-
return this.errorState;
1266-
}
1263+
customErrorStateMatcher = () => this.errorState;
12671264
}
12681265

12691266
@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 {FormControl, FormGroupDirective, NgControl, NgForm} from '@angular/forms';
34+
import {FormGroupDirective, NgControl, NgForm} from '@angular/forms';
3535
import {getSupportedInputTypes} from '../core/platform/features';
3636
import {
3737
getMdInputContainerDuplicatedHintError,
@@ -248,7 +248,7 @@ export class MdInputDirective implements OnChanges, OnDestroy, DoCheck {
248248

249249
// Force setter to be called in case id was not specified.
250250
this.id = this.id;
251-
this._errorOptions = errorOptions ? errorOptions : {};
251+
this._errorOptions = errorOptions || {};
252252
this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher;
253253

254254
// On some versions of iOS the caret gets stuck in the wrong place when holding down the delete
@@ -321,9 +321,8 @@ export class MdInputDirective implements OnChanges, OnDestroy, DoCheck {
321321
/** Re-evaluates the error state. This is only relevant with @angular/forms. */
322322
private _updateErrorState() {
323323
const oldState = this._isErrorState;
324-
const control = this._ngControl;
325-
const parent = this._parentFormGroup || this._parentForm;
326-
const newState = control && this.errorStateMatcher(control.control as FormControl, parent);
324+
const newState = this.errorStateMatcher(this._ngControl,
325+
this._parentFormGroup || this._parentForm);
327326

328327
if (newState !== oldState) {
329328
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: () => {
@@ -2675,6 +2677,46 @@ describe('MdSelect', () => {
26752677
.toBe('true', 'Expected aria-invalid to be set to true.');
26762678
});
26772679

2680+
it('should be able to override the error matching behavior via an @Input', () => {
2681+
fixture.destroy();
2682+
2683+
const customErrorFixture = TestBed.createComponent(CustomErrorBehaviorSelect);
2684+
const component = customErrorFixture.componentInstance;
2685+
const matcher = jasmine.createSpy('error state matcher').and.returnValue(true);
2686+
2687+
customErrorFixture.detectChanges();
2688+
2689+
expect(component.control.invalid).toBe(false);
2690+
expect(component.select._isErrorState()).toBe(false);
2691+
2692+
customErrorFixture.componentInstance.errorStateMatcher = matcher;
2693+
customErrorFixture.detectChanges();
2694+
2695+
expect(component.select._isErrorState()).toBe(true);
2696+
expect(matcher).toHaveBeenCalled();
2697+
});
2698+
2699+
it('should be able to override the error matching behavior via the injection token', () => {
2700+
const errorOptions: ErrorOptions = {
2701+
errorStateMatcher: jasmine.createSpy('error state matcher').and.returnValue(true)
2702+
};
2703+
2704+
fixture.destroy();
2705+
2706+
TestBed.resetTestingModule().configureTestingModule({
2707+
imports: [MdSelectModule, ReactiveFormsModule, FormsModule, NoopAnimationsModule],
2708+
declarations: [SelectInsideFormGroup],
2709+
providers: [{ provide: MD_ERROR_GLOBAL_OPTIONS, useValue: errorOptions }],
2710+
});
2711+
2712+
const errorFixture = TestBed.createComponent(SelectInsideFormGroup);
2713+
const component = errorFixture.componentInstance;
2714+
2715+
errorFixture.detectChanges();
2716+
2717+
expect(component.select._isErrorState()).toBe(true);
2718+
expect(errorOptions.errorStateMatcher).toHaveBeenCalled();
2719+
});
26782720
});
26792721

26802722
});
@@ -3147,6 +3189,7 @@ class InvalidSelectInForm {
31473189
})
31483190
class SelectInsideFormGroup {
31493191
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
3192+
@ViewChild(MdSelect) select: MdSelect;
31503193
formControl = new FormControl('', Validators.required);
31513194
formGroup = new FormGroup({
31523195
food: this.formControl
@@ -3212,3 +3255,23 @@ class BasicSelectWithoutFormsMultiple {
32123255

32133256
@ViewChild(MdSelect) select: MdSelect;
32143257
}
3258+
3259+
@Component({
3260+
template: `
3261+
<md-select placeholder="Food" [formControl]="control" [errorStateMatcher]="errorStateMatcher">
3262+
<md-option *ngFor="let food of foods" [value]="food.value">
3263+
{{ food.viewValue }}
3264+
</md-option>
3265+
</md-select>
3266+
`
3267+
})
3268+
class CustomErrorBehaviorSelect {
3269+
@ViewChild(MdSelect) select: MdSelect;
3270+
control = new FormControl();
3271+
foods: any[] = [
3272+
{ value: 'steak-0', viewValue: 'Steak' },
3273+
{ value: 'pizza-1', viewValue: 'Pizza' },
3274+
];
3275+
errorStateMatcher = () => false;
3276+
}
3277+

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
@@ -217,6 +223,9 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
217223
/** Deals with configuring placeholder options */
218224
private _placeholderOptions: PlaceholderOptions;
219225

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

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

399412
super(renderer, elementRef);
400413

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

410425
ngOnInit() {
@@ -633,12 +648,7 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
633648

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

644654
/**

0 commit comments

Comments
 (0)