feat(input): add directive for displaying error messages (#3560)

* feat(input): add directive for displayed error messages

Adds the `md-error` directive that can be utilised to display validation errors to the user. Example:

```
<md-input-container>
  <input mdInput placeholder="email" [formControl]="emailFormControl">
  <md-error *ngIf="emailFormControl.errors.required">This field is required</md-error>
  <md-error *ngIf="emailFormControl.errors.pattern">
    Please enter a valid email address
  </md-error>
</md-input-container>
```

The `md-input-container` behavior is as follows:
* If there is an error and the user interacted with the input, the errors will be shown.
* If there is an error and the user submitted the a form that wraps the input, the errors will be shown.
* If there are errors to be shown on an input container that has `md-hint`-s, the hints will be hidden.
* If an input with hints and errors becomes valid, the hint won't be displayed anymore.

**Note:** At the moment, all hints will be hidden when an error is shown. This might not be intended for some cases (e.g. with a character counter). It might make sense to only hide the one in the `start` slot, but this could be addressed separately.

* chore: lint and aot errors

* fix: address feedback

* fix: address more feedback

* chore: use FormGroup properly

* refactor: switch back to showing hints when valid

* errror
This commit is contained in:
Kristiyan Kostadinov
2017-03-28 01:37:34 +02:00
committed by tinayuangao
parent e482cb6821
commit c29f8ca44a
11 changed files with 391 additions and 34 deletions
+45
View File
@@ -51,6 +51,51 @@
</md-card-content>
</md-card>
<md-card class="demo-card demo-basic">
<md-toolbar color="primary">Error messages</md-toolbar>
<md-card-content>
<h4>Regular</h4>
<p>
<md-input-container>
<input mdInput placeholder="example" [(ngModel)]="errorMessageExample1" required>
<md-error>This field is required</md-error>
</md-input-container>
<md-input-container>
<input mdInput placeholder="email" [formControl]="emailFormControl">
<md-error *ngIf="emailFormControl.hasError('required')">
This field is required
</md-error>
<md-error *ngIf="emailFormControl.hasError('pattern')">
Please enter a valid email address
</md-error>
</md-input-container>
</p>
<h4>With hint</h4>
<md-input-container>
<input mdInput placeholder="example" [(ngModel)]="errorMessageExample2" required>
<md-error>This field is required</md-error>
<md-hint>Please type something here</md-hint>
</md-input-container>
<form novalidate>
<h4>Inside a form</h4>
<md-input-container>
<input mdInput name="example" placeholder="example"
[(ngModel)]="errorMessageExample3" required>
<md-error>This field is required</md-error>
</md-input-container>
<button color="primary" md-raised-button>Submit</button>
</form>
</md-card-content>
</md-card>
<md-card class="demo-card demo-basic">
<md-toolbar color="primary">Prefix + Suffix</md-toolbar>
<md-card-content>
+6
View File
@@ -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) {
+6 -1
View File
@@ -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,
+4 -2
View File
@@ -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})
+14 -3
View File
@@ -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;
}
}
+11 -3
View File
@@ -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 {
+11 -2
View File
@@ -36,6 +36,15 @@
[class.mat-warn]="dividerColor == 'warn'"></span>
</div>
<div *ngIf="hintLabel != ''" [attr.id]="_hintLabelId" class="mat-hint">{{hintLabel}}</div>
<ng-content select="md-hint, mat-hint"></ng-content>
<div class="mat-input-subscript-wrapper" [ngSwitch]="_getDisplayedMessages()">
<div *ngSwitchCase="'error'" [@transitionMessages]="_subscriptAnimationState">
<ng-content select="md-error, mat-error"></ng-content>
</div>
<div class="mat-input-hint-wrapper" *ngSwitchCase="'hint'"
[@transitionMessages]="_subscriptAnimationState">
<div *ngIf="hintLabel" [id]="_hintLabelId" class="mat-hint">{{hintLabel}}</div>
<ng-content select="md-hint, mat-hint"></ng-content>
</div>
</div>
</div>
+40 -11
View File
@@ -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;
+175 -5
View File
@@ -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<MdInputContainerWithFormErrorMessages>;
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: `<md-input-container><input></md-input-container>`
})
class MdInputContainerMissingMdInputTestController {}
@Component({
template: `
<form #form="ngForm" novalidate>
<md-input-container>
<input mdInput [formControl]="formControl">
<md-hint>Please type something</md-hint>
<md-error *ngIf="renderError">This field is required</md-error>
</md-input-container>
</form>
`
})
class MdInputContainerWithFormErrorMessages {
@ViewChild('form') form: NgForm;
formControl = new FormControl('', Validators.required);
renderError = true;
}
@Component({
template: `
<form [formGroup]="formGroup" novalidate>
<md-input-container>
<input mdInput formControlName="name">
<md-hint>Please type something</md-hint>
<md-error>This field is required</md-error>
</md-input-container>
</form>
`
})
class MdInputContainerWithFormGroupErrorMessages {
@ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective;
formGroup = new FormGroup({
name: new FormControl('', Validators.required)
});
}
+64 -5
View File
@@ -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<MdErrorDirective>;
@ContentChildren(MdHint) _hintChildren: QueryList<MdHint>;
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.
+15 -2
View File
@@ -1,4 +1,4 @@
`<md-input-container>` is a wrapper for native `input` and `textarea` elements. This container
`<md-input-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