From 42dab5e5aa1aa1e4ebed550672c85dc6e713009f Mon Sep 17 00:00:00 2001 From: crisbeto Date: Wed, 8 Mar 2017 23:42:44 +0100 Subject: [PATCH 1/7] 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: ``` This field is required Please enter a valid email address ``` 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. --- src/demo-app/input/input-demo.html | 43 +++++++ src/demo-app/input/input-demo.ts | 6 + src/lib/autocomplete/autocomplete.spec.ts | 7 +- src/lib/input/_input-theme.scss | 14 +- src/lib/input/index.ts | 8 +- src/lib/input/input-container.html | 12 +- src/lib/input/input-container.scss | 47 +++++-- src/lib/input/input-container.spec.ts | 149 +++++++++++++++++++++- src/lib/input/input-container.ts | 76 ++++++++++- src/lib/input/input.md | 17 ++- 10 files changed, 347 insertions(+), 32 deletions(-) diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index 7559ddf3891e..9b7bdb706325 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -51,6 +51,49 @@ + + 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/input/_input-theme.scss b/src/lib/input/_input-theme.scss index 391a5c7346a0..25c1ef54bae0 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,7 @@ } } - .mat-input-container.ng-invalid.ng-touched:not(.mat-focused) { + .mat-input-invalid { .mat-input-placeholder, .mat-placeholder-required { color: $input-underline-color-warn; @@ -73,5 +73,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..834eb80ada9a 100644 --- a/src/lib/input/index.ts +++ b/src/lib/input/index.ts @@ -1,5 +1,5 @@ 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 +12,8 @@ import {PlatformModule} from '../core/platform/index'; MdInputContainer, MdHint, MdTextareaAutosize, - MdInputDirective + MdInputDirective, + MdErrorDirective ], imports: [ CommonModule, @@ -24,7 +25,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..4ed1a6d4a968 100644 --- a/src/lib/input/input-container.html +++ b/src/lib/input/input-container.html @@ -36,6 +36,14 @@ [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..f967f41675e8 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,53 @@ $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-hint-wrapper { position: absolute; font-size: 75%; - bottom: 0; + top: 100%; + width: 100%; + margin-top: -$mat-input-wrapper-spacing; +} + +// 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; &.mat-right { - right: 0; + float: right; } [dir='rtl'] & { - right: 0; - left: auto; + float: right; &.mat-right { - right: auto; - left: 0; + float: left; } } } +// Clears the floats on the hints. Necessary for the `transform` animation to work. +.mat-input-hint-clearfix { + &::before, + &::after { + content: ''; + display: table; + } + + &::after { + clear: both; + } +} + +// Single errror 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..f8db9292ff43 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -1,12 +1,14 @@ -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} 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 +23,8 @@ describe('MdInputContainer', function () { MdInputModule.forRoot(), PlatformModule.forRoot(), FormsModule, - ReactiveFormsModule + ReactiveFormsModule, + NoopAnimationsModule ], declarations: [ MdInputContainerPlaceholderRequiredTestComponent, @@ -50,7 +53,8 @@ describe('MdInputContainer', function () { MdInputContainerMissingMdInputTestController, MdInputContainerMultipleHintTestController, MdInputContainerMultipleHintMixedTestController, - MdInputContainerWithDynamicPlaceholder + MdInputContainerWithDynamicPlaceholder, + MdInputContainerWithErrorMessages ], }); @@ -551,6 +555,124 @@ describe('MdInputContainer', function () { expect(labelEl.classList).not.toContain('mat-float'); }); + describe('error messages', () => { + let fixture: ComponentFixture; + let testComponent: MdInputContainerWithErrorMessages; + let containerEl: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(MdInputContainerWithErrorMessages); + 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.pristine).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 hide the error messages 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.'); + + 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.'); + }); + }); + })); + + it('should hide the hints when there are errors and not show them again when' + + ' the input becomes valid', async(() => { + + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(1, 'Expected one hint to be shown on load.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no errors to be shown on load.'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(0, 'Expected no hints to be shown after interaction.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(1, 'Expected one error to be shown after interaction.'); + + testComponent.formControl.setValue('something'); + fixture.detectChanges(); + + fixture.whenStable().then(() => { + expect(containerEl.querySelectorAll('md-hint').length) + .toBe(0, 'Expected no hints to be shown after the value is set.'); + expect(containerEl.querySelectorAll('md-error').length) + .toBe(0, 'Expected no errors to be shown after the value is set.'); + }); + }); + })); + + 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 +897,20 @@ class MdTextareaWithBindings { template: `` }) class MdInputContainerMissingMdInputTestController {} + +@Component({ + template: ` +
+ + + Please type something + This field is required + +
+ ` +}) +class MdInputContainerWithErrorMessages { + @ViewChild('form') form: NgForm; + formControl = new FormControl('', Validators.required); + renderError = true; +} diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index d11b5a67f207..324fe5c3273f 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} 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') + ]) + ]) + ], 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. */ + _messageAnimationState: string = ''; + /** Text for the input hint. */ @Input() get hintLabel() { return this._hintLabel; } @@ -288,8 +318,14 @@ 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) { } + ngAfterContentInit() { if (!this._mdInputChild) { throw new MdInputContainerMissingMdInputError(); @@ -303,6 +339,12 @@ export class MdInputContainer implements AfterContentInit { this._mdInputChild._placeholderChange.subscribe(() => this._validatePlaceholders()); } + ngAfterViewInit() { + // Avoid animations on load. + this._messageAnimationState = '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 +357,30 @@ 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 : false; + const isTouched = control ? control.touched : false; + const isSubmitted = this._parentForm ? this._parentForm.submitted : false; + + return isInvalid && (isTouched || isSubmitted); + } + + /** Determines whether to display hints, errors or no messages at all. */ + _getDisplayedMessages(): 'error'|'hint'|'none' { + if (this._errorChildren.length > 0) { + if (this._isErrorState()) { + return 'error'; + } else if (this._mdInputChild._ngControl) { + let control = this._mdInputChild._ngControl; + return (control.valid && control.touched) ? 'none' : 'hint'; + } + } + + return '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 From c7e2d69e410d04713823a32d2180729250f97f3f Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sat, 11 Mar 2017 00:16:57 +0100 Subject: [PATCH 2/7] chore: lint and aot errors --- src/demo-app/input/input-demo.html | 4 ++-- src/lib/input/index.ts | 8 +++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index 9b7bdb706325..defb0e548752 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -64,8 +64,8 @@

Regular

- This field is required - + This field is required + Please enter a valid email address diff --git a/src/lib/input/index.ts b/src/lib/input/index.ts index 834eb80ada9a..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, MdErrorDirective} 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'; From a9d144a542bc62ed7a88bc41fe6f6ada5daada45 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Wed, 15 Mar 2017 20:25:24 +0100 Subject: [PATCH 3/7] fix: address feedback --- src/demo-app/input/input-demo.html | 6 ++++-- src/lib/input/input-container.html | 2 +- src/lib/input/input-container.scss | 14 +------------- src/lib/input/input-container.ts | 2 +- 4 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index defb0e548752..eb5ad83e3724 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -64,8 +64,10 @@

Regular

- This field is required - + + This field is required + + Please enter a valid email address diff --git a/src/lib/input/input-container.html b/src/lib/input/input-container.html index 4ed1a6d4a968..2dee95d046a9 100644 --- a/src/lib/input/input-container.html +++ b/src/lib/input/input-container.html @@ -41,7 +41,7 @@ -
+
{{hintLabel}}
diff --git a/src/lib/input/input-container.scss b/src/lib/input/input-container.scss index f967f41675e8..e92a8b6b5be0 100644 --- a/src/lib/input/input-container.scss +++ b/src/lib/input/input-container.scss @@ -228,6 +228,7 @@ $mat-input-underline-disabled-background-image: top: 100%; width: 100%; margin-top: -$mat-input-wrapper-spacing; + overflow: hidden; // prevents multi-line errors from overlapping the input } // The hint is shown below the underline. There can be @@ -249,19 +250,6 @@ $mat-input-underline-disabled-background-image: } } -// Clears the floats on the hints. Necessary for the `transform` animation to work. -.mat-input-hint-clearfix { - &::before, - &::after { - content: ''; - display: table; - } - - &::after { - clear: both; - } -} - // Single errror message displayed beneath the input. .mat-input-error { display: block; diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index 324fe5c3273f..1c64b93d3df4 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -368,7 +368,7 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit { } /** Determines whether to display hints, errors or no messages at all. */ - _getDisplayedMessages(): 'error'|'hint'|'none' { + _getDisplayedMessages(): 'error' | 'hint' | 'none' { if (this._errorChildren.length > 0) { if (this._isErrorState()) { return 'error'; From 670670897110ee8efa0f59a05dd691cfc32a0485 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Thu, 16 Mar 2017 20:16:41 +0100 Subject: [PATCH 4/7] fix: address more feedback --- src/demo-app/input/input-demo.html | 4 +- src/lib/core/compatibility/compatibility.ts | 6 +- src/lib/input/_input-theme.scss | 3 + src/lib/input/input-container.html | 6 +- src/lib/input/input-container.scss | 3 + src/lib/input/input-container.spec.ts | 70 ++++++++++++++++++--- src/lib/input/input-container.ts | 20 +++--- 7 files changed, 89 insertions(+), 23 deletions(-) diff --git a/src/demo-app/input/input-demo.html b/src/demo-app/input/input-demo.html index eb5ad83e3724..c2495400c6e7 100644 --- a/src/demo-app/input/input-demo.html +++ b/src/demo-app/input/input-demo.html @@ -64,10 +64,10 @@

Regular

- + This field is required - + Please enter a valid email address 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 25c1ef54bae0..d669d56642c8 100644 --- a/src/lib/input/_input-theme.scss +++ b/src/lib/input/_input-theme.scss @@ -64,6 +64,9 @@ } } + // 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 { diff --git a/src/lib/input/input-container.html b/src/lib/input/input-container.html index 2dee95d046a9..f7dd76da4ad2 100644 --- a/src/lib/input/input-container.html +++ b/src/lib/input/input-container.html @@ -37,12 +37,12 @@
-
+
-
-
{{hintLabel}}
+
+
{{hintLabel}}
diff --git a/src/lib/input/input-container.scss b/src/lib/input/input-container.scss index e92a8b6b5be0..441f5c5c19af 100644 --- a/src/lib/input/input-container.scss +++ b/src/lib/input/input-container.scss @@ -237,6 +237,9 @@ $mat-input-underline-disabled-background-image: 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 { float: right; } diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index f8db9292ff43..46ae625d0cdb 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -1,6 +1,14 @@ import {async, TestBed, inject, ComponentFixture} from '@angular/core/testing'; import {Component, ViewChild} from '@angular/core'; -import {FormsModule, ReactiveFormsModule, FormControl, NgForm, Validators} from '@angular/forms'; +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'; @@ -54,7 +62,8 @@ describe('MdInputContainer', function () { MdInputContainerMultipleHintTestController, MdInputContainerMultipleHintMixedTestController, MdInputContainerWithDynamicPlaceholder, - MdInputContainerWithErrorMessages + MdInputContainerWithFormErrorMessages, + MdInputContainerWithFormGroupErrorMessages ], }); @@ -556,19 +565,19 @@ describe('MdInputContainer', function () { }); describe('error messages', () => { - let fixture: ComponentFixture; - let testComponent: MdInputContainerWithErrorMessages; + let fixture: ComponentFixture; + let testComponent: MdInputContainerWithFormErrorMessages; let containerEl: HTMLElement; beforeEach(() => { - fixture = TestBed.createComponent(MdInputContainerWithErrorMessages); + 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.pristine).toBe(true, 'Expected untouched form control'); + expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control'); expect(containerEl.querySelectorAll('md-error').length).toBe(0, 'Expected no error messages'); }); @@ -604,6 +613,34 @@ describe('MdInputContainer', function () { }); })); + 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.formControl.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 error messages once the input becomes valid', async(() => { testComponent.formControl.markAsTouched(); fixture.detectChanges(); @@ -909,8 +946,27 @@ class MdInputContainerMissingMdInputTestController {} ` }) -class MdInputContainerWithErrorMessages { +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; + onSubmit() { } + formControl = new FormControl('', Validators.required); + formGroup = new FormGroup({ name: this.formControl }); +} diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index 1c64b93d3df4..3026b695336d 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -24,7 +24,7 @@ import { trigger, } from '@angular/animations'; import {coerceBooleanProperty} from '../core'; -import {NgControl, NgForm} from '@angular/forms'; +import {NgControl, NgForm, FormGroupDirective} from '@angular/forms'; import {getSupportedInputTypes} from '../core/platform/features'; import { MdInputContainerDuplicatedHintError, @@ -257,7 +257,7 @@ export class MdInputDirective { state('enter', style({ opacity: 1, transform: 'translateY(0%)' })), transition('void => enter', [ style({ opacity: 0, transform: 'translateY(-100%)' }), - animate('300ms') + animate('300ms cubic-bezier(0.55, 0, 0.55, 0.2)') ]) ]) ], @@ -292,7 +292,7 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit { get _canPlaceholderFloat() { return this._floatPlaceholder !== 'never'; } /** State of the md-hint and md-error animations. */ - _messageAnimationState: string = ''; + _subscriptAnimationState: string = ''; /** Text for the input hint. */ @Input() @@ -324,7 +324,8 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit { constructor( private _changeDetectorRef: ChangeDetectorRef, - @Optional() private _parentForm: NgForm) { } + @Optional() private _parentForm: NgForm, + @Optional() private _parentFormGroup: FormGroupDirective) { } ngAfterContentInit() { if (!this._mdInputChild) { @@ -341,7 +342,7 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit { ngAfterViewInit() { // Avoid animations on load. - this._messageAnimationState = 'enter'; + this._subscriptAnimationState = 'enter'; this._changeDetectorRef.detectChanges(); } @@ -360,11 +361,12 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit { /** Whether the input container is in an error state. */ _isErrorState(): boolean { const control = this._mdInputChild._ngControl; - const isInvalid = control ? control.invalid : false; - const isTouched = control ? control.touched : false; - const isSubmitted = this._parentForm ? this._parentForm.submitted : false; + 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); + return !!(isInvalid && (isTouched || isSubmitted)); } /** Determines whether to display hints, errors or no messages at all. */ From dcd029a72dced1267d080d72c822c18a39ae8a0d Mon Sep 17 00:00:00 2001 From: crisbeto Date: Thu, 16 Mar 2017 22:31:36 +0100 Subject: [PATCH 5/7] chore: use FormGroup properly --- src/lib/input/input-container.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index 46ae625d0cdb..9e6b88f8db6c 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -623,7 +623,7 @@ describe('MdInputContainer', function () { component = groupFixture.componentInstance; containerEl = groupFixture.debugElement.query(By.css('md-input-container')).nativeElement; - expect(component.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + 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'); @@ -955,9 +955,9 @@ class MdInputContainerWithFormErrorMessages { @Component({ template: ` -
+ - + Please type something This field is required @@ -966,7 +966,7 @@ class MdInputContainerWithFormErrorMessages { }) class MdInputContainerWithFormGroupErrorMessages { @ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective; - onSubmit() { } - formControl = new FormControl('', Validators.required); - formGroup = new FormGroup({ name: this.formControl }); + formGroup = new FormGroup({ + name: new FormControl('', Validators.required) + }); } From 3a829903436f7bd2c5178cc81b8d3399d8a51541 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Fri, 17 Mar 2017 18:06:15 +0100 Subject: [PATCH 6/7] refactor: switch back to showing hints when valid --- src/lib/input/input-container.html | 5 ++-- src/lib/input/input-container.scss | 15 +++++++++++- src/lib/input/input-container.spec.ts | 35 ++++----------------------- src/lib/input/input-container.ts | 15 +++--------- 4 files changed, 25 insertions(+), 45 deletions(-) diff --git a/src/lib/input/input-container.html b/src/lib/input/input-container.html index f7dd76da4ad2..653c903bbd83 100644 --- a/src/lib/input/input-container.html +++ b/src/lib/input/input-container.html @@ -36,12 +36,13 @@ [class.mat-warn]="dividerColor == 'warn'">
-
+
-
+
{{hintLabel}}
diff --git a/src/lib/input/input-container.scss b/src/lib/input/input-container.scss index 441f5c5c19af..306eb452a3fe 100644 --- a/src/lib/input/input-container.scss +++ b/src/lib/input/input-container.scss @@ -222,7 +222,7 @@ $mat-input-underline-disabled-background-image: // 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-hint-wrapper { +.mat-input-subscript-wrapper { position: absolute; font-size: 75%; top: 100%; @@ -231,6 +231,19 @@ $mat-input-underline-disabled-background-image: 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 { diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input-container.spec.ts index 9e6b88f8db6c..cfff09e68786 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input-container.spec.ts @@ -641,7 +641,7 @@ describe('MdInputContainer', function () { }); })); - it('should hide the error messages once the input becomes valid', async(() => { + it('should hide the errors and show the hints once the input becomes valid', async(() => { testComponent.formControl.markAsTouched(); fixture.detectChanges(); @@ -650,6 +650,8 @@ describe('MdInputContainer', function () { .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(); @@ -659,39 +661,12 @@ describe('MdInputContainer', function () { '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 hide the hints when there are errors and not show them again when' + - ' the input becomes valid', async(() => { - - expect(containerEl.querySelectorAll('md-hint').length) - .toBe(1, 'Expected one hint to be shown on load.'); - expect(containerEl.querySelectorAll('md-error').length) - .toBe(0, 'Expected no errors to be shown on load.'); - - testComponent.formControl.markAsTouched(); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-hint').length) - .toBe(0, 'Expected no hints to be shown after interaction.'); - expect(containerEl.querySelectorAll('md-error').length) - .toBe(1, 'Expected one error to be shown after interaction.'); - - testComponent.formControl.setValue('something'); - fixture.detectChanges(); - - fixture.whenStable().then(() => { - expect(containerEl.querySelectorAll('md-hint').length) - .toBe(0, 'Expected no hints to be shown after the value is set.'); - expect(containerEl.querySelectorAll('md-error').length) - .toBe(0, 'Expected no errors to be shown after the value is set.'); - }); - }); - })); - it('should not hide the hint if there are no error messages', async(() => { testComponent.renderError = false; fixture.detectChanges(); diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts index 3026b695336d..06730719dacc 100644 --- a/src/lib/input/input-container.ts +++ b/src/lib/input/input-container.ts @@ -369,18 +369,9 @@ export class MdInputContainer implements AfterViewInit, AfterContentInit { return !!(isInvalid && (isTouched || isSubmitted)); } - /** Determines whether to display hints, errors or no messages at all. */ - _getDisplayedMessages(): 'error' | 'hint' | 'none' { - if (this._errorChildren.length > 0) { - if (this._isErrorState()) { - return 'error'; - } else if (this._mdInputChild._ngControl) { - let control = this._mdInputChild._ngControl; - return (control.valid && control.touched) ? 'none' : 'hint'; - } - } - - return 'hint'; + /** Determines whether to display hints or errors. */ + _getDisplayedMessages(): 'error' | 'hint' { + return (this._errorChildren.length > 0 && this._isErrorState()) ? 'error' : 'hint'; } /** From 3744a13d410426a7c66001e0d603490da9ba2b28 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 21 Mar 2017 19:18:44 +0100 Subject: [PATCH 7/7] errror --- src/lib/input/input-container.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/input/input-container.scss b/src/lib/input/input-container.scss index 306eb452a3fe..1453f5b8cfd8 100644 --- a/src/lib/input/input-container.scss +++ b/src/lib/input/input-container.scss @@ -266,7 +266,7 @@ $mat-input-underline-disabled-background-image: } } -// Single errror message displayed beneath the input. +// Single error message displayed beneath the input. .mat-input-error { display: block; }