diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index 7559ddf3891e..c2495400c6e7 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -51,6 +51,51 @@ + + Error messages + +

Regular

+ +

+ + + This field is required + + + + + + This field is required + + + Please enter a valid email address + + +

+ +

With hint

+ + + + This field is required + Please type something here + + + +
+

Inside a form

+ + + + This field is required + + + +
+
+
+ Prefix + Suffix diff --git a/src/demo-app/input/input-demo.ts b/src/demo-app/input/input-demo.ts index e8fb5838141c..291769258cea 100644 --- a/src/demo-app/input/input-demo.ts +++ b/src/demo-app/input/input-demo.ts @@ -4,6 +4,8 @@ import {FormControl, Validators} from '@angular/forms'; let max = 5; +const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + @Component({ moduleId: module.id, selector: 'input-demo', @@ -17,6 +19,9 @@ export class InputDemo { ctrlDisabled = false; name: string; + errorMessageExample1: string; + errorMessageExample2: string; + errorMessageExample3: string; items: any[] = [ { value: 10 }, { value: 20 }, @@ -26,6 +31,7 @@ export class InputDemo { ]; rows = 8; formControl = new FormControl('hello', Validators.required); + emailFormControl = new FormControl('', [Validators.required, Validators.pattern(EMAIL_REGEX)]); model = 'hello'; addABunch(n: number) { diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index e4fd3f8ee9d0..416c3e7da81b 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -1,6 +1,7 @@ import {TestBed, async, fakeAsync, tick, ComponentFixture} from '@angular/core/testing'; import {Component, OnDestroy, QueryList, ViewChild, ViewChildren} from '@angular/core'; import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {MdAutocompleteModule, MdAutocompleteTrigger} from './index'; import {OverlayContainer} from '../core/overlay/overlay-container'; import {MdInputModule} from '../input/index'; @@ -27,7 +28,11 @@ describe('MdAutocomplete', () => { dir = 'ltr'; TestBed.configureTestingModule({ imports: [ - MdAutocompleteModule.forRoot(), MdInputModule.forRoot(), FormsModule, ReactiveFormsModule + MdAutocompleteModule.forRoot(), + MdInputModule.forRoot(), + FormsModule, + ReactiveFormsModule, + NoopAnimationsModule ], declarations: [ SimpleAutocomplete, diff --git a/src/lib/core/compatibility/compatibility.ts b/src/lib/core/compatibility/compatibility.ts index 7bb8ee9eea1d..52d0e29db78c 100644 --- a/src/lib/core/compatibility/compatibility.ts +++ b/src/lib/core/compatibility/compatibility.ts @@ -70,7 +70,8 @@ export const MAT_ELEMENTS_SELECTOR = ` mat-spinner, mat-tab, mat-tab-group, - mat-toolbar`; + mat-toolbar, + mat-error`; /** Selector that matches all elements that may have style collisions with AngularJS Material. */ export const MD_ELEMENTS_SELECTOR = ` @@ -130,7 +131,8 @@ export const MD_ELEMENTS_SELECTOR = ` md-spinner, md-tab, md-tab-group, - md-toolbar`; + md-toolbar, + md-error`; /** Directive that enforces that the `mat-` prefix cannot be used. */ @Directive({selector: MAT_ELEMENTS_SELECTOR}) diff --git a/src/lib/input/_input-theme.scss b/src/lib/input/_input-theme.scss index 391a5c7346a0..d669d56642c8 100644 --- a/src/lib/input/_input-theme.scss +++ b/src/lib/input/_input-theme.scss @@ -8,12 +8,12 @@ $warn: map-get($theme, warn); $background: map-get($theme, background); $foreground: map-get($theme, foreground); - + // Placeholder colors. Required is used for the `*` star shown in the placeholder. $input-placeholder-color: mat-color($foreground, hint-text); $input-floating-placeholder-color: mat-color($primary); $input-required-placeholder-color: mat-color($accent); - + // Underline colors. $input-underline-color: mat-color($foreground, divider); $input-underline-color-accent: mat-color($accent); @@ -64,7 +64,10 @@ } } - .mat-input-container.ng-invalid.ng-touched:not(.mat-focused) { + // Styling for the error state of the input container. Note that while the same can be + // achieved with the ng-* classes, we use this approach in order to ensure that the same + // logic is used to style the error state and to show the error messages. + .mat-input-invalid { .mat-input-placeholder, .mat-placeholder-required { color: $input-underline-color-warn; @@ -73,5 +76,13 @@ .mat-input-underline { border-color: $input-underline-color-warn; } + + .mat-input-ripple { + background-color: $input-underline-color-warn; + } + } + + .mat-input-error { + color: $input-underline-color-warn; } } diff --git a/src/lib/input/index.ts b/src/lib/input/index.ts index d0a7d8a6532f..ddf62e0bc2bb 100644 --- a/src/lib/input/index.ts +++ b/src/lib/input/index.ts @@ -1,5 +1,11 @@ import {NgModule, ModuleWithProviders} from '@angular/core'; -import {MdPlaceholder, MdInputContainer, MdHint, MdInputDirective} from './input-container'; +import { + MdPlaceholder, + MdInputContainer, + MdHint, + MdInputDirective, + MdErrorDirective, +} from './input-container'; import {MdTextareaAutosize} from './autosize'; import {CommonModule} from '@angular/common'; import {FormsModule} from '@angular/forms'; @@ -12,7 +18,8 @@ import {PlatformModule} from '../core/platform/index'; MdInputContainer, MdHint, MdTextareaAutosize, - MdInputDirective + MdInputDirective, + MdErrorDirective ], imports: [ CommonModule, @@ -24,7 +31,8 @@ import {PlatformModule} from '../core/platform/index'; MdInputContainer, MdHint, MdTextareaAutosize, - MdInputDirective + MdInputDirective, + MdErrorDirective ], }) export class MdInputModule { diff --git a/src/lib/input/input-container.html b/src/lib/input/input-container.html index cefe5a06f8d1..653c903bbd83 100644 --- a/src/lib/input/input-container.html +++ b/src/lib/input/input-container.html @@ -36,6 +36,15 @@ [class.mat-warn]="dividerColor == 'warn'"> -
{{hintLabel}}
- +
+
+ +
+ +
+
{{hintLabel}}
+ +
+
diff --git a/src/lib/input/input-container.scss b/src/lib/input/input-container.scss index c664d84f83ff..1453f5b8cfd8 100644 --- a/src/lib/input/input-container.scss +++ b/src/lib/input/input-container.scss @@ -4,6 +4,7 @@ $mat-input-floating-placeholder-scale-factor: 0.75 !default; +$mat-input-wrapper-spacing: 1em !default; // Gradient for showing the dashed line when the input is disabled. $mat-input-underline-disabled-background-image: @@ -41,7 +42,7 @@ $mat-input-underline-disabled-background-image: // Global wrapper. We need to apply margin to the element for spacing, but // cannot apply it to the host element directly. .mat-input-wrapper { - margin: 1em 0; + margin: $mat-input-wrapper-spacing 0; // Account for the underline which has 4px of margin + 2px of border. padding-bottom: 6px; } @@ -219,29 +220,57 @@ $mat-input-underline-disabled-background-image: } } -// The hint is shown below the underline. There can be more than one; one at the start -// and one at the end. -.mat-hint { - display: block; +// Wrapper for the hints and error messages. Provides positioning and text size. +// Note that we're using `top` in order to allow for stacked children to flow downwards. +.mat-input-subscript-wrapper { position: absolute; font-size: 75%; - bottom: 0; + top: 100%; + width: 100%; + margin-top: -$mat-input-wrapper-spacing; + overflow: hidden; // prevents multi-line errors from overlapping the input +} + +// Clears the floats on the hints. This is necessary for the hint animation to work. +.mat-input-hint-wrapper { + &::before, + &::after { + content: ' '; + display: table; + } + + &::after { + clear: both; + } +} +// The hint is shown below the underline. There can be +// more than one; one at the start and one at the end. +.mat-hint { + display: block; + float: left; + + // We use floats here, as opposed to flexbox, in order to make it + // easier to reverse their location in rtl and to ensure that they're + // aligned properly in some cases (e.g. when there is only an `end` hint). &.mat-right { - right: 0; + float: right; } [dir='rtl'] & { - right: 0; - left: auto; + float: right; &.mat-right { - right: auto; - left: 0; + float: left; } } } +// Single error message displayed beneath the input. +.mat-input-error { + display: block; +} + .mat-input-prefix, .mat-input-suffix { // Prevents the prefix and suffix from stretching together with the container. width: 0.1px; diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index e5f98722d5bc..cfff09e68786 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -1,12 +1,22 @@ -import {async, TestBed, inject} from '@angular/core/testing'; -import {Component} from '@angular/core'; -import {FormsModule, ReactiveFormsModule, FormControl} from '@angular/forms'; +import {async, TestBed, inject, ComponentFixture} from '@angular/core/testing'; +import {Component, ViewChild} from '@angular/core'; +import { + FormsModule, + ReactiveFormsModule, + FormControl, + NgForm, + Validators, + FormGroupDirective, + FormGroup, +} from '@angular/forms'; import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {MdInputModule} from './index'; import {MdInputContainer, MdInputDirective} from './input-container'; import {Platform} from '../core/platform/platform'; import {PlatformModule} from '../core/platform/index'; import {wrappedErrorMessage} from '../core/testing/wrapped-error-message'; +import {dispatchFakeEvent} from '../core/testing/dispatch-events'; import { MdInputContainerMissingMdInputError, MdInputContainerPlaceholderConflictError, @@ -21,7 +31,8 @@ describe('MdInputContainer', function () { MdInputModule.forRoot(), PlatformModule.forRoot(), FormsModule, - ReactiveFormsModule + ReactiveFormsModule, + NoopAnimationsModule ], declarations: [ MdInputContainerPlaceholderRequiredTestComponent, @@ -50,7 +61,9 @@ describe('MdInputContainer', function () { MdInputContainerMissingMdInputTestController, MdInputContainerMultipleHintTestController, MdInputContainerMultipleHintMixedTestController, - MdInputContainerWithDynamicPlaceholder + MdInputContainerWithDynamicPlaceholder, + MdInputContainerWithFormErrorMessages, + MdInputContainerWithFormGroupErrorMessages ], }); @@ -551,6 +564,127 @@ describe('MdInputContainer', function () { expect(labelEl.classList).not.toContain('mat-float'); }); + describe('error messages', () => { + let fixture: ComponentFixture; + let testComponent: MdInputContainerWithFormErrorMessages; + let containerEl: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(MdInputContainerWithFormErrorMessages); + fixture.detectChanges(); + testComponent = fixture.componentInstance; + containerEl = fixture.debugElement.query(By.css('md-input-container')).nativeElement; + }); + + it('should not show any errors if the user has not interacted', () => { + expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control'); + expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); + }); + + it('should display an error message when the input is touched and invalid', async(() => { + expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.classList) + .toContain('mat-input-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + }); + })); + + it('should display an error message when the parent form is submitted', async(() => { + expect(testComponent.form.submitted).toBe(false, 'Expected form not to have been submitted'); + expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); + + dispatchFakeEvent(fixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(testComponent.form.submitted).toBe(true, 'Expected form to have been submitted'); + expect(containerEl.classList) + .toContain('mat-input-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + }); + })); + + it('should display an error message when the parent form group is submitted', async(() => { + fixture.destroy(); + + let groupFixture = TestBed.createComponent(MdInputContainerWithFormGroupErrorMessages); + let component: MdInputContainerWithFormGroupErrorMessages; + + groupFixture.detectChanges(); + component = groupFixture.componentInstance; + containerEl = groupFixture.debugElement.query(By.css('md-input-container')).nativeElement; + + expect(component.formGroup.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); + expect(component.formGroupDirective.submitted) + .toBe(false, 'Expected form not to have been submitted'); + + dispatchFakeEvent(groupFixture.debugElement.query(By.css('form')).nativeElement, 'submit'); + groupFixture.detectChanges(); + + groupFixture.whenStable().then(() => { + expect(component.formGroupDirective.submitted) + .toBe(true, 'Expected form to have been submitted'); + expect(containerEl.classList) + .toContain('mat-input-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + }); + })); + + it('should hide the errors and show the hints once the input becomes valid', async(() => { + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.classList) + .toContain('mat-input-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(0, 'Expected no hints to be shown.'); + + testComponent.formControl.setValue('something'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.classList).not.toContain('mat-input-invalid', + 'Expected container not to have the invalid class when valid.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no error messages when the input is valid.'); + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(1, 'Expected one hint to be shown once the input is valid.'); + }); + }); + })); + + it('should not hide the hint if there are no error messages', async(() => { + testComponent.renderError = false; + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(1, 'Expected one hint to be shown on load.'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(1, 'Expected one hint to still be shown.'); + }); + })); + + }); + }); @Component({ @@ -775,3 +909,39 @@ class MdTextareaWithBindings { template: `` }) class MdInputContainerMissingMdInputTestController {} + +@Component({ + template: ` +
+ + + Please type something + This field is required + +
+ ` +}) +class MdInputContainerWithFormErrorMessages { + @ViewChild('form') form: NgForm; + formControl = new FormControl('', Validators.required); + renderError = true; +} + + +@Component({ + template: ` +
+ + + Please type something + This field is required + +
+ ` +}) +class MdInputContainerWithFormGroupErrorMessages { + @ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective; + formGroup = new FormGroup({ + name: new FormControl('', Validators.required) + }); +} diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index d11b5a67f207..06730719dacc 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -1,21 +1,30 @@ import { + AfterViewInit, AfterContentInit, Component, ContentChild, ContentChildren, Directive, ElementRef, - EventEmitter, Input, Optional, Output, - QueryList, + EventEmitter, Renderer, + ChangeDetectorRef, + ViewEncapsulation, Self, - ViewEncapsulation + QueryList, } from '@angular/core'; +import { + animate, + state, + style, + transition, + trigger, +} from '@angular/animations'; import {coerceBooleanProperty} from '../core'; -import {NgControl} from '@angular/forms'; +import {NgControl, NgForm, FormGroupDirective} from '@angular/forms'; import {getSupportedInputTypes} from '../core/platform/features'; import { MdInputContainerDuplicatedHintError, @@ -72,6 +81,14 @@ export class MdHint { @Input() id: string = `md-input-hint-${nextUniqueId++}`; } +/** Directive, used to display a single error message under the input. */ +@Directive({ + selector: 'md-error, mat-error', + host: { + '[class.mat-input-error]': 'true' + } +}) +export class MdErrorDirective { } /** The input directive, used to mark the input that `MdInputContainer` is wrapping. */ @Directive({ @@ -235,10 +252,20 @@ export class MdInputDirective { selector: 'md-input-container, mat-input-container', templateUrl: 'input-container.html', styleUrls: ['input-container.css'], + animations: [ + trigger('transitionMessages', [ + state('enter', style({ opacity: 1, transform: 'translateY(0%)' })), + transition('void => enter', [ + style({ opacity: 0, transform: 'translateY(-100%)' }), + animate('300ms cubic-bezier(0.55, 0, 0.55, 0.2)') + ]) + ]) + ], host: { // Remove align attribute to prevent it from interfering with layout. '[attr.align]': 'null', '[class.mat-input-container]': 'true', + '[class.mat-input-invalid]': '_isErrorState()', '[class.mat-focused]': '_mdInputChild.focused', '[class.ng-untouched]': '_shouldForward("untouched")', '[class.ng-touched]': '_shouldForward("touched")', @@ -251,7 +278,7 @@ export class MdInputDirective { }, encapsulation: ViewEncapsulation.None, }) -export class MdInputContainer implements AfterContentInit { +export class MdInputContainer implements AfterViewInit, AfterContentInit { /** Alignment of the input container's content. */ @Input() align: 'start' | 'end' = 'start'; @@ -264,6 +291,9 @@ export class MdInputContainer implements AfterContentInit { /** Whether the placeholder can float or not. */ get _canPlaceholderFloat() { return this._floatPlaceholder !== 'never'; } + /** State of the md-hint and md-error animations. */ + _subscriptAnimationState: string = ''; + /** Text for the input hint. */ @Input() get hintLabel() { return this._hintLabel; } @@ -288,8 +318,15 @@ export class MdInputContainer implements AfterContentInit { @ContentChild(MdPlaceholder) _placeholderChild: MdPlaceholder; + @ContentChildren(MdErrorDirective) _errorChildren: QueryList; + @ContentChildren(MdHint) _hintChildren: QueryList; + constructor( + private _changeDetectorRef: ChangeDetectorRef, + @Optional() private _parentForm: NgForm, + @Optional() private _parentFormGroup: FormGroupDirective) { } + ngAfterContentInit() { if (!this._mdInputChild) { throw new MdInputContainerMissingMdInputError(); @@ -303,6 +340,12 @@ export class MdInputContainer implements AfterContentInit { this._mdInputChild._placeholderChange.subscribe(() => this._validatePlaceholders()); } + ngAfterViewInit() { + // Avoid animations on load. + this._subscriptAnimationState = 'enter'; + this._changeDetectorRef.detectChanges(); + } + /** Determines whether a class from the NgControl should be forwarded to the host element. */ _shouldForward(prop: string): boolean { let control = this._mdInputChild ? this._mdInputChild._ngControl : null; @@ -315,6 +358,22 @@ export class MdInputContainer implements AfterContentInit { /** Focuses the underlying input. */ _focusInput() { this._mdInputChild.focus(); } + /** Whether the input container is in an error state. */ + _isErrorState(): boolean { + const control = this._mdInputChild._ngControl; + const isInvalid = control && control.invalid; + const isTouched = control && control.touched; + const isSubmitted = (this._parentFormGroup && this._parentFormGroup.submitted) || + (this._parentForm && this._parentForm.submitted); + + return !!(isInvalid && (isTouched || isSubmitted)); + } + + /** Determines whether to display hints or errors. */ + _getDisplayedMessages(): 'error' | 'hint' { + return (this._errorChildren.length > 0 && this._isErrorState()) ? 'error' : 'hint'; + } + /** * Ensure that there is only one placeholder (either `input` attribute or child element with the * `md-placeholder` attribute. diff --git a/src/lib/input/input.md b/src/lib/input/input.md index 9e17b3baf7bb..a1cbe4f3cfce 100644 --- a/src/lib/input/input.md +++ b/src/lib/input/input.md @@ -1,4 +1,4 @@ -`` is a wrapper for native `input` and `textarea` elements. This container +`` is a wrapper for native `input` and `textarea` elements. This container applies Material Design styles and behavior while still allowing direct access to the underlying native element. @@ -14,7 +14,7 @@ elements inside `md-input-container` as well. This includes Angular directives s The only limitations are that the `type` attribute can only be one of the values supported by `md-input-container` and the native element cannot specify a `placeholder` attribute if the -`md-input-container` also contains a `md-placeholder` element. +`md-input-container` also contains a `md-placeholder` element. ### Supported `input` types @@ -33,6 +33,19 @@ be used with `md-input-container`: * url * week +### Error messages + +Error messages can be shown beneath an input by specifying `md-error` elements inside the +`md-input-container`. Errors are hidden by default and will be displayed on invalid inputs after +the user has interacted with the element or the parent form has been submitted. In addition, +whenever errors are displayed, the container's `md-hint` labels will be hidden. + +If an input element can have more than one error state, it is up to the consumer to toggle which +messages should be displayed. This can be done with CSS, `ngIf` or `ngSwitch`. + +Note that, while multiple error messages can be displayed at the same time, it is recommended to +only show one at a time. + ### Placeholder A placeholder is an indicative text displayed in the input zone when the input does not contain