diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 80fc29e372d0..96e3fea241bc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -96,7 +96,9 @@ /src/material-experimental/mdc-checkbox/** @mmalerba /src/material-experimental/mdc-chips/** @mmalerba /src/material-experimental/mdc-dialog/** @devversion +/src/material-experimental/mdc-form-field/** @devversion @mmalerba /src/material-experimental/mdc-helpers/** @mmalerba +/src/material-experimental/mdc-input/** @devversion @mmalerba /src/material-experimental/mdc-list/** @mmalerba /src/material-experimental/mdc-menu/** @crisbeto /src/material-experimental/mdc-select/** @crisbeto @@ -160,6 +162,7 @@ /src/dev-app/mdc-card/** @mmalerba /src/dev-app/mdc-checkbox/** @mmalerba /src/dev-app/mdc-chips/** @mmalerba +/src/dev-app/mdc-input/** @devversion @mmalerba /src/dev-app/mdc-menu/** @crisbeto /src/dev-app/mdc-progress-bar/** @crisbeto /src/dev-app/mdc-radio/** @mmalerba diff --git a/rollup-globals.bzl b/rollup-globals.bzl index ce23975ec919..2f0ae3966c3b 100644 --- a/rollup-globals.bzl +++ b/rollup-globals.bzl @@ -65,7 +65,7 @@ ROLLUP_GLOBALS = { "@material/tab-bar": "mdc.tabBar", "@material/tab-indicator": "mdc.tabIndicator", "@material/tab-scroller": "mdc.tabScroller", - "@material/text-field": "mdc.textField", + "@material/textfield": "mdc.textfield", "@material/top-app-bar": "mdc.topAppBar", # Third-party libraries. diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index b392d425ed2a..6a78c7c0fd97 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -50,6 +50,7 @@ ng_module( "//src/dev-app/mdc-card", "//src/dev-app/mdc-checkbox", "//src/dev-app/mdc-chips", + "//src/dev-app/mdc-input", "//src/dev-app/mdc-menu", "//src/dev-app/mdc-progress-bar", "//src/dev-app/mdc-radio", diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index ce74df90bbb6..192858a501e5 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -73,6 +73,7 @@ export class DevAppLayout { {name: 'MDC Card', route: '/mdc-card'}, {name: 'MDC Checkbox', route: '/mdc-checkbox'}, {name: 'MDC Chips', route: '/mdc-chips'}, + {name: 'MDC Input', route: '/mdc-input'}, {name: 'MDC Menu', route: '/mdc-menu'}, {name: 'MDC Radio', route: '/mdc-radio'}, {name: 'MDC Progress Bar', route: '/mdc-progress-bar'}, diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index 3d5a29bda459..09d2a91e3c29 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -63,6 +63,7 @@ export const DEV_APP_ROUTES: Routes = [ loadChildren: 'mdc-progress-bar/mdc-progress-bar-demo-module#MdcProgressBarDemoModule' }, {path: 'mdc-chips', loadChildren: 'mdc-chips/mdc-chips-demo-module#MdcChipsDemoModule'}, + {path: 'mdc-input', loadChildren: 'mdc-input/mdc-input-demo-module#MdcInputDemoModule'}, {path: 'mdc-menu', loadChildren: 'mdc-menu/mdc-menu-demo-module#MdcMenuDemoModule'}, {path: 'mdc-radio', loadChildren: 'mdc-radio/mdc-radio-demo-module#MdcRadioDemoModule'}, { diff --git a/src/dev-app/mdc-input/BUILD.bazel b/src/dev-app/mdc-input/BUILD.bazel new file mode 100644 index 000000000000..5b8258a52031 --- /dev/null +++ b/src/dev-app/mdc-input/BUILD.bazel @@ -0,0 +1,34 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_module", "sass_binary") + +ng_module( + name = "mdc-input", + srcs = glob(["**/*.ts"]), + assets = [ + ":mdc_input_demo_scss", + "mdc-input-demo.html", + ], + deps = [ + "//src/material-experimental/mdc-form-field", + "//src/material-experimental/mdc-input", + "//src/material/autocomplete", + "//src/material/button", + "//src/material/button-toggle", + "//src/material/card", + "//src/material/checkbox", + "//src/material/icon", + "//src/material/tabs", + "//src/material/toolbar", + "@npm//@angular/forms", + "@npm//@angular/router", + ], +) + +sass_binary( + name = "mdc_input_demo_scss", + src = "mdc-input-demo.scss", + deps = [ + "//src/cdk/text-field:text_field_scss_lib", + ], +) diff --git a/src/dev-app/mdc-input/mdc-input-demo-module.ts b/src/dev-app/mdc-input/mdc-input-demo-module.ts new file mode 100644 index 000000000000..23bf722afe38 --- /dev/null +++ b/src/dev-app/mdc-input/mdc-input-demo-module.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright Google LLC 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 {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatFormFieldModule} from '@angular/material-experimental/mdc-form-field'; +import {MatInputModule} from '@angular/material-experimental/mdc-input'; +import {MatAutocompleteModule} from '@angular/material/autocomplete'; +import {MatButtonModule} from '@angular/material/button'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; +import {MatCardModule} from '@angular/material/card'; +import {MatCheckboxModule} from '@angular/material/checkbox'; +import {MatIconModule} from '@angular/material/icon'; +import {MatTabsModule} from '@angular/material/tabs'; +import {MatToolbarModule} from '@angular/material/toolbar'; +import {RouterModule} from '@angular/router'; +import {MdcInputDemo} from './mdc-input-demo'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatButtonToggleModule, + MatCardModule, + MatCheckboxModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatTabsModule, + MatToolbarModule, + ReactiveFormsModule, + RouterModule.forChild([{path: '', component: MdcInputDemo}]), + ], + declarations: [MdcInputDemo], +}) +export class MdcInputDemoModule {} diff --git a/src/dev-app/mdc-input/mdc-input-demo.html b/src/dev-app/mdc-input/mdc-input-demo.html new file mode 100644 index 000000000000..05be065daee2 --- /dev/null +++ b/src/dev-app/mdc-input/mdc-input-demo.html @@ -0,0 +1,652 @@ + + Basic + +
+ + Company (disabled) + + + + + + +
+ + + + + + Long last name that will be truncated + + +
+

+ + Address + + + + Address 2 + + +

+ + + + +
+ + City + + + + + State + + + + + Postal code + + + mode_edit + {{postalCode.value.length}} / 5 + + +
+
+
+
+ + + Error messages + +

Regular

+ +

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

+ +

With hint

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

Inside a form

+ + + Example + + This field is required + + + +
+ +

With a custom error function

+ + Example + + This field is required + + +
+
+ + + Prefix + Suffix + +

Text

+ + Amount + + + .00 + + +

Icons

+ + Amount + + calendar_today + mode_edit + + +

Icon buttons

+ + Amount + + + + + +

+ + Fill + Outline + +

+
+
+ + + Divider Colors + +

Input

+ + Default color + + + + Accent color + + + + Warn color + + + +

Textarea

+ + Default color + + + + Accent color + + + + Warn color + + + +

With error

+ + Default color + + This field is required + + + Accent color + + This field is required + + + Warn color + + This field is required + +
+
+ + + Hints + +

Input

+

+ + Character count (100 max) + + + {{characterCountInputHintExample.value.length}} / 100 + + +

+ +

Textarea

+

+ + Character count (100 max) + + + {{characterCountTextareaHintExample.value.length}} / 100 + + +

+
+
+ + + + + Hello  + + First name + + , + how are you? + + + +

+ + Disabled field + + + + Required field + + +

+

+ + 100% width label + + +

+

+ + Character count (100 max) + + {{input.value.length}} / 100 + +

+

+ + Show hint label + + +

+ +

+ + + + I favorite bold label + + + I also home italic hint labels + + +

+

+ + Show hint label with character count + + {{hintLabelWithCharCount.value.length}} + +

+

+ + Enter text to count + + + Enter some text to count how many characters are in it. The character count is shown on + the right. + + + Length: {{longHint.value.length}} + + +

+

+ Check to change the color: + + + +

+

+ Check to make required: + + {{requiredField ? 'Required field' : 'Not required field'}} + + +

+

+ Check to hide the required marker: + + + {{hideRequiredMarker ? 'Required Without Marker' : 'Required With Marker'}} + + + +

+

+ + Auto Float + Always Float + +

+ +

+ + Label + + +

+ +

+ + What is your favorite color? + + +

+ +

+ + Prefixed + +

Example: 
+ + + Suffixed + + .00 € + +
+ Both: + + Email address + + email  +  @gmail.com + +

+ +

+ Empty: + +

+
+
+ + + Number Inputs + + + + + + + + + + + + +
Table + + + + +
{{i+1}} + + Value + + + + + {{item.value}}
+
+
+ + + Forms + + + Reactive + + + + Template + + + + +
+ + Delayed value + + +
+
+
+ + + + Floating labels + + +
+ + + + + + + + + + Only label + + + + + Label and placeholder + + + + + Always float + + + + + Label w/ placeholder + + +
+ + + + +
+
+ + + Textarea Autosize + +

Regular <textarea>

+ + +

Regular <textarea> with maxRows and minRows

+
+   + +
+ + + +

<textarea> with mat-form-field

+
+ + Autosized textarea + + +
+ +

<textarea> with ngModel

+
+ +
+ + +

<textarea> with bindable autosize

+ + Autosize enabled + + +
+
+ + + Appearance + + + Fill appearance + + This field is required + Please type something here + + + + Outline appearance + + This field is required + Please type something here + + + + + +
+ + Fill appearance + + This field is required + Please type something here + + + + Outline appearance + + This field is required + Please type something here + +
+
+
+ + + Autofill + +
+ + Use custom autofill style + + + Autofill monitored + + + + is autofilled? {{isAutofilled ? 'yes' : 'no'}} +
+
+
+ + + Textarea form-fields + + + Label + + + + Label + + +

+ Disable textarea form-fields +

+
+
+ + + Appearance toggle + + + Label + + +

+ + Fill + Outline + +

+
+
+ + + Autocomplete + + + Pick Number + + + {{option}} + + + + + + + + Outline form field in a tab + + + + + Tab 1 input + + + + + + Tab 2 input + + + + + + diff --git a/src/dev-app/mdc-input/mdc-input-demo.scss b/src/dev-app/mdc-input/mdc-input-demo.scss new file mode 100644 index 000000000000..4e7a58d008ec --- /dev/null +++ b/src/dev-app/mdc-input/mdc-input-demo.scss @@ -0,0 +1,45 @@ +@import '../../cdk/text-field/text-field'; + +.demo-basic { + padding: 0; +} + +.demo-basic .mat-card-content { + padding: 16px; +} + +.demo-horizontal-spacing { + margin: 0 12px; +} + +.demo-full-width { + width: 100%; +} + +.demo-card { + margin: 16px; + + mat-card-content { + font-size: 16px; + } +} + +.demo-text-align-end { + text-align: end; +} + +.demo-textarea { + resize: none; + border: none; + overflow: auto; + padding: 0; + background: lightblue; +} + +.demo-custom-autofill-style { + @include cdk-text-field-autofill-color(transparent, red); +} + +.demo-rows { + width: 30px; +} diff --git a/src/dev-app/mdc-input/mdc-input-demo.ts b/src/dev-app/mdc-input/mdc-input-demo.ts new file mode 100644 index 000000000000..518025d44048 --- /dev/null +++ b/src/dev-app/mdc-input/mdc-input-demo.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright Google LLC 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 {ChangeDetectionStrategy, Component} from '@angular/core'; +import {FormControl, Validators} from '@angular/forms'; +import {ErrorStateMatcher, FloatLabelType} from '@angular/material/core'; +import {MatFormFieldAppearance} from '@angular/material-experimental/mdc-form-field'; + + +let max = 5; + +const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'mdc-input-demo', + templateUrl: 'mdc-input-demo.html', + styleUrls: ['mdc-input-demo.css'], +}) +export class MdcInputDemo { + floatingLabel: FloatLabelType = 'auto'; + color: boolean; + requiredField: boolean; + disableTextarea: boolean; + hideRequiredMarker: boolean; + ctrlDisabled = false; + textareaNgModelValue: string; + textareaAutosizeEnabled = false; + appearance: MatFormFieldAppearance = 'fill'; + prefixSuffixAppearance: MatFormFieldAppearance = 'fill'; + placeholderTestControl = new FormControl('', Validators.required); + options: string[] = ['One', 'Two', 'Three']; + + name: string; + errorMessageExample1: string; + errorMessageExample2: string; + errorMessageExample3: string; + errorMessageExample4: string; + dividerColorExample1: string; + dividerColorExample2: string; + dividerColorExample3: string; + items: {value: number}[] = [ + {value: 10}, + {value: 20}, + {value: 30}, + {value: 40}, + {value: 50}, + ]; + rows = 8; + formControl = new FormControl('hello', Validators.required); + emailFormControl = new FormControl('', [Validators.required, Validators.pattern(EMAIL_REGEX)]); + delayedFormControl = new FormControl(''); + model = 'hello'; + isAutofilled = false; + customAutofillStyle = true; + + legacyAppearance: string; + standardAppearance: string; + fillAppearance: string; + outlineAppearance: string; + + constructor() { + setTimeout(() => this.delayedFormControl.setValue('hello'), 100); + } + + addABunch(n: number) { + for (let x = 0; x < n; x++) { + this.items.push({ value: ++max }); + } + } + + customErrorStateMatcher: ErrorStateMatcher = { + isErrorState: (control: FormControl | null) => { + if (control) { + const hasInteraction = control.dirty || control.touched; + const isInvalid = control.invalid; + + return !!(hasInteraction && isInvalid); + } + + return false; + } + }; + + togglePlaceholderTestValue() { + this.placeholderTestControl.setValue(this.placeholderTestControl.value === '' ? 'Value' : ''); + } + + togglePlaceholderTestTouched() { + this.placeholderTestControl.touched ? + this.placeholderTestControl.markAsUntouched() : + this.placeholderTestControl.markAsTouched(); + } + + parseNumber(value: string): number { + return Number(value); + } +} diff --git a/src/dev-app/system-config-tmpl.js b/src/dev-app/system-config-tmpl.js index 4bccbd526fba..8c00c1b22e5f 100644 --- a/src/dev-app/system-config-tmpl.js +++ b/src/dev-app/system-config-tmpl.js @@ -121,7 +121,7 @@ var map = Object.assign({ '@material/tab-bar': '@material/tab-bar/dist/mdc.tabBar.js', '@material/tab-indicator': '@material/tab-indicator/dist/mdc.tabIndicator.js', '@material/tab-scroller': '@material/tab-scroller/dist/mdc.tabScroller.js', - '@material/text-field': '@material/textfield/dist/mdc.textfield.js', + '@material/textfield': '@material/textfield/dist/mdc.textfield.js', '@material/top-app-bar': '@material/top-app-bar/dist/mdc.topAppBar.js' }, pathMapping); diff --git a/src/material-experimental/config.bzl b/src/material-experimental/config.bzl index 3dd94266f45c..80258a224e98 100644 --- a/src/material-experimental/config.bzl +++ b/src/material-experimental/config.bzl @@ -7,7 +7,9 @@ entryPoints = [ "mdc-checkbox/testing", "mdc-chips", "mdc-chips/testing", + "mdc-form-field", "mdc-helpers", + "mdc-input", "mdc-list", "mdc-menu", "mdc-menu/testing", diff --git a/src/material-experimental/mdc-form-field/BUILD.bazel b/src/material-experimental/mdc-form-field/BUILD.bazel new file mode 100644 index 000000000000..ae6ea1ee56c5 --- /dev/null +++ b/src/material-experimental/mdc-form-field/BUILD.bazel @@ -0,0 +1,108 @@ +package(default_visibility = ["//visibility:public"]) + +load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") +load( + "//tools:defaults.bzl", + "ng_e2e_test_library", + "ng_module", + "sass_binary", + "sass_library", +) + +ng_module( + name = "mdc-form-field", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + assets = [":form_field_scss"] + glob(["**/*.html"]), + module_name = "@angular/material-experimental/mdc-form-field", + deps = [ + "//src/cdk/observers", + "//src/material/core", + "//src/material/form-field", + "@npm//@angular/forms", + "@npm//@material/floating-label", + "@npm//@material/line-ripple", + "@npm//@material/textfield", + "@npm//rxjs", + ], +) + +sass_library( + name = "mdc_form_field_scss_lib", + srcs = ["_mdc-form-field.scss"], + deps = [ + ":form_field_partials", + "//src/cdk/a11y:a11y_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + ], +) + +sass_binary( + name = "form_field_scss", + src = "form-field.scss", + include_paths = [ + "external/npm/node_modules", + ], + deps = [ + ":form_field_partials", + ":mdc_form_field_scss", + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + ], +) + +# TODO(devversion): Import all of the individual Sass mixins once feature targeting is available +# for MDC text-field, notched-outline, floating-label and line-ripple. +sass_library( + name = "mdc_form_field_scss", + srcs = [ + "@npm//:node_modules/@material/floating-label/mdc-floating-label.scss", + "@npm//:node_modules/@material/line-ripple/mdc-line-ripple.scss", + "@npm//:node_modules/@material/notched-outline/mdc-notched-outline.scss", + "@npm//:node_modules/@material/ripple/common.scss", + "@npm//:node_modules/@material/textfield/_functions.scss", + "@npm//:node_modules/@material/textfield/character-counter/mdc-text-field-character-counter.scss", + "@npm//:node_modules/@material/textfield/helper-text/mdc-text-field-helper-text.scss", + "@npm//:node_modules/@material/textfield/icon/mdc-text-field-icon.scss", + "@npm//:node_modules/@material/textfield/mdc-text-field.scss", + ], + deps = [ + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + ], +) + +sass_library( + name = "form_field_partials", + srcs = [ + "_form-field-bottom-line.scss", + "_form-field-sizing.scss", + "_form-field-subscript.scss", + "_mdc-text-field-structure-overrides.scss", + "_mdc-text-field-textarea-overrides.scss", + ], + deps = [ + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + ], +) + +########### +# Testing +########### + +ng_e2e_test_library( + name = "e2e_test_sources", + srcs = glob(["**/*.e2e.spec.ts"]), + deps = [ + "//src/cdk/testing/private/e2e", + ], +) + +e2e_test_suite( + name = "e2e_tests", + deps = [ + ":e2e_test_sources", + "//src/cdk/testing/private/e2e", + ], +) diff --git a/src/material-experimental/mdc-form-field/README.md b/src/material-experimental/mdc-form-field/README.md new file mode 100644 index 000000000000..92c84ec0b6f6 --- /dev/null +++ b/src/material-experimental/mdc-form-field/README.md @@ -0,0 +1 @@ +This is a placeholder for the MDC-based implementation of form-field. diff --git a/src/material-experimental/mdc-form-field/_form-field-bottom-line.scss b/src/material-experimental/mdc-form-field/_form-field-bottom-line.scss new file mode 100644 index 000000000000..765905659cff --- /dev/null +++ b/src/material-experimental/mdc-form-field/_form-field-bottom-line.scss @@ -0,0 +1,36 @@ +@import 'form-field-sizing'; +@import '../mdc-helpers/mdc-helpers'; + +@mixin _mat-form-field-bottom-line() { + // Bottom line for form-fields. MDC by default only has a bottom-line for inputs + // and textareas. This does not work for us because we support abstract form-field + // controls which might not render an input or textarea. Additionally, the default MDC + // bottom-line does only cover the width of the input, while we also want it to cover + // prefixes and suffixes. + .mat-mdc-form-field-bottom-line { + position: absolute; + bottom: 0; + width: 100%; + height: 1px; + border-bottom-width: 1px; + border-bottom-style: solid; + } +} + +@mixin _mat-form-field-bottom-line-theme() { + // Sets the color of the bottom-line. Our custom bottom-line is based on the default + // MDC bottom-line that only works for inputs and textareas (hence we need a custom + // bottom-line that works for all type of form-field controls). To replicate the + // appearance of the default MDC bottom-line, we use the same theming variables. + .mat-mdc-form-field-bottom-line { + @include mdc-theme-prop(border-bottom-color, $mdc-text-field-bottom-line-idle); + + .mdc-text-field--disabled & { + @include mdc-theme-prop(border-bottom-color, $mdc-text-field-disabled-border); + } + + .mdc-text-field--invalid & { + @include mdc-theme-prop(border-bottom-color, $mdc-text-field-error); + } + } +} diff --git a/src/material-experimental/mdc-form-field/_form-field-sizing.scss b/src/material-experimental/mdc-form-field/_form-field-sizing.scss new file mode 100644 index 000000000000..91961a0487d6 --- /dev/null +++ b/src/material-experimental/mdc-form-field/_form-field-sizing.scss @@ -0,0 +1,17 @@ +@import '@material/textfield/variables'; + +// Top spacing of the form-field outline. MDC does not have a variable for this +// and just hard-codes it into their styles. +$mat-form-field-outline-top-spacing: 12px; + +// Baseline based on the default height of the MDC text-field. +$mat-form-field-baseline: $mdc-text-field-height / 2; + +// Infix stretches to fit the container, but naturally wants to be this wide. We set +// this in order to have a a consistent natural size for the various types of controls +// that can go in a form field. +$mat-form-field-default-infix-width: 180px !default; + +// Minimum amount of space between start and end hints in the subscript. MDC does not +// have built-in support for hints. +$mat-form-field-hint-min-space: 1em !default; diff --git a/src/material-experimental/mdc-form-field/_form-field-subscript.scss b/src/material-experimental/mdc-form-field/_form-field-subscript.scss new file mode 100644 index 000000000000..2e8af414919a --- /dev/null +++ b/src/material-experimental/mdc-form-field/_form-field-subscript.scss @@ -0,0 +1,67 @@ +@import 'form-field-sizing'; +@import '../mdc-helpers/mdc-helpers'; + +@mixin _mat-form-field-subscript() { + // Wrapper for the hints and error messages. + .mat-mdc-form-field-subscript-wrapper { + box-sizing: border-box; + width: 100%; + // prevents multi-line errors from overlapping the control. + overflow: hidden; + } + + // Scale down icons in the subscript to be the same size as the text. + .mat-mdc-form-field-subscript-wrapper .mat-icon { + width: 1em; + height: 1em; + font-size: inherit; + vertical-align: baseline; + } + + // Clears the floats on the hints. This is necessary for the hint animation to work. + .mat-mdc-form-field-hint-wrapper { + display: flex; + } + + // Spacer used to make sure start and end hints have enough space between them. + .mat-mdc-form-field-hint-spacer { + flex: 1 0 $mat-form-field-hint-min-space; + } + + // Single error message displayed beneath the form field underline. + .mat-mdc-form-field-error { + display: block; + } +} + +@mixin _mat-form-field-subscript-theme() { + // MDC does not have built-in error treatment. + .mat-mdc-form-field-error { + @include mdc-theme-prop(color, $mdc-text-field-error); + } +} + +@mixin _mat-form-field-subscript-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 subscript. + $subscript-font-scale: 0.75; + // Font size to use for the subscript text. + $subscript-font-size: $subscript-font-scale * 100%; + // The space between the bottom of the text-field area and the subscript. Mocks in the spec 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. + $subscript-margin-top: 0.5em / $subscript-font-scale; + // The minimum height applied to the subscript to reserve space for subscript text. 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 subscript has a reduced font size. + $subscript-min-height: ($subscript-margin-top + $line-height) * $subscript-font-scale; + + // The subscript wrapper has a minimum height to avoid that the form-field + // jumps when hints or errors are displayed. + .mat-mdc-form-field-subscript-wrapper { + min-height: $subscript-min-height; + font-size: $subscript-font-size; + margin-top: $subscript-margin-top; + } +} diff --git a/src/material-experimental/mdc-form-field/_mdc-form-field.scss b/src/material-experimental/mdc-form-field/_mdc-form-field.scss new file mode 100644 index 000000000000..b68d267f42ea --- /dev/null +++ b/src/material-experimental/mdc-form-field/_mdc-form-field.scss @@ -0,0 +1,17 @@ +@import '@material/textfield/variables'; +@import '../mdc-helpers/mdc-helpers'; +@import 'form-field-subscript'; +@import 'form-field-bottom-line'; + +@mixin mat-form-field-theme-mdc($theme) { + @include mat-using-mdc-theme($theme) { + @include _mat-form-field-subscript-theme(); + @include _mat-form-field-bottom-line-theme(); + } +} + +@mixin mat-form-field-typography-mdc($config) { + @include mat-using-mdc-typography($config) { + @include _mat-form-field-subscript-typography($config); + } +} diff --git a/src/material-experimental/mdc-form-field/_mdc-text-field-structure-overrides.scss b/src/material-experimental/mdc-form-field/_mdc-text-field-structure-overrides.scss new file mode 100644 index 000000000000..f5df7d8d385e --- /dev/null +++ b/src/material-experimental/mdc-form-field/_mdc-text-field-structure-overrides.scss @@ -0,0 +1,80 @@ +@import 'form-field-sizing'; + +// Mixin that can be included to override the default MDC text-field +// styles to fit our needs. See individual comments for context on why +// certain MDC styles need to be modified. +@mixin _mat-mdc-text-field-structure-overrides() { + // Always hide the asterisk displayed by MDC. This is necessary because MDC can only display + // the asterisk if the label is directly preceded by the input. MDC does this because it + // relies on CSS to figure out if the input/textarea is required. This does not apply for us + // because it's not guaranteed that the form control is an input/textarea. The required state + // is handled as part of the registered form-field control instance. The asterisk will be + // rendered conditionally through the floating label. + .mat-mdc-form-field .mdc-floating-label::after { + display: none; + } + + .mat-mdc-input-element { + // By default, MDC limits the height of a form-field to the height of an input, and + // overwrites the height to "auto" if a textarea is used. As mentioned in the comment + // above for the floating label, we do not know what type of control is used, so we + // shift the fixed height to the actual input element and let the form-field adjust + // based on the needed height. This makes it work for both input and textarea. + height: $mdc-text-field-height; + + // Unset the border set by MDC. We move the border (which serves as the Material Design + // text-field bottom line) into its own element. This is necessary because we want the + // bottom-line to span across the whole form-field (including prefixes and suffixes). + border: none; + } + + // Root element of the mdc-text-field. As explained in the height overwrites above, MDC + // sets a default height on the text-field root element. This is not desired since we + // want the element to be able to expand as needed. + .mat-mdc-text-field-wrapper { + height: auto; + flex: auto; + } + + // The default MDC text-field implementation does not support labels which always float. + // MDC only renders the placeholder if the input is focused. We extend this to show the + // placeholder if the form-field label is set to always float. + // TODO(devversion): consider getting a mixin or variables for this (currently not available). + // Stylelint no-prefixes rule disabled because MDC text-field only uses "::placeholder" too. + /* stylelint-disable-next-line material/no-prefixes */ + .mat-mdc-form-field-label-always-float .mdc-text-field__input::placeholder { + transition-delay: 40ms; + transition-duration: 110ms; + opacity: 1; + } + + // The additional nesting is a temporary until the notched-outline is decoupled from the + // floating label. See https://github.com/material-components/material-components-web/issues/5326 + // TODO(devversion): Remove this workaround/nesting once the feature is available. + .mat-mdc-text-field-wrapper:not(.mdc-text-field--outlined) { + // Reset horizontal spacing of the input. This is necessary because we move the horizontal + // spacing to the form-field flex container. We do this because prefixes and suffixes should + // adjoin the actual control infix. The spacing is still needed for outline and fill + // appearances and should surround the prefixes, suffixes and infix. Note that we need + // increased specificity because MDC has high specificity for the default padding styles. + .mat-mdc-input-element { + padding: { + left: 0; + right: 0; + } + } + + // We removed the horizontal inset on input elements, but need to re-add the spacing to + // the actual form-field flex container that contains the prefixes, suffixes and infix. + .mat-mdc-form-field-flex { + padding: 0 $mdc-text-field-input-padding; + } + + // Since we moved the horizontal spacing from the input to the form-field flex container + // and the MDC floating label tries to account for the horizontal spacing, we need to reset + // the shifting since there is no padding the label needs to account for. + .mdc-floating-label { + left: 0; + } + } +} diff --git a/src/material-experimental/mdc-form-field/_mdc-text-field-textarea-overrides.scss b/src/material-experimental/mdc-form-field/_mdc-text-field-textarea-overrides.scss new file mode 100644 index 000000000000..4097d3acdaf7 --- /dev/null +++ b/src/material-experimental/mdc-form-field/_mdc-text-field-textarea-overrides.scss @@ -0,0 +1,51 @@ +@import 'form-field-sizing'; + +// MDCs default textarea styles cannot be used because they only apply if a special +// class is applied to the "mdc-text-field" wrapper. Since we cannot know whether the +// registered form-field control is a textarea and MDC by default does not have styles +// for textareas in the fill appearance, we add our own minimal textarea styles +// which are scoped to the actual textarea element (i.e. not require a class in the +// text-field wrapper) and integrate better with the any configured appearance. + +// Mixin that can be included to override the default MDC text-field styles +// to properly support textareas. +@mixin _mat-mdc-text-field-textarea-overrides() { + // Ensures that textarea elements inside of the form-field have proper vertical spacing + // to account for the floating label. Also ensures that there is no vertical text overflow. + // TODO(devversion): remove extra specificity if we removed the ":not(outline-appearance)" + // selector from the structure overwrites. Blocked on material-components-web#5326. + .mat-mdc-form-field > .mat-mdc-text-field-wrapper .mat-mdc-form-field-infix + .mat-mdc-textarea-input { + resize: vertical; + box-sizing: border-box; + height: auto; + // Using padding for textareas causes a bad user experience because the text outside + // of the text box will overflow vertically. In order to avoid this overflow where small + // parts of the previous/followed lines are visible, we use margin for vertical spacing. + margin: $mat-form-field-baseline 0 $mdc-text-field-input-padding-bottom; + padding: 0; + border: none; + } + + // By default, MDC aligns the label using percentage. This will be overwritten based + // on whether a textarea is used. This is not possible in our implementation of the + // form-field because we do not know what type of form-field control is set up. Hence + // we always use a fixed position for the label. This does not have any implications. + .mat-mdc-text-field-wrapper .mdc-floating-label { + top: $mat-form-field-baseline; + } + + // In the outline appearance, the textarea needs slightly reduced top spacing because + // the label overflows the outline by 50%. Additionally, horizontal spacing needs to be + // added since the outline is part of the "infix" and we need to account for the outline. + // TODO(devversion): horizontal spacing and extra specificity can be removed once the + // following feature is available: material-components-web#5326. + .mat-mdc-form-field > .mat-mdc-text-field-wrapper.mdc-text-field--outlined + .mat-mdc-form-field-infix .mat-mdc-textarea-input { + margin-top: $mat-form-field-outline-top-spacing; + padding: { + left: $mdc-text-field-input-padding; + right: $mdc-text-field-input-padding; + } + } +} diff --git a/src/material-experimental/mdc-form-field/directives/error.ts b/src/material-experimental/mdc-form-field/directives/error.ts new file mode 100644 index 000000000000..1dc62bab0b43 --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/error.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC 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: 'mat-error', + host: { + 'class': 'mat-mdc-form-field-error', + 'role': 'alert', + '[id]': 'id', + } +}) +export class MatError { + @Input() id: string = `mat-error-${nextUniqueId++}`; +} diff --git a/src/material-experimental/mdc-form-field/directives/floating-label.ts b/src/material-experimental/mdc-form-field/directives/floating-label.ts new file mode 100644 index 000000000000..c9d091154c35 --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/floating-label.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC 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, ElementRef, Input, OnDestroy} from '@angular/core'; +import {MDCFloatingLabel} from '@material/floating-label'; + +/** + * Internal directive that creates an instance of the MDC floating label + * component. Using a directive allows us to conditionally render a floating label + * in the template without having to manually instantiate the `MDCFloatingLabel` component. + * + * The component is responsible for setting up the floating label styles, and for providing + * an @Input that can be used by the form-field to toggle floating state of the label. + */ +@Directive({ + selector: 'label[matFormFieldFloatingLabel]', + host: { + 'class': 'mdc-floating-label', + }, +}) +export class MatFormFieldFloatingLabel extends MDCFloatingLabel implements OnDestroy { + + @Input() + get floating() { return this._floating; } + set floating(shouldFloat: boolean) { + if (shouldFloat !== this._floating) { + this._floating = shouldFloat; + this.float(shouldFloat); + } + } + private _floating = false; + + constructor(elementRef: ElementRef) { + super(elementRef.nativeElement); + } + + ngOnDestroy() { + this.destroy(); + } +} diff --git a/src/material-experimental/mdc-form-field/directives/hint.ts b/src/material-experimental/mdc-form-field/directives/hint.ts new file mode 100644 index 000000000000..073a93d4b2dc --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/hint.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC 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: 'mat-hint', + host: { + 'class': 'mat-mdc-form-field-hint', + '[class.mat-form-field-hint-end]': 'align == "end"', + '[id]': 'id', + // Remove align attribute to prevent it from interfering with layout. + '[attr.align]': 'null', + } +}) +export class MatHint { + /** 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/material-experimental/mdc-form-field/directives/label.ts b/src/material-experimental/mdc-form-field/directives/label.ts new file mode 100644 index 000000000000..3801266ca371 --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/label.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC 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 label for a `mat-form-field`. */ +@Directive({ + selector: 'mat-label', +}) +export class MatLabel { +} diff --git a/src/material-experimental/mdc-form-field/directives/line-ripple.ts b/src/material-experimental/mdc-form-field/directives/line-ripple.ts new file mode 100644 index 000000000000..98a048ac4dff --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/line-ripple.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC 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, ElementRef, OnDestroy} from '@angular/core'; +import {MDCLineRipple} from '@material/line-ripple'; + +/** + * Internal directive that creates an instance of the MDC line-ripple component. Using a + * directive allows us to conditionally render a line-ripple in the template without having + * to manually create and destroy the `MDCLineRipple` component whenever the condition changes. + * + * The directive sets up the styles for the line-ripple and provides an API for activating + * and deactivating the line-ripple. + */ +@Directive({ + selector: 'div[matFormFieldLineRipple]', + host: { + 'class': 'mdc-line-ripple', + }, +}) +export class MatFormFieldLineRipple extends MDCLineRipple implements OnDestroy { + constructor(elementRef: ElementRef) { + super(elementRef.nativeElement); + } + + ngOnDestroy() { + this.destroy(); + } +} diff --git a/src/material-experimental/mdc-form-field/directives/notched-outline.html b/src/material-experimental/mdc-form-field/directives/notched-outline.html new file mode 100644 index 000000000000..08b287042c07 --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/notched-outline.html @@ -0,0 +1,5 @@ +
+
+ +
+
diff --git a/src/material-experimental/mdc-form-field/directives/notched-outline.ts b/src/material-experimental/mdc-form-field/directives/notched-outline.ts new file mode 100644 index 000000000000..98a8c9d77432 --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/notched-outline.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright Google LLC 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 { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, OnDestroy, + ViewEncapsulation +} from '@angular/core'; +import {MDCNotchedOutline} from '@material/notched-outline'; + +/** + * Internal directive that creates an instance of the MDC notched-outline component. Using + * a directive allows us to conditionally render a notched-outline in the template without + * having to manually create and destroy the `MDCNotchedOutline` component whenever the + * appearance changes. + * + * The directive sets up the HTML structure and styles for the notched-outline, but also + * exposes a programmatic API to toggle the state of the notch. + */ +@Component({ + selector: 'div[matFormFieldNotchedOutline]', + templateUrl: './notched-outline.html', + host: { + 'class': 'mdc-notched-outline', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, +}) +export class MatFormFieldNotchedOutline implements AfterViewInit, OnDestroy { + private _mdcNotchedOutline: MDCNotchedOutline; + + constructor(private _elementRef: ElementRef) {} + + ngAfterViewInit() { + // The notch component relies on the view to be initialized. This means + // that we cannot extend from the "MDCNotchedOutline". + this._mdcNotchedOutline = MDCNotchedOutline.attachTo(this._elementRef.nativeElement); + } + + ngOnDestroy() { + this._mdcNotchedOutline.destroy(); + } + + /** + * Updates classes and styles to open the notch to the specified width. + * @param notchWidth The notch width in the outline. + */ + notch(notchWidth: number) { + this._mdcNotchedOutline.notch(notchWidth); + } + + /** Closes the notch. */ + closeNotch() { + this._mdcNotchedOutline.closeNotch(); + } +} diff --git a/src/material-experimental/mdc-form-field/directives/prefix.ts b/src/material-experimental/mdc-form-field/directives/prefix.ts new file mode 100644 index 000000000000..8c5ee9da0ca7 --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/prefix.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC 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 in front of the form field. */ +@Directive({ + selector: '[matPrefix]', +}) +export class MatPrefix {} diff --git a/src/material-experimental/mdc-form-field/directives/suffix.ts b/src/material-experimental/mdc-form-field/directives/suffix.ts new file mode 100644 index 000000000000..9e61eec01cf9 --- /dev/null +++ b/src/material-experimental/mdc-form-field/directives/suffix.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC 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: '[matSuffix]', +}) +export class MatSuffix {} diff --git a/src/material-experimental/mdc-form-field/form-field.e2e.spec.ts b/src/material-experimental/mdc-form-field/form-field.e2e.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/material-experimental/mdc-form-field/form-field.html b/src/material-experimental/mdc-form-field/form-field.html new file mode 100644 index 000000000000..854de64c1267 --- /dev/null +++ b/src/material-experimental/mdc-form-field/form-field.html @@ -0,0 +1,69 @@ + + + + + +
+
+
+ +
+ +
+ + + + + + +
+ +
+ + +
+ +
+ +
+
+ +
+
+
+ +
+
+ +
+ +
+ {{hintLabel}} + +
+ +
+
diff --git a/src/material-experimental/mdc-form-field/form-field.scss b/src/material-experimental/mdc-form-field/form-field.scss new file mode 100644 index 000000000000..ec4716cac20e --- /dev/null +++ b/src/material-experimental/mdc-form-field/form-field.scss @@ -0,0 +1,45 @@ +// TODO(devversion): do not import all text-field styles. Use feature targeting once available. +@import '@material/textfield/mdc-text-field'; +@import 'form-field-sizing'; +@import 'form-field-subscript'; +@import 'form-field-bottom-line'; +@import 'mdc-text-field-textarea-overrides'; +@import 'mdc-text-field-structure-overrides'; + +// MDC text-field overwrites. +@include _mat-mdc-text-field-textarea-overrides(); +@include _mat-mdc-text-field-structure-overrides(); + +// Include the subscript and bottom-line styles. +@include _mat-form-field-subscript(); +@include _mat-form-field-bottom-line(); + +// Host element of the form-field. It contains the mdc-text-field wrapper +// and the subscript wrapper. +.mat-mdc-form-field { + display: inline-flex; + // this container contains the text-field and the subscript. The subscript + // should be displayed below the text-field. Hence the column direction. + flex-direction: column; + vertical-align: middle; +} + +// Container that contains the prefixes, infix and suffixes. These elements should +// be aligned vertically in the baseline and in a single row. +.mat-mdc-form-field-flex { + display: inline-flex; + align-items: baseline; + box-sizing: border-box; + width: 100%; +} + +// Infix that contains the projected content (usually an input or a textarea). We ensure +// that the projected form-field control and content can stretch as needed, but we also +// apply a default infix width to make the form-field's look natural. +.mat-mdc-form-field-infix { + flex: auto; + min-width: 0; + width: $mat-form-field-default-infix-width; + // Needed so that the floating label does not overlap with prefixes or suffixes. + position: relative; +} diff --git a/src/material-experimental/mdc-form-field/form-field.ts b/src/material-experimental/mdc-form-field/form-field.ts new file mode 100644 index 000000000000..5c8b6fa60515 --- /dev/null +++ b/src/material-experimental/mdc-form-field/form-field.ts @@ -0,0 +1,499 @@ +/** + * @license + * Copyright Google LLC 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, InjectionToken, + Input, + isDevMode, + OnDestroy, + Optional, + QueryList, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import {NgControl} from '@angular/forms'; +import { + FloatLabelType, + LabelOptions, + MAT_LABEL_GLOBAL_OPTIONS, + ThemePalette +} from '@angular/material/core'; +import { + getMatFormFieldDuplicatedHintError, + getMatFormFieldMissingControlError, + MatFormField as NonMdcFormField, + matFormFieldAnimations, + MatFormFieldControl, +} from '@angular/material/form-field'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {MDCTextFieldAdapter, MDCTextFieldFoundation} from '@material/textfield'; +import {Subject} from 'rxjs'; +import {takeUntil} from 'rxjs/operators'; +import {MatError} from './directives/error'; +import {MatFormFieldFloatingLabel} from './directives/floating-label'; +import {MatHint} from './directives/hint'; +import {MatLabel} from './directives/label'; +import {MatFormFieldLineRipple} from './directives/line-ripple'; +import {MatFormFieldNotchedOutline} from './directives/notched-outline'; +import {MatPrefix} from './directives/prefix'; +import {MatSuffix} from './directives/suffix'; + +/** Type for the available floatLabel values. */ +export type FloatLabelType = 'always' | 'auto'; + +/** Possible appearance styles for the form field. */ +export type MatFormFieldAppearance = 'fill' | 'outline'; + +/** + * Represents the default options for the form field that can be configured + * using the `MAT_FORM_FIELD_DEFAULT_OPTIONS` injection token. + */ +export interface MatFormFieldDefaultOptions { + appearance?: MatFormFieldAppearance; + hideRequiredMarker?: boolean; +} + +/** + * Injection token that can be used to configure the + * default options for all form field within an app. + */ +export const MAT_FORM_FIELD_DEFAULT_OPTIONS = + new InjectionToken('MAT_FORM_FIELD_DEFAULT_OPTIONS'); + +let nextUniqueId = 0; + +/** Default appearance used by the form-field. */ +const DEFAULT_APPEARANCE: MatFormFieldAppearance = 'fill'; + +/** Default appearance used by the form-field. */ +const DEFAULT_FLOAT_LABEL: FloatLabelType = 'auto'; + +/** Container for form controls that applies Material Design styling and behavior. */ +@Component({ + selector: 'mat-form-field', + exportAs: 'matFormField', + templateUrl: './form-field.html', + styleUrls: ['./form-field.css'], + animations: [matFormFieldAnimations.transitionMessages], + host: { + 'class': 'mat-mdc-form-field', + '[class.mat-mdc-form-field-label-always-float]': '_shouldAlwaysFloat()', + '[class.mat-form-field-invalid]': '_control.errorState', + '[class.mat-form-field-disabled]': '_control.disabled', + '[class.mat-form-field-autofilled]': '_control.autofilled', + '[class.mat-focused]': '_control.focused', + '[class.mat-accent]': 'color == "accent"', + '[class.mat-warn]': 'color == "warn"', + '[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")', + '[class._mat-animation-noopable]': '!_animationsEnabled', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + // Temporary workaround that allows us to test the MDC form-field against + // components which inject the non-mdc form-field (e.g. autocomplete). + {provide: NonMdcFormField, useExisting: MatFormField} + ] +}) +export class MatFormField implements AfterViewInit, OnDestroy, AfterContentChecked, + AfterContentInit { + @ViewChild('textField') _textField: ElementRef; + @ViewChild(MatFormFieldFloatingLabel) _floatingLabel: MatFormFieldFloatingLabel|undefined; + @ViewChild(MatFormFieldNotchedOutline) _notchedOutline: MatFormFieldNotchedOutline|undefined; + @ViewChild(MatFormFieldLineRipple) _lineRipple: MatFormFieldLineRipple|undefined; + + @ContentChild(MatLabel) _labelChildNonStatic: MatLabel|undefined; + @ContentChild(MatLabel, {static: true}) _labelChildStatic: MatLabel|undefined; + @ContentChild(MatFormFieldControl) _formFieldControl: MatFormFieldControl; + @ContentChildren(MatPrefix, {descendants: true}) _prefixChildren: QueryList; + @ContentChildren(MatSuffix, {descendants: true}) _suffixChildren: QueryList; + @ContentChildren(MatError, {descendants: true}) _errorChildren: QueryList; + @ContentChildren(MatHint, {descendants: true}) _hintChildren: QueryList; + + /** Whether the required marker should be hidden. */ + @Input() hideRequiredMarker: boolean = false; + + /** The color palette for the form-field. */ + @Input() color: ThemePalette = 'primary'; + + /** Whether the label should always float or float as the user types. */ + @Input() + get floatLabel(): FloatLabelType { + return this._floatLabel || (this._labelOptions && this._labelOptions.float) + || DEFAULT_FLOAT_LABEL; + } + set floatLabel(value: FloatLabelType) { + if (value !== this._floatLabel) { + this._floatLabel = value; + // For backwards compatibility. Custom form-field controls or directives might set + // the "floatLabel" input and expect the form-field view to be updated automatically. + // e.g. autocomplete trigger. Ideally we'd get rid of this and the consumers would just + // emit the "stateChanges" observable. TODO(devversion): consider removing. + this._changeDetectorRef.markForCheck(); + } + } + private _floatLabel: FloatLabelType; + + /** The form-field appearance style. */ + @Input() + get appearance(): MatFormFieldAppearance { return this._appearance; } + set appearance(value: MatFormFieldAppearance) { + this._appearance = value || (this._defaults && this._defaults.appearance) || DEFAULT_APPEARANCE; + } + private _appearance: MatFormFieldAppearance = DEFAULT_APPEARANCE; + + /** Text for the form field hint. */ + @Input() + get hintLabel(): string { return this._hintLabel; } + set hintLabel(value: string) { + this._hintLabel = value; + this._processHints(); + } + private _hintLabel = ''; + + // Unique id for the hint label. + _hintLabelId: string = `mat-hint-${nextUniqueId++}`; + + // Unique id for the internal form field label. + _labelId = `mat-form-field-label-${nextUniqueId++}`; + + /** Whether the Angular animations are enabled. */ + _animationsEnabled: boolean; + + /** State of the mat-hint and mat-error animations. */ + _subscriptAnimationState: string = ''; + + /** Gets the current form field control */ + get _control(): MatFormFieldControl { + return this._explicitFormFieldControl || this._formFieldControl; + } + set _control(value) { this._explicitFormFieldControl = value; } + + private _destroyed = new Subject(); + private _isFocused: boolean|null = null; + private _explicitFormFieldControl: MatFormFieldControl; + private _foundation: MDCTextFieldFoundation; + private _adapter: MDCTextFieldAdapter = { + addClass: className => this._textField.nativeElement.classList.add(className), + removeClass: className => this._textField.nativeElement.classList.remove(className), + hasClass: className => this._textField.nativeElement.classList.contains(className), + + hasLabel: () => this._hasFloatingLabel(), + isFocused: () => this._control.focused, + hasOutline: () => this._hasOutline(), + + // MDC text-field will call this method on focus, blur and value change. It expects us + // to update the floating label state accordingly. Though we make this a noop because we + // want to react to floating label state changes through change detection. Relying on this + // adapter method would mean that the label would not update if the custom form-field control + // sets "shouldLabelFloat" to true, or if the "floatLabel" input binding changes to "always". + floatLabel: () => {}, + + // Label shaking is not supported yet. It will require a new API for form field + // controls to trigger the shaking. This can be a feature in the future. + // TODO(devversion): explore options on how to integrate label shaking. + shakeLabel: () => {}, + + getLabelWidth: () => this._floatingLabel ? this._floatingLabel.getWidth() : 0, + notchOutline: labelWidth => this._notchedOutline && this._notchedOutline.notch(labelWidth), + closeOutline: () => this._notchedOutline && this._notchedOutline.closeNotch(), + + activateLineRipple: () => this._lineRipple && this._lineRipple.activate(), + deactivateLineRipple: () => this._lineRipple && this._lineRipple.deactivate(), + + // The foundation tries to register events on the input. This is not matching + // our concept of abstract form field controls. We handle each event manually + // in "ngDoCheck" based on the form-field control state. The following events + // need to be handled: focus, blur. We do not handle the "input" event since + // that one is only needed for the text-field character count, which we do + // not implement as part of the form-field, but should be implemented manually + // by consumers using template bindings. + registerInputInteractionHandler: () => {}, + deregisterInputInteractionHandler: () => {}, + + // We do not have a reference to the native input since we work with abstract form field + // controls. MDC needs a reference to the native input optionally to handle character + // counting and value updating. These are both things we do not handle from within the + // form-field, so we can just return null. + getNativeInput: () => null, + + // This method will never be called since we do not have the ability to add event listeners + // to the native input. This is because the form control is not necessarily an input, and + // the form field deals with abstract form controls of any type. + setLineRippleTransformOrigin: () => {}, + + // The foundation tries to register click and keyboard events on the form-field to figure out + // if the input value changes through user interaction. Based on that, the foundation tries + // to focus the input. Since we do not handle the input value as part of the form-field, nor + // it's guaranteed to be an input (see adapter methods above), this is a noop. + deregisterTextFieldInteractionHandler: () => {}, + registerTextFieldInteractionHandler: () => {}, + + // The foundation tries to setup a "MutationObserver" in order to watch for attributes + // like "maxlength" or "pattern" to change. The foundation will update the validity state + // based on that. We do not need this logic since we handle the validity through the + // abstract form control instance. + deregisterValidationAttributeChangeHandler: () => {}, + registerValidationAttributeChangeHandler: () => null as any, + }; + + constructor(private _elementRef: ElementRef, + private _changeDetectorRef: ChangeDetectorRef, + @Optional() @Inject(MAT_FORM_FIELD_DEFAULT_OPTIONS) + private _defaults?: MatFormFieldDefaultOptions, + @Optional() @Inject(MAT_LABEL_GLOBAL_OPTIONS) private _labelOptions?: LabelOptions, + @Optional() @Inject(ANIMATION_MODULE_TYPE) _animationMode?: string) { + this._animationsEnabled = _animationMode !== 'NoopAnimations'; + + if (_defaults && _defaults.appearance) { + this.appearance = _defaults.appearance; + } else if (_defaults && _defaults.hideRequiredMarker) { + this.hideRequiredMarker = true; + } + } + + ngAfterViewInit() { + this._foundation = new MDCTextFieldFoundation(this._adapter); + + // MDC uses the "shouldFloat" getter to know whether the label is currently floating. This + // does not match our implementation of when the label floats because we support more cases. + // For example, consumers can set "@Input floatLabel" to always, or the custom form-field + // control can set "MatFormFieldControl#shouldLabelFloat" to true. To ensure that MDC knows + // when the label is floating, we overwrite the property to be based on the method we use to + // determine the current state of the floating label. + Object.defineProperty(this._foundation, 'shouldFloat', { + get: () => this._shouldLabelFloat(), + }); + + // Initial focus state sync. This happens rarely, but we want to account for + // it in case the form-field control has "focused" set to true on init. + this._updateFocusState(); + // Initial notch update since we overwrote the "shouldFloat" getter. + this._rerenderOutlineNotch(); + // Enable animations now. This ensures we don't animate on initial render. + this._subscriptAnimationState = 'enter'; + } + + ngAfterContentInit() { + this._assertFormFieldControl(); + this._initializeControl(); + this._initializeSubscript(); + } + + ngAfterContentChecked() { + this._assertFormFieldControl(); + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + /** + * Gets an ElementRef for the element that a overlay attached to the form-field + * should be positioned relative to. + */ + getConnectedOverlayOrigin(): ElementRef { + return this._textField || this._elementRef; + } + + /** Animates the placeholder up and locks it in position. */ + _animateAndLockLabel(): void { + // This is for backwards compatibility only. Consumers of the form-field might use + // this method. e.g. the autocomplete trigger. This method has been added to the non-MDC + // form-field because setting "floatLabel" to "always" caused the label to float without + // animation. This is different in MDC where the label always animates, so this method + // is no longer necessary. There doesn't seem any benefit in adding logic to allow changing + // the floating label state without animations. The non-MDC implementation was inconsistent + // because it always animates if "floatLabel" is set away from "always". + // TODO(devversion): consider removing this method when releasing the MDC form-field. + if (this._hasFloatingLabel()) { + this.floatLabel = 'always'; + } + } + + /** Initializes the registered form-field control. */ + private _initializeControl() { + const control = this._control; + + if (control.controlType) { + this._elementRef.nativeElement.classList.add( + `mat-mdc-form-field-type-${control.controlType}`); + } + + // Subscribe to changes in the child control state in order to update the form field UI. + control.stateChanges.subscribe(() => { + this._updateFocusState(); + this._syncDescribedByIds(); + this._changeDetectorRef.markForCheck(); + }); + + // Run change detection if the value changes. + if (control.ngControl && control.ngControl.valueChanges) { + control.ngControl.valueChanges + .pipe(takeUntil(this._destroyed)) + .subscribe(() => this._changeDetectorRef.markForCheck()); + } + } + + /** + * Initializes the subscript by validating hints and synchronizing "aria-describedby" ids + * with the custom form-field control. Also subscribes to hint and error changes in order + * to be able to validate and synchronize ids on change. + */ + private _initializeSubscript() { + // Re-validate when the number of hints changes. + this._hintChildren.changes.subscribe(() => { + this._processHints(); + this._changeDetectorRef.markForCheck(); + }); + + // Update the aria-described by when the number of errors changes. + this._errorChildren.changes.subscribe(() => { + this._syncDescribedByIds(); + this._changeDetectorRef.markForCheck(); + }); + + // Initial mat-hint validation and subscript describedByIds sync. + this._validateHints(); + this._syncDescribedByIds(); + } + + /** Throws an error if the form field's control is missing. */ + private _assertFormFieldControl() { + if (!this._control) { + throw getMatFormFieldMissingControlError(); + } + } + private _updateFocusState() { + // Usually the MDC foundation would call "activateFocus" and "deactivateFocus" whenever + // certain DOM events are emitted. This is not possible in our implementation of the + // form-field because we support abstract form field controls which are not necessarily + // of type input, nor do we have a reference to a native form-field control element. Instead + // we handle the focus by checking if the abstract form-field control focused state changes. + if (this._control.focused && !this._isFocused) { + this._isFocused = true; + this._foundation.activateFocus(); + } else if (!this._control.focused && (this._isFocused || this._isFocused === null)) { + this._isFocused = false; + this._foundation.deactivateFocus(); + } + } + + _rerenderOutlineNotch() { + if (this._floatingLabel && this._hasOutline()) { + this._foundation.notchOutline(this._shouldLabelFloat()); + } + } + + /** Whether the floating label should always float or not. */ + _shouldAlwaysFloat() { + return this.floatLabel === 'always'; + } + + _hasOutline() { + return this.appearance === 'outline'; + } + + _hasFloatingLabel() { + return !!this._labelChildNonStatic || !!this._labelChildStatic; + } + + _shouldLabelFloat() { + return this._control.shouldLabelFloat || this._shouldAlwaysFloat(); + } + + /** Determines whether a class from the NgControl should be forwarded to the host element. */ + _shouldForward(prop: keyof NgControl): boolean { + const ngControl = this._control ? this._control.ngControl : null; + return ngControl && ngControl[prop]; + } + + /** Determines whether to display hints or errors. */ + _getDisplayedMessages(): 'error' | 'hint' { + return (this._errorChildren && this._errorChildren.length > 0 && + this._control.errorState) ? 'error' : 'hint'; + } + + /** 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 "mat-hint" alignment specified. The hint + * label specified set through the input is being considered as "start" aligned. + * + * This method is a noop if Angular runs in production mode. + */ + private _validateHints() { + if (isDevMode() && this._hintChildren) { + let startHint: MatHint; + let endHint: MatHint; + this._hintChildren.forEach((hint: MatHint) => { + if (hint.align === 'start') { + if (startHint || this.hintLabel) { + throw getMatFormFieldDuplicatedHintError('start'); + } + startHint = hint; + } else if (hint.align === 'end') { + if (endHint) { + throw getMatFormFieldDuplicatedHintError('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') { + const startHint = this._hintChildren ? + this._hintChildren.find(hint => hint.align === 'start') : null; + const 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(error => error.id); + } + + this._control.setDescribedByIds(ids); + } + } +} diff --git a/src/material-experimental/mdc-form-field/index.ts b/src/material-experimental/mdc-form-field/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/mdc-form-field/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +export * from './public-api'; diff --git a/src/material-experimental/mdc-form-field/module.ts b/src/material-experimental/mdc-form-field/module.ts new file mode 100644 index 000000000000..6b363af8b1b5 --- /dev/null +++ b/src/material-experimental/mdc-form-field/module.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC 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 {ObserversModule} from '@angular/cdk/observers'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatCommonModule} from '@angular/material/core'; +import {MatError} from './directives/error'; +import {MatFormFieldFloatingLabel} from './directives/floating-label'; +import {MatHint} from './directives/hint'; +import {MatLabel} from './directives/label'; +import {MatFormFieldLineRipple} from './directives/line-ripple'; +import {MatFormFieldNotchedOutline} from './directives/notched-outline'; +import {MatPrefix} from './directives/prefix'; +import {MatSuffix} from './directives/suffix'; +import {MatFormField} from './form-field'; + +@NgModule({ + imports: [ + MatCommonModule, + CommonModule, + ObserversModule + ], + exports: [ + MatFormField, + MatLabel, + MatHint, + MatError, + MatPrefix, + MatSuffix, + MatCommonModule + ], + declarations: [ + MatFormField, + MatLabel, + MatError, + MatHint, + MatPrefix, + MatSuffix, + MatFormFieldFloatingLabel, + MatFormFieldNotchedOutline, + MatFormFieldLineRipple + ], +}) +export class MatFormFieldModule { +} diff --git a/src/material-experimental/mdc-form-field/public-api.ts b/src/material-experimental/mdc-form-field/public-api.ts new file mode 100644 index 000000000000..81be8096f147 --- /dev/null +++ b/src/material-experimental/mdc-form-field/public-api.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +export { + MatFormFieldControl, + getMatFormFieldDuplicatedHintError, + getMatFormFieldMissingControlError, +} from '@angular/material/form-field'; + +export * from './directives/label'; +export * from './directives/error'; +export * from './directives/hint'; +export * from './directives/prefix'; +export * from './directives/suffix'; +export * from './form-field'; +export * from './module'; diff --git a/src/material-experimental/mdc-input/BUILD.bazel b/src/material-experimental/mdc-input/BUILD.bazel new file mode 100644 index 000000000000..db06b3245844 --- /dev/null +++ b/src/material-experimental/mdc-input/BUILD.bazel @@ -0,0 +1,88 @@ +package(default_visibility = ["//visibility:public"]) + +load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") +load( + "//tools:defaults.bzl", + "ng_e2e_test_library", + "ng_module", + "ng_test_library", + "ng_web_test_suite", + "sass_library", +) + +ng_module( + name = "mdc-input", + srcs = glob( + ["**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + assets = glob(["**/*.html"]), + module_name = "@angular/material-experimental/mdc-input", + deps = [ + "//src/material-experimental/mdc-form-field", + "//src/material/core", + "//src/material/input", + "@npm//@angular/forms", + "@npm//@material/textfield", + ], +) + +sass_library( + name = "mdc_input_scss_lib", + srcs = glob(["**/_*.scss"]), + deps = [ + "//src/cdk/a11y:a11y_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + ], +) + +########### +# Testing +########### + +ng_test_library( + name = "input_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":mdc-input", + "//src/cdk/platform", + "//src/cdk/testing/private", + "//src/material-experimental/mdc-form-field", + "//src/material/core", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + static_files = [ + "@npm//:node_modules/@material/textfield/dist/mdc.textfield.js", + "@npm//:node_modules/@material/line-ripple/dist/mdc.lineRipple.js", + "@npm//:node_modules/@material/notched-outline/dist/mdc.notchedOutline.js", + "@npm//:node_modules/@material/floating-label/dist/mdc.floatingLabel.js", + ], + deps = [ + ":input_tests_lib", + "//src/material-experimental:mdc_require_config.js", + ], +) + +ng_e2e_test_library( + name = "e2e_test_sources", + srcs = glob(["**/*.e2e.spec.ts"]), + deps = [ + "//src/cdk/testing/private/e2e", + ], +) + +e2e_test_suite( + name = "e2e_tests", + deps = [ + ":e2e_test_sources", + "//src/cdk/testing/private/e2e", + ], +) diff --git a/src/material-experimental/mdc-input/README.md b/src/material-experimental/mdc-input/README.md new file mode 100644 index 000000000000..73eeea50298e --- /dev/null +++ b/src/material-experimental/mdc-input/README.md @@ -0,0 +1 @@ +This is a placeholder for the MDC-based implementation of input. diff --git a/src/material-experimental/mdc-input/_mdc-input.scss b/src/material-experimental/mdc-input/_mdc-input.scss new file mode 100644 index 000000000000..fc5826be566b --- /dev/null +++ b/src/material-experimental/mdc-input/_mdc-input.scss @@ -0,0 +1,9 @@ +@import '../mdc-helpers/mdc-helpers'; + +@mixin mat-input-theme-mdc($theme) { + @include mat-using-mdc-theme($theme) {} +} + +@mixin mat-input-typography-mdc($config) { + @include mat-using-mdc-typography($config) {} +} diff --git a/src/material-experimental/mdc-input/index.ts b/src/material-experimental/mdc-input/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/mdc-input/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +export * from './public-api'; diff --git a/src/material-experimental/mdc-input/input.e2e.spec.ts b/src/material-experimental/mdc-input/input.e2e.spec.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/material-experimental/mdc-input/input.spec.ts b/src/material-experimental/mdc-input/input.spec.ts new file mode 100644 index 000000000000..37c75f4ea473 --- /dev/null +++ b/src/material-experimental/mdc-input/input.spec.ts @@ -0,0 +1,1588 @@ +import {Platform, PlatformModule} from '@angular/cdk/platform'; +import {dispatchFakeEvent, wrappedErrorMessage} from '@angular/cdk/testing/private'; +import { + ChangeDetectionStrategy, + Component, + Directive, + ErrorHandler, + Provider, + Type, + ViewChild, +} from '@angular/core'; +import {ComponentFixture, fakeAsync, flush, TestBed} from '@angular/core/testing'; +import { + FormControl, + FormGroup, + FormGroupDirective, + FormsModule, + NgForm, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { + getMatFormFieldDuplicatedHintError, + getMatFormFieldMissingControlError, + MAT_FORM_FIELD_DEFAULT_OPTIONS, + MatFormField, + MatFormFieldAppearance, + MatFormFieldModule, +} from '@angular/material-experimental/mdc-form-field'; +import { + ErrorStateMatcher, + MAT_LABEL_GLOBAL_OPTIONS, + ShowOnDirtyErrorStateMatcher, +} from '@angular/material/core'; +import {By} from '@angular/platform-browser'; +import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; +import {MAT_INPUT_VALUE_ACCESSOR, MatInput, MatInputModule} from './index'; + +describe('MatMdcInput without forms', () => { + it('should default to floating labels', fakeAsync(() => { + let fixture = createComponent(MatInputWithLabel); + fixture.detectChanges(); + + let formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField.floatLabel).toBe('auto', + 'Expected MatInput to set floatingLabel to auto by default.'); + })); + + it('should default to global floating label type', fakeAsync(() => { + let fixture = createComponent(MatInputWithLabel, [{ + provide: MAT_LABEL_GLOBAL_OPTIONS, useValue: {float: 'always'} + }]); + fixture.detectChanges(); + + let formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField.floatLabel).toBe('always', + 'Expected MatInput to set floatingLabel to always from global option.'); + })); + + it('should not be treated as empty if type is date', fakeAsync(() => { + const platform = new Platform(); + + if (!(platform.TRIDENT || (platform.SAFARI && !platform.IOS))) { + const fixture = createComponent(MatInputDateTestController); + fixture.detectChanges(); + const formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField).toBeTruthy(); + expect(formField!._control.empty).toBe(false); + } + })); + + // Safari Desktop and IE don't support type="date" and fallback to type="text". + it('should be treated as empty if type is date in Safari Desktop or IE', fakeAsync(() => { + const platform = new Platform(); + + if (platform.TRIDENT || (platform.SAFARI && !platform.IOS)) { + let fixture = createComponent(MatInputDateTestController); + fixture.detectChanges(); + + const formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField).toBeTruthy(); + expect(formField!._control.empty).toBe(true); + } + })); + + it('should treat text input type as empty at init', fakeAsync(() => { + let fixture = createComponent(MatInputTextTestController); + fixture.detectChanges(); + + const formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField).toBeTruthy(); + expect(formField!._control.empty).toBe(true); + })); + + it('should treat password input type as empty at init', fakeAsync(() => { + let fixture = createComponent(MatInputPasswordTestController); + fixture.detectChanges(); + + const formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField).toBeTruthy(); + expect(formField!._control.empty).toBe(true); + })); + + it('should treat number input type as empty at init', fakeAsync(() => { + let fixture = createComponent(MatInputNumberTestController); + fixture.detectChanges(); + + const formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField).toBeTruthy(); + expect(formField!._control.empty).toBe(true); + })); + + it('should not be empty after input entered', fakeAsync(() => { + let fixture = createComponent(MatInputTextTestController); + fixture.detectChanges(); + + let inputEl = fixture.debugElement.query(By.css('input'))!; + const formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField).toBeTruthy(); + expect(formField!._control.empty).toBe(true); + + inputEl.nativeElement.value = 'hello'; + // Simulate input event. + inputEl.triggerEventHandler('input', {target: inputEl.nativeElement}); + fixture.detectChanges(); + expect(formField!._control.empty).toBe(false); + })); + + it('should update the placeholder when input entered', fakeAsync(() => { + let fixture = createComponent(MatInputWithStaticLabel); + fixture.detectChanges(); + + const inputEl = fixture.debugElement.query(By.css('input'))!; + const formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + + expect(formField).toBeTruthy(); + expect(formField._control.empty).toBe(true); + expect(formField._shouldLabelFloat()).toBe(false); + + // Update the value of the input. + inputEl.nativeElement.value = 'Text'; + + // Fake behavior of the `(input)` event which should trigger a change detection. + fixture.detectChanges(); + + expect(formField._control.empty).toBe(false); + expect(formField._shouldLabelFloat()).toBe(true); + })); + + it('should not be empty when the value set before view init', fakeAsync(() => { + let fixture = createComponent(MatInputWithValueBinding); + fixture.detectChanges(); + const formField = fixture.debugElement.query(By.directive(MatFormField))! + .componentInstance as MatFormField; + expect(formField._control.empty).toBe(false); + + fixture.componentInstance.value = ''; + fixture.detectChanges(); + + expect(formField._control.empty).toBe(true); + })); + + it('should add id', fakeAsync(() => { + let fixture = createComponent(MatInputTextTestController); + fixture.detectChanges(); + + const inputElement: HTMLInputElement = + fixture.debugElement.query(By.css('input'))!.nativeElement; + const labelElement: HTMLInputElement = + fixture.debugElement.query(By.css('label'))!.nativeElement; + + expect(inputElement.id).toBeTruthy(); + expect(inputElement.id).toEqual(labelElement.getAttribute('for')!); + })); + + it('should add aria-owns to the label for the associated control', fakeAsync(() => { + let fixture = createComponent(MatInputTextTestController); + fixture.detectChanges(); + + const inputElement: HTMLInputElement = + fixture.debugElement.query(By.css('input'))!.nativeElement; + const labelElement: HTMLInputElement = + fixture.debugElement.query(By.css('label'))!.nativeElement; + + expect(labelElement.getAttribute('aria-owns')).toBe(inputElement.id); + })); + + it('should add aria-required reflecting the required state', fakeAsync(() => { + const fixture = createComponent(MatInputWithRequired); + fixture.detectChanges(); + + const inputElement: HTMLInputElement = + fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(inputElement.getAttribute('aria-required')) + .toBe('false', 'Expected aria-required to reflect required state of false'); + + fixture.componentInstance.required = true; + fixture.detectChanges(); + + expect(inputElement.getAttribute('aria-required')) + .toBe('true', 'Expected aria-required to reflect required state of true'); + })); + + it('should not overwrite existing id', fakeAsync(() => { + let fixture = createComponent(MatInputWithId); + fixture.detectChanges(); + + const inputElement: HTMLInputElement = + fixture.debugElement.query(By.css('input'))!.nativeElement; + const labelElement: HTMLInputElement = + fixture.debugElement.query(By.css('label'))!.nativeElement; + + expect(inputElement.id).toBe('test-id'); + expect(labelElement.getAttribute('for')).toBe('test-id'); + })); + + it('validates there\'s only one hint label per side', () => { + let fixture = createComponent(MatInputInvalidHintTestController); + expect(() => fixture.detectChanges()).toThrowError( + wrappedErrorMessage(getMatFormFieldDuplicatedHintError('start'))); + }); + + it('validates there\'s only one hint label per side (attribute)', () => { + let fixture = createComponent(MatInputInvalidHint2TestController); + + expect(() => fixture.detectChanges()).toThrowError( + wrappedErrorMessage(getMatFormFieldDuplicatedHintError('start'))); + }); + + it('validates that matInput child is present', fakeAsync(() => { + let fixture = createComponent(MatInputMissingMatInputTestController); + + expect(() => fixture.detectChanges()).toThrowError( + wrappedErrorMessage(getMatFormFieldMissingControlError())); + })); + + it('validates that matInput child is present after initialization', fakeAsync(() => { + let fixture = createComponent(MatInputWithNgIf); + + expect(() => fixture.detectChanges()).not.toThrowError( + wrappedErrorMessage(getMatFormFieldMissingControlError())); + + fixture.componentInstance.renderInput = false; + + expect(() => fixture.detectChanges()).toThrowError( + wrappedErrorMessage(getMatFormFieldMissingControlError())); + })); + + it('validates the type', fakeAsync(() => { + let fixture = createComponent(MatInputInvalidTypeTestController); + + // Technically this throws during the OnChanges detection phase, + // so the error is really a ChangeDetectionError and it becomes + // hard to build a full exception to compare with. + // We just check for any exception in this case. + expect(() => fixture.detectChanges()).toThrow( + /* new MatInputUnsupportedTypeError('file') */); + })); + + it('supports hint labels attribute', fakeAsync(() => { + let fixture = createComponent(MatInputHintLabelTestController); + fixture.detectChanges(); + + // If the hint label is empty, expect no label. + expect(fixture.debugElement.query(By.css('.mat-mdc-form-field-hint'))).toBeNull(); + + fixture.componentInstance.label = 'label'; + fixture.detectChanges(); + expect(fixture.debugElement.query(By.css('.mat-mdc-form-field-hint'))).not.toBeNull(); + })); + + it('sets an id on hint labels', fakeAsync(() => { + let fixture = createComponent(MatInputHintLabelTestController); + + fixture.componentInstance.label = 'label'; + fixture.detectChanges(); + + let hint = fixture.debugElement.query(By.css('.mat-mdc-form-field-hint'))!.nativeElement; + + expect(hint.getAttribute('id')).toBeTruthy(); + })); + + it('supports hint labels elements', fakeAsync(() => { + let fixture = createComponent(MatInputHintLabel2TestController); + fixture.detectChanges(); + + // In this case, we should have an empty . + let el = fixture.debugElement.query(By.css('mat-hint'))!.nativeElement; + expect(el.textContent).toBeFalsy(); + + fixture.componentInstance.label = 'label'; + fixture.detectChanges(); + el = fixture.debugElement.query(By.css('mat-hint'))!.nativeElement; + expect(el.textContent).toBe('label'); + })); + + it('sets an id on the hint element', fakeAsync(() => { + let fixture = createComponent(MatInputHintLabel2TestController); + + fixture.componentInstance.label = 'label'; + fixture.detectChanges(); + + let hint = fixture.debugElement.query(By.css('mat-hint'))!.nativeElement; + + expect(hint.getAttribute('id')).toBeTruthy(); + })); + + it('supports label required star', fakeAsync(() => { + let fixture = createComponent(MatInputLabelRequiredTestComponent); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label'))!; + expect(el).not.toBeNull(); + expect(el.nativeElement.textContent).toBe('hello *'); + })); + + it('should hide the required star if input is disabled', () => { + const fixture = createComponent(MatInputLabelRequiredTestComponent); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + const el = fixture.debugElement.query(By.css('label'))!; + + expect(el).not.toBeNull(); + expect(el.nativeElement.textContent).toBe('hello'); + }); + + it('should hide the required star from screen readers', fakeAsync(() => { + let fixture = createComponent(MatInputLabelRequiredTestComponent); + fixture.detectChanges(); + + let el = fixture.debugElement + .query(By.css('.mat-mdc-form-field-required-marker'))!.nativeElement; + + expect(el.getAttribute('aria-hidden')).toBe('true'); + })); + + it('hide label required star when set to hide the required marker', fakeAsync(() => { + let fixture = createComponent(MatInputLabelRequiredTestComponent); + fixture.detectChanges(); + + let el = fixture.debugElement.query(By.css('label'))!; + expect(el).not.toBeNull(); + expect(el.nativeElement.textContent).toBe('hello *'); + + fixture.componentInstance.hideRequiredMarker = true; + fixture.detectChanges(); + + expect(el.nativeElement.textContent).toBe('hello'); + })); + + it('supports the disabled attribute as binding', fakeAsync(() => { + const fixture = createComponent(MatInputWithDisabled); + fixture.detectChanges(); + + const wrapperEl = + fixture.debugElement.query(By.css('.mat-mdc-text-field-wrapper'))!.nativeElement; + const inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(wrapperEl.classList.contains('mdc-text-field--disabled')) + .toBe(false, `Expected form field not to start out disabled.`); + expect(inputEl.disabled).toBe(false); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(wrapperEl.classList.contains('mdc-text-field--disabled')) + .toBe(true, `Expected form field to look disabled after property is set.`); + expect(inputEl.disabled).toBe(true); + })); + + it('supports the disabled attribute as binding for select', fakeAsync(() => { + const fixture = createComponent(MatInputSelect); + fixture.detectChanges(); + + const wrapperEl = + fixture.debugElement.query(By.css('.mat-mdc-text-field-wrapper'))!.nativeElement; + const selectEl = fixture.debugElement.query(By.css('select'))!.nativeElement; + + expect(wrapperEl.classList.contains('mdc-text-field--disabled')) + .toBe(false, `Expected form field not to start out disabled.`); + expect(selectEl.disabled).toBe(false); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(wrapperEl.classList.contains('mdc-text-field--disabled')) + .toBe(true, `Expected form field to look disabled after property is set.`); + expect(selectEl.disabled).toBe(true); + })); + + it('should add a class to the form field if it has a native select', fakeAsync(() => { + const fixture = createComponent(MatInputSelect); + fixture.detectChanges(); + + const formField = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + + expect(formField.classList).toContain('mat-mdc-form-field-type-mat-native-select'); + })); + + it('supports the required attribute as binding', fakeAsync(() => { + let fixture = createComponent(MatInputWithRequired); + fixture.detectChanges(); + + let inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(inputEl.required).toBe(false); + + fixture.componentInstance.required = true; + fixture.detectChanges(); + + expect(inputEl.required).toBe(true); + })); + + it('supports the required attribute as binding for select', fakeAsync(() => { + const fixture = createComponent(MatInputSelect); + fixture.detectChanges(); + + const selectEl = fixture.debugElement.query(By.css('select'))!.nativeElement; + + expect(selectEl.required).toBe(false); + + fixture.componentInstance.required = true; + fixture.detectChanges(); + + expect(selectEl.required).toBe(true); + })); + + it('supports the type attribute as binding', fakeAsync(() => { + let fixture = createComponent(MatInputWithType); + fixture.detectChanges(); + + let inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(inputEl.type).toBe('text'); + + fixture.componentInstance.type = 'password'; + fixture.detectChanges(); + + expect(inputEl.type).toBe('password'); + })); + + it('supports textarea', fakeAsync(() => { + let fixture = createComponent(MatInputTextareaWithBindings); + fixture.detectChanges(); + + const textarea: HTMLTextAreaElement = fixture.nativeElement.querySelector('textarea'); + expect(textarea).not.toBeNull(); + })); + + it('supports select', fakeAsync(() => { + const fixture = createComponent(MatInputSelect); + fixture.detectChanges(); + + const nativeSelect: HTMLTextAreaElement = fixture.nativeElement.querySelector('select'); + expect(nativeSelect).not.toBeNull(); + })); + + it('sets the aria-describedby when a hintLabel is set', fakeAsync(() => { + let fixture = createComponent(MatInputHintLabelTestController); + + fixture.componentInstance.label = 'label'; + fixture.detectChanges(); + + let hint = fixture.debugElement.query(By.css('.mat-mdc-form-field-hint'))!.nativeElement; + let input = fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(input.getAttribute('aria-describedby')).toBe(hint.getAttribute('id')); + })); + + it('sets the aria-describedby to the id of the mat-hint', fakeAsync(() => { + let fixture = createComponent(MatInputHintLabel2TestController); + + fixture.componentInstance.label = 'label'; + fixture.detectChanges(); + + let hint = fixture.debugElement.query(By.css('.mat-mdc-form-field-hint'))!.nativeElement; + let input = fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(input.getAttribute('aria-describedby')).toBe(hint.getAttribute('id')); + })); + + it('sets the aria-describedby with multiple mat-hint instances', fakeAsync(() => { + let fixture = createComponent(MatInputMultipleHintTestController); + + fixture.componentInstance.startId = 'start'; + fixture.componentInstance.endId = 'end'; + fixture.detectChanges(); + + let input = fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(input.getAttribute('aria-describedby')).toBe('start end'); + })); + + it('sets the aria-describedby when a hintLabel is set, in addition to a mat-hint', + fakeAsync(() => { + let fixture = createComponent(MatInputMultipleHintMixedTestController); + + fixture.detectChanges(); + + let hintLabel = fixture.debugElement.query( + By.css('.mat-mdc-form-field-hint:not(.mat-form-field-hint-end)'))!.nativeElement; + let endLabel = fixture.debugElement + .query(By.css('.mat-mdc-form-field-hint.mat-form-field-hint-end'))!.nativeElement; + let input = fixture.debugElement.query(By.css('input'))!.nativeElement; + let ariaValue = input.getAttribute('aria-describedby'); + + expect(ariaValue).toBe(`${hintLabel.getAttribute('id')} ${endLabel.getAttribute('id')}`); + })); + + it('should float when floatLabel is set to default and text is entered', fakeAsync(() => { + let fixture = createComponent(MatInputWithDynamicLabel); + fixture.detectChanges(); + + let inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; + let labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement; + + expect(labelEl.classList).toContain('mdc-floating-label--float-above'); + + fixture.componentInstance.shouldFloat = 'auto'; + fixture.detectChanges(); + + expect(labelEl.classList).not.toContain('mdc-floating-label--float-above'); + + // Update the value of the input. + inputEl.value = 'Text'; + + // Fake behavior of the `(input)` event which should trigger a change detection. + fixture.detectChanges(); + + expect(labelEl.classList).toContain('mdc-floating-label--float-above'); + })); + + it('should always float the label when floatLabel is set to always', fakeAsync(() => { + let fixture = createComponent(MatInputWithDynamicLabel); + fixture.detectChanges(); + + let inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; + let labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement; + + expect(labelEl.classList).toContain('mdc-floating-label--float-above'); + + fixture.detectChanges(); + + // Update the value of the input. + inputEl.value = 'Text'; + + // Fake behavior of the `(input)` event which should trigger a change detection. + fixture.detectChanges(); + + expect(labelEl.classList).toContain('mdc-floating-label--float-above'); + })); + + it('should float labels when select has value', fakeAsync(() => { + const fixture = createComponent(MatInputSelect); + fixture.detectChanges(); + + const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement; + expect(labelEl.classList).toContain('mdc-floating-label--float-above'); + })); + + it('should not float the label if the selectedIndex is negative', fakeAsync(() => { + const fixture = createComponent(MatInputSelect); + fixture.detectChanges(); + + const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement; + const selectEl: HTMLSelectElement = fixture.nativeElement.querySelector('select'); + + expect(labelEl.classList).toContain('mdc-floating-label--float-above'); + + selectEl.selectedIndex = -1; + fixture.detectChanges(); + + expect(labelEl.classList).not.toContain('mdc-floating-label--float-above'); + })); + + it('should not float labels when select has no value, no option label, ' + + 'no option innerHtml', fakeAsync(() => { + const fixture = createComponent(MatInputSelectWithNoLabelNoValue); + fixture.detectChanges(); + + const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement; + expect(labelEl.classList).not.toContain('mdc-floating-label--float-above'); + })); + + it('should floating labels when select has no value but has option label', + fakeAsync(() => { + const fixture = createComponent(MatInputSelectWithLabel); + fixture.detectChanges(); + + const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement; + expect(labelEl.classList).toContain('mdc-floating-label--float-above'); + })); + + it('should floating labels when select has no value but has option innerHTML', + fakeAsync(() => { + const fixture = createComponent(MatInputSelectWithInnerHtml); + fixture.detectChanges(); + + const labelEl = fixture.debugElement.query(By.css('label'))!.nativeElement; + expect(labelEl.classList).toContain('mdc-floating-label--float-above'); + })); + + it('should not throw if a native select does not have options', fakeAsync(() => { + const fixture = createComponent(MatInputSelectWithoutOptions); + expect(() => fixture.detectChanges()).not.toThrow(); + })); + + it('should be able to toggle the floating label programmatically', fakeAsync(() => { + const fixture = createComponent(MatInputWithId); + + fixture.detectChanges(); + + const formField = fixture.debugElement.query(By.directive(MatFormField))!; + const containerInstance = formField.componentInstance as MatFormField; + const label = formField.nativeElement.querySelector('label'); + + expect(containerInstance.floatLabel).toBe('auto'); + expect(label.classList).not.toContain('mdc-floating-label--float-above'); + + fixture.componentInstance.floatLabel = 'always'; + fixture.detectChanges(); + + expect(containerInstance.floatLabel).toBe('always'); + expect(label.classList).toContain('mdc-floating-label--float-above'); + })); + + it('should not have prefix and suffix elements when none are specified', fakeAsync(() => { + let fixture = createComponent(MatInputWithId); + fixture.detectChanges(); + + let prefixEl = fixture.debugElement.query(By.css('.mat-mdc-form-field-prefix')); + let suffixEl = fixture.debugElement.query(By.css('.mat-mdc-form-field-suffix')); + + expect(prefixEl).toBeNull(); + expect(suffixEl).toBeNull(); + })); + + it('should add prefix and suffix elements when specified', fakeAsync(() => { + const fixture = createComponent(MatInputWithPrefixAndSuffix); + fixture.detectChanges(); + + const prefixEl = fixture.debugElement.query(By.css('.mat-mdc-form-field-prefix'))!; + const suffixEl = fixture.debugElement.query(By.css('.mat-mdc-form-field-suffix'))!; + + expect(prefixEl).not.toBeNull(); + expect(suffixEl).not.toBeNull(); + expect(prefixEl.nativeElement.innerText.trim()).toEqual('Prefix'); + expect(suffixEl.nativeElement.innerText.trim()).toEqual('Suffix'); + })); + + it('should update empty class when value changes programmatically and OnPush', fakeAsync(() => { + let fixture = createComponent(MatInputOnPush); + fixture.detectChanges(); + + let component = fixture.componentInstance; + let label = fixture.debugElement.query(By.css('label'))!.nativeElement; + + expect(label.classList).not.toContain('mdc-floating-label--float-above'); + + component.formControl.setValue('something'); + fixture.detectChanges(); + + expect(label.classList).toContain('mdc-floating-label--float-above'); + })); + + it('should set the focused class when the input is focused', fakeAsync(() => { + let fixture = createComponent(MatInputTextTestController); + fixture.detectChanges(); + + let input = fixture.debugElement.query(By.directive(MatInput))! + .injector.get(MatInput); + let container = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + + // Call the focus handler directly to avoid flakyness where + // browsers don't focus elements if the window is minimized. + input._focusChanged(true); + fixture.detectChanges(); + + expect(container.classList).toContain('mat-focused'); + })); + + it('should remove the focused class if the input becomes disabled while focused', + fakeAsync(() => { + const fixture = createComponent(MatInputTextTestController); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.directive(MatInput))! + .injector.get(MatInput); + const container = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + + // Call the focus handler directly to avoid flakyness where + // browsers don't focus elements if the window is minimized. + input._focusChanged(true); + fixture.detectChanges(); + + expect(container.classList).toContain('mat-focused'); + + input.disabled = true; + fixture.detectChanges(); + + expect(container.classList).not.toContain('mat-focused'); + })); + + it('should not highlight when focusing a readonly input', fakeAsync(() => { + let fixture = createComponent(MatInputWithReadonlyInput); + fixture.detectChanges(); + + let input = + fixture.debugElement.query(By.directive(MatInput))!.injector.get(MatInput); + let container = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + + // Call the focus handler directly to avoid flakiness where + // browsers don't focus elements if the window is minimized. + input._focusChanged(true); + fixture.detectChanges(); + + expect(input.focused).toBe(false); + expect(container.classList).not.toContain('mat-focused'); + })); + + it('should reset the highlight when a readonly input is blurred', fakeAsync(() => { + const fixture = createComponent(MatInputWithReadonlyInput); + fixture.detectChanges(); + + const inputDebugElement = fixture.debugElement.query(By.directive(MatInput))!; + const input = inputDebugElement.injector.get(MatInput); + const container = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + + fixture.componentInstance.isReadonly = false; + fixture.detectChanges(); + + // Call the focus handler directly to avoid flakyness where + // browsers don't focus elements if the window is minimized. + input._focusChanged(true); + fixture.detectChanges(); + + expect(input.focused).toBe(true); + expect(container.classList).toContain('mat-focused'); + + fixture.componentInstance.isReadonly = true; + fixture.detectChanges(); + + input._focusChanged(false); + fixture.detectChanges(); + + expect(input.focused).toBe(false); + expect(container.classList).not.toContain('mat-focused'); + })); + + it('should not add the `placeholder` attribute if there is no placeholder', () => { + const fixture = createComponent(MatInputWithoutPlaceholder); + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(input.hasAttribute('placeholder')).toBe(false); + }); + + it('should not add the native select class if the control is not a native select', () => { + const fixture = createComponent(MatInputWithId); + fixture.detectChanges(); + const formField = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + + expect(formField.classList).not.toContain('mat-mdc-form-field-type-mat-native-select'); + }); + + it('should use the native input value when determining whether ' + + 'the element is empty with a custom accessor', fakeAsync(() => { + let fixture = createComponent(MatInputWithCustomAccessor, [], [], [CustomMatInputAccessor]); + fixture.detectChanges(); + let formField = fixture.debugElement + .query(By.directive(MatFormField))!.componentInstance as MatFormField; + + expect(formField._control.empty).toBe(true); + + fixture.nativeElement.querySelector('input').value = 'abc'; + fixture.detectChanges(); + + expect(formField._control.empty).toBe(false); + })); + +}); + +describe('MatMdcInput with forms', () => { + describe('error messages', () => { + let fixture: ComponentFixture; + let testComponent: MatInputWithFormErrorMessages; + let containerEl: HTMLElement; + let inputEl: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = createComponent(MatInputWithFormErrorMessages); + fixture.detectChanges(); + testComponent = fixture.componentInstance; + containerEl = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; + })); + + it('should not show any errors if the user has not interacted', fakeAsync(() => { + expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control'); + expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message'); + expect(inputEl.getAttribute('aria-invalid')) + .toBe('false', 'Expected aria-invalid to be set to "false".'); + })); + + it('should display an error message when the input is touched and invalid', fakeAsync(() => { + expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + flush(); + + expect(containerEl.classList) + .toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(inputEl.getAttribute('aria-invalid')) + .toBe('true', 'Expected aria-invalid to be set to "true".'); + })); + + it('should display an error message when the parent form is submitted', fakeAsync(() => { + expect(testComponent.form.submitted).toBe(false, 'Expected form not to have been submitted'); + expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message'); + + dispatchFakeEvent(fixture.debugElement.query(By.css('form'))!.nativeElement, 'submit'); + fixture.detectChanges(); + flush(); + + expect(testComponent.form.submitted).toBe(true, 'Expected form to have been submitted'); + expect(containerEl.classList) + .toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(inputEl.getAttribute('aria-invalid')) + .toBe('true', 'Expected aria-invalid to be set to "true".'); + })); + + it('should display an error message when the parent form group is submitted', fakeAsync(() => { + fixture.destroy(); + TestBed.resetTestingModule(); + + let groupFixture = createComponent(MatInputWithFormGroupErrorMessages); + let component: MatInputWithFormGroupErrorMessages; + + groupFixture.detectChanges(); + component = groupFixture.componentInstance; + containerEl = groupFixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + inputEl = groupFixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(component.formGroup.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message'); + expect(inputEl.getAttribute('aria-invalid')) + .toBe('false', 'Expected aria-invalid to be set to "false".'); + expect(component.formGroupDirective.submitted) + .toBe(false, 'Expected form not to have been submitted'); + + dispatchFakeEvent(groupFixture.debugElement.query(By.css('form'))!.nativeElement, 'submit'); + groupFixture.detectChanges(); + flush(); + + expect(component.formGroupDirective.submitted) + .toBe(true, 'Expected form to have been submitted'); + expect(containerEl.classList) + .toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(inputEl.getAttribute('aria-invalid')) + .toBe('true', 'Expected aria-invalid to be set to "true".'); + })); + + it('should hide the errors and show the hints once the input becomes valid', fakeAsync(() => { + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + flush(); + + expect(containerEl.classList) + .toContain('mat-form-field-invalid', 'Expected container to have the invalid CSS class.'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error message to have been rendered.'); + expect(containerEl.querySelectorAll('mat-hint').length) + .toBe(0, 'Expected no hints to be shown.'); + + testComponent.formControl.setValue('something'); + fixture.detectChanges(); + flush(); + + expect(containerEl.classList).not.toContain('mat-form-field-invalid', + 'Expected container not to have the invalid class when valid.'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(0, 'Expected no error messages when the input is valid.'); + expect(containerEl.querySelectorAll('mat-hint').length) + .toBe(1, 'Expected one hint to be shown once the input is valid.'); + })); + + it('should not hide the hint if there are no error messages', fakeAsync(() => { + testComponent.renderError = false; + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('mat-hint').length) + .toBe(1, 'Expected one hint to be shown on load.'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + flush(); + + expect(containerEl.querySelectorAll('mat-hint').length) + .toBe(1, 'Expected one hint to still be shown.'); + })); + + it('should set the proper role on the error messages', fakeAsync(() => { + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + expect(containerEl.querySelector('mat-error')!.getAttribute('role')).toBe('alert'); + })); + + it('sets the aria-describedby to reference errors when in error state', fakeAsync(() => { + let hintId = fixture.debugElement + .query(By.css('.mat-mdc-form-field-hint'))!.nativeElement.getAttribute('id'); + let describedBy = inputEl.getAttribute('aria-describedby'); + + expect(hintId).toBeTruthy('hint should be shown'); + expect(describedBy).toBe(hintId); + + fixture.componentInstance.formControl.markAsTouched(); + fixture.detectChanges(); + + let errorIds = fixture.debugElement.queryAll(By.css('.mat-mdc-form-field-error')) + .map(el => el.nativeElement.getAttribute('id')).join(' '); + describedBy = inputEl.getAttribute('aria-describedby'); + + expect(errorIds).toBeTruthy('errors should be shown'); + expect(describedBy).toBe(errorIds); + })); + }); + + describe('custom error behavior', () => { + + it('should display an error message when a custom error matcher returns true', fakeAsync(() => { + let fixture = createComponent(MatInputWithCustomErrorStateMatcher); + fixture.detectChanges(); + + let component = fixture.componentInstance; + let containerEl = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + + const control = component.formGroup.get('name')!; + + expect(control.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(0, 'Expected no error messages'); + + control.markAsTouched(); + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(0, 'Expected no error messages after being touched.'); + + component.errorState = true; + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error messages to have been rendered.'); + })); + + it('should display an error message when global error matcher returns true', fakeAsync(() => { + let fixture = createComponent(MatInputWithFormErrorMessages, [{ + provide: ErrorStateMatcher, useValue: {isErrorState: () => true}} + ]); + + fixture.detectChanges(); + + let containerEl = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + let testComponent = fixture.componentInstance; + + // Expect the control to still be untouched but the error to show due to the global setting + expect(testComponent.formControl.untouched).toBe(true, 'Expected untouched form control'); + expect(containerEl.querySelectorAll('mat-error').length).toBe(1, 'Expected an error message'); + })); + + it('should display an error message when using ShowOnDirtyErrorStateMatcher', fakeAsync(() => { + let fixture = createComponent(MatInputWithFormErrorMessages, [{ + provide: ErrorStateMatcher, useClass: ShowOnDirtyErrorStateMatcher + }]); + fixture.detectChanges(); + + let containerEl = fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + let testComponent = fixture.componentInstance; + + expect(testComponent.formControl.invalid).toBe(true, 'Expected form control to be invalid'); + expect(containerEl.querySelectorAll('mat-error').length).toBe(0, 'Expected no error message'); + + testComponent.formControl.markAsTouched(); + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(0, 'Expected no error messages when touched'); + + testComponent.formControl.markAsDirty(); + fixture.detectChanges(); + + expect(containerEl.querySelectorAll('mat-error').length) + .toBe(1, 'Expected one error message when dirty'); + })); + }); + + it('should update the value when using FormControl.setValue', fakeAsync(() => { + let fixture = createComponent(MatInputWithFormControl); + fixture.detectChanges(); + + let input = fixture.debugElement.query(By.directive(MatInput))! + .injector.get(MatInput); + + expect(input.value).toBeFalsy(); + + fixture.componentInstance.formControl.setValue('something'); + + expect(input.value).toBe('something'); + })); + + it('should display disabled styles when using FormControl.disable()', fakeAsync(() => { + const fixture = createComponent(MatInputWithFormControl); + fixture.detectChanges(); + + const formFieldEl = + fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + const inputEl = fixture.debugElement.query(By.css('input'))!.nativeElement; + + expect(formFieldEl.classList) + .not.toContain('mat-form-field-disabled', `Expected form field not to start out disabled.`); + expect(inputEl.disabled).toBe(false); + + fixture.componentInstance.formControl.disable(); + fixture.detectChanges(); + + expect(formFieldEl.classList).toContain('mat-form-field-disabled', + `Expected form field to look disabled after disable() is called.`); + expect(inputEl.disabled).toBe(true); + })); + + it('should not treat the number 0 as empty', fakeAsync(() => { + let fixture = createComponent(MatInputZeroTestController); + fixture.detectChanges(); + flush(); + + fixture.detectChanges(); + + let formField = fixture.debugElement + .query(By.directive(MatFormField))!.componentInstance as MatFormField; + expect(formField).not.toBeNull(); + expect(formField._control.empty).toBe(false); + })); + + it('should update when the form field value is patched without emitting', fakeAsync(() => { + const fixture = createComponent(MatInputWithFormControl); + fixture.detectChanges(); + + let formField = fixture.debugElement + .query(By.directive(MatFormField))!.componentInstance as MatFormField; + + expect(formField._control.empty).toBe(true); + + fixture.componentInstance.formControl.patchValue('value', {emitEvent: false}); + fixture.detectChanges(); + + expect(formField._control.empty).toBe(false); + })); + +}); + +describe('MatFormField default options', () => { + it('should be fill appearance if no default options provided', () => { + const fixture = createComponent(MatInputWithAppearance); + fixture.detectChanges(); + expect(fixture.componentInstance.formField.appearance).toBe('fill'); + }); + + it('should be fill appearance if empty default options provided', () => { + const fixture = createComponent(MatInputWithAppearance, [{ + provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {}} + ]); + + fixture.detectChanges(); + expect(fixture.componentInstance.formField.appearance).toBe('fill'); + }); + + it('should be able to change the default appearance', () => { + const fixture = createComponent(MatInputWithAppearance, [{ + provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {appearance: 'outline'}} + ]); + fixture.detectChanges(); + expect(fixture.componentInstance.formField.appearance).toBe('outline'); + }); + + it('should default hideRequiredMarker to false', () => { + const fixture = createComponent(MatInputWithAppearance, [{ + provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {}} + ]); + + fixture.detectChanges(); + expect(fixture.componentInstance.formField.hideRequiredMarker).toBe(false); + }); + + it('should be able to change the default value of hideRequiredMarker', () => { + const fixture = createComponent(MatInputWithAppearance, [{ + provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, useValue: {hideRequiredMarker: true}} + ]); + + fixture.detectChanges(); + expect(fixture.componentInstance.formField.hideRequiredMarker).toBe(true); + }); + +}); + +function createComponent(component: Type, + providers: Provider[] = [], + imports: any[] = [], + declarations: any[] = []): ComponentFixture { + TestBed.configureTestingModule({ + imports: [ + FormsModule, + MatFormFieldModule, + MatInputModule, + BrowserAnimationsModule, + PlatformModule, + ReactiveFormsModule, + ...imports + ], + declarations: [component, ...declarations], + providers: [ + // Custom error handler that re-throws the error. Errors happening within + // change detection phase will be reported through the handler and thrown + // in Ivy. Since we do not want to pollute the "console.error", but rather + // just rely on the actual error interrupting the test, we re-throw here. + {provide: ErrorHandler, useValue: ({handleError: (err: any) => { throw err; }})}, + ...providers + ], + }).compileComponents(); + + return TestBed.createComponent(component); +} + + +@Component({ + template: ` + + Label + + ` +}) +class MatInputWithId { + floatLabel: 'always' | 'auto' = 'auto'; +} + +@Component({ + template: `` +}) +class MatInputWithDisabled { + disabled: boolean; +} + +@Component({ + template: `` +}) +class MatInputWithRequired { + required: boolean; +} + +@Component({ + template: `` +}) +class MatInputWithType { + type: string; +} + +@Component({ + template: ` + + hello + + ` +}) +class MatInputLabelRequiredTestComponent { + hideRequiredMarker: boolean = false; + disabled: boolean = false; +} + +@Component({ + template: ` + + + ` +}) +class MatInputWithFormControl { + formControl = new FormControl(); +} + +@Component({ + template: `{{label}}` +}) +class MatInputHintLabel2TestController { + label: string = ''; +} + +@Component({ + template: `` +}) +class MatInputHintLabelTestController { + label: string = ''; +} + +@Component({template: ``}) +class MatInputInvalidTypeTestController { + t = 'file'; +} + +@Component({ + template: ` + + + World + ` +}) +class MatInputInvalidHint2TestController {} + +@Component({ + template: ` + + + Hello + World + ` +}) +class MatInputInvalidHintTestController {} + +@Component({ + template: ` + + + Hello + World + ` +}) +class MatInputMultipleHintTestController { + startId: string; + endId: string; +} + +@Component({ + template: ` + + + World + ` +}) +class MatInputMultipleHintMixedTestController {} + +@Component({ + template: ` + + + ` +}) +class MatInputDateTestController {} + +@Component({ + template: ` + + Label + + ` +}) +class MatInputTextTestController {} + +@Component({ + template: ` + + + ` +}) +class MatInputPasswordTestController {} + +@Component({ + template: ` + + + ` +}) +class MatInputNumberTestController {} + +@Component({ + template: ` + + + ` +}) +class MatInputZeroTestController { + value = 0; +} + +@Component({ + template: ` + + + ` +}) +class MatInputWithValueBinding { + value: string = 'Initial'; +} + +@Component({ + template: ` + + + + ` +}) +class MatInputWithStaticLabel {} + +@Component({ + template: ` + + Label + + ` +}) +class MatInputWithDynamicLabel { + shouldFloat: 'always' | 'auto' = 'always'; +} + +@Component({ + template: ` + + + ` +}) +class MatInputTextareaWithBindings { + rows: number = 4; + cols: number = 8; + wrap: string = 'hard'; +} + +@Component({ + template: `` +}) +class MatInputMissingMatInputTestController {} + +@Component({ + template: ` +
+ + + Please type something + This field is required + +
+ ` +}) +class MatInputWithFormErrorMessages { + @ViewChild('form') form: NgForm; + formControl = new FormControl('', Validators.required); + renderError = true; +} + +@Component({ + template: ` +
+ + + Please type something + This field is required + +
+ ` +}) +class MatInputWithCustomErrorStateMatcher { + formGroup = new FormGroup({ + name: new FormControl('', Validators.required) + }); + + errorState = false; + + customErrorStateMatcher = { + isErrorState: () => this.errorState + }; +} + +@Component({ + template: ` +
+ + + Please type something + This field is required + +
+ ` +}) +class MatInputWithFormGroupErrorMessages { + @ViewChild(FormGroupDirective) formGroupDirective: FormGroupDirective; + formGroup = new FormGroup({ + name: new FormControl('', Validators.required) + }); +} + +@Component({ + template: ` + +
Prefix
+ +
Suffix
+
+ ` +}) +class MatInputWithPrefixAndSuffix {} + +@Component({ + template: ` + + + + ` +}) +class MatInputWithNgIf { + renderInput = true; +} + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + Label + + + ` +}) +class MatInputOnPush { + formControl = new FormControl(''); +} + +@Component({ + template: ` + + + + ` +}) +class MatInputWithReadonlyInput { + isReadonly = true; +} + +@Component({ + template: ` + + Label + + + ` +}) +class MatInputWithLabel {} + +@Component({ + template: ` + + + + ` +}) +class MatInputWithAppearance { + @ViewChild(MatFormField) formField: MatFormField; + appearance: MatFormFieldAppearance; +} + +@Component({ + template: ` + + + + ` +}) +class MatInputWithoutPlaceholder { +} + +@Component({ + template: ` + + Label + + ` +}) +class MatInputSelect { + disabled: boolean; + required: boolean; +} + +@Component({ + template: ` + + Form-field label + + ` +}) +class MatInputSelectWithNoLabelNoValue {} + +@Component({ + template: ` + + Label + + ` +}) +class MatInputSelectWithLabel {} + +@Component({ + template: ` + + Label + + ` +}) +class MatInputSelectWithInnerHtml {} + +@Component({ + template: ` + + + ` +}) +class MatInputWithCustomAccessor {} + +@Component({ + template: ` + + + ` +}) +class MatInputSelectWithoutOptions {} + + +/** Custom component that never has a value. Used for testing the `MAT_INPUT_VALUE_ACCESSOR`. */ +@Directive({ + selector: 'input[customInputAccessor]', + providers: [{ + provide: MAT_INPUT_VALUE_ACCESSOR, + useExisting: CustomMatInputAccessor + }] +}) +class CustomMatInputAccessor { + get value() { return this._value; } + set value(_value: any) {} + private _value = null; +} diff --git a/src/material-experimental/mdc-input/input.ts b/src/material-experimental/mdc-input/input.ts new file mode 100644 index 000000000000..c44f69027cfd --- /dev/null +++ b/src/material-experimental/mdc-input/input.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC 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'; +import {MatFormFieldControl} from '@angular/material/form-field'; +import {MatInput as BaseMatInput} from '@angular/material/input'; + +// workaround until we have feature targeting for MDC text-field. At that +// point we can just use the actual "MatInput" class and apply the MDC text-field +// styles appropriately. + +@Directive({ + selector: `input[matInput], textarea[matInput], select[matNativeControl], + input[matNativeControl], textarea[matNativeControl]`, + exportAs: 'matInput', + host: { + 'class': 'mat-mdc-input-element mdc-text-field__input', + '[class.mat-input-server]': '_isServer', + '[class.mat-mdc-textarea-input]': '_isTextarea()', + // 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', + '[disabled]': 'disabled', + '[required]': 'required', + '[attr.placeholder]': 'placeholder', + '[attr.readonly]': 'readonly && !_isNativeSelect || null', + '[attr.aria-describedby]': '_ariaDescribedby || null', + '[attr.aria-invalid]': 'errorState', + '[attr.aria-required]': 'required.toString()', + '(blur)': '_focusChanged(false)', + '(focus)': '_focusChanged(true)', + '(input)': '_onInput()', + }, + providers: [{provide: MatFormFieldControl, useExisting: MatInput}], +}) +export class MatInput extends BaseMatInput { + static ngAcceptInputType_disabled: boolean | string | null | undefined; + static ngAcceptInputType_readonly: boolean | string | null | undefined; + static ngAcceptInputType_required: boolean | string | null | undefined; + static ngAcceptInputType_value: any; +} + diff --git a/src/material-experimental/mdc-input/module.ts b/src/material-experimental/mdc-input/module.ts new file mode 100644 index 000000000000..132584453188 --- /dev/null +++ b/src/material-experimental/mdc-input/module.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC 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 {TextFieldModule} from '@angular/cdk/text-field'; +import {CommonModule} from '@angular/common'; +import {NgModule} from '@angular/core'; +import {MatCommonModule} from '@angular/material/core'; +import {MatFormFieldModule} from '@angular/material-experimental/mdc-form-field'; +import {MatInput} from './input'; + +@NgModule({ + imports: [MatCommonModule, MatFormFieldModule, CommonModule], + exports: [MatInput, MatFormFieldModule, TextFieldModule, MatCommonModule], + declarations: [MatInput], +}) +export class MatInputModule {} diff --git a/src/material-experimental/mdc-input/public-api.ts b/src/material-experimental/mdc-input/public-api.ts new file mode 100644 index 000000000000..59ca77fabb0e --- /dev/null +++ b/src/material-experimental/mdc-input/public-api.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google LLC 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 + */ + +export {getMatInputUnsupportedTypeError, MAT_INPUT_VALUE_ACCESSOR} from '@angular/material/input'; +export {MatInput} from './input'; +export {MatInputModule} from './module'; diff --git a/src/material-experimental/mdc-theming/BUILD.bazel b/src/material-experimental/mdc-theming/BUILD.bazel index 6573f9871a90..5116ac5b2d34 100644 --- a/src/material-experimental/mdc-theming/BUILD.bazel +++ b/src/material-experimental/mdc-theming/BUILD.bazel @@ -12,6 +12,8 @@ sass_library( "//src/material-experimental/mdc-card:mdc_card_scss_lib", "//src/material-experimental/mdc-checkbox:mdc_checkbox_scss_lib", "//src/material-experimental/mdc-chips:mdc_chips_scss_lib", + "//src/material-experimental/mdc-form-field:mdc_form_field_scss_lib", + "//src/material-experimental/mdc-input:mdc_input_scss_lib", "//src/material-experimental/mdc-list:mdc_list_scss_lib", "//src/material-experimental/mdc-menu:mdc_menu_scss_lib", "//src/material-experimental/mdc-progress-bar:mdc_progress_bar_scss_lib", diff --git a/src/material-experimental/mdc-theming/_all-theme.scss b/src/material-experimental/mdc-theming/_all-theme.scss index 9fae2830013f..d1ad30ee87e9 100644 --- a/src/material-experimental/mdc-theming/_all-theme.scss +++ b/src/material-experimental/mdc-theming/_all-theme.scss @@ -9,6 +9,8 @@ @import '../mdc-tabs/mdc-tabs'; @import '../mdc-table/mdc-table'; @import '../mdc-progress-bar/mdc-progress-bar'; +@import '../mdc-input/mdc-input'; +@import '../mdc-form-field/mdc-form-field'; @mixin angular-material-theme-mdc($theme) { @include mat-button-theme-mdc($theme); @@ -23,6 +25,8 @@ @include mat-radio-theme-mdc($theme); @include mat-slide-toggle-theme-mdc($theme); @include mat-table-theme-mdc($theme); + @include mat-form-field-theme-mdc($theme); + @include mat-input-theme-mdc($theme); // TODO(andrewjs): Add this back when MDC syncs their slider code into Google-internal code // @include mat-slider-theme-mdc($theme); @include mat-tabs-theme-mdc($theme); diff --git a/src/material-experimental/mdc-typography/BUILD.bazel b/src/material-experimental/mdc-typography/BUILD.bazel index 1be4b16b315f..78f9fbbd449e 100644 --- a/src/material-experimental/mdc-typography/BUILD.bazel +++ b/src/material-experimental/mdc-typography/BUILD.bazel @@ -12,6 +12,8 @@ sass_library( "//src/material-experimental/mdc-card:mdc_card_scss_lib", "//src/material-experimental/mdc-checkbox:mdc_checkbox_scss_lib", "//src/material-experimental/mdc-chips:mdc_chips_scss_lib", + "//src/material-experimental/mdc-form-field:mdc_form_field_scss_lib", + "//src/material-experimental/mdc-input:mdc_input_scss_lib", "//src/material-experimental/mdc-list:mdc_list_scss_lib", "//src/material-experimental/mdc-menu:mdc_menu_scss_lib", "//src/material-experimental/mdc-progress-bar:mdc_progress_bar_scss_lib", diff --git a/src/material-experimental/mdc-typography/_all-typography.scss b/src/material-experimental/mdc-typography/_all-typography.scss index 849d1bb54426..8edfe99ccb10 100644 --- a/src/material-experimental/mdc-typography/_all-typography.scss +++ b/src/material-experimental/mdc-typography/_all-typography.scss @@ -10,6 +10,8 @@ @import '../mdc-tabs/mdc-tabs'; @import '../mdc-table/mdc-table'; @import '../mdc-progress-bar/mdc-progress-bar'; +@import '../mdc-input/mdc-input'; +@import '../mdc-form-field/mdc-form-field'; @mixin angular-material-typography-mdc($config: null) { @if $config == null { @@ -30,4 +32,6 @@ @include mat-tabs-typography-mdc($config); @include mat-table-typography-mdc($config); @include mat-progress-bar-typography-mdc($config); + @include mat-input-typography-mdc($config); + @include mat-form-field-typography-mdc($config); } diff --git a/src/material-experimental/mdc_require_config.js b/src/material-experimental/mdc_require_config.js index 37cd8f410522..bff597ceb6a1 100644 --- a/src/material-experimental/mdc_require_config.js +++ b/src/material-experimental/mdc_require_config.js @@ -31,7 +31,7 @@ require.config({ '@material/tab-indicator': '/base/npm/node_modules/@material/tab-indicator/dist/mdc.tabIndicator', '@material/tab-scroller': '/base/npm/node_modules/@material/tab-scroller/dist/mdc.tabScroller', '@material/data-table': '/base/npm/node_modules/@material/data-table/dist/mdc.dataTable', - '@material/text-field': '/base/npm/node_modules/@material/textfield/dist/mdc.textField', + '@material/textfield': '/base/npm/node_modules/@material/textfield/dist/mdc.textfield', '@material/top-app-bar': '/base/npm/node_modules/@material/top-app-bar/dist/mdc.topAppBar', } }); diff --git a/src/material/input/input-module.ts b/src/material/input/input-module.ts index d2a8c27f57b5..871ce745d5d5 100644 --- a/src/material/input/input-module.ts +++ b/src/material/input/input-module.ts @@ -14,7 +14,6 @@ import {MatFormFieldModule} from '@angular/material/form-field'; import {MatTextareaAutosize} from './autosize'; import {MatInput} from './input'; - @NgModule({ declarations: [MatInput, MatTextareaAutosize], imports: [ diff --git a/src/material/input/input.ts b/src/material/input/input.ts index ed1d2ca176e0..2363b4046cd3 100644 --- a/src/material/input/input.ts +++ b/src/material/input/input.ts @@ -332,6 +332,11 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl< // FormsModule or ReactiveFormsModule, because Angular forms also listens to input events. } + /** Determines if the component host is a textarea. */ + _isTextarea() { + return this._elementRef.nativeElement.nodeName.toLowerCase() === 'textarea'; + } + /** Does some manual dirty checking on the native input `value` property. */ protected _dirtyCheckNativeValue() { const newValue = this._elementRef.nativeElement.value; @@ -361,11 +366,6 @@ export class MatInput extends _MatInputMixinBase implements MatFormFieldControl< return validity && validity.badInput; } - /** Determines if the component host is a textarea. */ - protected _isTextarea() { - return this._elementRef.nativeElement.nodeName.toLowerCase() === 'textarea'; - } - /** * Implemented as part of MatFormFieldControl. * @docs-private diff --git a/test/karma-system-config.js b/test/karma-system-config.js index a4a0539a7cdc..2217f451db57 100644 --- a/test/karma-system-config.js +++ b/test/karma-system-config.js @@ -36,7 +36,7 @@ System.config({ '@material/tab-bar': 'node:@material/tab-bar/dist/mdc.tabBar.js', '@material/tab-indicator': 'node:@material/tab-indicator/dist/mdc.tabIndicator.js', '@material/tab-scroller': 'node:@material/tab-scroller/dist/mdc.tabScroller.js', - '@material/text-field': 'node:@material/textfield/dist/mdc.textField.js', + '@material/textfield': 'node:@material/textfield/dist/mdc.textfield.js', '@material/top-app-bar': 'node:@material/top-app-bar/dist/mdc.topAppBar.js', // Angular specific mappings. @@ -198,8 +198,12 @@ System.config({ 'dist/packages/material-experimental/mdc-checkbox/index.js', '@angular/material-experimental/mdc-chips': 'dist/packages/material-experimental/mdc-chips/index.js', + '@angular/material-experimental/mdc-form-field': + 'dist/packages/material-experimental/mdc-form-field/index.js', '@angular/material-experimental/mdc-helpers': 'dist/packages/material-experimental/mdc-helpers/index.js', + '@angular/material-experimental/mdc-input': + 'dist/packages/material-experimental/mdc-input/index.js', '@angular/material-experimental/mdc-list': 'dist/packages/material-experimental/mdc-list/index.js', '@angular/material-experimental/mdc-menu': diff --git a/tools/public_api_guard/material/input.d.ts b/tools/public_api_guard/material/input.d.ts index 13f907211b98..de3b6d09e69a 100644 --- a/tools/public_api_guard/material/input.d.ts +++ b/tools/public_api_guard/material/input.d.ts @@ -38,7 +38,7 @@ export declare class MatInput extends _MatInputMixinBase implements MatFormField _focusChanged(isFocused: boolean): void; protected _isBadInput(): boolean; protected _isNeverEmpty(): boolean; - protected _isTextarea(): boolean; + _isTextarea(): boolean; _onInput(): void; protected _validateType(): void; focus(options?: FocusOptions): void;