From da4eb2de811ae1b7863222877c445894fa449305 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 11 Aug 2020 13:16:24 +0200 Subject: [PATCH] feat(material-experimental/mdc-autocomplete): implement MDC-based mat-autocomplete * Moves all of the autocomplete logic into base classes so that it can be reused between the standard and MDC components. * Re-implements `mat-autocomplete` using the logic from the existing one and the styling from MDC. The MDC-based autocomplete behaves identically to the existing one, with the only minor difference being that MDC one fixes a long-standing issue where we expect a hardcoded height for each of the options. It was easier to fix the bug and add logic to support arbitrary option heights than to add more logic to account for MDC's styles. --- .github/CODEOWNERS | 1 + src/dev-app/BUILD.bazel | 1 + src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/dev-app/routes.ts | 4 + src/dev-app/mdc-autocomplete/BUILD.bazel | 26 + .../mdc-autocomplete-demo-module.ts | 35 + .../mdc-autocomplete-demo.html | 76 + .../mdc-autocomplete-demo.scss | 21 + .../mdc-autocomplete/mdc-autocomplete-demo.ts | 145 + .../mdc-autocomplete/BUILD.bazel | 54 +- .../mdc-autocomplete/README.md | 99 +- .../mdc-autocomplete/_autocomplete-theme.scss | 23 +- .../mdc-autocomplete/autocomplete-origin.ts | 20 + .../mdc-autocomplete/autocomplete-trigger.ts | 81 + .../mdc-autocomplete/autocomplete.e2e.spec.ts | 1 - .../mdc-autocomplete/autocomplete.html | 10 + .../mdc-autocomplete/autocomplete.scss | 41 +- .../mdc-autocomplete/autocomplete.spec.ts | 3024 +++++++++++++++++ .../mdc-autocomplete/autocomplete.ts | 47 + .../mdc-autocomplete/module.ts | 30 +- .../mdc-autocomplete/public-api.ts | 18 + .../mdc-core/option/optgroup.ts | 10 +- .../mdc-theming/BUILD.bazel | 1 + .../mdc-theming/_all-theme.scss | 2 + .../autocomplete/autocomplete-module.ts | 14 +- .../autocomplete/autocomplete-origin.ts | 14 +- .../autocomplete/autocomplete-trigger.ts | 136 +- src/material/autocomplete/autocomplete.ts | 82 +- src/material/core/option/option.ts | 6 +- src/material/select/select.ts | 5 +- .../kitchen-sink-mdc/kitchen-sink-mdc.html | 7 + .../kitchen-sink-mdc/kitchen-sink-mdc.ts | 2 + .../material/autocomplete.d.ts | 143 +- tools/public_api_guard/material/core.d.ts | 2 +- 34 files changed, 3988 insertions(+), 194 deletions(-) create mode 100644 src/dev-app/mdc-autocomplete/BUILD.bazel create mode 100644 src/dev-app/mdc-autocomplete/mdc-autocomplete-demo-module.ts create mode 100644 src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.html create mode 100644 src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.scss create mode 100644 src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.ts create mode 100644 src/material-experimental/mdc-autocomplete/autocomplete-origin.ts create mode 100644 src/material-experimental/mdc-autocomplete/autocomplete-trigger.ts delete mode 100644 src/material-experimental/mdc-autocomplete/autocomplete.e2e.spec.ts create mode 100644 src/material-experimental/mdc-autocomplete/autocomplete.html create mode 100644 src/material-experimental/mdc-autocomplete/autocomplete.spec.ts create mode 100644 src/material-experimental/mdc-autocomplete/autocomplete.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 4170974ada74..5270e03fee76 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -144,6 +144,7 @@ /src/dev-app/baseline/** @mmalerba /src/dev-app/bottom-sheet/** @jelbourn @crisbeto /src/dev-app/button-toggle/** @jelbourn +/src/dev-app/mdc-autocomplete/** @crisbeto /src/dev-app/button/** @jelbourn /src/dev-app/card/** @jelbourn /src/dev-app/cdk-experimental-listbox/** @jelbourn @nielsr98 diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 886792ef85ee..d851fc81c991 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -46,6 +46,7 @@ ng_module( "//src/dev-app/input", "//src/dev-app/list", "//src/dev-app/live-announcer", + "//src/dev-app/mdc-autocomplete", "//src/dev-app/mdc-button", "//src/dev-app/mdc-card", "//src/dev-app/mdc-checkbox", diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 0d27e62951aa..a4072db6f11c 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -74,6 +74,7 @@ export class DevAppLayout { {name: 'Typography', route: '/typography'}, {name: 'Virtual Scrolling', route: '/virtual-scroll'}, {name: 'YouTube Player', route: '/youtube-player'}, + {name: 'MDC Autocomplete', route: '/mdc-autocomplete'}, {name: 'MDC Button', route: '/mdc-button'}, {name: 'MDC Card', route: '/mdc-card'}, {name: 'MDC Checkbox', route: '/mdc-checkbox'}, diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index a0a71b07246a..05d1b027333e 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -69,6 +69,10 @@ export const DEV_APP_ROUTES: Routes = [ path: 'menubar', loadChildren: 'menubar/mat-menubar-demo-module#MatMenuBarDemoModule' }, + { + path: 'mdc-autocomplete', + loadChildren: 'mdc-autocomplete/mdc-autocomplete-demo-module#MdcAutocompleteDemoModule' + }, {path: 'mdc-button', loadChildren: 'mdc-button/mdc-button-demo-module#MdcButtonDemoModule'}, {path: 'mdc-card', loadChildren: 'mdc-card/mdc-card-demo-module#MdcCardDemoModule'}, { diff --git a/src/dev-app/mdc-autocomplete/BUILD.bazel b/src/dev-app/mdc-autocomplete/BUILD.bazel new file mode 100644 index 000000000000..fc2ddaf6cb7b --- /dev/null +++ b/src/dev-app/mdc-autocomplete/BUILD.bazel @@ -0,0 +1,26 @@ +load("//tools:defaults.bzl", "ng_module", "sass_binary") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "mdc-autocomplete", + srcs = glob(["**/*.ts"]), + assets = [ + "mdc-autocomplete-demo.html", + ":mdc_autocomplete_demo_scss", + ], + deps = [ + "//src/material-experimental/mdc-autocomplete", + "//src/material-experimental/mdc-button", + "//src/material-experimental/mdc-card", + "//src/material-experimental/mdc-form-field", + "//src/material-experimental/mdc-input", + "@npm//@angular/forms", + "@npm//@angular/router", + ], +) + +sass_binary( + name = "mdc_autocomplete_demo_scss", + src = "mdc-autocomplete-demo.scss", +) diff --git a/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo-module.ts b/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo-module.ts new file mode 100644 index 000000000000..a8a5bfb7a1a7 --- /dev/null +++ b/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo-module.ts @@ -0,0 +1,35 @@ +/** + * @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 {MatAutocompleteModule} from '@angular/material-experimental/mdc-autocomplete'; +import {MatButtonModule} from '@angular/material-experimental/mdc-button'; +import {MatCardModule} from '@angular/material-experimental/mdc-card'; +import {MatFormFieldModule} from '@angular/material-experimental/mdc-form-field'; +import {MatInputModule} from '@angular/material-experimental/mdc-input'; +import {RouterModule} from '@angular/router'; +import {MdcAutocompleteDemo} from './mdc-autocomplete-demo'; + +@NgModule({ + imports: [ + CommonModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatCardModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule, + RouterModule.forChild([{path: '', component: MdcAutocompleteDemo}]), + ], + declarations: [MdcAutocompleteDemo], +}) +export class MdcAutocompleteDemoModule { +} diff --git a/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.html b/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.html new file mode 100644 index 000000000000..35c345def16b --- /dev/null +++ b/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.html @@ -0,0 +1,76 @@ +Space above cards: +
+
+ + Reactive length: {{ tempStates?.length }} +
Reactive value: {{ stateCtrl.value | json }}
+
Reactive dirty: {{ stateCtrl.dirty }}
+ + + State + + + + {{ state.name }} + ({{ state.code }}) + + + + + + + + + + +
+ + + +
Template-driven value (currentState): {{ currentState }}
+
Template-driven dirty: {{ modelDir ? modelDir.dirty : false }}
+ + + + State + + + + {{ state.name }} + + + + + + + + + + +
+ + +
Option groups (currentGroupedState): {{ currentGroupedState }}
+ + + State + + +
+
+ + + + {{ state.name }} + + diff --git a/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.scss b/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.scss new file mode 100644 index 000000000000..ac30976a6222 --- /dev/null +++ b/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.scss @@ -0,0 +1,21 @@ +.demo-autocomplete { + display: flex; + flex-flow: row wrap; + + .mat-mdc-card { + width: 400px; + margin: 24px; + padding: 16px; + } + + .mat-mdc-form-field { + margin-top: 16px; + min-width: 200px; + max-width: 100%; + } +} + +.demo-secondary-text { + color: rgba(0, 0, 0, 0.54); + margin-left: 8px; +} diff --git a/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.ts b/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.ts new file mode 100644 index 000000000000..eeac4b5e7b5f --- /dev/null +++ b/src/dev-app/mdc-autocomplete/mdc-autocomplete-demo.ts @@ -0,0 +1,145 @@ +/** + * @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 {Component, ViewChild} from '@angular/core'; +import {FormControl, NgModel} from '@angular/forms'; +import {Observable} from 'rxjs'; +import {map, startWith} from 'rxjs/operators'; + + +export interface State { + code: string; + name: string; +} + +export interface StateGroup { + letter: string; + states: State[]; +} + +@Component({ + selector: 'mdc-autocomplete-demo', + templateUrl: 'mdc-autocomplete-demo.html', + styleUrls: ['mdc-autocomplete-demo.css'] +}) +export class MdcAutocompleteDemo { + stateCtrl: FormControl; + currentState = ''; + currentGroupedState = ''; + topHeightCtrl = new FormControl(0); + + reactiveStates: Observable; + tdStates: State[]; + + tdDisabled = false; + + @ViewChild(NgModel) modelDir: NgModel; + + groupedStates: StateGroup[]; + filteredGroupedStates: StateGroup[]; + states: State[] = [ + {code: 'AL', name: 'Alabama'}, + {code: 'AK', name: 'Alaska'}, + {code: 'AZ', name: 'Arizona'}, + {code: 'AR', name: 'Arkansas'}, + {code: 'CA', name: 'California'}, + {code: 'CO', name: 'Colorado'}, + {code: 'CT', name: 'Connecticut'}, + {code: 'DE', name: 'Delaware'}, + {code: 'FL', name: 'Florida'}, + {code: 'GA', name: 'Georgia'}, + {code: 'HI', name: 'Hawaii'}, + {code: 'ID', name: 'Idaho'}, + {code: 'IL', name: 'Illinois'}, + {code: 'IN', name: 'Indiana'}, + {code: 'IA', name: 'Iowa'}, + {code: 'KS', name: 'Kansas'}, + {code: 'KY', name: 'Kentucky'}, + {code: 'LA', name: 'Louisiana'}, + {code: 'ME', name: 'Maine'}, + {code: 'MD', name: 'Maryland'}, + {code: 'MA', name: 'Massachusetts'}, + {code: 'MI', name: 'Michigan'}, + {code: 'MN', name: 'Minnesota'}, + {code: 'MS', name: 'Mississippi'}, + {code: 'MO', name: 'Missouri'}, + {code: 'MT', name: 'Montana'}, + {code: 'NE', name: 'Nebraska'}, + {code: 'NV', name: 'Nevada'}, + {code: 'NH', name: 'New Hampshire'}, + {code: 'NJ', name: 'New Jersey'}, + {code: 'NM', name: 'New Mexico'}, + {code: 'NY', name: 'New York'}, + {code: 'NC', name: 'North Carolina'}, + {code: 'ND', name: 'North Dakota'}, + {code: 'OH', name: 'Ohio'}, + {code: 'OK', name: 'Oklahoma'}, + {code: 'OR', name: 'Oregon'}, + {code: 'PA', name: 'Pennsylvania'}, + {code: 'RI', name: 'Rhode Island'}, + {code: 'SC', name: 'South Carolina'}, + {code: 'SD', name: 'South Dakota'}, + {code: 'TN', name: 'Tennessee'}, + {code: 'TX', name: 'Texas'}, + {code: 'UT', name: 'Utah'}, + {code: 'VT', name: 'Vermont'}, + {code: 'VA', name: 'Virginia'}, + {code: 'WA', name: 'Washington'}, + {code: 'WV', name: 'West Virginia'}, + {code: 'WI', name: 'Wisconsin'}, + {code: 'WY', name: 'Wyoming'}, + ]; + + constructor() { + this.tdStates = this.states; + this.stateCtrl = new FormControl({code: 'CA', name: 'California'}); + this.reactiveStates = this.stateCtrl.valueChanges + .pipe( + startWith(this.stateCtrl.value), + map(val => this.displayFn(val)), + map(name => this.filterStates(name)) + ); + + this.filteredGroupedStates = this.groupedStates = + this.states.reduce((groups, state) => { + let group = groups.find(g => g.letter === state.name[0]); + + if (!group) { + group = { letter: state.name[0], states: [] }; + groups.push(group); + } + + group.states.push({ code: state.code, name: state.name }); + + return groups; + }, []); + } + + displayFn(value: any): string { + return value && typeof value === 'object' ? value.name : value; + } + + filterStates(val: string) { + return val ? this._filter(this.states, val) : this.states; + } + + filterStateGroups(val: string) { + if (val) { + return this.groupedStates + .map(group => ({ letter: group.letter, states: this._filter(group.states, val) })) + .filter(group => group.states.length > 0); + } + + return this.groupedStates; + } + + private _filter(states: State[], val: string) { + const filterValue = val.toLowerCase(); + return states.filter(state => state.name.toLowerCase().startsWith(filterValue)); + } +} diff --git a/src/material-experimental/mdc-autocomplete/BUILD.bazel b/src/material-experimental/mdc-autocomplete/BUILD.bazel index 567fea3f0b76..2b3f4221c640 100644 --- a/src/material-experimental/mdc-autocomplete/BUILD.bazel +++ b/src/material-experimental/mdc-autocomplete/BUILD.bazel @@ -1,5 +1,4 @@ -load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") -load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "sass_binary", "sass_library") +load("//tools:defaults.bzl", "ng_module", "ng_test_library", "ng_web_test_suite", "sass_binary", "sass_library") package(default_visibility = ["//visibility:public"]) @@ -16,7 +15,12 @@ ng_module( ] + glob(["**/*.html"]), module_name = "@angular/material-experimental/mdc-autocomplete", deps = [ + "//src/cdk/overlay", + "//src/cdk/scrolling", + "//src/material-experimental/mdc-core", + "//src/material/autocomplete", "//src/material/core", + "@npm//@angular/common", ], ) @@ -25,6 +29,7 @@ sass_library( srcs = glob(["**/_*.scss"]), deps = [ "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", ], ) @@ -34,21 +39,48 @@ sass_binary( include_paths = [ "external/npm/node_modules", ], - deps = [], + deps = [ + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + "//src/material/core:core_scss_lib", + ], ) -ng_e2e_test_library( - name = "e2e_test_sources", - srcs = glob(["**/*.e2e.spec.ts"]), +ng_test_library( + name = "mdc_autocomplete_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = [ + "**/*.e2e.spec.ts", + ], + ), deps = [ - "//src/cdk/testing/private/e2e", + ":mdc-autocomplete", + "//src/cdk/bidi", + "//src/cdk/keycodes", + "//src/cdk/overlay", + "//src/cdk/platform", + "//src/cdk/scrolling", + "//src/cdk/testing/private", + "//src/material-experimental/mdc-core", + "//src/material-experimental/mdc-form-field", + "//src/material-experimental/mdc-input", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", + "@npm//rxjs", ], ) -e2e_test_suite( - name = "e2e_tests", +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/dom/dist/mdc.dom.js", + ], deps = [ - ":e2e_test_sources", - "//src/cdk/testing/private/e2e", + ":mdc_autocomplete_tests_lib", + "//src/material-experimental:mdc_require_config.js", ], ) diff --git a/src/material-experimental/mdc-autocomplete/README.md b/src/material-experimental/mdc-autocomplete/README.md index cf873b4c072b..3bc3ab60171f 100644 --- a/src/material-experimental/mdc-autocomplete/README.md +++ b/src/material-experimental/mdc-autocomplete/README.md @@ -1 +1,98 @@ - +This is prototype of an alternate version of `` built on top of +[MDC Web](https://github.com/material-components/material-components-web). It demonstrates how +Angular Material could use MDC Web under the hood while still exposing the same API Angular users as +the existing ``. This component is experimental and should not be used in production. + +## How to use +Assuming your application is already up and running using Angular Material, you can add this +component by following these steps: + +1. Install Angular Material Experimental & MDC WEB: + + ```bash + npm i material-components-web @angular/material-experimental + ``` + +2. In your `angular.json`, make sure `node_modules/` is listed as a Sass include path. This is + needed for the Sass compiler to be able to find the MDC Web Sass files. + + ```json + ... + "styles": [ + "src/styles.scss" + ], + "stylePreprocessorOptions": { + "includePaths": [ + "node_modules/" + ] + }, + ... + ``` + +3. Import the experimental `MatAutocompleteModule` and add it to the module that declares your + component: + + ```ts + import {MatAutocompleteModule} from '@angular/material-experimental/mdc-autocomplete'; + + @NgModule({ + declarations: [MyComponent], + imports: [MatAutocompleteModule], + }) + export class MyModule {} + ``` + +4. Add use `` in your component's template, just like you would the normal + ``: + + ```html + + + Option 1 + Option 2 + + ``` + +5. Add the theme and typography mixins to your Sass. (There is currently no pre-built CSS option for + the experimental ``): + + ```scss + @import '~@angular/material/theming'; + @import '~@angular/material-experimental/mdc-autocomplete'; + + $my-primary: mat-palette($mat-indigo); + $my-accent: mat-palette($mat-pink, A200, A100, A400); + $my-theme: mat-light-theme(( + color: ( + primary: $my-primary, + accent: $my-accent + ) + )); + + @include mat-mdc-autocomplete-theme($my-theme); + @include mat-mdc-autocomplete-typography(); + ``` + +## API differences +The experimental autocomplete API closely matches the +[API of the standard autocomplete](https://material.angular.io/components/autocomplete/api). +`@angular/material-experimental/mdc-autocomplete` exports symbols with the same name and public +interface as all of the symbols found under `@angular/material/autocomplete` + +## Replacing the standard autocomplete in an existing app +Because the experimental API mirrors the API for the standard autocomplete, it can easily be swapped +in by just changing the import paths. There is currently no schematic for this, but you can run the +following string replace across your TypeScript files: + +```bash +grep -lr --include="*.ts" --exclude-dir="node_modules" \ + --exclude="*.d.ts" "['\"]@angular/material/autocomplete['\"]" | xargs sed -i \ + "s/['\"]@angular\/material\/autocomplete['\"]/'@angular\/material-experimental\/mdc-autocomplete'/g" +``` + +CSS styles and tests that depend on implementation details of `mat-autocomplete` (such as getting +elements from the template by class name) will need to be manually updated. + +There are some small visual differences between this autocomplete and the standard one. This +autocomplete has a different font size and elevation `box-shadow`, as well as padding at the top +and bottom of the list. diff --git a/src/material-experimental/mdc-autocomplete/_autocomplete-theme.scss b/src/material-experimental/mdc-autocomplete/_autocomplete-theme.scss index b07d543a736d..a457b3ec7e75 100644 --- a/src/material-experimental/mdc-autocomplete/_autocomplete-theme.scss +++ b/src/material-experimental/mdc-autocomplete/_autocomplete-theme.scss @@ -1,8 +1,27 @@ +@import '@material/menu-surface/mixins.import'; +@import '@material/list/mixins.import'; @import '../mdc-helpers/mdc-helpers'; -@mixin mat-mdc-autocomplete-color($config-or-theme) {} +@mixin mat-mdc-autocomplete-color($config-or-theme) { + $config: mat-get-color-config($config-or-theme); + @include mat-using-mdc-theme($config) { + @include mdc-menu-surface-core-styles($mat-theme-styles-query); + @include mdc-list-without-ripple($mat-theme-styles-query); + } +} + +@mixin mat-mdc-autocomplete-typography($config-or-theme) { + $config: mat-get-typography-config($config-or-theme); + @include mat-using-mdc-typography($config) { + @include mdc-menu-surface-core-styles($mat-typography-styles-query); -@mixin mat-mdc-autocomplete-typography($config-or-theme) {} + .mat-mdc-autocomplete-panel { + // Note that we include this private mixin, because the public one adds + // a bunch of styles that we aren't using for the autocomplete panel. + @include mdc-list-base_($mat-typography-styles-query); + } + } +} @mixin mat-mdc-autocomplete-density($config-or-theme) {} diff --git a/src/material-experimental/mdc-autocomplete/autocomplete-origin.ts b/src/material-experimental/mdc-autocomplete/autocomplete-origin.ts new file mode 100644 index 000000000000..5e5faf3cdfce --- /dev/null +++ b/src/material-experimental/mdc-autocomplete/autocomplete-origin.ts @@ -0,0 +1,20 @@ +/** + * @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 {_MatAutocompleteOriginBase} from '@angular/material/autocomplete'; + +/** + * Directive applied to an element to make it usable + * as a connection point for an autocomplete panel. + */ +@Directive({ + selector: '[matAutocompleteOrigin]', + exportAs: 'matAutocompleteOrigin', +}) +export class MatAutocompleteOrigin extends _MatAutocompleteOriginBase {} diff --git a/src/material-experimental/mdc-autocomplete/autocomplete-trigger.ts b/src/material-experimental/mdc-autocomplete/autocomplete-trigger.ts new file mode 100644 index 000000000000..89ae1ad589b6 --- /dev/null +++ b/src/material-experimental/mdc-autocomplete/autocomplete-trigger.ts @@ -0,0 +1,81 @@ +/** + * @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, forwardRef} from '@angular/core'; +import {NG_VALUE_ACCESSOR} from '@angular/forms'; +import {_MatAutocompleteTriggerBase} from '@angular/material/autocomplete'; +import {_countGroupLabelsBeforeOption, _getOptionScrollPosition} from '@angular/material/core'; + +/** + * Provider that allows the autocomplete to register as a ControlValueAccessor. + * @docs-private + */ +export const MAT_AUTOCOMPLETE_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MatAutocompleteTrigger), + multi: true +}; + +@Directive({ + selector: `input[matAutocomplete], textarea[matAutocomplete]`, + host: { + 'class': 'mat-autocomplete-trigger', + '[attr.autocomplete]': 'autocompleteAttribute', + '[attr.role]': 'autocompleteDisabled ? null : "combobox"', + '[attr.aria-autocomplete]': 'autocompleteDisabled ? null : "list"', + '[attr.aria-activedescendant]': '(panelOpen && activeOption) ? activeOption.id : null', + '[attr.aria-expanded]': 'autocompleteDisabled ? null : panelOpen.toString()', + '[attr.aria-owns]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id', + '[attr.aria-haspopup]': '!autocompleteDisabled', + // Note: we use `focusin`, as opposed to `focus`, in order to open the panel + // a little earlier. This avoids issues where IE delays the focusing of the input. + '(focusin)': '_handleFocus()', + '(blur)': '_onTouched()', + '(input)': '_handleInput($event)', + '(keydown)': '_handleKeydown($event)', + }, + exportAs: 'matAutocompleteTrigger', + providers: [MAT_AUTOCOMPLETE_VALUE_ACCESSOR] +}) +export class MatAutocompleteTrigger extends _MatAutocompleteTriggerBase { + protected _aboveClass = 'mat-mdc-autocomplete-panel-above'; + + protected _scrollToOption(index: number): void { + // Given that we are not actually focusing active options, we must manually adjust scroll + // to reveal options below the fold. First, we find the offset of the option from the top + // of the panel. If that offset is below the fold, the new scrollTop will be the offset - + // the panel height + the option height, so the active option will be just visible at the + // bottom of the panel. If that offset is above the top of the visible panel, the new scrollTop + // will become the offset. If that offset is visible within the panel already, the scrollTop is + // not adjusted. + const autocomplete = this.autocomplete; + const labelCount = _countGroupLabelsBeforeOption(index, + autocomplete.options, autocomplete.optionGroups); + + if (index === 0 && labelCount === 1) { + // If we've got one group label before the option and we're at the top option, + // scroll the list to the top. This is better UX than scrolling the list to the + // top of the option, because it allows the user to read the top group's label. + autocomplete._setScrollTop(0); + } else { + const option = autocomplete.options.toArray()[index]; + + if (option) { + const element = option._getHostElement(); + const newScrollPosition = _getOptionScrollPosition( + element.offsetTop, + element.offsetHeight, + autocomplete._getScrollTop(), + autocomplete.panel.nativeElement.offsetHeight + ); + + autocomplete._setScrollTop(newScrollPosition); + } + } + } +} diff --git a/src/material-experimental/mdc-autocomplete/autocomplete.e2e.spec.ts b/src/material-experimental/mdc-autocomplete/autocomplete.e2e.spec.ts deleted file mode 100644 index da2033b44a86..000000000000 --- a/src/material-experimental/mdc-autocomplete/autocomplete.e2e.spec.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO: copy tests from existing mat-autocomplete, update as necessary to fix. diff --git a/src/material-experimental/mdc-autocomplete/autocomplete.html b/src/material-experimental/mdc-autocomplete/autocomplete.html new file mode 100644 index 000000000000..ddf5bae4c074 --- /dev/null +++ b/src/material-experimental/mdc-autocomplete/autocomplete.html @@ -0,0 +1,10 @@ + +
+ +
+
diff --git a/src/material-experimental/mdc-autocomplete/autocomplete.scss b/src/material-experimental/mdc-autocomplete/autocomplete.scss index 08c2291905e4..52069092f44f 100644 --- a/src/material-experimental/mdc-autocomplete/autocomplete.scss +++ b/src/material-experimental/mdc-autocomplete/autocomplete.scss @@ -1 +1,40 @@ -// TODO: implement MDC-based autocomplete +@import '@material/menu-surface/mixins.import'; +@import '@material/list/mixins.import'; +@import '@material/list/variables.import'; +@import '../../cdk/a11y/a11y'; +@import '../mdc-helpers/mdc-helpers'; + +@include mdc-menu-surface-core-styles($query: structure); + +.mat-mdc-autocomplete-panel { + width: 100%; // Ensures that the panel matches the overlay width. + max-height: 256px; // Prevents lists with a lot of option from growing too high. + position: static; // MDC uses `absolute` by default which will throw off our positioning. + visibility: hidden; + + // Note that we include this private mixin, because the public + // one adds a bunch of styles that we aren't using for the menu. + @include mdc-list-base_($query: structure); + @include cdk-high-contrast(active, off) { + outline: solid 1px; + } + + .cdk-overlay-pane:not(.mat-mdc-autocomplete-panel-above) & { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .mat-mdc-autocomplete-panel-above & { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } +} + +// These classes are used to toggle the panel's visibility depending on whether it has any options. +.mat-mdc-autocomplete-visible { + visibility: visible; +} + +.mat-mdc-autocomplete-hidden { + visibility: hidden; +} diff --git a/src/material-experimental/mdc-autocomplete/autocomplete.spec.ts b/src/material-experimental/mdc-autocomplete/autocomplete.spec.ts new file mode 100644 index 000000000000..617aa5d9af18 --- /dev/null +++ b/src/material-experimental/mdc-autocomplete/autocomplete.spec.ts @@ -0,0 +1,3024 @@ +import {Directionality} from '@angular/cdk/bidi'; +import {DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW} from '@angular/cdk/keycodes'; +import {Overlay, OverlayContainer} from '@angular/cdk/overlay'; +import {_supportsShadowDom} from '@angular/cdk/platform'; +import {ScrollDispatcher} from '@angular/cdk/scrolling'; +import { + MockNgZone, + clearElement, + createKeyboardEvent, + dispatchEvent, + dispatchFakeEvent, + dispatchKeyboardEvent, + typeInElement, +} from '@angular/cdk/testing/private'; +import { + ChangeDetectionStrategy, + Component, + NgZone, + OnDestroy, + OnInit, + Provider, + QueryList, + Type, + ViewChild, + ViewChildren, + ViewEncapsulation, +} from '@angular/core'; +import { + async, + ComponentFixture, + fakeAsync, + flush, + inject, + TestBed, + tick, +} from '@angular/core/testing'; +import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; +import {MatOption, MatOptionSelectionChange} from '@angular/material-experimental/mdc-core'; +import {MatFormField, MatFormFieldModule} from '@angular/material-experimental/mdc-form-field'; +import {By} from '@angular/platform-browser'; +import {MatInputModule} from '@angular/material-experimental/mdc-input'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {EMPTY, Observable, Subject, Subscription} from 'rxjs'; +import {map, startWith} from 'rxjs/operators'; + + +import { + getMatAutocompleteMissingPanelError, + MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, + MAT_AUTOCOMPLETE_SCROLL_STRATEGY, + MatAutocomplete, + MatAutocompleteModule, + MatAutocompleteOrigin, + MatAutocompleteSelectedEvent, + MatAutocompleteTrigger, +} from './index'; + + +describe('MDC-based MatAutocomplete', () => { + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + let zone: MockNgZone; + + // Creates a test component fixture. + function createComponent(component: Type, providers: Provider[] = []) { + TestBed.configureTestingModule({ + imports: [ + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + FormsModule, + ReactiveFormsModule, + NoopAnimationsModule + ], + declarations: [component], + providers: [ + {provide: NgZone, useFactory: () => zone = new MockNgZone()}, + ...providers + ] + }); + + TestBed.compileComponents(); + + inject([OverlayContainer], (oc: OverlayContainer) => { + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + })(); + + return TestBed.createComponent(component); + } + + afterEach(inject([OverlayContainer], (currentOverlayContainer: OverlayContainer) => { + // Since we're resetting the testing module in some of the tests, + // we can potentially have multiple overlay containers. + currentOverlayContainer.ngOnDestroy(); + overlayContainer.ngOnDestroy(); + })); + + describe('panel toggling', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(SimpleAutocomplete); + fixture.detectChanges(); + input = fixture.debugElement.query(By.css('input'))!.nativeElement; + }); + + it('should open the panel when the input is focused', () => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to start out closed.`); + + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to read open when input is focused.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to display when input is focused.`); + expect(overlayContainerElement.textContent) + .toContain('California', `Expected panel to display when input is focused.`); + }); + + it('should not open the panel on focus if the input is readonly', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + input.readOnly = true; + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false, 'Expected panel state to start out closed.'); + dispatchFakeEvent(input, 'focusin'); + flush(); + + fixture.detectChanges(); + expect(trigger.panelOpen).toBe(false, 'Expected panel to stay closed.'); + })); + + it('should not open using the arrow keys when the input is readonly', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + input.readOnly = true; + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false, 'Expected panel state to start out closed.'); + dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW); + flush(); + + fixture.detectChanges(); + expect(trigger.panelOpen).toBe(false, 'Expected panel to stay closed.'); + })); + + it('should open the panel programmatically', () => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to start out closed.`); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to read open when opened programmatically.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to display when opened programmatically.`); + expect(overlayContainerElement.textContent) + .toContain('California', `Expected panel to display when opened programmatically.`); + }); + + it('should show the panel when the first open is after the initial zone stabilization', + async(() => { + // Note that we're running outside the Angular zone, in order to be able + // to test properly without the subscription from `_subscribeToClosingActions` + // giving us a false positive. + fixture.ngZone!.runOutsideAngular(() => { + fixture.componentInstance.trigger.openPanel(); + + Promise.resolve().then(() => { + expect(fixture.componentInstance.panel.showPanel) + .toBe(true, `Expected panel to be visible.`); + }); + }); + })); + + it('should close the panel when the user clicks away', fakeAsync(() => { + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + zone.simulateZoneExit(); + dispatchFakeEvent(document, 'click'); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected clicking outside the panel to set its state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected clicking outside the panel to close the panel.`); + })); + + it('should close the panel when the user taps away on a touch device', fakeAsync(() => { + dispatchFakeEvent(input, 'focus'); + fixture.detectChanges(); + flush(); + dispatchFakeEvent(document, 'touchend'); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected tapping outside the panel to set its state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected tapping outside the panel to close the panel.`); + })); + + it('should close the panel when an option is clicked', fakeAsync(() => { + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const option = overlayContainerElement.querySelector('mat-option') as HTMLElement; + option.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected clicking an option to set the panel state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected clicking an option to close the panel.`); + })); + + it('should close the panel when a newly created option is clicked', fakeAsync(() => { + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + zone.simulateZoneExit(); + + // Filter down the option list to a subset of original options ('Alabama', 'California') + typeInElement(input, 'al'); + fixture.detectChanges(); + tick(); + + let options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[0].click(); + + // Changing value from 'Alabama' to 'al' to re-populate the option list, + // ensuring that 'California' is created new. + dispatchFakeEvent(input, 'focusin'); + clearElement(input); + typeInElement(input, 'al'); + fixture.detectChanges(); + tick(); + + options = overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected clicking a new option to set the panel state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected clicking a new option to close the panel.`); + })); + + it('should close the panel programmatically', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected closing programmatically to set the panel state to closed.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected closing programmatically to close the panel.`); + }); + + it('should not throw when attempting to close the panel of a destroyed autocomplete', () => { + const trigger = fixture.componentInstance.trigger; + + trigger.openPanel(); + fixture.detectChanges(); + fixture.destroy(); + + expect(() => trigger.closePanel()).not.toThrow(); + }); + + it('should hide the panel when the options list is empty', fakeAsync(() => { + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + + const panel = + overlayContainerElement.querySelector('.mat-mdc-autocomplete-panel') as HTMLElement; + + expect(panel.classList) + .toContain('mat-mdc-autocomplete-visible', `Expected panel to start out visible.`); + + // Filter down the option list such that no options match the value + typeInElement(input, 'af'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(panel.classList) + .toContain('mat-mdc-autocomplete-hidden', `Expected panel to hide itself when empty.`); + })); + + it('should keep the label floating until the panel closes', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + expect(fixture.componentInstance.formField.floatLabel) + .toEqual('always', 'Expected label to float as soon as panel opens.'); + + zone.simulateZoneExit(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.formField.floatLabel) + .toEqual('auto', 'Expected label to return to auto state after panel closes.'); + })); + + it('should not open the panel when the `input` event is invoked on a non-focused input', () => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to start out closed.`); + + input.value = 'Alabama'; + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to stay closed.`); + }); + + it('should not mess with label placement if set to never', fakeAsync(() => { + fixture.componentInstance.floatLabel = 'never'; + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + expect(fixture.componentInstance.formField.floatLabel) + .toEqual('never', 'Expected label to stay static.'); + flush(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.formField.floatLabel) + .toEqual('never', 'Expected label to stay in static state after close.'); + })); + + it('should not mess with label placement if set to always', fakeAsync(() => { + fixture.componentInstance.floatLabel = 'always'; + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + expect(fixture.componentInstance.formField.floatLabel) + .toEqual('always', 'Expected label to stay elevated on open.'); + flush(); + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.formField.floatLabel) + .toEqual('always', 'Expected label to stay elevated after close.'); + })); + + it('should toggle the visibility when typing and closing the panel', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.mat-mdc-autocomplete-panel')!.classList) + .toContain('mat-mdc-autocomplete-visible', 'Expected panel to be visible.'); + + typeInElement(input, 'x'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.mat-mdc-autocomplete-panel')!.classList) + .toContain('mat-mdc-autocomplete-hidden', 'Expected panel to be hidden.'); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + clearElement(input); + typeInElement(input, 'al'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.mat-mdc-autocomplete-panel')!.classList) + .toContain('mat-mdc-autocomplete-visible', 'Expected panel to be visible.'); + })); + + it('should animate the label when the input is focused', () => { + const inputContainer = fixture.componentInstance.formField; + + spyOn(inputContainer, '_animateAndLockLabel'); + expect(inputContainer._animateAndLockLabel).not.toHaveBeenCalled(); + + dispatchFakeEvent(fixture.debugElement.query(By.css('input'))!.nativeElement, 'focusin'); + expect(inputContainer._animateAndLockLabel).toHaveBeenCalled(); + }); + + it('should provide the open state of the panel', fakeAsync(() => { + expect(fixture.componentInstance.panel.isOpen).toBeFalsy( + `Expected the panel to be unopened initially.`); + + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.panel.isOpen).toBeTruthy( + `Expected the panel to be opened on focus.`); + })); + + it('should emit an event when the panel is opened', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).toHaveBeenCalled(); + }); + + it('should not emit the `opened` event when no options are being shown', () => { + fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).not.toHaveBeenCalled(); + }); + + it('should emit the `opened` event if the options come in after the panel is shown', + fakeAsync(() => { + fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).not.toHaveBeenCalled(); + + fixture.componentInstance.filteredStates = fixture.componentInstance.states = + [{name: 'California', code: 'CA'}]; + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).toHaveBeenCalled(); + })); + + it('should not emit the opened event multiple times while typing', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).toHaveBeenCalledTimes(1); + + typeInElement(input, 'Alabam'); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(fixture.componentInstance.openedSpy).toHaveBeenCalledTimes(1); + })); + + it('should emit an event when the panel is closed', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.closedSpy).toHaveBeenCalled(); + }); + + it('should not emit the `closed` event when no options were shown', () => { + fixture.componentInstance.filteredStates = fixture.componentInstance.states = []; + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + + expect(fixture.componentInstance.closedSpy).not.toHaveBeenCalled(); + }); + + it('should not be able to open the panel if the autocomplete is disabled', () => { + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to start out closed.`); + + fixture.componentInstance.autocompleteDisabled = true; + fixture.detectChanges(); + + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel to remain closed.`); + }); + + it('should continue to update the model if the autocomplete is disabled', () => { + fixture.componentInstance.autocompleteDisabled = true; + fixture.detectChanges(); + + typeInElement(input, 'hello'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value).toBe('hello'); + }); + + it('should set aria-haspopup depending on whether the autocomplete is disabled', () => { + expect(input.getAttribute('aria-haspopup')).toBe('true'); + + fixture.componentInstance.autocompleteDisabled = true; + fixture.detectChanges(); + + expect(input.getAttribute('aria-haspopup')).toBe('false'); + }); + + }); + + it('should not close the panel when clicking on the input', fakeAsync(() => { + const fixture = createComponent(SimpleAutocomplete); + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('input'))!.nativeElement; + + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + zone.simulateZoneExit(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, 'Expected panel to be opened on focus.'); + + input.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, 'Expected panel to remain opened after clicking on the input.'); + })); + + it('should not close the panel when clicking on the input inside shadow DOM', fakeAsync(() => { + // This test is only relevant for Shadow DOM-capable browsers. + if (!_supportsShadowDom()) { + return; + } + + const fixture = createComponent(SimpleAutocompleteShadowDom); + fixture.detectChanges(); + const input = fixture.debugElement.query(By.css('input'))!.nativeElement; + + dispatchFakeEvent(input, 'focusin'); + fixture.detectChanges(); + zone.simulateZoneExit(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, 'Expected panel to be opened on focus.'); + + input.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, 'Expected panel to remain opened after clicking on the input.'); + })); + + it('should have the correct text direction in RTL', () => { + const rtlFixture = createComponent(SimpleAutocomplete, [ + {provide: Directionality, useFactory: () => ({value: 'rtl', change: EMPTY})}, + ]); + + rtlFixture.detectChanges(); + rtlFixture.componentInstance.trigger.openPanel(); + rtlFixture.detectChanges(); + + const boundingBox = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + expect(boundingBox.getAttribute('dir')).toEqual('rtl'); + }); + + it('should update the panel direction if it changes for the trigger', () => { + const dirProvider = {value: 'rtl', change: EMPTY}; + const rtlFixture = createComponent(SimpleAutocomplete, [ + {provide: Directionality, useFactory: () => dirProvider}, + ]); + + rtlFixture.detectChanges(); + rtlFixture.componentInstance.trigger.openPanel(); + rtlFixture.detectChanges(); + + let boundingBox = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + expect(boundingBox.getAttribute('dir')).toEqual('rtl'); + + rtlFixture.componentInstance.trigger.closePanel(); + rtlFixture.detectChanges(); + + dirProvider.value = 'ltr'; + rtlFixture.componentInstance.trigger.openPanel(); + rtlFixture.detectChanges(); + + boundingBox = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + expect(boundingBox.getAttribute('dir')).toEqual('ltr'); + }); + + it('should be able to set a custom value for the `autocomplete` attribute', () => { + const fixture = createComponent(AutocompleteWithNativeAutocompleteAttribute); + const input = fixture.nativeElement.querySelector('input'); + + fixture.detectChanges(); + + expect(input.getAttribute('autocomplete')).toBe('changed'); + }); + + it('should not throw when typing in an element with a null and disabled autocomplete', () => { + const fixture = createComponent(InputWithoutAutocompleteAndDisabled); + fixture.detectChanges(); + + expect(() => { + dispatchKeyboardEvent(fixture.nativeElement.querySelector('input'), 'keydown', SPACE); + fixture.detectChanges(); + }).not.toThrow(); + }); + + describe('forms integration', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + + beforeEach(() => { + fixture = createComponent(SimpleAutocomplete); + fixture.detectChanges(); + + input = fixture.debugElement.query(By.css('input'))!.nativeElement; + }); + + it('should update control value as user types with input value', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + typeInElement(input, 'a'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value) + .toEqual('a', 'Expected control value to be updated as user types.'); + + typeInElement(input, 'l'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value) + .toEqual('al', 'Expected control value to be updated as user types.'); + }); + + it('should update control value when autofilling', () => { + // Simulate the browser autofilling the input by setting a value and + // dispatching an `input` event while the input is out of focus. + expect(document.activeElement).not.toBe(input, 'Expected input not to have focus.'); + input.value = 'Alabama'; + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value) + .toBe('Alabama', 'Expected value to be propagated to the form control.'); + }); + + it('should update control value when option is selected with option value', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.value) + .toEqual({code: 'CA', name: 'California'}, + 'Expected control value to equal the selected option value.'); + })); + + it('should update the control back to a string if user types after an option is selected', + fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + clearElement(input); + typeInElement(input, 'Californi'); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.stateCtrl.value) + .toEqual('Californi', 'Expected control value to revert back to string.'); + })); + + it('should fill the text field with display value when an option is selected', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(input.value) + .toContain('California', `Expected text field to fill with selected value.`); + })); + + it('should fill the text field with value if displayWith is not set', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + fixture.componentInstance.panel.displayWith = null; + fixture.componentInstance.options.toArray()[1].value = 'test value'; + fixture.detectChanges(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + + fixture.detectChanges(); + expect(input.value) + .toContain('test value', `Expected input to fall back to selected option's value.`); + })); + + it('should fill the text field correctly if value is set to obj programmatically', + fakeAsync(() => { + fixture.componentInstance.stateCtrl.setValue({code: 'AL', name: 'Alabama'}); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(input.value) + .toContain('Alabama', `Expected input to fill with matching option's viewValue.`); + })); + + it('should clear the text field if value is reset programmatically', fakeAsync(() => { + typeInElement(input, 'Alabama'); + fixture.detectChanges(); + tick(); + + fixture.componentInstance.stateCtrl.reset(); + tick(); + + fixture.detectChanges(); + tick(); + + expect(input.value).toEqual('', `Expected input value to be empty after reset.`); + })); + + it('should disable input in view when disabled programmatically', () => { + const formFieldElement = + fixture.debugElement.query(By.css('.mat-mdc-form-field'))!.nativeElement; + + expect(input.disabled) + .toBe(false, `Expected input to start out enabled in view.`); + expect(formFieldElement.classList.contains('mat-form-field-disabled')) + .toBe(false, `Expected input underline to start out with normal styles.`); + + fixture.componentInstance.stateCtrl.disable(); + fixture.detectChanges(); + + expect(input.disabled) + .toBe(true, `Expected input to be disabled in view when disabled programmatically.`); + expect(formFieldElement.classList.contains('mat-form-field-disabled')) + .toBe(true, `Expected input underline to display disabled styles.`); + }); + + it('should mark the autocomplete control as dirty as user types', () => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + typeInElement(input, 'a'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(true, `Expected control to become dirty when the user types into the input.`); + }); + + it('should mark the autocomplete control as dirty when an option is selected', fakeAsync(() => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + const options = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + options[1].click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(true, `Expected control to become dirty when an option was selected.`); + })); + + it('should not mark the control dirty when the value is set programmatically', () => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + fixture.componentInstance.stateCtrl.setValue('AL'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to stay pristine if value is set programmatically.`); + }); + + it('should mark the autocomplete control as touched on blur', () => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + expect(fixture.componentInstance.stateCtrl.touched) + .toBe(false, `Expected control to start out untouched.`); + + dispatchFakeEvent(input, 'blur'); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.touched) + .toBe(true, `Expected control to become touched on blur.`); + }); + + it('should disable the input when used with a value accessor and without `matInput`', () => { + overlayContainer.ngOnDestroy(); + fixture.destroy(); + TestBed.resetTestingModule(); + + const plainFixture = createComponent(PlainAutocompleteInputWithFormControl); + plainFixture.detectChanges(); + input = plainFixture.nativeElement.querySelector('input'); + + expect(input.disabled).toBe(false); + + plainFixture.componentInstance.formControl.disable(); + plainFixture.detectChanges(); + + expect(input.disabled).toBe(true); + }); + + }); + + describe('keyboard events', () => { + let fixture: ComponentFixture; + let input: HTMLInputElement; + let DOWN_ARROW_EVENT: KeyboardEvent; + let UP_ARROW_EVENT: KeyboardEvent; + let ENTER_EVENT: KeyboardEvent; + + beforeEach(fakeAsync(() => { + fixture = createComponent(SimpleAutocomplete); + fixture.detectChanges(); + + input = fixture.debugElement.query(By.css('input'))!.nativeElement; + DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); + UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW); + ENTER_EVENT = createKeyboardEvent('keydown', ENTER); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + })); + + it('should not focus the option when DOWN key is pressed', () => { + spyOn(fixture.componentInstance.options.first, 'focus'); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + expect(fixture.componentInstance.options.first.focus).not.toHaveBeenCalled(); + }); + + it('should not close the panel when DOWN key is pressed', () => { + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to stay open when DOWN key is pressed.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to keep displaying when DOWN key is pressed.`); + expect(overlayContainerElement.textContent) + .toContain('California', `Expected panel to keep displaying when DOWN key is pressed.`); + }); + + it('should set the active item to the first option when DOWN key is pressed', () => { + const componentInstance = fixture.componentInstance; + const optionEls = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + + expect(componentInstance.trigger.panelOpen) + .toBe(true, 'Expected first down press to open the pane.'); + + componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(componentInstance.trigger.activeOption === componentInstance.options.first) + .toBe(true, 'Expected first option to be active.'); + expect(optionEls[0].classList).toContain('mat-mdc-option-active'); + expect(optionEls[1].classList).not.toContain('mat-mdc-option-active'); + + componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(componentInstance.trigger.activeOption === componentInstance.options.toArray()[1]) + .toBe(true, 'Expected second option to be active.'); + expect(optionEls[0].classList).not.toContain('mat-mdc-option-active'); + expect(optionEls[1].classList).toContain('mat-mdc-option-active'); + }); + + it('should set the active item to the last option when UP key is pressed', () => { + const componentInstance = fixture.componentInstance; + const optionEls = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + + expect(componentInstance.trigger.panelOpen) + .toBe(true, 'Expected first up press to open the pane.'); + + componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); + fixture.detectChanges(); + + expect(componentInstance.trigger.activeOption === componentInstance.options.last) + .toBe(true, 'Expected last option to be active.'); + expect(optionEls[10].classList).toContain('mat-mdc-option-active'); + expect(optionEls[0].classList).not.toContain('mat-mdc-option-active'); + + componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(componentInstance.trigger.activeOption === componentInstance.options.first) + .toBe(true, 'Expected first option to be active.'); + expect(optionEls[0].classList).toContain('mat-mdc-option-active'); + }); + + it('should set the active item properly after filtering', fakeAsync(() => { + const componentInstance = fixture.componentInstance; + + componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + tick(); + fixture.detectChanges(); + })); + + it('should set the active item properly after filtering', () => { + const componentInstance = fixture.componentInstance; + + typeInElement(input, 'o'); + fixture.detectChanges(); + + componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + const optionEls = + overlayContainerElement.querySelectorAll('mat-option') as NodeListOf; + + expect(componentInstance.trigger.activeOption === componentInstance.options.first) + .toBe(true, 'Expected first option to be active.'); + expect(optionEls[0].classList).toContain('mat-mdc-option-active'); + expect(optionEls[1].classList).not.toContain('mat-mdc-option-active'); + }); + + it('should fill the text field when an option is selected with ENTER', fakeAsync(() => { + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + flush(); + fixture.detectChanges(); + + fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + expect(input.value) + .toContain('Alabama', `Expected text field to fill with selected value on ENTER.`); + })); + + it('should prevent the default enter key action', fakeAsync(() => { + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + flush(); + + fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); + + expect(ENTER_EVENT.defaultPrevented) + .toBe(true, 'Expected the default action to have been prevented.'); + })); + + it('should not prevent the default enter action for a closed panel after a user action', () => { + fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); + fixture.detectChanges(); + + fixture.componentInstance.trigger.closePanel(); + fixture.detectChanges(); + fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); + + expect(ENTER_EVENT.defaultPrevented).toBe(false, 'Default action should not be prevented.'); + }); + + it('should fill the text field, not select an option, when SPACE is entered', () => { + typeInElement(input, 'New'); + fixture.detectChanges(); + + const SPACE_EVENT = createKeyboardEvent('keydown', SPACE); + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + fixture.componentInstance.trigger._handleKeydown(SPACE_EVENT); + fixture.detectChanges(); + + expect(input.value).not.toContain('New York', `Expected option not to be selected on SPACE.`); + }); + + it('should mark the control dirty when selecting an option from the keyboard', fakeAsync(() => { + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(false, `Expected control to start out pristine.`); + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + flush(); + fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + + expect(fixture.componentInstance.stateCtrl.dirty) + .toBe(true, `Expected control to become dirty when option was selected by ENTER.`); + })); + + it('should open the panel again when typing after making a selection', fakeAsync(() => { + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + flush(); + fixture.componentInstance.trigger._handleKeydown(ENTER_EVENT); + fixture.detectChanges(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(false, `Expected panel state to read closed after ENTER key.`); + expect(overlayContainerElement.textContent) + .toEqual('', `Expected panel to close after ENTER key.`); + + dispatchFakeEvent(input, 'focusin'); + clearElement(input); + typeInElement(input, 'Alabama'); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.trigger.panelOpen) + .toBe(true, `Expected panel state to read open when typing in input.`); + expect(overlayContainerElement.textContent) + .toContain('Alabama', `Expected panel to display when typing in input.`); + })); + + it('should not open the panel if the `input` event was dispatched with changing the value', + fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + + dispatchFakeEvent(input, 'focusin'); + typeInElement(input, 'A'); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + + trigger.closePanel(); + fixture.detectChanges(); + + expect(trigger.panelOpen).toBe(false, 'Expected panel to be closed.'); + + // Dispatch the event without actually changing the value + // to simulate what happen in some cases on IE. + dispatchFakeEvent(input, 'input'); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(false, 'Expected panel to stay closed.'); + })); + + it('should scroll to active options below the fold', () => { + const trigger = fixture.componentInstance.trigger; + const scrollContainer = + document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!; + + trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`); + + // These down arrows will set the 6th option active, below the fold. + [1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT)); + + // Expect option bottom minus the panel height plus padding (288 - 256 + 8 = 40) + expect(scrollContainer.scrollTop) + .toEqual(40, `Expected panel to reveal the sixth option.`); + }); + + it('should scroll to active options on UP arrow', () => { + const scrollContainer = + document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!; + + fixture.componentInstance.trigger._handleKeydown(UP_ARROW_EVENT); + fixture.detectChanges(); + + // Expect option bottom minus the panel height plus padding (528 - 256 + 8 = 272) + expect(scrollContainer.scrollTop).toEqual(280, `Expected panel to reveal last option.`); + }); + + it('should not scroll to active options that are fully in the panel', () => { + const trigger = fixture.componentInstance.trigger; + const scrollContainer = + document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!; + + trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`); + + // These down arrows will set the 6th option active, below the fold. + [1, 2, 3, 4, 5].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT)); + + // Expect option bottom minus the panel height plus the padding (288 - 256 + 8 = 40) + expect(scrollContainer.scrollTop) + .toEqual(40, `Expected panel to reveal the sixth option.`); + + // These up arrows will set the 2nd option active + [4, 3, 2, 1].forEach(() => trigger._handleKeydown(UP_ARROW_EVENT)); + + // Expect no scrolling to have occurred. Still showing bottom of 6th option. + expect(scrollContainer.scrollTop) + .toEqual(40, `Expected panel not to scroll up since sixth option still fully visible.`); + }); + + it('should scroll to active options that are above the panel', () => { + const trigger = fixture.componentInstance.trigger; + const scrollContainer = + document.querySelector('.cdk-overlay-pane .mat-mdc-autocomplete-panel')!; + + trigger._handleKeydown(DOWN_ARROW_EVENT); + fixture.detectChanges(); + + expect(scrollContainer.scrollTop).toEqual(0, `Expected panel not to scroll.`); + + // These down arrows will set the 7th option active, below the fold. + [1, 2, 3, 4, 5, 6].forEach(() => trigger._handleKeydown(DOWN_ARROW_EVENT)); + + // These up arrows will set the 2nd option active + [5, 4, 3, 2, 1].forEach(() => trigger._handleKeydown(UP_ARROW_EVENT)); + + // Expect to show the top of the 2nd option at the top of the panel + expect(scrollContainer.scrollTop) + .toEqual(56, `Expected panel to scroll up when option is above panel.`); + }); + + it('should close the panel when pressing escape', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + + input.focus(); + flush(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(input, 'Expected input to be focused.'); + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + fixture.detectChanges(); + + expect(document.activeElement).toBe(input, 'Expected input to continue to be focused.'); + expect(trigger.panelOpen).toBe(false, 'Expected panel to be closed.'); + })); + + it('should prevent the default action when pressing escape', fakeAsync(() => { + const escapeEvent = dispatchKeyboardEvent(input, 'keydown', ESCAPE); + fixture.detectChanges(); + + expect(escapeEvent.defaultPrevented).toBe(true); + })); + + it('should close the panel when pressing ALT + UP_ARROW', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + const upArrowEvent = createKeyboardEvent('keydown', UP_ARROW, undefined, {alt: true}); + spyOn(upArrowEvent, 'stopPropagation').and.callThrough(); + + input.focus(); + flush(); + fixture.detectChanges(); + + expect(document.activeElement).toBe(input, 'Expected input to be focused.'); + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + + dispatchEvent(document.body, upArrowEvent); + fixture.detectChanges(); + + expect(document.activeElement).toBe(input, 'Expected input to continue to be focused.'); + expect(trigger.panelOpen).toBe(false, 'Expected panel to be closed.'); + expect(upArrowEvent.stopPropagation).toHaveBeenCalled(); + })); + + it('should close the panel when tabbing away from a trigger without results', fakeAsync(() => { + fixture.componentInstance.states = []; + fixture.componentInstance.filteredStates = []; + fixture.detectChanges(); + input.focus(); + flush(); + + expect(overlayContainerElement.querySelector('.mat-mdc-autocomplete-panel')) + .toBeTruthy('Expected panel to be rendered.'); + + dispatchKeyboardEvent(input, 'keydown', TAB); + fixture.detectChanges(); + + expect(overlayContainerElement.querySelector('.mat-mdc-autocomplete-panel')) + .toBeFalsy('Expected panel to be removed.'); + })); + + it('should reset the active option when closing with the escape key', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + + trigger.openPanel(); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + expect(!!trigger.activeOption).toBe(false, 'Expected no active option.'); + + // Press the down arrow a few times. + [1, 2, 3].forEach(() => { + trigger._handleKeydown(DOWN_ARROW_EVENT); + tick(); + fixture.detectChanges(); + }); + + // Note that this casts to a boolean, in order to prevent Jasmine + // from crashing when trying to stringify the option if the test fails. + expect(!!trigger.activeOption).toBe(true, 'Expected to find an active option.'); + + dispatchKeyboardEvent(document.body, 'keydown', ESCAPE); + tick(); + + expect(!!trigger.activeOption).toBe(false, 'Expected no active options.'); + })); + + it('should reset the active option when closing by selecting with enter', fakeAsync(() => { + const trigger = fixture.componentInstance.trigger; + + trigger.openPanel(); + fixture.detectChanges(); + tick(); + + expect(trigger.panelOpen).toBe(true, 'Expected panel to be open.'); + expect(!!trigger.activeOption).toBe(false, 'Expected no active option.'); + + // Press the down arrow a few times. + [1, 2, 3].forEach(() => { + trigger._handleKeydown(DOWN_ARROW_EVENT); + tick(); + fixture.detectChanges(); + }); + + // Note that this casts to a boolean, in order to prevent Jasmine + // from crashing when trying to stringify the option if the test fails. + expect(!!trigger.activeOption).toBe(true, 'Expected to find an active option.'); + + trigger._handleKeydown(ENTER_EVENT); + tick(); + + expect(!!trigger.activeOption).toBe(false, 'Expected no active options.'); + })); + + }); + + describe('option groups', () => { + let DOWN_ARROW_EVENT: KeyboardEvent; + let UP_ARROW_EVENT: KeyboardEvent; + + beforeEach(() => { + DOWN_ARROW_EVENT = createKeyboardEvent('keydown', DOWN_ARROW); + UP_ARROW_EVENT = createKeyboardEvent('keydown', UP_ARROW); + }); + + it('should scroll to active options below the fold', fakeAsync(() => { + const fixture = createComponent(AutocompleteWithGroups); + fixture.detectChanges(); + + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + fixture.detectChanges(); + const container = document.querySelector('.mat-mdc-autocomplete-panel') as HTMLElement; + + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + tick(); + fixture.detectChanges(); + expect(container.scrollTop).toBe(0, 'Expected the panel not to scroll.'); + + // Press the down arrow five times. + [1, 2, 3, 4, 5].forEach(() => { + fixture.componentInstance.trigger._handleKeydown(DOWN_ARROW_EVENT); + tick(); + }); + + //