diff --git a/src/lib/select/_select-theme.scss b/src/lib/select/_select-theme.scss index 7d9b898b6a48..651f742000ef 100644 --- a/src/lib/select/_select-theme.scss +++ b/src/lib/select/_select-theme.scss @@ -66,8 +66,7 @@ } } - .mat-select:focus:not(.mat-select-disabled).mat-warn, - .mat-select:not(:focus).ng-invalid.ng-touched:not(.mat-select-disabled) { + .mat-select:focus:not(.mat-select-disabled).mat-warn, .mat-select-invalid { @include _mat-select-inner-content-theme($warn); } } diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index ef8bd567ad59..14a975235367 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -1,5 +1,3 @@ -import {TestBed, async, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing'; -import {By} from '@angular/platform-browser'; import { Component, DebugElement, @@ -9,7 +7,19 @@ import { ChangeDetectionStrategy, OnInit, } from '@angular/core'; +import { + ControlValueAccessor, + FormControl, + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + FormGroup, + FormGroupDirective, + Validators, +} from '@angular/forms'; +import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {TestBed, async, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing'; import {MdSelectModule} from './index'; import {OverlayContainer} from '../core/overlay/overlay-container'; import {MdSelect} from './select'; @@ -17,9 +27,6 @@ import {getMdSelectDynamicMultipleError, getMdSelectNonArrayValueError} from './ import {MdOption} from '../core/option/option'; import {Directionality} from '../core/bidi/index'; import {DOWN_ARROW, UP_ARROW, ENTER, SPACE, HOME, END, TAB} from '../core/keyboard/keycodes'; -import { - ControlValueAccessor, FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule -} from '@angular/forms'; import {Subject} from 'rxjs/Subject'; import {ViewportRuler} from '../core/overlay/position/viewport-ruler'; import {dispatchFakeEvent, dispatchKeyboardEvent, wrappedErrorMessage} from '@angular/cdk/testing'; @@ -66,7 +73,8 @@ describe('MdSelect', () => { InvalidSelectInForm, BasicSelectWithoutForms, BasicSelectWithoutFormsPreselected, - BasicSelectWithoutFormsMultiple + BasicSelectWithoutFormsMultiple, + SelectInsideFormGroup ], providers: [ {provide: OverlayContainer, useFactory: () => { @@ -1719,11 +1727,12 @@ describe('MdSelect', () => { 'mat-select-required', `Expected the mat-select-required class to be set.`); }); - it('should set aria-invalid for selects that are invalid', () => { + it('should set aria-invalid for selects that are invalid and touched', () => { expect(select.getAttribute('aria-invalid')) .toEqual('false', `Expected aria-invalid attr to be false for valid selects.`); fixture.componentInstance.isRequired = true; + fixture.componentInstance.control.markAsTouched(); fixture.detectChanges(); expect(select.getAttribute('aria-invalid')) @@ -2571,6 +2580,77 @@ describe('MdSelect', () => { }); + describe('error state', () => { + let fixture: ComponentFixture; + let testComponent: SelectInsideFormGroup; + let select: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SelectInsideFormGroup); + fixture.detectChanges(); + testComponent = fixture.componentInstance; + select = fixture.debugElement.query(By.css('md-select')).nativeElement; + }); + + it('should not set the invalid class on a clean select', () => { + expect(testComponent.formGroup.untouched).toBe(true, 'Expected the form to be untouched.'); + expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid.'); + expect(select.classList) + .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); + expect(select.getAttribute('aria-invalid')) + .toBe('false', 'Expected aria-invalid to be set to false.'); + }); + + it('should appear as invalid if it becomes touched', () => { + expect(select.classList) + .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); + expect(select.getAttribute('aria-invalid')) + .toBe('false', 'Expected aria-invalid to be set to false.'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + expect(select.classList) + .toContain('mat-select-invalid', 'Expected select to appear invalid.'); + expect(select.getAttribute('aria-invalid')) + .toBe('true', 'Expected aria-invalid to be set to true.'); + }); + + it('should not have the invalid class when the select becomes valid', () => { + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + expect(select.classList) + .toContain('mat-select-invalid', 'Expected select to appear invalid.'); + expect(select.getAttribute('aria-invalid')) + .toBe('true', 'Expected aria-invalid to be set to true.'); + + testComponent.formControl.setValue('pizza-1'); + fixture.detectChanges(); + + expect(select.classList) + .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); + expect(select.getAttribute('aria-invalid')) + .toBe('false', 'Expected aria-invalid to be set to false.'); + }); + + it('should appear as invalid when the parent form group is submitted', () => { + expect(select.classList) + .not.toContain('mat-select-invalid', 'Expected select not to appear invalid.'); + expect(select.getAttribute('aria-invalid')) + .toBe('false', 'Expected aria-invalid to be set to false.'); + + dispatchFakeEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + expect(select.classList) + .toContain('mat-select-invalid', 'Expected select to appear invalid.'); + expect(select.getAttribute('aria-invalid')) + .toBe('true', 'Expected aria-invalid to be set to true.'); + }); + + }); + }); @@ -2918,6 +2998,7 @@ class BasicSelectWithTheming { theme: string; } + @Component({ selector: 'reset-values-select', template: ` @@ -2944,7 +3025,6 @@ class ResetValuesSelect { @ViewChild(MdSelect) select: MdSelect; } - @Component({ template: ` @@ -3028,6 +3108,25 @@ class InvalidSelectInForm { } +@Component({ + template: ` +
+ + Steak + Pizza + +
+ ` +}) +class SelectInsideFormGroup { + @ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective; + formControl = new FormControl('', Validators.required); + formGroup = new FormGroup({ + food: this.formControl + }); +} + + @Component({ template: ` diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index 9bd7ff685fed..e9fd301effbb 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -28,6 +28,7 @@ import { ChangeDetectionStrategy, InjectionToken, } from '@angular/core'; +import {NgForm, FormGroupDirective} from '@angular/forms'; import {MdOption, MdOptionSelectionChange, MdOptgroup} from '../core/option/index'; import {ENTER, SPACE, UP_ARROW, DOWN_ARROW, HOME, END} from '../core/keyboard/keycodes'; import {FocusKeyManager} from '../core/a11y/focus-key-manager'; @@ -153,9 +154,10 @@ export const _MdSelectMixinBase = mixinColor(mixinDisabled(MdSelectBase), 'prima '[attr.aria-labelledby]': 'ariaLabelledby', '[attr.aria-required]': 'required.toString()', '[attr.aria-disabled]': 'disabled.toString()', - '[attr.aria-invalid]': '_control?.invalid || "false"', + '[attr.aria-invalid]': '_isErrorState()', '[attr.aria-owns]': '_optionIds', '[class.mat-select-disabled]': 'disabled', + '[class.mat-select-invalid]': '_isErrorState()', '[class.mat-select-required]': 'required', 'class': 'mat-select', '(keydown)': '_handleClosedKeydown($event)', @@ -368,10 +370,13 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On renderer: Renderer2, elementRef: ElementRef, @Optional() private _dir: Directionality, + @Optional() private _parentForm: NgForm, + @Optional() private _parentFormGroup: FormGroupDirective, @Self() @Optional() public _control: NgControl, @Attribute('tabindex') tabIndex: string, @Optional() @Inject(MD_PLACEHOLDER_GLOBAL_OPTIONS) placeholderOptions: PlaceholderOptions, @Inject(MD_SELECT_SCROLL_STRATEGY) private _scrollStrategyFactory) { + super(renderer, elementRef); if (this._control) { @@ -605,6 +610,16 @@ export class MdSelect extends _MdSelectMixinBase implements AfterContentInit, On return this._selectionModel && this._selectionModel.hasValue(); } + /** Whether the select is in an error state. */ + _isErrorState(): boolean { + const isInvalid = this._control && this._control.invalid; + const isTouched = this._control && this._control.touched; + const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) || + (this._parentForm && this._parentForm.submitted); + + return !!(isInvalid && (isTouched || isSubmitted)); + } + /** * Sets the scroll position of the scroll container. This must be called after * the overlay pane is attached or the scroll container element will not yet be