Skip to content

Commit cf4ec30

Browse files
committed
fix(select): consistent error behavior to md-input-container
* 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 0c03946 commit cf4ec30

File tree

3 files changed

+128
-11
lines changed

3 files changed

+128
-11
lines changed

src/lib/select/_select-theme.scss

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,13 @@
5151
&.mat-accent {
5252
@include _mat-select-inner-content-theme($accent);
5353
}
54+
55+
.mat-select-placeholder::after {
56+
color: mat-color($warn);
57+
}
5458
}
5559

56-
.mat-select:focus:not(.mat-select-disabled).mat-warn,
57-
.mat-select:not(:focus).ng-invalid.ng-touched:not(.mat-select-disabled) {
60+
.mat-select:focus:not(.mat-select-disabled).mat-warn, .mat-select-invalid {
5861
@include _mat-select-inner-content-theme($warn);
5962
}
6063
}

src/lib/select/select.spec.ts

Lines changed: 108 additions & 7 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,27 @@ 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+
NgForm,
19+
Validators,
20+
} from '@angular/forms';
21+
import {By} from '@angular/platform-browser';
1222
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
23+
import {TestBed, async, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing';
1324
import {MdSelectModule} from './index';
1425
import {OverlayContainer} from '../core/overlay/overlay-container';
1526
import {MdSelect, MdSelectFloatPlaceholderType} from './select';
1627
import {getMdSelectDynamicMultipleError, getMdSelectNonArrayValueError} from './select-errors';
1728
import {MdOption} from '../core/option/option';
1829
import {Dir} from '../core/rtl/dir';
1930
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';
2331
import {Subject} from 'rxjs/Subject';
2432
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
2533
import {dispatchFakeEvent, dispatchKeyboardEvent} from '../core/testing/dispatch-events';
@@ -56,7 +64,8 @@ describe('MdSelect', () => {
5664
BasicSelectInitiallyHidden,
5765
BasicSelectNoPlaceholder,
5866
BasicSelectWithTheming,
59-
ResetValuesSelect
67+
ResetValuesSelect,
68+
SelectInsideFormGroup
6069
],
6170
providers: [
6271
{provide: OverlayContainer, useFactory: () => {
@@ -1387,11 +1396,12 @@ describe('MdSelect', () => {
13871396
.toEqual('true', `Expected aria-required attr to be true for required selects.`);
13881397
});
13891398

1390-
it('should set aria-invalid for selects that are invalid', () => {
1399+
it('should set aria-invalid for selects that are invalid and touched', () => {
13911400
expect(select.getAttribute('aria-invalid'))
13921401
.toEqual('false', `Expected aria-invalid attr to be false for valid selects.`);
13931402

13941403
fixture.componentInstance.isRequired = true;
1404+
fixture.componentInstance.control.markAsTouched();
13951405
fixture.detectChanges();
13961406

13971407
expect(select.getAttribute('aria-invalid'))
@@ -2144,6 +2154,77 @@ describe('MdSelect', () => {
21442154

21452155
});
21462156

2157+
describe('error state', () => {
2158+
let fixture: ComponentFixture<SelectInsideFormGroup>;
2159+
let testComponent: SelectInsideFormGroup;
2160+
let select: HTMLElement;
2161+
2162+
beforeEach(() => {
2163+
fixture = TestBed.createComponent(SelectInsideFormGroup);
2164+
fixture.detectChanges();
2165+
testComponent = fixture.componentInstance;
2166+
select = fixture.debugElement.query(By.css('md-select')).nativeElement;
2167+
});
2168+
2169+
it('should not set the invalid class on a clean select', () => {
2170+
expect(testComponent.formGroup.untouched).toBe(true, 'Expected the form to be untouched.');
2171+
expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid.');
2172+
expect(select.classList)
2173+
.not.toContain('mat-select-invalid', 'Expected select not to appear invalid.');
2174+
expect(select.getAttribute('aria-invalid'))
2175+
.toBe('false', 'Expected aria-invalid to be set to false.');
2176+
});
2177+
2178+
it('should appear as invalid if it becomes touched', () => {
2179+
expect(select.classList)
2180+
.not.toContain('mat-select-invalid', 'Expected select not to appear invalid.');
2181+
expect(select.getAttribute('aria-invalid'))
2182+
.toBe('false', 'Expected aria-invalid to be set to false.');
2183+
2184+
testComponent.formControl.markAsTouched();
2185+
fixture.detectChanges();
2186+
2187+
expect(select.classList)
2188+
.toContain('mat-select-invalid', 'Expected select to appear invalid.');
2189+
expect(select.getAttribute('aria-invalid'))
2190+
.toBe('true', 'Expected aria-invalid to be set to true.');
2191+
});
2192+
2193+
it('should not have the invalid class when the select becomes valid', () => {
2194+
testComponent.formControl.markAsTouched();
2195+
fixture.detectChanges();
2196+
2197+
expect(select.classList)
2198+
.toContain('mat-select-invalid', 'Expected select to appear invalid.');
2199+
expect(select.getAttribute('aria-invalid'))
2200+
.toBe('true', 'Expected aria-invalid to be set to true.');
2201+
2202+
testComponent.formControl.setValue('pizza-1');
2203+
fixture.detectChanges();
2204+
2205+
expect(select.classList)
2206+
.not.toContain('mat-select-invalid', 'Expected select not to appear invalid.');
2207+
expect(select.getAttribute('aria-invalid'))
2208+
.toBe('false', 'Expected aria-invalid to be set to false.');
2209+
});
2210+
2211+
it('should appear as invalid when the parent form group is submitted', () => {
2212+
expect(select.classList)
2213+
.not.toContain('mat-select-invalid', 'Expected select not to appear invalid.');
2214+
expect(select.getAttribute('aria-invalid'))
2215+
.toBe('false', 'Expected aria-invalid to be set to false.');
2216+
2217+
dispatchFakeEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit');
2218+
fixture.detectChanges();
2219+
2220+
expect(select.classList)
2221+
.toContain('mat-select-invalid', 'Expected select to appear invalid.');
2222+
expect(select.getAttribute('aria-invalid'))
2223+
.toBe('true', 'Expected aria-invalid to be set to true.');
2224+
});
2225+
2226+
});
2227+
21472228
});
21482229

21492230

@@ -2491,6 +2572,7 @@ class BasicSelectWithTheming {
24912572
theme: string;
24922573
}
24932574

2575+
24942576
@Component({
24952577
selector: 'reset-values-select',
24962578
template: `
@@ -2516,3 +2598,22 @@ class ResetValuesSelect {
25162598

25172599
@ViewChild(MdSelect) select: MdSelect;
25182600
}
2601+
2602+
2603+
@Component({
2604+
template: `
2605+
<form [formGroup]="formGroup">
2606+
<md-select placeholder="Food" formControlName="food">
2607+
<md-option value="steak-0">Steak</md-option>
2608+
<md-option value="pizza-1">Pizza</md-option>
2609+
</md-select>
2610+
</form>
2611+
`
2612+
})
2613+
class SelectInsideFormGroup {
2614+
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
2615+
formControl = new FormControl('', Validators.required);
2616+
formGroup = new FormGroup({
2617+
food: this.formControl
2618+
});
2619+
}

src/lib/select/select.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
Attribute,
1818
OnInit,
1919
} from '@angular/core';
20+
import {NgForm, FormGroupDirective} from '@angular/forms';
2021
import {MdOption, MdOptionSelectionChange} from '../core/option/option';
2122
import {ENTER, SPACE, UP_ARROW, DOWN_ARROW, HOME, END} from '../core/keyboard/keycodes';
2223
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
@@ -108,9 +109,10 @@ export type MdSelectFloatPlaceholderType = 'always' | 'never' | 'auto';
108109
'[attr.aria-labelledby]': 'ariaLabelledby',
109110
'[attr.aria-required]': 'required.toString()',
110111
'[attr.aria-disabled]': 'disabled.toString()',
111-
'[attr.aria-invalid]': '_control?.invalid || "false"',
112+
'[attr.aria-invalid]': '_isErrorState()',
112113
'[attr.aria-owns]': '_optionIds',
113114
'[class.mat-select-disabled]': 'disabled',
115+
'[class.mat-select-invalid]': '_isErrorState()',
114116
'[class.mat-select]': 'true',
115117
'(keydown)': '_handleClosedKeydown($event)',
116118
'(blur)': '_onBlur()',
@@ -316,7 +318,8 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
316318
constructor(private _element: ElementRef, private _renderer: Renderer2,
317319
private _viewportRuler: ViewportRuler, private _changeDetectorRef: ChangeDetectorRef,
318320
@Optional() private _dir: Dir, @Self() @Optional() public _control: NgControl,
319-
@Attribute('tabindex') tabIndex: string) {
321+
@Attribute('tabindex') tabIndex: string, @Optional() private _parentForm: NgForm,
322+
@Optional() private _parentFormGroup: FormGroupDirective) {
320323

321324
if (this._control) {
322325
this._control.valueAccessor = this;
@@ -536,6 +539,16 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
536539
this._setScrollTop();
537540
}
538541

542+
/** Whether the select is in an error state. */
543+
_isErrorState(): boolean {
544+
const isInvalid = this._control && this._control.invalid;
545+
const isTouched = this._control && this._control.touched;
546+
const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) ||
547+
(this._parentForm && this._parentForm.submitted);
548+
549+
return !!(isInvalid && (isTouched || isSubmitted));
550+
}
551+
539552
/**
540553
* Sets the scroll position of the scroll container. This must be called after
541554
* the overlay pane is attached or the scroll container element will not yet be

0 commit comments

Comments
 (0)