Skip to content

Commit

Permalink
fix(select): consistent error behavior to md-input-container
Browse files Browse the repository at this point in the history
* 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 angular#4611.
  • Loading branch information
crisbeto committed May 31, 2017
1 parent 3569805 commit 3d8c223
Show file tree
Hide file tree
Showing 3 changed files with 126 additions and 11 deletions.
7 changes: 5 additions & 2 deletions src/lib/select/_select-theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,13 @@
&.mat-accent {
@include _mat-select-inner-content-theme($accent);
}

.mat-select-placeholder::after {
color: mat-color($warn);
}
}

.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);
}
}
113 changes: 106 additions & 7 deletions src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import {TestBed, async, ComponentFixture, fakeAsync, tick, inject} from '@angular/core/testing';
import {By} from '@angular/platform-browser';
import {
Component,
DebugElement,
Expand All @@ -9,17 +7,27 @@ import {
ChangeDetectionStrategy,
OnInit,
} from '@angular/core';
import {
ControlValueAccessor,
FormControl,
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
FormGroup,
FormGroupDirective,
NgForm,
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, MdSelectFloatPlaceholderType} from './select';
import {getMdSelectDynamicMultipleError, getMdSelectNonArrayValueError} from './select-errors';
import {MdOption} from '../core/option/option';
import {Dir} from '../core/rtl/dir';
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} from '../core/testing/dispatch-events';
Expand Down Expand Up @@ -55,7 +63,8 @@ describe('MdSelect', () => {
SelectEarlyAccessSibling,
BasicSelectInitiallyHidden,
BasicSelectNoPlaceholder,
BasicSelectWithTheming
BasicSelectWithTheming,
SelectInsideFormGroup
],
providers: [
{provide: OverlayContainer, useFactory: () => {
Expand Down Expand Up @@ -1341,11 +1350,12 @@ describe('MdSelect', () => {
.toEqual('true', `Expected aria-required attr to be true for required selects.`);
});

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'))
Expand Down Expand Up @@ -2022,6 +2032,77 @@ describe('MdSelect', () => {

});

describe('error state', () => {
let fixture: ComponentFixture<SelectInsideFormGroup>;
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.');
});

});

});


Expand Down Expand Up @@ -2366,3 +2447,21 @@ class BasicSelectWithTheming {
@ViewChild(MdSelect) select: MdSelect;
theme: string;
}

@Component({
template: `
<form [formGroup]="formGroup">
<md-select placeholder="Food" formControlName="food">
<md-option value="steak-0">Steak</md-option>
<md-option value="pizza-1">Pizza</md-option>
</md-select>
</form>
`
})
class SelectInsideFormGroup {
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
formControl = new FormControl('', Validators.required);
formGroup = new FormGroup({
food: this.formControl
});
}
17 changes: 15 additions & 2 deletions src/lib/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
Attribute,
OnInit,
} from '@angular/core';
import {NgForm, FormGroupDirective} from '@angular/forms';
import {MdOption, MdOptionSelectionChange} from '../core/option/option';
import {ENTER, SPACE, UP_ARROW, DOWN_ARROW, HOME, END} from '../core/keyboard/keycodes';
import {FocusKeyManager} from '../core/a11y/focus-key-manager';
Expand Down Expand Up @@ -108,9 +109,10 @@ export type MdSelectFloatPlaceholderType = 'always' | 'never' | 'auto';
'[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]': 'true',
'(keydown)': '_handleClosedKeydown($event)',
'(blur)': '_onBlur()',
Expand Down Expand Up @@ -313,7 +315,8 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
constructor(private _element: ElementRef, private _renderer: Renderer2,
private _viewportRuler: ViewportRuler, private _changeDetectorRef: ChangeDetectorRef,
@Optional() private _dir: Dir, @Self() @Optional() public _control: NgControl,
@Attribute('tabindex') tabIndex: string) {
@Attribute('tabindex') tabIndex: string, @Optional() private _parentForm: NgForm,
@Optional() private _parentFormGroup: FormGroupDirective) {

if (this._control) {
this._control.valueAccessor = this;
Expand Down Expand Up @@ -533,6 +536,16 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
this._setScrollTop();
}

/** 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
Expand Down

0 comments on commit 3d8c223

Please sign in to comment.