diff --git a/src/demo-app/a11y/datepicker/datepicker-a11y.scss b/src/demo-app/a11y/datepicker/datepicker-a11y.scss index 5c3e663a9d46..946c2ec3a9a6 100644 --- a/src/demo-app/a11y/datepicker/datepicker-a11y.scss +++ b/src/demo-app/a11y/datepicker/datepicker-a11y.scss @@ -1,3 +1,3 @@ -.mat-input-container { +.mat-form-field { width: 250px; } diff --git a/src/demo-app/autocomplete/autocomplete-demo.scss b/src/demo-app/autocomplete/autocomplete-demo.scss index 8b4132523acf..f2018cc19e80 100644 --- a/src/demo-app/autocomplete/autocomplete-demo.scss +++ b/src/demo-app/autocomplete/autocomplete-demo.scss @@ -7,7 +7,7 @@ margin: 24px; } - .mat-input-container { + .mat-form-field { margin-top: 16px; } } diff --git a/src/demo-app/datepicker/datepicker-demo.html b/src/demo-app/datepicker/datepicker-demo.html index aa63ee703de1..c2ff2cd01a6b 100644 --- a/src/demo-app/datepicker/datepicker-demo.html +++ b/src/demo-app/datepicker/datepicker-demo.html @@ -9,20 +9,20 @@

Options

+ - + -

+ -

Result

@@ -40,6 +40,13 @@

Result

placeholder="Pick a date" (dateInput)="onDateInput($event)" (dateChange)="onDateChange($event)"> + + "{{resultPickerModel.getError('mdDatepickerParse').text}}" is not a valid date! @@ -47,13 +54,6 @@

Result

Too late! Date unavailable! - -

Last input: {{lastDateInput}}

Last change: {{lastDateChange}}

@@ -83,9 +83,9 @@

Input disabled datepicker

- - +

Input disabled, datepicker popup enabled

@@ -95,7 +95,7 @@

Input disabled, datepicker popup enabled

+ -

diff --git a/src/demo-app/demo-material-module.ts b/src/demo-app/demo-material-module.ts index 15f17c50c210..c05b4c6f9bd7 100644 --- a/src/demo-app/demo-material-module.ts +++ b/src/demo-app/demo-material-module.ts @@ -11,6 +11,7 @@ import { MdDatepickerModule, MdDialogModule, MdExpansionModule, + MdFormFieldModule, MdGridListModule, MdIconModule, MdInputModule, @@ -50,6 +51,7 @@ import {CdkTableModule} from '@angular/cdk/table'; MdDatepickerModule, MdDialogModule, MdExpansionModule, + MdFormFieldModule, MdGridListModule, MdIconModule, MdInputModule, diff --git a/src/demo-app/tabs/tabs-demo.scss b/src/demo-app/tabs/tabs-demo.scss index 767ff004d70e..fd69322feeb4 100644 --- a/src/demo-app/tabs/tabs-demo.scss +++ b/src/demo-app/tabs/tabs-demo.scss @@ -43,7 +43,7 @@ tabs-demo .mat-card { margin-top: 8px; } - .mat-input-container { + .mat-form-field { width: 100px; } diff --git a/src/e2e-app/e2e-app-module.ts b/src/e2e-app/e2e-app-module.ts index 606293fb165c..21c5afabb684 100644 --- a/src/e2e-app/e2e-app-module.ts +++ b/src/e2e-app/e2e-app-module.ts @@ -20,10 +20,24 @@ import {InputE2E} from './input/input-e2e'; import {SidenavE2E} from './sidenav/sidenav-e2e'; import {BlockScrollStrategyE2E} from './block-scroll-strategy/block-scroll-strategy-e2e'; import { - OverlayContainer, FullscreenOverlayContainer, MdGridListModule, MdProgressBarModule, - MdProgressSpinnerModule, MdTabsModule, MdRadioModule, MdSlideToggleModule, MdMenuModule, - MdListModule, MdInputModule, MdIconModule, MdDialogModule, MdCheckboxModule, MdButtonModule, - MdSidenavModule, MdNativeDateModule, + FullscreenOverlayContainer, + MdButtonModule, + MdCheckboxModule, + MdDialogModule, + MdFormFieldModule, + MdGridListModule, + MdIconModule, + MdInputModule, + MdListModule, + MdMenuModule, + MdNativeDateModule, + MdProgressBarModule, + MdProgressSpinnerModule, + MdRadioModule, + MdSidenavModule, + MdSlideToggleModule, + MdTabsModule, + OverlayContainer, } from '@angular/material'; import {ExampleModule} from '@angular/material-examples'; @@ -35,6 +49,7 @@ import {ExampleModule} from '@angular/material-examples'; MdButtonModule, MdCheckboxModule, MdDialogModule, + MdFormFieldModule, MdGridListModule, MdIconModule, MdInputModule, diff --git a/src/lib/autocomplete/autocomplete-trigger.ts b/src/lib/autocomplete/autocomplete-trigger.ts index ee98bc35fee3..234927443da4 100644 --- a/src/lib/autocomplete/autocomplete-trigger.ts +++ b/src/lib/autocomplete/autocomplete-trigger.ts @@ -40,7 +40,7 @@ import {Observable} from 'rxjs/Observable'; import {MdOptionSelectionChange, MdOption} from '../core/option/option'; import {ENTER, UP_ARROW, DOWN_ARROW, ESCAPE} from '../core/keyboard/keycodes'; import {Directionality} from '../core/bidi/index'; -import {MdInputContainer} from '../input/input-container'; +import {MdFormField} from '../form-field/index'; import {Subscription} from 'rxjs/Subscription'; import {merge} from 'rxjs/observable/merge'; import {fromEvent} from 'rxjs/observable/fromEvent'; @@ -153,7 +153,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { private _changeDetectorRef: ChangeDetectorRef, @Inject(MD_AUTOCOMPLETE_SCROLL_STRATEGY) private _scrollStrategy, @Optional() private _dir: Directionality, - @Optional() @Host() private _inputContainer: MdInputContainer, + @Optional() @Host() private _formField: MdFormField, @Optional() @Inject(DOCUMENT) private _document: any) {} ngOnDestroy() { @@ -246,8 +246,8 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { fromEvent(this._document, 'touchend') )).call(filter, (event: MouseEvent | TouchEvent) => { const clickTarget = event.target as HTMLElement; - const inputContainer = this._inputContainer ? - this._inputContainer._elementRef.nativeElement : null; + const inputContainer = this._formField ? + this._formField._elementRef.nativeElement : null; return this._panelOpen && clickTarget !== this._element.nativeElement && @@ -329,8 +329,8 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { * This method manually floats the placeholder until the panel can be closed. */ private _floatPlaceholder(): void { - if (this._inputContainer && this._inputContainer.floatPlaceholder === 'auto') { - this._inputContainer.floatPlaceholder = 'always'; + if (this._formField && this._formField.floatPlaceholder === 'auto') { + this._formField.floatPlaceholder = 'always'; this._manuallyFloatingPlaceholder = true; } } @@ -338,7 +338,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { /** If the placeholder has been manually elevated, return it to its normal state. */ private _resetPlaceholder(): void { if (this._manuallyFloatingPlaceholder) { - this._inputContainer.floatPlaceholder = 'auto'; + this._formField.floatPlaceholder = 'auto'; this._manuallyFloatingPlaceholder = false; } } @@ -408,10 +408,10 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { // The display value can also be the number zero and shouldn't fall back to an empty string. const inputValue = toDisplay != null ? toDisplay : ''; - // If it's used in a Material container, we should set it through - // the property so it can go through the change detection. - if (this._inputContainer) { - this._inputContainer._mdInputChild.value = inputValue; + // If it's used within a `MdFormField`, we should set it through the property so it can go + // through change detection. + if (this._formField) { + this._formField._control.value = inputValue; } else { this._element.nativeElement.value = inputValue; } @@ -469,7 +469,7 @@ export class MdAutocompleteTrigger implements ControlValueAccessor, OnDestroy { } private _getConnectedElement(): ElementRef { - return this._inputContainer ? this._inputContainer._connectionContainerRef : this._element; + return this._formField ? this._formField._connectionContainerRef : this._element; } /** Returns the width of the input element, so the panel width can match it. */ diff --git a/src/lib/autocomplete/autocomplete.spec.ts b/src/lib/autocomplete/autocomplete.spec.ts index 927cddd98b0a..1599f189717a 100644 --- a/src/lib/autocomplete/autocomplete.spec.ts +++ b/src/lib/autocomplete/autocomplete.spec.ts @@ -23,7 +23,7 @@ import {Directionality, Direction} from '../core/bidi/index'; import {Subscription} from 'rxjs/Subscription'; import {ENTER, DOWN_ARROW, SPACE, UP_ARROW, ESCAPE} from '../core/keyboard/keycodes'; import {MdOption} from '../core/option/option'; -import {MdInputContainer} from '../input/input-container'; +import {MdFormField, MdFormFieldModule} from '../form-field/index'; import {Observable} from 'rxjs/Observable'; import {Subject} from 'rxjs/Subject'; import {createKeyboardEvent, dispatchFakeEvent, typeInElement} from '@angular/cdk/testing'; @@ -41,6 +41,7 @@ describe('MdAutocomplete', () => { TestBed.configureTestingModule({ imports: [ MdAutocompleteModule, + MdFormFieldModule, MdInputModule, FormsModule, ReactiveFormsModule, @@ -517,7 +518,7 @@ describe('MdAutocomplete', () => { it('should disable input in view when disabled programmatically', () => { const inputUnderline = - fixture.debugElement.query(By.css('.mat-input-underline')).nativeElement; + fixture.debugElement.query(By.css('.mat-form-field-underline')).nativeElement; expect(input.disabled) .toBe(false, `Expected input to start out enabled in view.`); @@ -1319,10 +1320,10 @@ describe('MdAutocomplete', () => { fixture.detectChanges(); const input = fixture.nativeElement.querySelector('input'); - const placeholder = fixture.nativeElement.querySelector('.mat-input-placeholder'); + const placeholder = fixture.nativeElement.querySelector('.mat-form-field-placeholder'); expect(input.value).toBe('California'); - expect(placeholder.classList).not.toContain('mat-empty'); + expect(placeholder.classList).not.toContain('mat-form-field-empty'); })); }); @@ -1417,7 +1418,7 @@ class SimpleAutocomplete implements OnDestroy { @ViewChild(MdAutocompleteTrigger) trigger: MdAutocompleteTrigger; @ViewChild(MdAutocomplete) panel: MdAutocomplete; - @ViewChild(MdInputContainer) inputContainer: MdInputContainer; + @ViewChild(MdFormField) inputContainer: MdFormField; @ViewChildren(MdOption) options: QueryList; states = [ diff --git a/src/lib/chips/chip-list.spec.ts b/src/lib/chips/chip-list.spec.ts index f3b91356d9c9..5d656d96cb6e 100644 --- a/src/lib/chips/chip-list.spec.ts +++ b/src/lib/chips/chip-list.spec.ts @@ -9,6 +9,7 @@ import {createKeyboardEvent} from '@angular/cdk/testing'; import {MdInputModule} from '../input/index'; import {LEFT_ARROW, RIGHT_ARROW, BACKSPACE, DELETE, TAB} from '../core/keyboard/keycodes'; import {Directionality} from '../core'; +import {MdFormFieldModule} from '../form-field/index'; describe('MdChipList', () => { let fixture: ComponentFixture; @@ -23,7 +24,7 @@ describe('MdChipList', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdChipsModule, MdInputModule, NoopAnimationsModule], + imports: [MdChipsModule, MdFormFieldModule, MdInputModule, NoopAnimationsModule], declarations: [ StandardChipList, InputContainerChipList ], diff --git a/src/lib/chips/chips.scss b/src/lib/chips/chips.scss index 947a69879049..b170a27039e1 100644 --- a/src/lib/chips/chips.scss +++ b/src/lib/chips/chips.scss @@ -32,7 +32,7 @@ $mat-chips-chip-margin: 8px; } } - .mat-input-prefix & { + .mat-form-field-prefix & { &:last-child { margin-right: $mat-chips-chip-margin; } @@ -76,7 +76,7 @@ $mat-chips-chip-margin: 8px; } } -.mat-input-prefix .mat-chip-list-wrapper { +.mat-form-field-prefix .mat-chip-list-wrapper { margin-bottom: $mat-chips-chip-margin; } diff --git a/src/lib/core/compatibility/compatibility.ts b/src/lib/core/compatibility/compatibility.ts index c6ba4d7f574b..53ed876ff3d4 100644 --- a/src/lib/core/compatibility/compatibility.ts +++ b/src/lib/core/compatibility/compatibility.ts @@ -75,6 +75,7 @@ export const MAT_ELEMENTS_SELECTOR = ` mat-hint, mat-icon, mat-input-container, + mat-form-field, mat-list, mat-list-item, mat-menu, @@ -151,6 +152,7 @@ export const MD_ELEMENTS_SELECTOR = ` md-hint, md-icon, md-input-container, + md-form-field, md-list, md-list-item, md-menu, diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index a7c7c80161ef..f442ef708e00 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -27,6 +27,7 @@ @import '../../toolbar/toolbar-theme'; @import '../../tooltip/tooltip-theme'; @import '../../snack-bar/simple-snack-bar-theme'; +@import '../../form-field/form-field-theme'; // Create a theme. @@ -42,6 +43,7 @@ @include mat-datepicker-theme($theme); @include mat-dialog-theme($theme); @include mat-expansion-panel-theme($theme); + @include mat-form-field-theme($theme); @include mat-grid-list-theme($theme); @include mat-icon-theme($theme); @include mat-input-theme($theme); diff --git a/src/lib/core/typography/_all-typography.scss b/src/lib/core/typography/_all-typography.scss index 4453d6a4ef57..35e77fd257e6 100644 --- a/src/lib/core/typography/_all-typography.scss +++ b/src/lib/core/typography/_all-typography.scss @@ -28,6 +28,7 @@ @import '../../snack-bar/simple-snack-bar-theme'; @import '../option/option-theme'; @import '../option/optgroup-theme'; +@import '../../form-field/form-field-theme'; // Includes all of the typographic styles. @@ -47,6 +48,7 @@ @include mat-datepicker-typography($config); @include mat-dialog-typography($config); @include mat-expansion-panel-typography($config); + @include mat-form-field-typography($config); @include mat-grid-list-typography($config); @include mat-icon-typography($config); @include mat-input-typography($config); diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index 156e481f9746..89b326ee348b 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -31,7 +31,7 @@ import { Validators } from '@angular/forms'; import {Subscription} from 'rxjs/Subscription'; -import {MdInputContainer} from '../input/input-container'; +import {MdFormField} from '../form-field/index'; import {DOWN_ARROW} from '../core/keyboard/keycodes'; import {DateAdapter} from '../core/datetime/index'; import {createMissingDateImplError} from './datepicker-errors'; @@ -212,7 +212,7 @@ export class MdDatepickerInput implements AfterContentInit, ControlValueAcces private _renderer: Renderer2, @Optional() private _dateAdapter: DateAdapter, @Optional() @Inject(MD_DATE_FORMATS) private _dateFormats: MdDateFormats, - @Optional() private _mdInputContainer: MdInputContainer) { + @Optional() private _mdInputContainer: MdFormField) { if (!this._dateAdapter) { throw createMissingDateImplError('DateAdapter'); } diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 8b7309003719..1b2b44678c57 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -16,6 +16,7 @@ import { createKeyboardEvent, dispatchEvent, } from '@angular/cdk/testing'; +import {MdFormFieldModule} from '../form-field/index'; describe('MdDatepicker', () => { afterEach(inject([OverlayContainer], (container: OverlayContainer) => { @@ -28,6 +29,7 @@ describe('MdDatepicker', () => { imports: [ FormsModule, MdDatepickerModule, + MdFormFieldModule, MdInputModule, MdNativeDateModule, NoopAnimationsModule, @@ -596,7 +598,7 @@ describe('MdDatepicker', () => { it('should attach popup to input-container underline', () => { let attachToRef = testComponent.datepickerInput.getPopupConnectionElementRef(); - expect(attachToRef.nativeElement.classList.contains('mat-input-underline')) + expect(attachToRef.nativeElement.classList.contains('mat-form-field-underline')) .toBe(true, 'popup should be attached to input-container underline'); }); }); @@ -817,6 +819,7 @@ describe('MdDatepicker', () => { imports: [ FormsModule, MdDatepickerModule, + MdFormFieldModule, MdInputModule, NoopAnimationsModule, ReactiveFormsModule, @@ -840,7 +843,13 @@ describe('MdDatepicker', () => { beforeEach(async(() => { TestBed.configureTestingModule({ - imports: [MdDatepickerModule, MdInputModule, MdNativeDateModule, NoopAnimationsModule], + imports: [ + MdDatepickerModule, + MdFormFieldModule, + MdInputModule, + MdNativeDateModule, + NoopAnimationsModule + ], declarations: [StandardDatepicker], }).compileComponents(); diff --git a/src/lib/form-field/_form-field-theme.scss b/src/lib/form-field/_form-field-theme.scss new file mode 100644 index 000000000000..8f475fd4f96f --- /dev/null +++ b/src/lib/form-field/_form-field-theme.scss @@ -0,0 +1,218 @@ +@import '../core/theming/palette'; +@import '../core/theming/theming'; +@import '../core/style/form-common'; +@import '../core/typography/typography-utils'; + + +@mixin mat-form-field-theme($theme) { + $primary: map-get($theme, primary); + $accent: map-get($theme, accent); + $warn: map-get($theme, warn); + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + $is-dark-theme: map-get($theme, is-dark); + + // Placeholder colors. Required is used for the `*` star shown in the placeholder. + $placeholder-color: mat-color($foreground, secondary-text); + $floating-placeholder-color: mat-color($primary); + $required-placeholder-color: mat-color($accent); + + // Underline colors. + $underline-color: mat-color($foreground, divider, if($is-dark-theme, 0.7, 0.42)); + $underline-color-accent: mat-color($accent); + $underline-color-warn: mat-color($warn); + $underline-focused-color: mat-color($primary); + + .mat-form-field-placeholder { + color: $placeholder-color; + } + + .mat-hint { + color: mat-color($foreground, secondary-text); + } + + .mat-focused .mat-form-field-placeholder { + color: $floating-placeholder-color; + + &.mat-accent { + color: $underline-color-accent; + } + + &.mat-warn { + color: $underline-color-warn; + } + } + + .mat-form-field-autofill-float:-webkit-autofill + .mat-form-field-placeholder, + .mat-focused .mat-form-field-placeholder.mat-form-field-float { + .mat-form-field-required-marker { + color: $required-placeholder-color; + } + } + + .mat-form-field-underline { + background-color: $underline-color; + + &.mat-disabled { + @include mat-control-disabled-underline($underline-color); + } + } + + .mat-form-field-ripple { + background-color: $underline-focused-color; + + &.mat-accent { + background-color: $underline-color-accent; + } + + &.mat-warn { + background-color: $underline-color-warn; + } + } + + // Styling for the error state of the form field. 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-form-field-invalid { + .mat-form-field-placeholder { + color: $underline-color-warn; + + &.mat-accent, + &.mat-form-field-float .mat-form-field-required-marker { + color: $underline-color-warn; + } + } + + .mat-form-field-ripple { + background-color: $underline-color-warn; + } + } + + .mat-error { + color: $underline-color-warn; + } +} + +// Applies a floating placeholder above the form field control itself. +@mixin _mat-form-field-placeholder-floating($font-scale, $infix-padding, $infix-margin-top) { + // We use perspective to fix the text blurriness as described here: + // http://www.useragentman.com/blog/2014/05/04/fixing-typography-inside-of-2-d-css-transforms/ + // This results in a small jitter after the label floats on Firefox, which the + // translateZ fixes. + transform: translateY(-$infix-margin-top - $infix-padding) scale($font-scale) perspective(100px) + translateZ(0.001px); + // The tricks above used to smooth out the animation on chrome and firefox actually make things + // worse on IE, so we don't include them in the IE version. + -ms-transform: translateY(-$infix-margin-top - $infix-padding) + scale($font-scale); + + width: 100% / $font-scale; +} + +@mixin mat-form-field-typography($config) { + // The unit-less line-height from the font config. + $line-height: mat-line-height($config, input); + + // The amount to scale the font for the floating label and subscript. + $subscript-font-scale: 0.75; + // The amount to scale the font for the prefix and suffix icons. + $prefix-suffix-icon-font-scale: 1.5; + + // The amount of space between the top of the line and the top of the actual text + // (as a fraction of the font-size). + $line-spacing: ($line-height - 1) / 2; + // The padding on the infix. Mocks show half of the text size, but seem to measure from the edge + // of the text itself, not the edge of the line; therefore we subtract off the line spacing. + $infix-padding: 0.5em - $line-spacing; + // The margin applied to the form-field-infix to reserve space for the floating label. + $infix-margin-top: 1em * $line-height * $subscript-font-scale; + // Font size to use for the label and subscript text. + $subscript-font-size: $subscript-font-scale * 100%; + // Font size to use for the for the prefix and suffix icons. + $prefix-suffix-icon-font-size: $prefix-suffix-icon-font-scale * 100%; + // The space between the bottom of the .mat-form-field-flex area and the subscript wrapper. + // Mocks show half of the text size, but this margin is applied to an element with the subscript + // text font size, so we need to divide by the scale factor to make it half of the original text + // size. We again need to subtract off the line spacing since the mocks measure to the edge of the + // text, not the edge of the line. + $subscript-margin-top: 0.5em / $subscript-font-scale - ($line-spacing * 2); + // The padding applied to the form-field-wrapper to reserve space for the subscript, since it's + // absolutely positioned. This is a combination of the subscript's margin and line-height, but we + // need to multiply by the subscript font scale factor since the wrapper has a larger font size. + $wrapper-padding-bottom: ($subscript-margin-top + $line-height) * $subscript-font-scale; + + .mat-form-field { + font-family: mat-font-family($config); + font-size: inherit; + font-weight: mat-font-weight($config, input); + line-height: mat-line-height($config, input); + } + + .mat-form-field-wrapper { + padding-bottom: $wrapper-padding-bottom; + } + + .mat-form-field-prefix, + .mat-form-field-suffix { + // Allow icons in a prefix or suffix to adapt to the correct size. + .mat-icon { + font-size: $prefix-suffix-icon-font-size; + line-height: $line-height; + } + + // Allow icon buttons in a prefix or suffix to adapt to the correct size. + .mat-icon-button { + height: $prefix-suffix-icon-font-scale * 1em; + width: $prefix-suffix-icon-font-scale * 1em; + + .mat-icon { + height: $line-height * 1em; + line-height: $line-height; + } + } + } + + .mat-form-field-infix { + padding: $infix-padding 0; + // Throws off the baseline if we do it as a real margin, so we do it as a border instead. + border-top: $infix-margin-top solid transparent; + } + + .mat-form-field-autofill-float { + &:-webkit-autofill + .mat-form-field-placeholder-wrapper .mat-form-field-float { + @include _mat-form-field-placeholder-floating( + $subscript-font-scale, $infix-padding, $infix-margin-top); + } + } + + .mat-form-field-placeholder-wrapper { + top: -$infix-margin-top; + padding-top: $infix-margin-top; + } + + .mat-form-field-placeholder { + top: $infix-margin-top + $infix-padding; + + // Show the placeholder above the control when it's not empty, or focused. + &.mat-form-field-float:not(.mat-form-field-empty), + .mat-focused &.mat-form-field-float { + @include _mat-form-field-placeholder-floating($subscript-font-scale, + $infix-padding, $infix-margin-top); + } + } + + .mat-form-field-underline { + // We want the underline to start at the end of the content box, not the padding box, + // so we move it up by the padding amount. + bottom: $wrapper-padding-bottom; + } + + .mat-form-field-subscript-wrapper { + font-size: $subscript-font-size; + margin-top: $subscript-margin-top; + + // We want the subscript to start at the end of the content box, not the padding box, + // so we move it up by the padding amount (adjusted for the smaller font size); + top: calc(100% - #{$wrapper-padding-bottom / $subscript-font-scale}); + } +} diff --git a/src/lib/form-field/error.ts b/src/lib/form-field/error.ts new file mode 100644 index 000000000000..40d5bb1762ec --- /dev/null +++ b/src/lib/form-field/error.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, Input} from '@angular/core'; + + +let nextUniqueId = 0; + + +/** Single error message to be shown underneath the form field. */ +@Directive({ + selector: 'md-error, mat-error', + host: { + 'class': 'mat-error', + 'role': 'alert', + '[attr.id]': 'id', + } +}) +export class MdError { + @Input() id: string = `mat-error-${nextUniqueId++}`; +} diff --git a/src/lib/form-field/form-field-control.ts b/src/lib/form-field/form-field-control.ts new file mode 100644 index 000000000000..98a6ea02089b --- /dev/null +++ b/src/lib/form-field/form-field-control.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Observable} from 'rxjs/Observable'; +import {NgControl} from '@angular/forms'; + + +/** An interface which allows a control to work inside of a `MdFormField`. */ +export abstract class MdFormFieldControl { + /** The value of the control. */ + value: T; + + /** + * Stream that emits whenever the state of the control changes such that the parent `MdFormField` + * needs to run change detection. + */ + readonly stateChanges: Observable; + + /** The element ID for this control. */ + readonly id: string; + + /** The placeholder for this control. */ + readonly placeholder: string; + + /** Gets the NgControl for this control. */ + readonly ngControl: NgControl | null; + + /** Whether the control is focused. */ + readonly focused: boolean; + + /** Whether the control is empty. */ + readonly empty: boolean; + + /** Whether the control is required. */ + readonly required: boolean; + + /** Whether the control is disabled. */ + readonly disabled: boolean; + + /** Whether the control is in an error state. */ + readonly errorState: boolean; + + /** Sets the list of element IDs that currently describe this control. */ + abstract setDescribedByIds(ids: string[]): void; + + /** Focuses this control. */ + abstract focus(): void; +} diff --git a/src/lib/form-field/form-field-errors.ts b/src/lib/form-field/form-field-errors.ts new file mode 100644 index 000000000000..008393915e4c --- /dev/null +++ b/src/lib/form-field/form-field-errors.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** @docs-private */ +export function getMdFormFieldPlaceholderConflictError(): Error { + return Error('Placeholder attribute and child element were both specified.'); +} + +/** @docs-private */ +export function getMdFormFieldDuplicatedHintError(align: string): Error { + return Error(`A hint was already declared for 'align="${align}"'.`); +} + +/** @docs-private */ +export function getMdFormFieldMissingControlError(): Error { + return Error('md-form-field must contain a MdFormFieldControl. ' + + 'Did you forget to add mdInput to the native input or textarea element?'); +} diff --git a/src/lib/form-field/form-field.html b/src/lib/form-field/form-field.html new file mode 100644 index 000000000000..cfb7bfedab83 --- /dev/null +++ b/src/lib/form-field/form-field.html @@ -0,0 +1,57 @@ +
+
+
+ +
+ +
+ + + + + +
+ +
+ +
+
+ +
+ +
+ +
+
+ +
+ +
+ +
{{hintLabel}}
+ +
+ +
+
+
diff --git a/src/lib/input/input-container.scss b/src/lib/form-field/form-field.scss similarity index 51% rename from src/lib/input/input-container.scss rename to src/lib/form-field/form-field.scss index 0154daac6635..d4e76aedded5 100644 --- a/src/lib/input/input-container.scss +++ b/src/lib/form-field/form-field.scss @@ -3,12 +3,12 @@ // Min amount of space between start and end hint. -$mat-input-hint-min-space: 1em !default; +$mat-form-field-hint-min-space: 1em !default; // The height of the underline. -$mat-input-underline-height: 1px !default; +$mat-form-field-underline-height: 1px !default; -.mat-input-container { +.mat-form-field { display: inline-block; position: relative; width: 200px; @@ -23,20 +23,20 @@ $mat-input-underline-height: 1px !default; // Global wrapper. We need to apply margin to the element for spacing, but // cannot apply it to the host element directly. -.mat-input-wrapper { +.mat-form-field-wrapper { position: relative; } // We use a flex layout to baseline align the prefix and suffix elements. // The underline is outside of it so it can cover all of the elements under this flex container. -.mat-input-flex { +.mat-form-field-flex { display: inline-flex; align-items: baseline; width: 100%; } -.mat-input-prefix, -.mat-input-suffix { +.mat-form-field-prefix, +.mat-form-field-suffix { white-space: nowrap; flex: none; @@ -56,75 +56,32 @@ $mat-input-underline-height: 1px !default; } } -.mat-input-infix { +.mat-form-field-infix { display: block; position: relative; flex: auto; } -// The Input element proper. -.mat-input-element { - // Font needs to be inherited, because by default has a system font. - font: inherit; - - // The Material input should match whatever background it is above. - background: transparent; - - // If background matches current background then so should the color for proper contrast - color: currentColor; - - // By default, has a padding, border, outline and a default width. - border: none; - outline: none; - padding: 0; - margin: 0; - width: 100%; - - // Prevent textareas from being resized outside the container. - max-width: 100%; - resize: vertical; - - // Needed to make last line of the textarea line up with the baseline. - vertical-align: bottom; - - // Undo the red box-shadow glow added by Firefox on invalid inputs. - // See https://developer.mozilla.org/en-US/docs/Web/CSS/:-moz-ui-invalid - &:-moz-ui-invalid { - box-shadow: none; +// Pseudo-class for Chrome and Safari auto-fill to move the placeholder to the floating position. +// This is necessary because these browsers do not actually fire any events when a form auto-fill is +// occurring. Once the autofill is committed, a change event happen and the regular md-form-field +// classes take over to fulfill this behaviour. Assumes the autofill is non-empty. +.mat-form-field-autofill-float:-webkit-autofill + .mat-form-field-placeholder-wrapper { + // The control is still technically empty at this point, so we need to hide non-floating + // placeholders to prevent overlapping with the autofilled value. + .mat-form-field-placeholder { + display: none; } - // Pseudo-class for Chrome and Safari auto-fill to move the placeholder to - // the floating position. This is necessary because these browsers do not actually - // fire any events when a form auto-fill is occurring. - // Once the autofill is committed, a change event happen and the regular md-input-container - // classes take over to fulfill this behaviour. - // Assumes the autofill is non-empty. - &:-webkit-autofill + .mat-input-placeholder-wrapper { - // The input is still technically empty at this point, so we need to hide non-floating - // placeholders to prevent overlapping with the autofilled value. - .mat-input-placeholder { - display: none; - } - - .mat-float { - display: block; - transition: none; - } - } - - // Note that we can't use something like visibility: hidden or - // display: none, because IE ends up preventing the user from - // focusing the input altogether. - @include input-placeholder { - // Needs to be !important, because the placeholder will end up inheriting the - // input color in IE, if the consumer overrides it with a higher specificity. - color: transparent !important; + .mat-form-field-float { + display: block; + transition: none; } } // Used to hide the placeholder overflow on IE, since IE doesn't take transform into account when // determining overflow. -.mat-input-placeholder-wrapper { +.mat-form-field-placeholder-wrapper { position: absolute; left: 0; box-sizing: content-box; @@ -134,16 +91,11 @@ $mat-input-underline-height: 1px !default; pointer-events: none; // We shouldn't catch mouse events (let them through). } -// Prevents IE from always adding a scrollbar by default. -textarea.mat-input-element { - overflow: auto; -} - // The placeholder label. This is invisible unless it is. The logic to show it is // basically `empty || (float && (!empty || focused))`. Float is dependent on the -// `floatingPlaceholder` input. -.mat-input-placeholder { - // The placeholder is after the , but needs to be aligned top-left of the +// `floatingPlaceholder` property. +.mat-form-field-placeholder { + // The placeholder is after the form field control, but needs to be aligned top-left of the // infix
. position: absolute; left: 0; @@ -163,15 +115,15 @@ textarea.mat-input-element { transform-origin: 0 0; transition: transform $swift-ease-out-duration $swift-ease-out-timing-function, - color $swift-ease-out-duration $swift-ease-out-timing-function, - width $swift-ease-out-duration $swift-ease-out-timing-function; + color $swift-ease-out-duration $swift-ease-out-timing-function, + width $swift-ease-out-duration $swift-ease-out-timing-function; - // Hide the placeholder initially, and only show it when it's floating or the input is empty. + // Hide the placeholder initially, and only show it when it's floating or the control is empty. display: none; - &.mat-empty, - &.mat-float:not(.mat-empty), - .mat-focused &.mat-float { + &.mat-form-field-empty, + &.mat-form-field-float:not(.mat-form-field-empty), + .mat-focused &.mat-form-field-float { display: block; } @@ -182,17 +134,17 @@ textarea.mat-input-element { } } -// Disable the placeholder animation when the input is not empty (this prevents placeholder +// Disable the placeholder animation when the control is not empty (this prevents placeholder // animating up when the value is set programmatically). -.mat-input-placeholder:not(.mat-empty) { +.mat-form-field-placeholder:not(.mat-form-field-empty) { transition: none; } -// The underline is what's shown under the input, its prefix and its suffix. +// The underline is what's shown under the control, its prefix and its suffix. // The ripple is the blue animation coming on top of it. -.mat-input-underline { +.mat-form-field-underline { position: absolute; - height: $mat-input-underline-height; + height: $mat-form-field-underline-height; width: 100%; &.mat-disabled { @@ -200,9 +152,9 @@ textarea.mat-input-element { background-color: transparent; } - .mat-input-ripple { + .mat-form-field-ripple { position: absolute; - height: $mat-input-underline-height; + height: $mat-form-field-underline-height; top: 0; left: 0; width: 100%; @@ -212,11 +164,11 @@ textarea.mat-input-element { transition: background-color $swift-ease-in-duration $swift-ease-in-timing-function; .mat-focused & { - height: $mat-input-underline-height * 2; + height: $mat-form-field-underline-height * 2; } .mat-focused &, - .mat-input-invalid & { + .mat-form-field-invalid & { visibility: visible; transform: scaleX(1); transition: transform 150ms linear, @@ -226,15 +178,15 @@ textarea.mat-input-element { } // Wrapper for the hints and error messages. -.mat-input-subscript-wrapper { +.mat-form-field-subscript-wrapper { position: absolute; width: 100%; - overflow: hidden; // prevents multi-line errors from overlapping the input + overflow: hidden; // prevents multi-line errors from overlapping the control } // Scale down icons in the placeholder and hint to be the same size as the text. -.mat-input-subscript-wrapper, -.mat-input-placeholder-wrapper { +.mat-form-field-subscript-wrapper, +.mat-form-field-placeholder-wrapper { .mat-icon { width: 1em; height: 1em; @@ -244,16 +196,16 @@ textarea.mat-input-element { } // Clears the floats on the hints. This is necessary for the hint animation to work. -.mat-input-hint-wrapper { +.mat-form-field-hint-wrapper { display: flex; } // Spacer used to make sure start and end hints have enough space between them. -.mat-input-hint-spacer { - flex: 1 0 $mat-input-hint-min-space; +.mat-form-field-hint-spacer { + flex: 1 0 $mat-form-field-hint-min-space; } -// Single error message displayed beneath the input. -.mat-input-error { +// Single error message displayed beneath the form field underline. +.mat-error { display: block; } diff --git a/src/lib/form-field/form-field.ts b/src/lib/form-field/form-field.ts new file mode 100644 index 000000000000..eba04bde9791 --- /dev/null +++ b/src/lib/form-field/form-field.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + AfterContentChecked, + AfterContentInit, + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChild, + ContentChildren, + ElementRef, + Inject, + Input, + Optional, + QueryList, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import {animate, state, style, transition, trigger} from '@angular/animations'; +import {coerceBooleanProperty} from '../core'; +import { + getMdFormFieldDuplicatedHintError, + getMdFormFieldMissingControlError, + getMdFormFieldPlaceholderConflictError, +} from './form-field-errors'; +import { + FloatPlaceholderType, + MD_PLACEHOLDER_GLOBAL_OPTIONS, + PlaceholderOptions +} from '../core/placeholder/placeholder-options'; +import {startWith} from '@angular/cdk/rxjs'; +import {MdError} from './error'; +import {MdFormFieldControl} from './form-field-control'; +import {MdHint} from './hint'; +import {MdPlaceholder} from './placeholder'; +import {MdPrefix} from './prefix'; +import {MdSuffix} from './suffix'; + + +let nextUniqueId = 0; + + +/** Container for form controls that applies Material Design styling and behavior. */ +@Component({ + moduleId: module.id, + // TODO(mmalerba): the input-container selectors and classes are deprecated and will be removed. + selector: 'md-input-container, mat-input-container, md-form-field, mat-form-field', + templateUrl: 'form-field.html', + // MdInput is a directive and can't have styles, so we need to include its styles here. + // The MdInput styles are fairly minimal so it shouldn't be a big deal for people who aren't using + // MdInput. + styleUrls: ['form-field.css', '../input/input.css'], + animations: [ + // TODO(mmalerba): Use angular animations for placeholder animation as well. + 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: { + 'class': 'mat-input-container mat-form-field', + '[class.mat-input-invalid]': '_control.errorState', + '[class.mat-form-field-invalid]': '_control.errorState', + '[class.mat-focused]': '_control.focused', + '[class.ng-untouched]': '_shouldForward("untouched")', + '[class.ng-touched]': '_shouldForward("touched")', + '[class.ng-pristine]': '_shouldForward("pristine")', + '[class.ng-dirty]': '_shouldForward("dirty")', + '[class.ng-valid]': '_shouldForward("valid")', + '[class.ng-invalid]': '_shouldForward("invalid")', + '[class.ng-pending]': '_shouldForward("pending")', + '(click)': '_control.focus()', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) + +export class MdFormField implements AfterViewInit, AfterContentInit, AfterContentChecked { + private _placeholderOptions: PlaceholderOptions; + + /** Color of the form field underline, based on the theme. */ + @Input() color: 'primary' | 'accent' | 'warn' = 'primary'; + + /** @deprecated Use `color` instead. */ + @Input() + get dividerColor() { return this.color; } + set dividerColor(value) { this.color = value; } + + /** Whether the required marker should be hidden. */ + @Input() + get hideRequiredMarker() { return this._hideRequiredMarker; } + set hideRequiredMarker(value: any) { + this._hideRequiredMarker = coerceBooleanProperty(value); + } + private _hideRequiredMarker: boolean; + + /** Whether the floating label should always float or not. */ + get _shouldAlwaysFloat() { return this._floatPlaceholder === 'always'; } + + /** 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 form field hint. */ + @Input() + get hintLabel() { return this._hintLabel; } + set hintLabel(value: string) { + this._hintLabel = value; + this._processHints(); + } + private _hintLabel = ''; + + // Unique id for the hint label. + _hintLabelId: string = `md-hint-${nextUniqueId++}`; + + /** Whether the placeholder should always float, never float or float as the user types. */ + @Input() + get floatPlaceholder() { return this._floatPlaceholder; } + set floatPlaceholder(value: FloatPlaceholderType) { + if (value !== this._floatPlaceholder) { + this._floatPlaceholder = value || this._placeholderOptions.float || 'auto'; + this._changeDetectorRef.markForCheck(); + } + } + private _floatPlaceholder: FloatPlaceholderType; + + /** Reference to the form field's underline element. */ + @ViewChild('underline') underlineRef: ElementRef; + @ViewChild('connectionContainer') _connectionContainerRef: ElementRef; + @ContentChild(MdFormFieldControl) _control: MdFormFieldControl; + @ContentChild(MdPlaceholder) _placeholderChild: MdPlaceholder; + @ContentChildren(MdError) _errorChildren: QueryList; + @ContentChildren(MdHint) _hintChildren: QueryList; + @ContentChildren(MdPrefix) _prefixChildren: QueryList; + @ContentChildren(MdSuffix) _suffixChildren: QueryList; + + constructor( + public _elementRef: ElementRef, private _changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(MD_PLACEHOLDER_GLOBAL_OPTIONS) placeholderOptions: PlaceholderOptions) { + this._placeholderOptions = placeholderOptions ? placeholderOptions : {}; + this.floatPlaceholder = this._placeholderOptions.float || 'auto'; + } + + ngAfterContentInit() { + this._validateControlChild(); + + // Subscribe to changes in the child control state in order to update the form field UI. + startWith.call(this._control.stateChanges, null).subscribe(() => { + this._validatePlaceholders(); + this._syncDescribedByIds(); + this._changeDetectorRef.markForCheck(); + }); + + let ngControl = this._control.ngControl; + if (ngControl && ngControl.valueChanges) { + ngControl.valueChanges.subscribe(() => { + this._changeDetectorRef.markForCheck(); + }); + } + + // Re-validate when the number of hints changes. + startWith.call(this._hintChildren.changes, null).subscribe(() => { + this._processHints(); + this._changeDetectorRef.markForCheck(); + }); + + // Update the aria-described by when the number of errors changes. + startWith.call(this._errorChildren.changes, null).subscribe(() => { + this._syncDescribedByIds(); + this._changeDetectorRef.markForCheck(); + }); + } + + ngAfterContentChecked() { + this._validateControlChild(); + } + + 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 ngControl = this._control ? this._control.ngControl : null; + return ngControl && (ngControl as any)[prop]; + } + + /** Whether the form field has a placeholder. */ + _hasPlaceholder() { + return !!(this._control.placeholder || this._placeholderChild); + } + + /** Determines whether to display hints or errors. */ + _getDisplayedMessages(): 'error' | 'hint' { + return (this._errorChildren && this._errorChildren.length > 0 && + this._control.errorState) ? 'error' : 'hint'; + } + + /** + * Ensure that there is only one placeholder (either `placeholder` attribute on the child control + * or child element with the `md-placeholder` directive). + */ + private _validatePlaceholders() { + if (this._control.placeholder && this._placeholderChild) { + throw getMdFormFieldPlaceholderConflictError(); + } + } + + /** Does any extra processing that is required when handling the hints. */ + private _processHints() { + this._validateHints(); + this._syncDescribedByIds(); + } + + /** + * Ensure that there is a maximum of one of each `` alignment specified, with the + * attribute being considered as `align="start"`. + */ + private _validateHints() { + if (this._hintChildren) { + let startHint: MdHint; + let endHint: MdHint; + this._hintChildren.forEach((hint: MdHint) => { + if (hint.align == 'start') { + if (startHint || this.hintLabel) { + throw getMdFormFieldDuplicatedHintError('start'); + } + startHint = hint; + } else if (hint.align == 'end') { + if (endHint) { + throw getMdFormFieldDuplicatedHintError('end'); + } + endHint = hint; + } + }); + } + } + + /** + * Sets the list of element IDs that describe the child control. This allows the control to update + * its `aria-describedby` attribute accordingly. + */ + private _syncDescribedByIds() { + if (this._control) { + let ids: string[] = []; + + if (this._getDisplayedMessages() === 'hint') { + let startHint = this._hintChildren ? + this._hintChildren.find(hint => hint.align === 'start') : null; + let endHint = this._hintChildren ? + this._hintChildren.find(hint => hint.align === 'end') : null; + + if (startHint) { + ids.push(startHint.id); + } else if (this._hintLabel) { + ids.push(this._hintLabelId); + } + + if (endHint) { + ids.push(endHint.id); + } + } else if (this._errorChildren) { + ids = this._errorChildren.map(mdError => mdError.id); + } + + this._control.setDescribedByIds(ids); + } + } + + /** Throws an error if the form field's control is missing. */ + protected _validateControlChild() { + if (!this._control) { + throw getMdFormFieldMissingControlError(); + } + } +} diff --git a/src/lib/form-field/hint.ts b/src/lib/form-field/hint.ts new file mode 100644 index 000000000000..3a5e21857aeb --- /dev/null +++ b/src/lib/form-field/hint.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, Input} from '@angular/core'; + + +let nextUniqueId = 0; + + +/** Hint text to be shown underneath the form field control. */ +@Directive({ + selector: 'md-hint, mat-hint', + host: { + 'class': 'mat-hint', + '[class.mat-right]': 'align == "end"', + '[attr.id]': 'id', + // Remove align attribute to prevent it from interfering with layout. + '[attr.align]': 'null', + } +}) +export class MdHint { + /** Whether to align the hint label at the start or end of the line. */ + @Input() align: 'start' | 'end' = 'start'; + + /** Unique ID for the hint. Used for the aria-describedby on the form field control. */ + @Input() id: string = `mat-hint-${nextUniqueId++}`; +} diff --git a/src/lib/form-field/index.ts b/src/lib/form-field/index.ts new file mode 100644 index 000000000000..71d8b6164677 --- /dev/null +++ b/src/lib/form-field/index.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {MdError} from './error'; +import {MdFormField} from './form-field'; +import {MdHint} from './hint'; +import {MdPlaceholder} from './placeholder'; +import {MdPrefix} from './prefix'; +import {MdSuffix} from './suffix'; +import {CommonModule} from '@angular/common'; +import {PlatformModule} from '../core/platform/index'; + + +@NgModule({ + declarations: [ + MdError, + MdHint, + MdFormField, + MdPlaceholder, + MdPrefix, + MdSuffix, + ], + imports: [ + CommonModule, + PlatformModule, + ], + exports: [ + MdError, + MdHint, + MdFormField, + MdPlaceholder, + MdPrefix, + MdSuffix, + ], +}) +export class MdFormFieldModule {} + + +export * from './error'; +export * from './form-field'; +export * from './form-field-control'; +export * from './form-field-errors'; +export * from './hint'; +export * from './placeholder'; +export * from './prefix'; +export * from './suffix'; + diff --git a/src/lib/form-field/placeholder.ts b/src/lib/form-field/placeholder.ts new file mode 100644 index 000000000000..260a4b8fef91 --- /dev/null +++ b/src/lib/form-field/placeholder.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive} from '@angular/core'; + + +/** The floating placeholder for an `MdFormField`. */ +@Directive({ + selector: 'md-placeholder, mat-placeholder' +}) +export class MdPlaceholder {} diff --git a/src/lib/form-field/prefix.ts b/src/lib/form-field/prefix.ts new file mode 100644 index 000000000000..3ac184b14331 --- /dev/null +++ b/src/lib/form-field/prefix.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive} from '@angular/core'; + + +/** Prefix to be placed the the front of the form field. */ +@Directive({ + selector: '[mdPrefix], [matPrefix]', +}) +export class MdPrefix {} diff --git a/src/lib/form-field/suffix.ts b/src/lib/form-field/suffix.ts new file mode 100644 index 000000000000..be70eec03959 --- /dev/null +++ b/src/lib/form-field/suffix.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive} from '@angular/core'; + + +/** Suffix to be placed at the end of the form field. */ +@Directive({ + selector: '[mdSuffix], [matSuffix]', +}) +export class MdSuffix {} diff --git a/src/lib/input/_input-theme.scss b/src/lib/input/_input-theme.scss index e4b9ea76a6d8..9cd15260e3c2 100644 --- a/src/lib/input/_input-theme.scss +++ b/src/lib/input/_input-theme.scss @@ -5,227 +5,25 @@ @mixin mat-input-theme($theme) { - $primary: map-get($theme, primary); - $accent: map-get($theme, accent); - $warn: map-get($theme, warn); - $background: map-get($theme, background); $foreground: map-get($theme, foreground); $is-dark-theme: map-get($theme, is-dark); - // Placeholder colors. Required is used for the `*` star shown in the placeholder. - $input-placeholder-color: mat-color($foreground, secondary-text); - $input-floating-placeholder-color: mat-color($primary); - $input-required-placeholder-color: mat-color($accent); - - // Underline colors. - $input-underline-color: mat-color($foreground, divider, if($is-dark-theme, 0.7, 0.42)); - $input-underline-color-accent: mat-color($accent); - $input-underline-color-warn: mat-color($warn); - $input-underline-focused-color: mat-color($primary); - - .mat-input-placeholder { - color: $input-placeholder-color; - } - - .mat-hint { - color: mat-color($foreground, secondary-text); - } - - // :focus is applied to the input, but we apply mat-focused to the other elements - // that need to listen to it. - .mat-focused .mat-input-placeholder { - color: $input-floating-placeholder-color; - - &.mat-accent { - color: $input-underline-color-accent; - } - - &.mat-warn { - color: $input-underline-color-warn; - } - } - .mat-input-element:disabled { color: mat-color($foreground, secondary-text, if($is-dark-theme, 0.7, 0.42)); } - - // See _mat-input-placeholder-floating mixin in input-container.scss - input.mat-input-element:-webkit-autofill + .mat-input-placeholder, - .mat-focused .mat-input-placeholder.mat-float { - .mat-placeholder-required { - color: $input-required-placeholder-color; - } - } - - .mat-input-underline { - background-color: $input-underline-color; - - &.mat-disabled { - @include mat-control-disabled-underline($input-underline-color); - } - } - - .mat-input-ripple { - background-color: $input-underline-focused-color; - - &.mat-accent { - background-color: $input-underline-color-accent; - } - - &.mat-warn { - background-color: $input-underline-color-warn; - } - } - - // 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 { - color: $input-underline-color-warn; - - &.mat-accent, - &.mat-float .mat-placeholder-required { - color: $input-underline-color-warn; - } - } - - .mat-input-ripple { - background-color: $input-underline-color-warn; - } - } - - .mat-input-error { - color: $input-underline-color-warn; - } -} - -// Applies a floating placeholder above the input itself. -@mixin _mat-input-placeholder-floating($font-scale, $infix-padding, $infix-margin-top) { - // We use perspecitve to fix the text blurriness as described here: - // http://www.useragentman.com/blog/2014/05/04/fixing-typography-inside-of-2-d-css-transforms/ - // This results in a small jitter after the label floats on Firefox, which the - // translateZ fixes. - transform: translateY(-$infix-margin-top - $infix-padding) - scale($font-scale) - perspective(100px) translateZ(0.001px); - // The tricks above used to smooth out the animation on chrome and firefox actually make things - // worse on IE, so we don't include them in the IE version. - -ms-transform: translateY(-$infix-margin-top - $infix-padding) - scale($font-scale); - - width: 100% / $font-scale; } @mixin mat-input-typography($config) { // The unit-less line-height from the font config. $line-height: mat-line-height($config, input); - // The amount to scale the font for the floating label and subscript. - $subscript-font-scale: 0.75; - // The amount to scale the font for the prefix and suffix icons. - $prefix-suffix-icon-font-scale: 1.5; - // The amount of space between the top of the line and the top of the actual text // (as a fraction of the font-size). $line-spacing: ($line-height - 1) / 2; - // The padding on the infix. Mocks show half of the text size, but seem to measure from the edge - // of the text itself, not the edge of the line; therefore we subtract off the line spacing. - $infix-padding: 0.5em - $line-spacing; - // The margin applied to the input-infix to reserve space for the floating label. - $infix-margin-top: 1em * $line-height * $subscript-font-scale; - // Font size to use for the label and subscript text. - $subscript-font-size: $subscript-font-scale * 100%; - // Font size to use for the for the prefix and suffix icons. - $prefix-suffix-icon-font-size: $prefix-suffix-icon-font-scale * 100%; - // The space between the bottom of the input table and the subscript container. Mocks show half of - // the text size, but this margin is applied to an element with the subscript text font size, so - // we need to divide by the scale factor to make it half of the original text size. We again need - // to subtract off the line spacing since the mocks measure to the edge of the text, not the edge - // of the line. - $subscript-margin-top: 0.5em / $subscript-font-scale - ($line-spacing * 2); - // The padding applied to the input-wrapper to reserve space for the subscript, since it's - // absolutely positioned. This is a combination of the subscript's margin and line-height, but we - // need to multiply by the subscript font scale factor since the wrapper has a larger font size. - $wrapper-padding-bottom: ($subscript-margin-top + $line-height) * $subscript-font-scale; - - .mat-input-container { - font-family: mat-font-family($config); - font-size: inherit; - font-weight: mat-font-weight($config, input); - line-height: mat-line-height($config, input); - } - - .mat-input-wrapper { - padding-bottom: $wrapper-padding-bottom; - } - - .mat-input-prefix, - .mat-input-suffix { - // Allow icons in a prefix or suffix to adapt to the correct size. - .mat-icon { - font-size: $prefix-suffix-icon-font-size; - line-height: $line-height; - } - - // Allow icon buttons in a prefix or suffix to adapt to the correct size. - .mat-icon-button { - height: $prefix-suffix-icon-font-scale * 1em; - width: $prefix-suffix-icon-font-scale * 1em; - - .mat-icon { - height: $line-height * 1em; - line-height: $line-height; - } - } - } - - .mat-input-infix { - padding: $infix-padding 0; - // Throws off the baseline if we do it as a real margin, so we do it as a border instead. - border-top: $infix-margin-top solid transparent; - } - - .mat-input-element { - &:-webkit-autofill + .mat-input-placeholder-wrapper .mat-float { - @include _mat-input-placeholder-floating($subscript-font-scale, - $infix-padding, $infix-margin-top); - } - } // elements seem to have their height set slightly too large on Safari causing the text to // be misaligned w.r.t. the placeholder. Adding this margin corrects it. input.mat-input-element { margin-top: -$line-spacing * 1em; } - - .mat-input-placeholder-wrapper { - top: -$infix-margin-top; - padding-top: $infix-margin-top; - } - - .mat-input-placeholder { - top: $infix-margin-top + $infix-padding; - - // Show the placeholder above the input when it's not empty, or focused. - &.mat-float:not(.mat-empty), .mat-focused &.mat-float { - @include _mat-input-placeholder-floating($subscript-font-scale, - $infix-padding, $infix-margin-top); - } - } - - .mat-input-underline { - // We want the underline to start at the end of the content box, not the padding box, - // so we move it up by the padding amount. - bottom: $wrapper-padding-bottom; - } - - .mat-input-subscript-wrapper { - font-size: $subscript-font-size; - margin-top: $subscript-margin-top; - - // We want the subscript to start at the end of the content box, not the padding box, - // so we move it up by the padding amount (adjusted for the smaller font size); - top: calc(100% - #{$wrapper-padding-bottom / $subscript-font-scale}); - } } diff --git a/src/lib/input/index.ts b/src/lib/input/index.ts index b616a558408d..495d4e8c37f7 100644 --- a/src/lib/input/index.ts +++ b/src/lib/input/index.ts @@ -7,43 +7,28 @@ */ import {NgModule} from '@angular/core'; -import { - MdErrorDirective, - MdHint, - MdInputContainer, - MdInputDirective, - MdPlaceholder, - MdPrefix, - MdSuffix -} from './input-container'; +import {MdInput} from './input'; import {MdTextareaAutosize} from './autosize'; import {CommonModule} from '@angular/common'; import {PlatformModule} from '../core/platform/index'; +import {MdFormFieldModule} from '../form-field/index'; @NgModule({ declarations: [ - MdErrorDirective, - MdHint, - MdInputContainer, - MdInputDirective, - MdPlaceholder, - MdPrefix, - MdSuffix, + MdInput, MdTextareaAutosize, ], imports: [ CommonModule, + MdFormFieldModule, PlatformModule, ], exports: [ - MdErrorDirective, - MdHint, - MdInputContainer, - MdInputDirective, - MdPlaceholder, - MdPrefix, - MdSuffix, + // We re-export the `MdFormFieldModule` since `MdInput` will almost always be used together with + // `MdFormField`. + MdFormFieldModule, + MdInput, MdTextareaAutosize, ], }) @@ -51,6 +36,6 @@ export class MdInputModule {} export * from './autosize'; -export * from './input-container'; -export * from './input-container-errors'; +export * from './input'; +export * from './input-errors'; diff --git a/src/lib/input/input-container-errors.ts b/src/lib/input/input-container-errors.ts deleted file mode 100644 index a5de9f863a37..000000000000 --- a/src/lib/input/input-container-errors.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -/** @docs-private */ -export function getMdInputContainerPlaceholderConflictError(): Error { - return Error('Placeholder attribute and child element were both specified.'); -} - -/** @docs-private */ -export function getMdInputContainerUnsupportedTypeError(type: string): Error { - return Error(`Input type "${type}" isn't supported by md-input-container.`); -} - -/** @docs-private */ -export function getMdInputContainerDuplicatedHintError(align: string): Error { - return Error(`A hint was already declared for 'align="${align}"'.`); -} - -/** @docs-private */ -export function getMdInputContainerMissingMdInputError(): Error { - return Error('md-input-container must contain an mdInput directive. ' + - 'Did you forget to add mdInput to the native input or textarea element?'); -} diff --git a/src/lib/input/input-container.html b/src/lib/input/input-container.html deleted file mode 100644 index 0714e4019a79..000000000000 --- a/src/lib/input/input-container.html +++ /dev/null @@ -1,53 +0,0 @@ -
-
-
- -
- -
- - - - - -
- -
- -
-
- -
- -
- -
-
- -
- -
-
{{hintLabel}}
- -
- -
-
-
diff --git a/src/lib/input/input-container.ts b/src/lib/input/input-container.ts deleted file mode 100644 index d6c27ceecf74..000000000000 --- a/src/lib/input/input-container.ts +++ /dev/null @@ -1,624 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { - AfterContentChecked, - AfterContentInit, - AfterViewInit, - ChangeDetectionStrategy, - ChangeDetectorRef, - Component, - ContentChild, - ContentChildren, - Directive, - DoCheck, - ElementRef, - Inject, - Input, - OnChanges, - OnDestroy, - Optional, - QueryList, - Renderer2, - Self, - ViewChild, - ViewEncapsulation, -} from '@angular/core'; -import {animate, state, style, transition, trigger} from '@angular/animations'; -import {coerceBooleanProperty, Platform} from '../core'; -import {FormControl, FormGroupDirective, NgControl, NgForm} from '@angular/forms'; -import {getSupportedInputTypes} from '../core/platform/features'; -import { - getMdInputContainerDuplicatedHintError, - getMdInputContainerMissingMdInputError, - getMdInputContainerPlaceholderConflictError, - getMdInputContainerUnsupportedTypeError -} from './input-container-errors'; -import { - FloatPlaceholderType, - MD_PLACEHOLDER_GLOBAL_OPTIONS, - PlaceholderOptions -} from '../core/placeholder/placeholder-options'; -import { - defaultErrorStateMatcher, - ErrorOptions, - ErrorStateMatcher, - MD_ERROR_GLOBAL_OPTIONS -} from '../core/error/error-options'; -import {Subject} from 'rxjs/Subject'; -import {startWith} from '@angular/cdk/rxjs'; - -// Invalid input type. Using one of these will throw an MdInputContainerUnsupportedTypeError. -const MD_INPUT_INVALID_TYPES = [ - 'button', - 'checkbox', - 'color', - 'file', - 'hidden', - 'image', - 'radio', - 'range', - 'reset', - 'submit' -]; - -let nextUniqueId = 0; - - -/** - * The placeholder directive. The content can declare this to implement more - * complex placeholders. - */ -@Directive({ - selector: 'md-placeholder, mat-placeholder' -}) -export class MdPlaceholder {} - - -/** Hint text to be shown underneath the input. */ -@Directive({ - selector: 'md-hint, mat-hint', - host: { - 'class': 'mat-hint', - '[class.mat-right]': 'align == "end"', - '[attr.id]': 'id', - } -}) -export class MdHint { - /** Whether to align the hint label at the start or end of the line. */ - @Input() align: 'start' | 'end' = 'start'; - - /** Unique ID for the hint. Used for the aria-describedby on the input. */ - @Input() id: string = `md-input-hint-${nextUniqueId++}`; -} - -/** Single error message to be shown underneath the input. */ -@Directive({ - selector: 'md-error, mat-error', - host: { - 'class': 'mat-input-error', - 'role': 'alert', - '[attr.id]': 'id', - } -}) -export class MdErrorDirective { - @Input() id: string = `md-input-error-${nextUniqueId++}`; -} - -/** Prefix to be placed the the front of the input. */ -@Directive({ - selector: '[mdPrefix], [matPrefix]' -}) -export class MdPrefix {} - - -/** Suffix to be placed at the end of the input. */ -@Directive({ - selector: '[mdSuffix], [matSuffix]' -}) -export class MdSuffix {} - - -/** Marker for the input element that `MdInputContainer` is wrapping. */ -@Directive({ - selector: `input[mdInput], textarea[mdInput], input[matInput], textarea[matInput]`, - host: { - 'class': 'mat-input-element', - // Native input properties that are overwritten by Angular inputs need to be synced with - // the native input element. Otherwise property bindings for those don't work. - '[id]': 'id', - '[placeholder]': 'placeholder', - '[disabled]': 'disabled', - '[required]': 'required', - '[attr.aria-describedby]': 'ariaDescribedby || null', - '[attr.aria-invalid]': '_isErrorState', - '(blur)': '_focusChanged(false)', - '(focus)': '_focusChanged(true)', - '(input)': '_onInput()', - } -}) -export class MdInputDirective implements OnChanges, OnDestroy, DoCheck { - /** Variables used as cache for getters and setters. */ - private _type = 'text'; - private _placeholder: string = ''; - private _disabled = false; - private _required = false; - private _readonly = false; - private _id: string; - private _uid = `md-input-${nextUniqueId++}`; - private _errorOptions: ErrorOptions; - private _previousNativeValue = this.value; - - /** Whether the input is in an error state. */ - _isErrorState = false; - - /** Whether the element is focused or not. */ - focused = false; - - /** Sets the aria-describedby attribute on the input for improved a11y. */ - ariaDescribedby: string; - - /** - * Stream that emits whenever the state of the input changes. This allows for other components - * (mostly `md-input-container`) that depend on the properties of `mdInput` to update their view. - */ - _stateChanges = new Subject(); - - /** Whether the element is disabled. */ - @Input() - get disabled() { return this._ngControl ? this._ngControl.disabled : this._disabled; } - set disabled(value: any) { this._disabled = coerceBooleanProperty(value); } - - /** Unique id of the element. */ - @Input() - get id() { return this._id; } - set id(value: string) { this._id = value || this._uid; } - - /** Placeholder attribute of the element. */ - @Input() placeholder: string = ''; - - /** Whether the element is required. */ - @Input() - get required() { return this._required; } - set required(value: any) { this._required = coerceBooleanProperty(value); } - - /** Input type of the element. */ - @Input() - get type() { return this._type; } - set type(value: string) { - this._type = value || 'text'; - this._validateType(); - - // When using Angular inputs, developers are no longer able to set the properties on the native - // input element. To ensure that bindings for `type` work, we need to sync the setter - // with the native property. Textarea elements don't support the type property or attribute. - if (!this._isTextarea() && getSupportedInputTypes().has(this._type)) { - this._renderer.setProperty(this._elementRef.nativeElement, 'type', this._type); - } - } - - /** Whether the element is readonly. */ - @Input() - get readonly() { return this._readonly; } - set readonly(value: any) { this._readonly = coerceBooleanProperty(value); } - - /** A function used to control when error messages are shown. */ - @Input() errorStateMatcher: ErrorStateMatcher; - - /** The input element's value. */ - get value() { return this._elementRef.nativeElement.value; } - set value(value: string) { - if (value !== this.value) { - this._elementRef.nativeElement.value = value; - this._stateChanges.next(); - } - } - - /** Whether the input is empty. */ - get empty() { - return !this._isNeverEmpty() && - (this.value == null || this.value === '') && - // Check if the input contains bad input. If so, we know that it only appears empty because - // the value failed to parse. From the user's perspective it is not empty. - // TODO(mmalerba): Add e2e test for bad input case. - !this._isBadInput(); - } - - private _neverEmptyInputTypes = [ - 'date', - 'datetime', - 'datetime-local', - 'month', - 'time', - 'week' - ].filter(t => getSupportedInputTypes().has(t)); - - constructor(private _elementRef: ElementRef, - private _renderer: Renderer2, - private _platform: Platform, - @Optional() @Self() public _ngControl: NgControl, - @Optional() private _parentForm: NgForm, - @Optional() private _parentFormGroup: FormGroupDirective, - @Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) { - - // Force setter to be called in case id was not specified. - this.id = this.id; - this._errorOptions = errorOptions ? errorOptions : {}; - this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher; - - // On some versions of iOS the caret gets stuck in the wrong place when holding down the delete - // key. In order to get around this we need to "jiggle" the caret loose. Since this bug only - // exists on iOS, we only bother to install the listener on iOS. - if (_platform.IOS) { - _renderer.listen(_elementRef.nativeElement, 'keyup', (event: Event) => { - let el = event.target as HTMLInputElement; - if (!el.value && !el.selectionStart && !el.selectionEnd) { - // Note: Just setting `0, 0` doesn't fix the issue. Setting `1, 1` fixes it for the first - // time that you type text and then hold delete. Toggling to `1, 1` and then back to - // `0, 0` seems to completely fix it. - el.setSelectionRange(1, 1); - el.setSelectionRange(0, 0); - } - }); - } - } - - ngOnChanges() { - this._stateChanges.next(); - } - - ngOnDestroy() { - this._stateChanges.complete(); - } - - ngDoCheck() { - if (this._ngControl) { - // We need to re-evaluate this on every change detection cycle, because there are some - // error triggers that we can't subscribe to (e.g. parent form submissions). This means - // that whatever logic is in here has to be super lean or we risk destroying the performance. - this._updateErrorState(); - } else { - // When the input isn't used together with `@angular/forms`, we need to check manually for - // changes to the native `value` property in order to update the floating label. - this._dirtyCheckNativeValue(); - } - } - - _onFocus() { - if (!this._readonly) { - this.focused = true; - } - } - - /** Focuses the input element. */ - focus() { - this._elementRef.nativeElement.focus(); - } - - /** Callback for the cases where the focused state of the input changes. */ - _focusChanged(isFocused: boolean) { - if (isFocused !== this.focused) { - this.focused = isFocused; - this._stateChanges.next(); - } - } - - _onInput() { - // This is a noop function and is used to let Angular know whenever the value changes. - // Angular will run a new change detection each time the `input` event has been dispatched. - // It's necessary that Angular recognizes the value change, because when floatingLabel - // is set to false and Angular forms aren't used, the placeholder won't recognize the - // value changes and will not disappear. - // Listening to the input event wouldn't be necessary when the input is using the - // FormsModule or ReactiveFormsModule, because Angular forms also listens to input events. - } - - /** Re-evaluates the error state. This is only relevant with @angular/forms. */ - private _updateErrorState() { - const oldState = this._isErrorState; - const control = this._ngControl; - const parent = this._parentFormGroup || this._parentForm; - const newState = control && this.errorStateMatcher(control.control as FormControl, parent); - - if (newState !== oldState) { - this._isErrorState = newState; - this._stateChanges.next(); - } - } - - /** Does some manual dirty checking on the native input `value` property. */ - private _dirtyCheckNativeValue() { - const newValue = this.value; - - if (this._previousNativeValue !== newValue) { - this._previousNativeValue = newValue; - this._stateChanges.next(); - } - } - - /** Make sure the input is a supported type. */ - private _validateType() { - if (MD_INPUT_INVALID_TYPES.indexOf(this._type) > -1) { - throw getMdInputContainerUnsupportedTypeError(this._type); - } - } - - /** Checks whether the input type isn't one of the types that are never empty. */ - private _isNeverEmpty() { - return this._neverEmptyInputTypes.indexOf(this._type) > -1; - } - - /** Checks whether the input is invalid based on the native validation. */ - private _isBadInput() { - // The `validity` property won't be present on platform-server. - let validity = (this._elementRef.nativeElement as HTMLInputElement).validity; - return validity && validity.badInput; - } - - /** Determines if the component host is a textarea. If not recognizable it returns false. */ - private _isTextarea() { - let nativeElement = this._elementRef.nativeElement; - - // In Universal, we don't have access to `nodeName`, but the same can be achieved with `name`. - // Note that this shouldn't be necessary once Angular switches to an API that resembles the - // DOM closer. - let nodeName = this._platform.isBrowser ? nativeElement.nodeName : nativeElement.name; - return nodeName ? nodeName.toLowerCase() === 'textarea' : false; - } -} - - -/** - * Container for text inputs that applies Material Design styling and behavior. - */ -@Component({ - moduleId: module.id, - 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', - '[class.mat-input-invalid]': '_mdInputChild._isErrorState', - '[class.mat-focused]': '_mdInputChild.focused', - '[class.ng-untouched]': '_shouldForward("untouched")', - '[class.ng-touched]': '_shouldForward("touched")', - '[class.ng-pristine]': '_shouldForward("pristine")', - '[class.ng-dirty]': '_shouldForward("dirty")', - '[class.ng-valid]': '_shouldForward("valid")', - '[class.ng-invalid]': '_shouldForward("invalid")', - '[class.ng-pending]': '_shouldForward("pending")', - '(click)': '_focusInput()', - }, - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, -}) - -export class MdInputContainer implements AfterViewInit, AfterContentInit, AfterContentChecked { - private _placeholderOptions: PlaceholderOptions; - - /** Color of the input divider, based on the theme. */ - @Input() color: 'primary' | 'accent' | 'warn' = 'primary'; - - /** @deprecated Use `color` instead. */ - @Input() - get dividerColor() { return this.color; } - set dividerColor(value) { this.color = value; } - - /** Whether the required marker should be hidden. */ - @Input() - get hideRequiredMarker() { return this._hideRequiredMarker; } - set hideRequiredMarker(value: any) { - this._hideRequiredMarker = coerceBooleanProperty(value); - } - private _hideRequiredMarker: boolean; - - /** Whether the floating label should always float or not. */ - get _shouldAlwaysFloat() { return this._floatPlaceholder === 'always'; } - - /** 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; } - set hintLabel(value: string) { - this._hintLabel = value; - this._processHints(); - } - private _hintLabel = ''; - - // Unique id for the hint label. - _hintLabelId: string = `md-input-hint-${nextUniqueId++}`; - - /** Whether the placeholder should always float, never float or float as the user types. */ - @Input() - get floatPlaceholder() { return this._floatPlaceholder; } - set floatPlaceholder(value: FloatPlaceholderType) { - if (value !== this._floatPlaceholder) { - this._floatPlaceholder = value || this._placeholderOptions.float || 'auto'; - this._changeDetectorRef.markForCheck(); - } - } - private _floatPlaceholder: FloatPlaceholderType; - - /** Reference to the input's underline element. */ - @ViewChild('underline') underlineRef: ElementRef; - @ViewChild('connectionContainer') _connectionContainerRef: ElementRef; - @ContentChild(MdInputDirective) _mdInputChild: MdInputDirective; - @ContentChild(MdPlaceholder) _placeholderChild: MdPlaceholder; - @ContentChildren(MdErrorDirective) _errorChildren: QueryList; - @ContentChildren(MdHint) _hintChildren: QueryList; - @ContentChildren(MdPrefix) _prefixChildren: QueryList; - @ContentChildren(MdSuffix) _suffixChildren: QueryList; - - constructor( - public _elementRef: ElementRef, - private _changeDetectorRef: ChangeDetectorRef, - @Optional() @Inject(MD_PLACEHOLDER_GLOBAL_OPTIONS) placeholderOptions: PlaceholderOptions) { - this._placeholderOptions = placeholderOptions ? placeholderOptions : {}; - this.floatPlaceholder = this._placeholderOptions.float || 'auto'; - } - - ngAfterContentInit() { - this._validateInputChild(); - - // Subscribe to changes in the child input state in order to update the container UI. - startWith.call(this._mdInputChild._stateChanges, null).subscribe(() => { - this._validatePlaceholders(); - this._syncAriaDescribedby(); - this._changeDetectorRef.markForCheck(); - }); - - if (this._mdInputChild._ngControl && this._mdInputChild._ngControl.valueChanges) { - this._mdInputChild._ngControl.valueChanges.subscribe(() => { - this._changeDetectorRef.markForCheck(); - }); - } - - // Re-validate when the number of hints changes. - startWith.call(this._hintChildren.changes, null).subscribe(() => { - this._processHints(); - this._changeDetectorRef.markForCheck(); - }); - - // Update the aria-described by when the number of errors changes. - startWith.call(this._errorChildren.changes, null).subscribe(() => { - this._syncAriaDescribedby(); - this._changeDetectorRef.markForCheck(); - }); - } - - ngAfterContentChecked() { - this._validateInputChild(); - } - - 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; - return control && (control as any)[prop]; - } - - /** Whether the input has a placeholder. */ - _hasPlaceholder() { - return !!(this._mdInputChild.placeholder || this._placeholderChild); - } - - /** Focuses the underlying input. */ - _focusInput() { - this._mdInputChild.focus(); - } - - /** Determines whether to display hints or errors. */ - _getDisplayedMessages(): 'error' | 'hint' { - let input = this._mdInputChild; - return (this._errorChildren && this._errorChildren.length > 0 && input._isErrorState) ? - 'error' : 'hint'; - } - - /** - * Ensure that there is only one placeholder (either `input` attribute or child element with the - * `md-placeholder` attribute. - */ - private _validatePlaceholders() { - if (this._mdInputChild.placeholder && this._placeholderChild) { - throw getMdInputContainerPlaceholderConflictError(); - } - } - - /** - * Does any extra processing that is required when handling the hints. - */ - private _processHints() { - this._validateHints(); - this._syncAriaDescribedby(); - } - - /** - * Ensure that there is a maximum of one of each `` alignment specified, with the - * attribute being considered as `align="start"`. - */ - private _validateHints() { - if (this._hintChildren) { - let startHint: MdHint; - let endHint: MdHint; - this._hintChildren.forEach((hint: MdHint) => { - if (hint.align == 'start') { - if (startHint || this.hintLabel) { - throw getMdInputContainerDuplicatedHintError('start'); - } - startHint = hint; - } else if (hint.align == 'end') { - if (endHint) { - throw getMdInputContainerDuplicatedHintError('end'); - } - endHint = hint; - } - }); - } - } - - /** - * Sets the child input's `aria-describedby` to a space-separated list of the ids - * of the currently-specified hints, as well as a generated id for the hint label. - */ - private _syncAriaDescribedby() { - if (this._mdInputChild) { - let ids: string[] = []; - - if (this._getDisplayedMessages() === 'hint') { - let startHint = this._hintChildren ? - this._hintChildren.find(hint => hint.align === 'start') : null; - let endHint = this._hintChildren ? - this._hintChildren.find(hint => hint.align === 'end') : null; - - if (startHint) { - ids.push(startHint.id); - } else if (this._hintLabel) { - ids.push(this._hintLabelId); - } - - if (endHint) { - ids.push(endHint.id); - } - } else if (this._errorChildren) { - ids = this._errorChildren.map(mdError => mdError.id); - } - - this._mdInputChild.ariaDescribedby = ids.join(' '); - } - } - - /** - * Throws an error if the container's input child was removed. - */ - protected _validateInputChild() { - if (!this._mdInputChild) { - throw getMdInputContainerMissingMdInputError(); - } - } -} diff --git a/src/lib/input/input-errors.ts b/src/lib/input/input-errors.ts new file mode 100644 index 000000000000..0ed0090f4e74 --- /dev/null +++ b/src/lib/input/input-errors.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** @docs-private */ +export function getMdInputUnsupportedTypeError(type: string): Error { + return Error(`Input type "${type}" isn't supported by mdInput.`); +} diff --git a/src/lib/input/input.scss b/src/lib/input/input.scss new file mode 100644 index 000000000000..b960fa972d02 --- /dev/null +++ b/src/lib/input/input.scss @@ -0,0 +1,49 @@ +@import '../core/style/variables'; +@import '../core/style/vendor-prefixes'; + + +// The Input element proper. +.mat-input-element { + // Font needs to be inherited, because by default has a system font. + font: inherit; + + // The Material input should match whatever background it is above. + background: transparent; + + // If background matches current background then so should the color for proper contrast + color: currentColor; + + // By default, has a padding, border, outline and a default width. + border: none; + outline: none; + padding: 0; + margin: 0; + width: 100%; + + // Prevent textareas from being resized outside the form field. + max-width: 100%; + resize: vertical; + + // Needed to make last line of the textarea line up with the baseline. + vertical-align: bottom; + + // Undo the red box-shadow glow added by Firefox on invalid inputs. + // See https://developer.mozilla.org/en-US/docs/Web/CSS/:-moz-ui-invalid + &:-moz-ui-invalid { + box-shadow: none; + } + + // Note that we can't use something like visibility: hidden or + // display: none, because IE ends up preventing the user from + // focusing the input altogether. + @include input-placeholder { + // Needs to be !important, because the placeholder will end up inheriting the + // input color in IE, if the consumer overrides it with a higher specificity. + color: transparent !important; + } +} + +// Prevents IE from always adding a scrollbar by default. +textarea.mat-input-element { + overflow: auto; +} diff --git a/src/lib/input/input-container.spec.ts b/src/lib/input/input.spec.ts similarity index 88% rename from src/lib/input/input-container.spec.ts rename to src/lib/input/input.spec.ts index 4c1b92ed567c..83ba4e6b7666 100644 --- a/src/lib/input/input-container.spec.ts +++ b/src/lib/input/input.spec.ts @@ -12,15 +12,17 @@ import { import {By} from '@angular/platform-browser'; import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {MdInputModule} from './index'; -import {MdInputContainer, MdInputDirective} from './input-container'; +import {MdInput} from './input'; import {Platform} from '../core/platform/platform'; import {PlatformModule} from '../core/platform/index'; import {wrappedErrorMessage, dispatchFakeEvent} from '@angular/cdk/testing'; import { - getMdInputContainerDuplicatedHintError, - getMdInputContainerMissingMdInputError, - getMdInputContainerPlaceholderConflictError -} from './input-container-errors'; + MdFormField, + MdFormFieldModule, + getMdFormFieldDuplicatedHintError, + getMdFormFieldMissingControlError, + getMdFormFieldPlaceholderConflictError, +} from '../form-field/index'; import {MD_PLACEHOLDER_GLOBAL_OPTIONS} from '../core/placeholder/placeholder-options'; import {MD_ERROR_GLOBAL_OPTIONS, showOnDirtyErrorStateMatcher} from '../core/error/error-options'; @@ -29,6 +31,7 @@ describe('MdInputContainer without forms', function () { TestBed.configureTestingModule({ imports: [ FormsModule, + MdFormFieldModule, MdInputModule, NoopAnimationsModule, PlatformModule, @@ -73,8 +76,8 @@ describe('MdInputContainer without forms', function () { let fixture = TestBed.createComponent(MdInputContainerWithId); fixture.detectChanges(); - let inputContainer = fixture.debugElement.query(By.directive(MdInputContainer)) - .componentInstance as MdInputContainer; + let inputContainer = fixture.debugElement.query(By.directive(MdFormField)) + .componentInstance as MdFormField; expect(inputContainer.floatPlaceholder).toBe('auto', 'Expected MdInputContainer to set floatingLabel to auto by default.'); }); @@ -84,6 +87,7 @@ describe('MdInputContainer without forms', function () { TestBed.configureTestingModule({ imports: [ FormsModule, + MdFormFieldModule, MdInputModule, NoopAnimationsModule ], @@ -96,8 +100,8 @@ describe('MdInputContainer without forms', function () { let fixture = TestBed.createComponent(MdInputContainerWithId); fixture.detectChanges(); - let inputContainer = fixture.debugElement.query(By.directive(MdInputContainer)) - .componentInstance as MdInputContainer; + let inputContainer = fixture.debugElement.query(By.directive(MdFormField)) + .componentInstance as MdFormField; expect(inputContainer.floatPlaceholder).toBe('always', 'Expected MdInputContainer to set floatingLabel to always from global option.'); }); @@ -110,7 +114,7 @@ describe('MdInputContainer without forms', function () { let el = fixture.debugElement.query(By.css('label')).nativeElement; expect(el).not.toBeNull(); - expect(el.classList.contains('mat-empty')).toBe(false); + expect(el.classList.contains('mat-form-field-empty')).toBe(false); } })); @@ -123,7 +127,7 @@ describe('MdInputContainer without forms', function () { let el = fixture.debugElement.query(By.css('label')).nativeElement; expect(el).not.toBeNull(); - expect(el.classList.contains('mat-empty')).toBe(true); + expect(el.classList.contains('mat-form-field-empty')).toBe(true); } })); @@ -133,7 +137,7 @@ describe('MdInputContainer without forms', function () { let el = fixture.debugElement.query(By.css('label')).nativeElement; expect(el).not.toBeNull(); - expect(el.classList.contains('mat-empty')).toBe(true); + expect(el.classList.contains('mat-form-field-empty')).toBe(true); }); it('should treat password input type as empty at init', () => { @@ -142,7 +146,7 @@ describe('MdInputContainer without forms', function () { let el = fixture.debugElement.query(By.css('label')).nativeElement; expect(el).not.toBeNull(); - expect(el.classList.contains('mat-empty')).toBe(true); + expect(el.classList.contains('mat-form-field-empty')).toBe(true); }); it('should treat number input type as empty at init', () => { @@ -151,7 +155,7 @@ describe('MdInputContainer without forms', function () { let el = fixture.debugElement.query(By.css('label')).nativeElement; expect(el).not.toBeNull(); - expect(el.classList.contains('mat-empty')).toBe(true); + expect(el.classList.contains('mat-form-field-empty')).toBe(true); }); it('should not be empty after input entered', async(() => { @@ -161,7 +165,7 @@ describe('MdInputContainer without forms', function () { let inputEl = fixture.debugElement.query(By.css('input')); let el = fixture.debugElement.query(By.css('label')).nativeElement; expect(el).not.toBeNull(); - expect(el.classList.contains('mat-empty')).toBe(true, 'should be empty'); + expect(el.classList.contains('mat-form-field-empty')).toBe(true, 'should be empty'); inputEl.nativeElement.value = 'hello'; // Simulate input event. @@ -169,7 +173,7 @@ describe('MdInputContainer without forms', function () { fixture.detectChanges(); el = fixture.debugElement.query(By.css('label')).nativeElement; - expect(el.classList.contains('mat-empty')).toBe(false, 'should not be empty'); + expect(el.classList.contains('mat-form-field-empty')).toBe(false, 'should not be empty'); })); it('should update the placeholder when input entered', async(() => { @@ -179,8 +183,8 @@ describe('MdInputContainer without forms', function () { let inputEl = fixture.debugElement.query(By.css('input')); let labelEl = fixture.debugElement.query(By.css('label')).nativeElement; - expect(labelEl.classList).toContain('mat-empty'); - expect(labelEl.classList).not.toContain('mat-float'); + expect(labelEl.classList).toContain('mat-form-field-empty'); + expect(labelEl.classList).not.toContain('mat-form-field-float'); // Update the value of the input. inputEl.nativeElement.value = 'Text'; @@ -188,22 +192,23 @@ describe('MdInputContainer without forms', function () { // Fake behavior of the `(input)` event which should trigger a change detection. fixture.detectChanges(); - expect(labelEl.classList).not.toContain('mat-empty'); - expect(labelEl.classList).not.toContain('mat-float'); + expect(labelEl.classList).not.toContain('mat-form-field-empty'); + expect(labelEl.classList).not.toContain('mat-form-field-float'); })); it('should not be empty when the value set before view init', async(() => { let fixture = TestBed.createComponent(MdInputContainerWithValueBinding); fixture.detectChanges(); - let placeholderEl = fixture.debugElement.query(By.css('.mat-input-placeholder')).nativeElement; + let placeholderEl = + fixture.debugElement.query(By.css('.mat-form-field-placeholder')).nativeElement; - expect(placeholderEl.classList).not.toContain('mat-empty'); + expect(placeholderEl.classList).not.toContain('mat-form-field-empty'); fixture.componentInstance.value = ''; fixture.detectChanges(); - expect(placeholderEl.classList).toContain('mat-empty'); + expect(placeholderEl.classList).toContain('mat-form-field-empty'); })); it('should add id', () => { @@ -236,40 +241,40 @@ describe('MdInputContainer without forms', function () { let fixture = TestBed.createComponent(MdInputContainerInvalidHintTestController); expect(() => fixture.detectChanges()).toThrowError( - wrappedErrorMessage(getMdInputContainerDuplicatedHintError('start'))); + wrappedErrorMessage(getMdFormFieldDuplicatedHintError('start'))); }); it('validates there\'s only one hint label per side (attribute)', () => { let fixture = TestBed.createComponent(MdInputContainerInvalidHint2TestController); expect(() => fixture.detectChanges()).toThrowError( - wrappedErrorMessage(getMdInputContainerDuplicatedHintError('start'))); + wrappedErrorMessage(getMdFormFieldDuplicatedHintError('start'))); }); it('validates there\'s only one placeholder', () => { let fixture = TestBed.createComponent(MdInputContainerInvalidPlaceholderTestController); expect(() => fixture.detectChanges()).toThrowError( - wrappedErrorMessage(getMdInputContainerPlaceholderConflictError())); + wrappedErrorMessage(getMdFormFieldPlaceholderConflictError())); }); it('validates that mdInput child is present', () => { let fixture = TestBed.createComponent(MdInputContainerMissingMdInputTestController); expect(() => fixture.detectChanges()).toThrowError( - wrappedErrorMessage(getMdInputContainerMissingMdInputError())); + wrappedErrorMessage(getMdFormFieldMissingControlError())); }); it('validates that mdInput child is present after initialization', async(() => { let fixture = TestBed.createComponent(MdInputContainerWithNgIf); expect(() => fixture.detectChanges()).not.toThrowError( - wrappedErrorMessage(getMdInputContainerMissingMdInputError())); + wrappedErrorMessage(getMdFormFieldMissingControlError())); fixture.componentInstance.renderInput = false; expect(() => fixture.detectChanges()).toThrowError( - wrappedErrorMessage(getMdInputContainerMissingMdInputError())); + wrappedErrorMessage(getMdFormFieldMissingControlError())); })); it('validates the type', () => { @@ -381,7 +386,7 @@ describe('MdInputContainer without forms', function () { let fixture = TestBed.createComponent(MdInputContainerPlaceholderRequiredTestComponent); fixture.detectChanges(); - let el = fixture.debugElement.query(By.css('.mat-placeholder-required')).nativeElement; + let el = fixture.debugElement.query(By.css('.mat-form-field-required-marker')).nativeElement; expect(el.getAttribute('aria-hidden')).toBe('true'); }); @@ -404,7 +409,8 @@ describe('MdInputContainer without forms', function () { const fixture = TestBed.createComponent(MdInputContainerWithDisabled); fixture.detectChanges(); - const underlineEl = fixture.debugElement.query(By.css('.mat-input-underline')).nativeElement; + const underlineEl = + fixture.debugElement.query(By.css('.mat-form-field-underline')).nativeElement; const inputEl = fixture.debugElement.query(By.css('input')).nativeElement; expect(underlineEl.classList.contains('mat-disabled')) @@ -496,8 +502,8 @@ describe('MdInputContainer without forms', function () { fixture.detectChanges(); - let hintLabel = fixture.debugElement.query(By.css('.mat-hint')).nativeElement; - let endLabel = fixture.debugElement.query(By.css('.mat-hint[align="end"]')).nativeElement; + let hintLabel = fixture.debugElement.query(By.css('.mat-hint:not(.mat-right)')).nativeElement; + let endLabel = fixture.debugElement.query(By.css('.mat-hint.mat-right')).nativeElement; let input = fixture.debugElement.query(By.css('input')).nativeElement; let ariaValue = input.getAttribute('aria-describedby'); @@ -511,14 +517,14 @@ describe('MdInputContainer without forms', function () { let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; let labelEl = fixture.debugElement.query(By.css('label')).nativeElement; - expect(labelEl.classList).not.toContain('mat-empty'); - expect(labelEl.classList).toContain('mat-float'); + expect(labelEl.classList).not.toContain('mat-form-field-empty'); + expect(labelEl.classList).toContain('mat-form-field-float'); fixture.componentInstance.shouldFloat = 'auto'; fixture.detectChanges(); - expect(labelEl.classList).toContain('mat-empty'); - expect(labelEl.classList).toContain('mat-float'); + expect(labelEl.classList).toContain('mat-form-field-empty'); + expect(labelEl.classList).toContain('mat-form-field-float'); // Update the value of the input. inputEl.value = 'Text'; @@ -526,8 +532,8 @@ describe('MdInputContainer without forms', function () { // Fake behavior of the `(input)` event which should trigger a change detection. fixture.detectChanges(); - expect(labelEl.classList).not.toContain('mat-empty'); - expect(labelEl.classList).toContain('mat-float'); + expect(labelEl.classList).not.toContain('mat-form-field-empty'); + expect(labelEl.classList).toContain('mat-form-field-float'); }); it('should always float the placeholder when floatPlaceholder is set to true', () => { @@ -537,8 +543,8 @@ describe('MdInputContainer without forms', function () { let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; let labelEl = fixture.debugElement.query(By.css('label')).nativeElement; - expect(labelEl.classList).not.toContain('mat-empty'); - expect(labelEl.classList).toContain('mat-float'); + expect(labelEl.classList).not.toContain('mat-form-field-empty'); + expect(labelEl.classList).toContain('mat-form-field-float'); fixture.detectChanges(); @@ -548,8 +554,8 @@ describe('MdInputContainer without forms', function () { // Fake behavior of the `(input)` event which should trigger a change detection. fixture.detectChanges(); - expect(labelEl.classList).not.toContain('mat-empty'); - expect(labelEl.classList).toContain('mat-float'); + expect(labelEl.classList).not.toContain('mat-form-field-empty'); + expect(labelEl.classList).toContain('mat-form-field-float'); }); @@ -562,8 +568,8 @@ describe('MdInputContainer without forms', function () { let inputEl = fixture.debugElement.query(By.css('input')).nativeElement; let labelEl = fixture.debugElement.query(By.css('label')).nativeElement; - expect(labelEl.classList).toContain('mat-empty'); - expect(labelEl.classList).not.toContain('mat-float'); + expect(labelEl.classList).toContain('mat-form-field-empty'); + expect(labelEl.classList).not.toContain('mat-form-field-float'); // Update the value of the input. inputEl.value = 'Text'; @@ -571,8 +577,8 @@ describe('MdInputContainer without forms', function () { // Fake behavior of the `(input)` event which should trigger a change detection. fixture.detectChanges(); - expect(labelEl.classList).not.toContain('mat-empty'); - expect(labelEl.classList).not.toContain('mat-float'); + expect(labelEl.classList).not.toContain('mat-form-field-empty'); + expect(labelEl.classList).not.toContain('mat-form-field-float'); }); it('should be able to toggle the floating placeholder programmatically', () => { @@ -580,26 +586,27 @@ describe('MdInputContainer without forms', function () { fixture.detectChanges(); - const inputContainer = fixture.debugElement.query(By.directive(MdInputContainer)); - const containerInstance = inputContainer.componentInstance as MdInputContainer; - const placeholder = inputContainer.nativeElement.querySelector('.mat-input-placeholder'); + const inputContainer = fixture.debugElement.query(By.directive(MdFormField)); + const containerInstance = inputContainer.componentInstance as MdFormField; + const placeholder = inputContainer.nativeElement.querySelector('.mat-form-field-placeholder'); expect(containerInstance.floatPlaceholder).toBe('auto'); - expect(placeholder.classList).toContain('mat-empty', 'Expected input to be considered empty.'); + expect(placeholder.classList) + .toContain('mat-form-field-empty', 'Expected input to be considered empty.'); containerInstance.floatPlaceholder = 'always'; fixture.detectChanges(); expect(placeholder.classList) - .not.toContain('mat-empty', 'Expected input to be considered not empty.'); + .not.toContain('mat-form-field-empty', 'Expected input to be considered not empty.'); }); it('should not have prefix and suffix elements when none are specified', () => { let fixture = TestBed.createComponent(MdInputContainerWithId); fixture.detectChanges(); - let prefixEl = fixture.debugElement.query(By.css('.mat-input-prefix')); - let suffixEl = fixture.debugElement.query(By.css('.mat-input-suffix')); + let prefixEl = fixture.debugElement.query(By.css('.mat-form-field-prefix')); + let suffixEl = fixture.debugElement.query(By.css('.mat-form-field-suffix')); expect(prefixEl).toBeNull(); expect(suffixEl).toBeNull(); @@ -609,8 +616,8 @@ describe('MdInputContainer without forms', function () { let fixture = TestBed.createComponent(MdInputContainerWithPrefixAndSuffix); fixture.detectChanges(); - let prefixEl = fixture.debugElement.query(By.css('.mat-input-prefix')); - let suffixEl = fixture.debugElement.query(By.css('.mat-input-suffix')); + let prefixEl = fixture.debugElement.query(By.css('.mat-form-field-prefix')); + let suffixEl = fixture.debugElement.query(By.css('.mat-form-field-suffix')); expect(prefixEl).not.toBeNull(); expect(suffixEl).not.toBeNull(); @@ -623,49 +630,32 @@ describe('MdInputContainer without forms', function () { fixture.detectChanges(); let component = fixture.componentInstance; - let placeholder = fixture.debugElement - .query(By.css('.mat-input-placeholder')).nativeElement; + let placeholder = + fixture.debugElement.query(By.css('.mat-form-field-placeholder')).nativeElement; - expect(placeholder.classList).toContain('mat-empty', 'Input initially empty'); + expect(placeholder.classList).toContain('mat-form-field-empty', 'Input initially empty'); component.formControl.setValue('something'); fixture.detectChanges(); - expect(placeholder.classList).not.toContain('mat-empty', 'Input no longer empty'); + expect(placeholder.classList).not.toContain('mat-form-field-empty', 'Input no longer empty'); }); it('should set the focused class when the input is focused', () => { let fixture = TestBed.createComponent(MdInputContainerTextTestController); fixture.detectChanges(); - let input = fixture.debugElement.query(By.directive(MdInputDirective)) - .injector.get(MdInputDirective); + let input = fixture.debugElement.query(By.directive(MdInput)) + .injector.get(MdInput); let container = fixture.debugElement.query(By.css('md-input-container')).nativeElement; // Call the focus handler directly to avoid flakyness where // browsers don't focus elements if the window is minimized. - input._onFocus(); + input._focusChanged(true); fixture.detectChanges(); expect(container.classList).toContain('mat-focused'); }); - - it('should not highlight when focusing a readonly input', () => { - let fixture = TestBed.createComponent(MdInputContainerWithReadonlyInput); - fixture.detectChanges(); - - let input = fixture.debugElement.query(By.directive(MdInputDirective)) - .injector.get(MdInputDirective); - let container = fixture.debugElement.query(By.css('md-input-container')).nativeElement; - - // Call the focus handler directly to avoid flakyness where - // browsers don't focus elements if the window is minimized. - input._onFocus(); - fixture.detectChanges(); - - expect(input.focused).toBe(false); - expect(container.classList).not.toContain('mat-focused'); - }); }); describe('MdInputContainer with forms', () => { @@ -674,6 +664,7 @@ describe('MdInputContainer with forms', () => { TestBed.configureTestingModule({ imports: [ FormsModule, + MdFormFieldModule, MdInputModule, NoopAnimationsModule, PlatformModule, @@ -721,7 +712,7 @@ describe('MdInputContainer with forms', () => { fixture.whenStable().then(() => { expect(containerEl.classList) - .toContain('mat-input-invalid', 'Expected container to have the invalid CSS class.'); + .toContain('mat-form-field-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(inputEl.getAttribute('aria-invalid')) @@ -740,7 +731,7 @@ describe('MdInputContainer with forms', () => { 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.'); + .toContain('mat-form-field-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(inputEl.getAttribute('aria-invalid')) @@ -773,7 +764,7 @@ describe('MdInputContainer with forms', () => { 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.'); + .toContain('mat-form-field-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(inputEl.getAttribute('aria-invalid')) @@ -787,7 +778,7 @@ describe('MdInputContainer with forms', () => { fixture.whenStable().then(() => { expect(containerEl.classList) - .toContain('mat-input-invalid', 'Expected container to have the invalid CSS class.'); + .toContain('mat-form-field-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) @@ -797,7 +788,7 @@ describe('MdInputContainer with forms', () => { fixture.detectChanges(); fixture.whenStable().then(() => { - expect(containerEl.classList).not.toContain('mat-input-invalid', + expect(containerEl.classList).not.toContain('mat-form-field-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.'); @@ -840,7 +831,7 @@ describe('MdInputContainer with forms', () => { fixture.componentInstance.formControl.markAsTouched(); fixture.detectChanges(); - let errorIds = fixture.debugElement.queryAll(By.css('.mat-input-error')) + let errorIds = fixture.debugElement.queryAll(By.css('.mat-error')) .map(el => el.nativeElement.getAttribute('id')).join(' '); describedBy = inputEl.getAttribute('aria-describedby'); @@ -888,6 +879,7 @@ describe('MdInputContainer with forms', () => { TestBed.configureTestingModule({ imports: [ FormsModule, + MdFormFieldModule, MdInputModule, NoopAnimationsModule, ReactiveFormsModule, @@ -919,6 +911,7 @@ describe('MdInputContainer with forms', () => { TestBed.configureTestingModule({ imports: [ FormsModule, + MdFormFieldModule, MdInputModule, NoopAnimationsModule, ReactiveFormsModule, @@ -961,8 +954,8 @@ describe('MdInputContainer with forms', () => { let fixture = TestBed.createComponent(MdInputContainerWithFormControl); fixture.detectChanges(); - let input = fixture.debugElement.query(By.directive(MdInputDirective)) - .injector.get(MdInputDirective); + let input = fixture.debugElement.query(By.directive(MdInput)) + .injector.get(MdInput); expect(input.value).toBeFalsy(); @@ -975,7 +968,8 @@ describe('MdInputContainer with forms', () => { const fixture = TestBed.createComponent(MdInputContainerWithFormControl); fixture.detectChanges(); - const underlineEl = fixture.debugElement.query(By.css('.mat-input-underline')).nativeElement; + const underlineEl = + fixture.debugElement.query(By.css('.mat-form-field-underline')).nativeElement; const inputEl = fixture.debugElement.query(By.css('input')).nativeElement; expect(underlineEl.classList) @@ -999,7 +993,7 @@ describe('MdInputContainer with forms', () => { let el = fixture.debugElement.query(By.css('label')).nativeElement; expect(el).not.toBeNull(); - expect(el.classList.contains('mat-empty')).toBe(false); + expect(el.classList.contains('mat-form-field-empty')).toBe(false); }); })); }); diff --git a/src/lib/input/input.ts b/src/lib/input/input.ts new file mode 100644 index 000000000000..f0449a9bc994 --- /dev/null +++ b/src/lib/input/input.ts @@ -0,0 +1,286 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + Directive, + DoCheck, + ElementRef, + Inject, + Input, + OnChanges, + OnDestroy, + Optional, + Renderer2, + Self, +} from '@angular/core'; +import {coerceBooleanProperty, Platform} from '../core'; +import {FormControl, FormGroupDirective, NgControl, NgForm} from '@angular/forms'; +import {getSupportedInputTypes} from '../core/platform/features'; +import {getMdInputUnsupportedTypeError} from './input-errors'; +import { + defaultErrorStateMatcher, + ErrorOptions, + ErrorStateMatcher, + MD_ERROR_GLOBAL_OPTIONS +} from '../core/error/error-options'; +import {Subject} from 'rxjs/Subject'; +import {MdFormFieldControl} from '../form-field/index'; + +// Invalid input type. Using one of these will throw an MdInputUnsupportedTypeError. +const MD_INPUT_INVALID_TYPES = [ + 'button', + 'checkbox', + 'color', + 'file', + 'hidden', + 'image', + 'radio', + 'range', + 'reset', + 'submit' +]; + +let nextUniqueId = 0; + + +/** Directive that allows a native input to work inside a `MdFormField`. */ +@Directive({ + selector: `input[mdInput], textarea[mdInput], input[matInput], textarea[matInput]`, + host: { + 'class': 'mat-input-element', + // Native input properties that are overwritten by Angular inputs need to be synced with + // the native input element. Otherwise property bindings for those don't work. + '[id]': 'id', + '[placeholder]': 'placeholder', + '[disabled]': 'disabled', + '[required]': 'required', + '[attr.aria-describedby]': '_ariaDescribedby || null', + '[attr.aria-invalid]': 'errorState', + '(blur)': '_focusChanged(false)', + '(focus)': '_focusChanged(true)', + '(input)': '_onInput()', + }, + providers: [{provide: MdFormFieldControl, useExisting: MdInput}], +}) +export class MdInput implements MdFormFieldControl, OnChanges, OnDestroy, DoCheck { + /** Variables used as cache for getters and setters. */ + private _type = 'text'; + private _disabled = false; + private _required = false; + private _id: string; + private _uid = `md-input-${nextUniqueId++}`; + private _errorOptions: ErrorOptions; + private _previousNativeValue = this.value; + + /** Whether the input is focused. */ + focused = false; + + /** Whether the input is in an error state. */ + errorState = false; + + /** The aria-describedby attribute on the input for improved a11y. */ + _ariaDescribedby: string; + + /** + * Stream that emits whenever the state of the input changes such that the wrapping `MdFormField` + * needs to run change detection. + */ + stateChanges = new Subject(); + + /** Whether the element is disabled. */ + @Input() + get disabled() { return this.ngControl ? this.ngControl.disabled : this._disabled; } + set disabled(value: any) { this._disabled = coerceBooleanProperty(value); } + + /** Unique id of the element. */ + @Input() + get id() { return this._id; } + set id(value: string) { this._id = value || this._uid; } + + /** Placeholder attribute of the element. */ + @Input() placeholder: string = ''; + + /** Whether the element is required. */ + @Input() + get required() { return this._required; } + set required(value: any) { this._required = coerceBooleanProperty(value); } + + /** Input type of the element. */ + @Input() + get type() { return this._type; } + set type(value: string) { + this._type = value || 'text'; + this._validateType(); + + // When using Angular inputs, developers are no longer able to set the properties on the native + // input element. To ensure that bindings for `type` work, we need to sync the setter + // with the native property. Textarea elements don't support the type property or attribute. + if (!this._isTextarea() && getSupportedInputTypes().has(this._type)) { + this._renderer.setProperty(this._elementRef.nativeElement, 'type', this._type); + } + } + + /** A function used to control when error messages are shown. */ + @Input() errorStateMatcher: ErrorStateMatcher; + + /** The input element's value. */ + get value() { return this._elementRef.nativeElement.value; } + set value(value: string) { + if (value !== this.value) { + this._elementRef.nativeElement.value = value; + this.stateChanges.next(); + } + } + + private _neverEmptyInputTypes = [ + 'date', + 'datetime', + 'datetime-local', + 'month', + 'time', + 'week' + ].filter(t => getSupportedInputTypes().has(t)); + + constructor(private _elementRef: ElementRef, + private _renderer: Renderer2, + private _platform: Platform, + @Optional() @Self() public ngControl: NgControl, + @Optional() private _parentForm: NgForm, + @Optional() private _parentFormGroup: FormGroupDirective, + @Optional() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) { + + // Force setter to be called in case id was not specified. + this.id = this.id; + this._errorOptions = errorOptions ? errorOptions : {}; + this.errorStateMatcher = this._errorOptions.errorStateMatcher || defaultErrorStateMatcher; + + // On some versions of iOS the caret gets stuck in the wrong place when holding down the delete + // key. In order to get around this we need to "jiggle" the caret loose. Since this bug only + // exists on iOS, we only bother to install the listener on iOS. + if (_platform.IOS) { + _renderer.listen(_elementRef.nativeElement, 'keyup', (event: Event) => { + let el = event.target as HTMLInputElement; + if (!el.value && !el.selectionStart && !el.selectionEnd) { + // Note: Just setting `0, 0` doesn't fix the issue. Setting `1, 1` fixes it for the first + // time that you type text and then hold delete. Toggling to `1, 1` and then back to + // `0, 0` seems to completely fix it. + el.setSelectionRange(1, 1); + el.setSelectionRange(0, 0); + } + }); + } + } + + ngOnChanges() { + this.stateChanges.next(); + } + + ngOnDestroy() { + this.stateChanges.complete(); + } + + ngDoCheck() { + if (this.ngControl) { + // We need to re-evaluate this on every change detection cycle, because there are some + // error triggers that we can't subscribe to (e.g. parent form submissions). This means + // that whatever logic is in here has to be super lean or we risk destroying the performance. + this._updateErrorState(); + } else { + // When the input isn't used together with `@angular/forms`, we need to check manually for + // changes to the native `value` property in order to update the floating label. + this._dirtyCheckNativeValue(); + } + } + + /** Callback for the cases where the focused state of the input changes. */ + _focusChanged(isFocused: boolean) { + if (isFocused !== this.focused) { + this.focused = isFocused; + this.stateChanges.next(); + } + } + + _onInput() { + // This is a noop function and is used to let Angular know whenever the value changes. + // Angular will run a new change detection each time the `input` event has been dispatched. + // It's necessary that Angular recognizes the value change, because when floatingLabel + // is set to false and Angular forms aren't used, the placeholder won't recognize the + // value changes and will not disappear. + // Listening to the input event wouldn't be necessary when the input is using the + // FormsModule or ReactiveFormsModule, because Angular forms also listens to input events. + } + + /** Re-evaluates the error state. This is only relevant with @angular/forms. */ + private _updateErrorState() { + const oldState = this.errorState; + const ngControl = this.ngControl; + const parent = this._parentFormGroup || this._parentForm; + const newState = ngControl && this.errorStateMatcher(ngControl.control as FormControl, parent); + + if (newState !== oldState) { + this.errorState = newState; + this.stateChanges.next(); + } + } + + /** Does some manual dirty checking on the native input `value` property. */ + private _dirtyCheckNativeValue() { + const newValue = this.value; + + if (this._previousNativeValue !== newValue) { + this._previousNativeValue = newValue; + this.stateChanges.next(); + } + } + + /** Make sure the input is a supported type. */ + private _validateType() { + if (MD_INPUT_INVALID_TYPES.indexOf(this._type) > -1) { + throw getMdInputUnsupportedTypeError(this._type); + } + } + + /** Checks whether the input type is one of the types that are never empty. */ + private _isNeverEmpty() { + return this._neverEmptyInputTypes.indexOf(this._type) > -1; + } + + /** Checks whether the input is invalid based on the native validation. */ + private _isBadInput() { + // The `validity` property won't be present on platform-server. + let validity = (this._elementRef.nativeElement as HTMLInputElement).validity; + return validity && validity.badInput; + } + + /** Determines if the component host is a textarea. If not recognizable it returns false. */ + private _isTextarea() { + let nativeElement = this._elementRef.nativeElement; + + // In Universal, we don't have access to `nodeName`, but the same can be achieved with `name`. + // Note that this shouldn't be necessary once Angular switches to an API that resembles the + // DOM closer. + let nodeName = this._platform.isBrowser ? nativeElement.nodeName : nativeElement.name; + return nodeName ? nodeName.toLowerCase() === 'textarea' : false; + } + + // Implemented as part of MdFormFieldControl. + get empty(): boolean { + return !this._isNeverEmpty() && + (this.value == null || this.value === '') && + // Check if the input contains bad input. If so, we know that it only appears empty because + // the value failed to parse. From the user's perspective it is not empty. + // TODO(mmalerba): Add e2e test for bad input case. + !this._isBadInput(); + } + + // Implemented as part of MdFormFieldControl. + setDescribedByIds(ids: string[]) { this._ariaDescribedby = ids.join(' '); } + + // Implemented as part of MdFormFieldControl. + focus() { this._elementRef.nativeElement.focus(); } +} diff --git a/src/lib/module.ts b/src/lib/module.ts index e5fa0671e79b..6d867b1d777a 100644 --- a/src/lib/module.ts +++ b/src/lib/module.ts @@ -48,6 +48,7 @@ import {MdExpansionModule} from './expansion/index'; import {MdTableModule} from './table/index'; import {MdSortModule} from './sort/index'; import {MdPaginatorModule} from './paginator/index'; +import {MdFormFieldModule} from './form-field/index'; const MATERIAL_MODULES = [ MdAutocompleteModule, @@ -60,6 +61,7 @@ const MATERIAL_MODULES = [ MdTableModule, MdDialogModule, MdExpansionModule, + MdFormFieldModule, MdGridListModule, MdIconModule, MdInputModule, diff --git a/src/lib/public_api.ts b/src/lib/public_api.ts index a42ab73d968b..7c4bdffb425a 100644 --- a/src/lib/public_api.ts +++ b/src/lib/public_api.ts @@ -25,6 +25,7 @@ export * from './checkbox/index'; export * from './datepicker/index'; export * from './dialog/index'; export * from './expansion/index'; +export * from './form-field/index'; export * from './grid-list/index'; export * from './icon/index'; export * from './input/index'; diff --git a/src/material-examples/material-module.ts b/src/material-examples/material-module.ts index eb050ff20f7b..c4f234d336cb 100644 --- a/src/material-examples/material-module.ts +++ b/src/material-examples/material-module.ts @@ -8,7 +8,7 @@ import { MdListModule, MdMenuModule, MdProgressBarModule, MdProgressSpinnerModule, MdRadioModule, MdSelectModule, MdSidenavModule, MdSliderModule, MdSortModule, MdSlideToggleModule, MdSnackBarModule, MdTableModule, MdTabsModule, MdToolbarModule, - MdTooltipModule + MdTooltipModule, MdFormFieldModule } from '@angular/material'; @NgModule({ @@ -22,6 +22,7 @@ import { MdChipsModule, MdDatepickerModule, MdDialogModule, + MdFormFieldModule, MdGridListModule, MdIconModule, MdInputModule, diff --git a/src/material-examples/table-filtering/table-filtering-example.css b/src/material-examples/table-filtering/table-filtering-example.css index cd8b70d5b9fa..5d643e27561d 100644 --- a/src/material-examples/table-filtering/table-filtering-example.css +++ b/src/material-examples/table-filtering/table-filtering-example.css @@ -22,7 +22,7 @@ justify-content: space-between; } -.mat-input-container { +.mat-form-field { font-size: 14px; flex-grow: 1; margin-left: 32px; diff --git a/src/material-examples/table-overview/table-overview-example.css b/src/material-examples/table-overview/table-overview-example.css index 50e84d1bbecd..c5c27f6fa424 100644 --- a/src/material-examples/table-overview/table-overview-example.css +++ b/src/material-examples/table-overview/table-overview-example.css @@ -16,7 +16,7 @@ border-bottom: 1px solid transparent; } -.mat-input-container { +.mat-form-field { font-size: 14px; flex-grow: 1; margin-top: 8px; diff --git a/src/universal-app/kitchen-sink/kitchen-sink.ts b/src/universal-app/kitchen-sink/kitchen-sink.ts index cf5b756795c1..73c641203a1b 100644 --- a/src/universal-app/kitchen-sink/kitchen-sink.ts +++ b/src/universal-app/kitchen-sink/kitchen-sink.ts @@ -10,6 +10,7 @@ import { MdDatepickerModule, MdDialogModule, MdExpansionModule, + MdFormFieldModule, MdGridListModule, MdIconModule, MdInputModule, @@ -72,6 +73,7 @@ export class KitchenSink { MdChipsModule, MdDatepickerModule, MdDialogModule, + MdFormFieldModule, MdGridListModule, MdIconModule, MdInputModule,