+
+
+ This field is required
+
+
+
+
+
+ This field is required
+
+
+ Please enter a valid email address
+
+
+
+
+
With hint
+
+
+
+ This field is required
+ Please type something here
+
+
+
+
+
+
+
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: `
+
+ `
+})
+class MdInputContainerWithFormErrorMessages {
+ @ViewChild('form') form: NgForm;
+ formControl = new FormControl('', Validators.required);
+ renderError = true;
+}
+
+
+@Component({
+ template: `
+
+ `
+})
+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