Skip to content

Commit 6f73b35

Browse files
crisbetoandrewseguin
authored andcommitted
fix(select): consistent error behavior to md-input-container (#4754)
* Gets `md-select` to behave in the same way as `md-input-container` when it comes to errors. This means highlighting itself when it is invalid and touched, or one of the parent forms/form groups is submitted. * Moves the error state logic into a separate function in order to avoid some hard-to-follow selectors and to potentially allow overrides. This should also be a first step to supporting `md-error` inside `md-select`. * Changes the required asterisk to always have the theme warn color, similarly to the input asterisk. Fixes #4611.
1 parent 0a5489f commit 6f73b35

File tree

3 files changed

+124
-11
lines changed

3 files changed

+124
-11
lines changed

src/lib/select/_select-theme.scss

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,7 @@
6666
}
6767
}
6868

69-
.mat-select:focus:not(.mat-select-disabled).mat-warn,
70-
.mat-select:not(:focus).ng-invalid.ng-touched:not(.mat-select-disabled) {
69+
.mat-select:focus:not(.mat-select-disabled).mat-warn, .mat-select-invalid {
7170
@include _mat-select-inner-content-theme($warn);
7271
}
7372
}

src/lib/select/select.spec.ts

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import {TestBed, async, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing';
2-
import {By} from '@angular/platform-browser';
31
import {
42
Component,
53
DebugElement,
@@ -9,17 +7,26 @@ import {
97
ChangeDetectionStrategy,
108
OnInit,
119
} from '@angular/core';
10+
import {
11+
ControlValueAccessor,
12+
FormControl,
13+
FormsModule,
14+
NG_VALUE_ACCESSOR,
15+
ReactiveFormsModule,
16+
FormGroup,
17+
FormGroupDirective,
18+
Validators,
19+
} from '@angular/forms';
20+
import {By} from '@angular/platform-browser';
1221
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
22+
import {TestBed, async, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing';
1323
import {MdSelectModule} from './index';
1424
import {OverlayContainer} from '../core/overlay/overlay-container';
1525
import {MdSelect} from './select';
1626
import {getMdSelectDynamicMultipleError, getMdSelectNonArrayValueError} from './select-errors';
1727
import {MdOption} from '../core/option/option';
1828
import {Directionality} from '../core/bidi/index';
1929
import {DOWN_ARROW, UP_ARROW, ENTER, SPACE, HOME, END, TAB} from '../core/keyboard/keycodes';
20-
import {
21-
ControlValueAccessor, FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule
22-
} from '@angular/forms';
2330
import {Subject} from 'rxjs/Subject';
2431
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
2532
import {dispatchFakeEvent, dispatchKeyboardEvent, wrappedErrorMessage} from '@angular/cdk/testing';
@@ -66,7 +73,8 @@ describe('MdSelect', () => {
6673
InvalidSelectInForm,
6774
BasicSelectWithoutForms,
6875
BasicSelectWithoutFormsPreselected,
69-
BasicSelectWithoutFormsMultiple
76+
BasicSelectWithoutFormsMultiple,
77+
SelectInsideFormGroup
7078
],
7179
providers: [
7280
{provide: OverlayContainer, useFactory: () => {
@@ -1719,11 +1727,12 @@ describe('MdSelect', () => {
17191727
'mat-select-required', `Expected the mat-select-required class to be set.`);
17201728
});
17211729

1722-
it('should set aria-invalid for selects that are invalid', () => {
1730+
it('should set aria-invalid for selects that are invalid and touched', () => {
17231731
expect(select.getAttribute('aria-invalid'))
17241732
.toEqual('false', `Expected aria-invalid attr to be false for valid selects.`);
17251733

17261734
fixture.componentInstance.isRequired = true;
1735+
fixture.componentInstance.control.markAsTouched();
17271736
fixture.detectChanges();
17281737

17291738
expect(select.getAttribute('aria-invalid'))
@@ -2571,6 +2580,77 @@ describe('MdSelect', () => {
25712580

25722581
});
25732582

2583+
describe('error state', () => {
2584+
let fixture: ComponentFixture<SelectInsideFormGroup>;
2585+
let testComponent: SelectInsideFormGroup;
2586+
let select: HTMLElement;
2587+
2588+
beforeEach(() => {
2589+
fixture = TestBed.createComponent(SelectInsideFormGroup);
2590+
fixture.detectChanges();
2591+
testComponent = fixture.componentInstance;
2592+
select = fixture.debugElement.query(By.css('md-select')).nativeElement;
2593+
});
2594+
2595+
it('should not set the invalid class on a clean select', () => {
2596+
expect(testComponent.formGroup.untouched).toBe(true, 'Expected the form to be untouched.');
2597+
expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid.');
2598+
expect(select.classList)
2599+
.not.toContain('mat-select-invalid', 'Expected select not to appear invalid.');
2600+
expect(select.getAttribute('aria-invalid'))
2601+
.toBe('false', 'Expected aria-invalid to be set to false.');
2602+
});
2603+
2604+
it('should appear as invalid if it becomes touched', () => {
2605+
expect(select.classList)
2606+
.not.toContain('mat-select-invalid', 'Expected select not to appear invalid.');
2607+
expect(select.getAttribute('aria-invalid'))
2608+
.toBe('false', 'Expected aria-invalid to be set to false.');
2609+
2610+
testComponent.formControl.markAsTouched();
2611+
fixture.detectChanges();
2612+
2613+
expect(select.classList)
2614+
.toContain('mat-select-invalid', 'Expected select to appear invalid.');
2615+
expect(select.getAttribute('aria-invalid'))
2616+
.toBe('true', 'Expected aria-invalid to be set to true.');
2617+
});
2618+
2619+
it('should not have the invalid class when the select becomes valid', () => {
2620+
testComponent.formControl.markAsTouched();
2621+
fixture.detectChanges();
2622+
2623+
expect(select.classList)
2624+
.toContain('mat-select-invalid', 'Expected select to appear invalid.');
2625+
expect(select.getAttribute('aria-invalid'))
2626+
.toBe('true', 'Expected aria-invalid to be set to true.');
2627+
2628+
testComponent.formControl.setValue('pizza-1');
2629+
fixture.detectChanges();
2630+
2631+
expect(select.classList)
2632+
.not.toContain('mat-select-invalid', 'Expected select not to appear invalid.');
2633+
expect(select.getAttribute('aria-invalid'))
2634+
.toBe('false', 'Expected aria-invalid to be set to false.');
2635+
});
2636+
2637+
it('should appear as invalid when the parent form group is submitted', () => {
2638+
expect(select.classList)
2639+
.not.toContain('mat-select-invalid', 'Expected select not to appear invalid.');
2640+
expect(select.getAttribute('aria-invalid'))
2641+
.toBe('false', 'Expected aria-invalid to be set to false.');
2642+
2643+
dispatchFakeEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit');
2644+
fixture.detectChanges();
2645+
2646+
expect(select.classList)
2647+
.toContain('mat-select-invalid', 'Expected select to appear invalid.');
2648+
expect(select.getAttribute('aria-invalid'))
2649+
.toBe('true', 'Expected aria-invalid to be set to true.');
2650+
});
2651+
2652+
});
2653+
25742654
});
25752655

25762656

@@ -2918,6 +2998,7 @@ class BasicSelectWithTheming {
29182998
theme: string;
29192999
}
29203000

3001+
29213002
@Component({
29223003
selector: 'reset-values-select',
29233004
template: `
@@ -2944,7 +3025,6 @@ class ResetValuesSelect {
29443025
@ViewChild(MdSelect) select: MdSelect;
29453026
}
29463027

2947-
29483028
@Component({
29493029
template: `
29503030
<md-select [formControl]="control">
@@ -3028,6 +3108,25 @@ class InvalidSelectInForm {
30283108
}
30293109

30303110

3111+
@Component({
3112+
template: `
3113+
<form [formGroup]="formGroup">
3114+
<md-select placeholder="Food" formControlName="food">
3115+
<md-option value="steak-0">Steak</md-option>
3116+
<md-option value="pizza-1">Pizza</md-option>
3117+
</md-select>
3118+
</form>
3119+
`
3120+
})
3121+
class SelectInsideFormGroup {
3122+
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
3123+
formControl = new FormControl('', Validators.required);
3124+
formGroup = new FormGroup({
3125+
food: this.formControl
3126+
});
3127+
}
3128+
3129+
30313130
@Component({
30323131
template: `
30333132
<md-select placeholder="Food" [(value)]="selectedFood">

src/lib/select/select.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
ChangeDetectionStrategy,
2929
InjectionToken,
3030
} from '@angular/core';
31+
import {NgForm, FormGroupDirective} from '@angular/forms';
3132
import {MdOption, MdOptionSelectionChange, MdOptgroup} from '../core/option/index';
3233
import {ENTER, SPACE, UP_ARROW, DOWN_ARROW, HOME, END} from '../core/keyboard/keycodes';
3334
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
@@ -153,9 +154,10 @@ export const _MdSelectMixinBase = mixinColor(mixinDisabled(MdSelectBase), 'prima
153154
'[attr.aria-labelledby]': 'ariaLabelledby',
154155
'[attr.aria-required]': 'required.toString()',
155156
'[attr.aria-disabled]': 'disabled.toString()',
156-
'[attr.aria-invalid]': '_control?.invalid || "false"',
157+
'[attr.aria-invalid]': '_isErrorState()',
157158
'[attr.aria-owns]': '_optionIds',
158159
'[class.mat-select-disabled]': 'disabled',
160+
'[class.mat-select-invalid]': '_isErrorState()',
159161
'[class.mat-select-required]': 'required',
160162
'class': 'mat-select',
161163
'(keydown)': '_handleClosedKeydown($event)',
@@ -368,10 +370,13 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
368370
renderer: Renderer2,
369371
elementRef: ElementRef,
370372
@Optional() private _dir: Directionality,
373+
@Optional() private _parentForm: NgForm,
374+
@Optional() private _parentFormGroup: FormGroupDirective,
371375
@Self() @Optional() public _control: NgControl,
372376
@Attribute('tabindex') tabIndex: string,
373377
@Optional() @Inject(MD_PLACEHOLDER_GLOBAL_OPTIONS) placeholderOptions: PlaceholderOptions,
374378
@Inject(MD_SELECT_SCROLL_STRATEGY) private _scrollStrategyFactory) {
379+
375380
super(renderer, elementRef);
376381

377382
if (this._control) {
@@ -605,6 +610,16 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On
605610
return this._selectionModel && this._selectionModel.hasValue();
606611
}
607612

613+
/** Whether the select is in an error state. */
614+
_isErrorState(): boolean {
615+
const isInvalid = this._control && this._control.invalid;
616+
const isTouched = this._control && this._control.touched;
617+
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
618+
(this._parentForm && this._parentForm.submitted);
619+
620+
return !!(isInvalid && (isTouched || isSubmitted));
621+
}
622+
608623
/**
609624
* Sets the scroll position of the scroll container. This must be called after
610625
* the overlay pane is attached or the scroll container element will not yet be

0 commit comments

Comments
 (0)