diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index addc84d69d93..014bf916a5bc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -94,6 +94,7 @@ /src/material-experimental/mdc-chips/** @mmalerba /src/material-experimental/mdc-helpers/** @mmalerba /src/material-experimental/mdc-menu/** @crisbeto +/src/material-experimental/mdc-select/** @crisbeto /src/material-experimental/mdc-progress-spinner/** @andrewseguin /src/material-experimental/mdc-progress-bar/** @andrewseguin # Note to implementer: please repossess diff --git a/src/cdk-experimental/testing/testbed/testbed-harness-environment.ts b/src/cdk-experimental/testing/testbed/testbed-harness-environment.ts index dabe3693fe1e..ea09606b4569 100644 --- a/src/cdk-experimental/testing/testbed/testbed-harness-environment.ts +++ b/src/cdk-experimental/testing/testbed/testbed-harness-environment.ts @@ -37,7 +37,7 @@ export class TestbedHarnessEnvironment extends HarnessEnvironment { } protected getDocumentRoot(): Element { - return this._fixture.nativeElement; + return document.body; } protected createTestElement(element: Element): TestElement { diff --git a/src/material-experimental/mdc-select/BUILD.bazel b/src/material-experimental/mdc-select/BUILD.bazel new file mode 100644 index 000000000000..286007a6d678 --- /dev/null +++ b/src/material-experimental/mdc-select/BUILD.bazel @@ -0,0 +1,100 @@ +package(default_visibility = ["//visibility:public"]) + +load("@io_bazel_rules_sass//:defs.bzl", "sass_binary", "sass_library") +load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "ng_test_library", "ng_web_test_suite", "ts_library") +load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") + +ng_module( + name = "mdc-select", + srcs = glob( + ["**/*.ts"], + exclude = [ + "**/*.spec.ts", + "harness/**", + ], + ), + assets = [ + # TODO: include scss assets + ] + glob(["**/*.html"]), + module_name = "@angular/material-experimental/mdc-select", + deps = [ + "//src/material/core", + ], +) + +ts_library( + name = "harness", + srcs = glob( + ["harness/**/*.ts"], + exclude = ["**/*.spec.ts"], + ), + deps = [ + "//src/cdk-experimental/testing", + ], +) + +sass_library( + name = "mdc_select_scss_lib", + srcs = glob(["**/_*.scss"]), + 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", + ], +) + +sass_binary( + name = "select_scss", + src = "select.scss", + include_paths = [ + "external/npm/node_modules", + ], + deps = [ + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + "//src/material/core:all_themes", + ], +) + +ng_test_library( + name = "select_tests_lib", + srcs = [ + "harness/select-harness.spec.ts", + ], + deps = [ + ":harness", + ":mdc-select", + "//src/cdk-experimental/testing", + "//src/cdk-experimental/testing/testbed", + "//src/cdk/platform", + "//src/cdk/testing", + "//src/material/form-field", + "//src/material/select", + "@npm//@angular/forms", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [ + ":select_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/private/testing/e2e", + ], +) + +e2e_test_suite( + name = "e2e_tests", + deps = [ + ":e2e_test_sources", + "//src/cdk/private/testing/e2e", + ], +) diff --git a/src/material-experimental/mdc-select/README.md b/src/material-experimental/mdc-select/README.md new file mode 100644 index 000000000000..cf873b4c072b --- /dev/null +++ b/src/material-experimental/mdc-select/README.md @@ -0,0 +1 @@ + diff --git a/src/material-experimental/mdc-select/_mdc-select.scss b/src/material-experimental/mdc-select/_mdc-select.scss new file mode 100644 index 000000000000..27059525b193 --- /dev/null +++ b/src/material-experimental/mdc-select/_mdc-select.scss @@ -0,0 +1,13 @@ +@import '../mdc-helpers/mdc-helpers'; + +@mixin mat-select-theme-mdc($theme) { + @include mat-using-mdc-theme($theme) { + // TODO: implement MDC-based select. + } +} + +@mixin mat-select-typography-mdc($config) { + @include mat-using-mdc-typography($config) { + // TODO: implement MDC-based select. + } +} diff --git a/src/material-experimental/mdc-select/harness/mdc-select-harness.ts b/src/material-experimental/mdc-select/harness/mdc-select-harness.ts new file mode 100644 index 000000000000..1e76a61b361b --- /dev/null +++ b/src/material-experimental/mdc-select/harness/mdc-select-harness.ts @@ -0,0 +1,18 @@ +/** + * @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 {ComponentHarness} from '@angular/cdk-experimental/testing'; + + +/** + * Harness for interacting with a MDC-based mat-select in tests. + * @dynamic + */ +export class MatSelectHarness extends ComponentHarness { + // TODO: implement once MDC select is done. +} diff --git a/src/material-experimental/mdc-select/harness/select-harness-filters.ts b/src/material-experimental/mdc-select/harness/select-harness-filters.ts new file mode 100644 index 000000000000..59c9e849f8f0 --- /dev/null +++ b/src/material-experimental/mdc-select/harness/select-harness-filters.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 type SelectHarnessFilters = { + id?: string; +}; diff --git a/src/material-experimental/mdc-select/harness/select-harness.spec.ts b/src/material-experimental/mdc-select/harness/select-harness.spec.ts new file mode 100644 index 000000000000..b96ba1c93c45 --- /dev/null +++ b/src/material-experimental/mdc-select/harness/select-harness.spec.ts @@ -0,0 +1,301 @@ +import {HarnessLoader} from '@angular/cdk-experimental/testing'; +import {TestbedHarnessEnvironment} from '@angular/cdk-experimental/testing/testbed'; +import {Component} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {ReactiveFormsModule, FormControl, Validators} from '@angular/forms'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatSelectModule} from '@angular/material/select'; +import {MatSelectModule as MatMdcSelectModule} from '../index'; +import {MatSelectHarness} from './select-harness'; +import {MatSelectHarness as MatMdcSelectHarness} from './mdc-select-harness'; + +let fixture: ComponentFixture; +let loader: HarnessLoader; +let harness: typeof MatSelectHarness; + +describe('MatSelectHarness', () => { + describe('non-MDC-based', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + MatSelectModule, + MatFormFieldModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [SelectHarnessTest], + }).compileComponents(); + + fixture = TestBed.createComponent(SelectHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + harness = MatSelectHarness; + }); + + runTests(); + }); + + describe('MDC-based', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + MatMdcSelectModule, + MatFormFieldModule, + NoopAnimationsModule, + ReactiveFormsModule, + ], + declarations: [SelectHarnessTest], + }).compileComponents(); + + fixture = TestBed.createComponent(SelectHarnessTest); + fixture.detectChanges(); + loader = TestbedHarnessEnvironment.loader(fixture); + // Public APIs are the same as MatSelectHarness, but cast + // is necessary because of different private fields. + harness = MatMdcSelectHarness as any; + }); + + // TODO: enable after MDC select is implemented + // runTests(); + }); +}); + +/** Shared tests to run on both the original and MDC-based select. */ +function runTests() { + it('should load all select harnesses', async () => { + const selects = await loader.getAllHarnesses(harness); + expect(selects.length).toBe(4); + }); + + it('should be able to check whether a select is in multi-selection mode', async () => { + const singleSelection = await loader.getHarness(harness.with({id: 'single-selection'})); + const multipleSelection = await loader.getHarness(harness.with({id: 'multiple-selection'})); + + expect(await singleSelection.isMultiple()).toBe(false); + expect(await multipleSelection.isMultiple()).toBe(true); + }); + + it('should get disabled state', async () => { + const singleSelection = await loader.getHarness(harness.with({id: 'single-selection'})); + const multipleSelection = await loader.getHarness(harness.with({id: 'multiple-selection'})); + + expect(await singleSelection.isDisabled()).toBe(false); + expect(await multipleSelection.isDisabled()).toBe(false); + + fixture.componentInstance.isDisabled = true; + fixture.detectChanges(); + + expect(await singleSelection.isDisabled()).toBe(true); + expect(await multipleSelection.isDisabled()).toBe(false); + }); + + it('should get required state', async () => { + const singleSelection = await loader.getHarness(harness.with({id: 'single-selection'})); + const multipleSelection = await loader.getHarness(harness.with({id: 'multiple-selection'})); + + expect(await singleSelection.isRequired()).toBe(false); + expect(await multipleSelection.isRequired()).toBe(false); + + fixture.componentInstance.isRequired = true; + fixture.detectChanges(); + + expect(await singleSelection.isRequired()).toBe(true); + expect(await multipleSelection.isRequired()).toBe(false); + }); + + it('should get valid state', async () => { + const singleSelection = await loader.getHarness(harness.with({id: 'single-selection'})); + const withFormControl = await loader.getHarness(harness.with({id: 'with-form-control'})); + + expect(await singleSelection.isValid()).toBe(true); + expect(await withFormControl.isValid()).toBe(false); + }); + + it('should focus and blur a select', async () => { + const select = await loader.getHarness(harness.with({id: 'single-selection'})); + expect(getActiveElementId()).not.toBe('single-selection'); + await select.focus(); + expect(getActiveElementId()).toBe('single-selection'); + await select.blur(); + expect(getActiveElementId()).not.toBe('single-selection'); + }); + + it('should be able to open and close a single-selection select', async () => { + const select = await loader.getHarness(harness.with({id: 'single-selection'})); + + expect(await select.isOpen()).toBe(false); + + await select.open(); + expect(await select.isOpen()).toBe(true); + + await select.close(); + expect(await select.isOpen()).toBe(false); + }); + + it('should be able to open and close a multi-selection select', async () => { + const select = await loader.getHarness(harness.with({id: 'multiple-selection'})); + + expect(await select.isOpen()).toBe(false); + + await select.open(); + expect(await select.isOpen()).toBe(true); + + await select.close(); + expect(await select.isOpen()).toBe(false); + }); + + it('should be able to get the select panel', async () => { + const select = await loader.getHarness(harness.with({id: 'single-selection'})); + await select.open(); + expect(await select.getPanel()).toBeTruthy(); + }); + + it('should be able to get the select options', async () => { + const select = await loader.getHarness(harness.with({id: 'single-selection'})); + await select.open(); + const options = await select.getOptions(); + + expect(options.length).toBe(11); + expect(await options[5].text()).toBe('New York'); + }); + + it('should be able to get the select panel groups', async () => { + const select = await loader.getHarness(harness.with({id: 'grouped'})); + await select.open(); + const groups = await select.getOptionGroups(); + const options = await select.getOptions(); + + expect(groups.length).toBe(3); + expect(options.length).toBe(11); + }); + + it('should be able to get the value text from a single-selection select', async () => { + const select = await loader.getHarness(harness.with({id: 'single-selection'})); + await select.open(); + const options = await select.getOptions(); + + await options[3].click(); + + expect(await select.getValueText()).toBe('Kansas'); + }); + + it('should be able to get the value text from a multi-selection select', async () => { + const select = await loader.getHarness(harness.with({id: 'multiple-selection'})); + await select.open(); + const options = await select.getOptions(); + + await options[3].click(); + await options[5].click(); + + expect(await select.getValueText()).toBe('Kansas, New York'); + }); + + it('should be able to get whether a single-selection select is empty', async () => { + const select = await loader.getHarness(harness.with({id: 'single-selection'})); + + expect(await select.isEmpty()).toBe(true); + + await select.open(); + const options = await select.getOptions(); + await options[3].click(); + + expect(await select.isEmpty()).toBe(false); + }); + + it('should be able to get whether a multi-selection select is empty', async () => { + const select = await loader.getHarness(harness.with({id: 'multiple-selection'})); + + expect(await select.isEmpty()).toBe(true); + + await select.open(); + const options = await select.getOptions(); + await options[3].click(); + await options[5].click(); + + expect(await select.isEmpty()).toBe(false); + }); + + it('should be able to click an option', async () => { + const control = fixture.componentInstance.formControl; + const select = await loader.getHarness(harness.with({id: 'with-form-control'})); + + expect(control.value).toBeFalsy(); + + await select.open(); + await (await select.getOptions())[1].click(); + + expect(control.value).toBe('CA'); + }); + +} + +function getActiveElementId() { + return document.activeElement ? document.activeElement.id : ''; +} + +@Component({ + template: ` + + + {{ state.name }} + + + + + + {{ state.name }} + + + + + + + {{ state.name }} + + + + + + + {{ state.name }} + + + ` +}) +class SelectHarnessTest { + formControl = new FormControl(undefined, [Validators.required]); + isDisabled = false; + isRequired = false; + states = [ + {code: 'AL', name: 'Alabama'}, + {code: 'CA', name: 'California'}, + {code: 'FL', name: 'Florida'}, + {code: 'KS', name: 'Kansas'}, + {code: 'MA', name: 'Massachusetts'}, + {code: 'NY', name: 'New York'}, + {code: 'OR', name: 'Oregon'}, + {code: 'PA', name: 'Pennsylvania'}, + {code: 'TN', name: 'Tennessee'}, + {code: 'VA', name: 'Virginia'}, + {code: 'WY', name: 'Wyoming'}, + ]; + + stateGroups = [ + { + name: 'One', + states: this.states.slice(0, 3) + }, + { + name: 'Two', + states: this.states.slice(3, 7) + }, + { + name: 'Three', + states: this.states.slice(7) + } + ]; +} + diff --git a/src/material-experimental/mdc-select/harness/select-harness.ts b/src/material-experimental/mdc-select/harness/select-harness.ts new file mode 100644 index 000000000000..4859c81fd27a --- /dev/null +++ b/src/material-experimental/mdc-select/harness/select-harness.ts @@ -0,0 +1,122 @@ +/** + * @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 {ComponentHarness, HarnessPredicate, TestElement} from '@angular/cdk-experimental/testing'; +import {SelectHarnessFilters} from './select-harness-filters'; + +/** Selector for the select panel. */ +const PANEL_SELECTOR = '.mat-select-panel'; + +/** + * Harness for interacting with a standard mat-select in tests. + * @dynamic + */ +export class MatSelectHarness extends ComponentHarness { + private _documentRootLocator = this.documentRootLocatorFactory(); + private _panel = this._documentRootLocator.locatorFor(PANEL_SELECTOR); + private _backdrop = this._documentRootLocator.locatorFor('.cdk-overlay-backdrop'); + private _optionalPanel = this._documentRootLocator.locatorForOptional(PANEL_SELECTOR); + private _options = this._documentRootLocator.locatorForAll(`${PANEL_SELECTOR} .mat-option`); + private _groups = this._documentRootLocator.locatorForAll(`${PANEL_SELECTOR} .mat-optgroup`); + private _trigger = this.locatorFor('.mat-select-trigger'); + private _value = this.locatorFor('.mat-select-value'); + + static hostSelector = '.mat-select'; + + /** + * Gets a `HarnessPredicate` that can be used to search for a select with + * specific attributes. + * @param options Options for narrowing the search. + * @return `HarnessPredicate` configured with the given options. + */ + static with(options: SelectHarnessFilters = {}): HarnessPredicate { + return new HarnessPredicate(MatSelectHarness) + .addOption('id', options.id, async (harness, id) => { + const harnessId = (await harness.host()).getAttribute('id'); + return (await harnessId) === id; + }); + } + + /** Gets a boolean promise indicating if the select is disabled. */ + async isDisabled(): Promise { + return (await this.host()).hasClass('mat-select-disabled'); + } + + /** Gets a boolean promise indicating if the select is valid. */ + async isValid(): Promise { + return !(await (await this.host()).hasClass('ng-invalid')); + } + + /** Gets a boolean promise indicating if the select is required. */ + async isRequired(): Promise { + return (await this.host()).hasClass('mat-select-required'); + } + + /** Gets a boolean promise indicating if the select is empty (no value is selected). */ + async isEmpty(): Promise { + return (await this.host()).hasClass('mat-select-empty'); + } + + /** Gets a boolean promise indicating if the select is in multi-selection mode. */ + async isMultiple(): Promise { + const ariaMultiselectable = (await this.host()).getAttribute('aria-multiselectable'); + return (await ariaMultiselectable) === 'true'; + } + + /** Gets a promise for the select's value text. */ + async getValueText(): Promise { + return (await this._value()).text(); + } + + /** Focuses the select and returns a void promise that indicates when the action is complete. */ + async focus(): Promise { + return (await this.host()).focus(); + } + + /** Blurs the select and returns a void promise that indicates when the action is complete. */ + async blur(): Promise { + return (await this.host()).blur(); + } + + /** Gets the select panel. */ + async getPanel(): Promise { + return this._panel(); + } + + /** Gets the options inside the select panel. */ + async getOptions(): Promise { + return this._options(); + } + + /** Gets the groups of options inside the panel. */ + async getOptionGroups(): Promise { + return this._groups(); + } + + /** Gets whether the select is open. */ + async isOpen(): Promise { + return !!(await this._optionalPanel()); + } + + /** Opens the select's panel. */ + async open(): Promise { + if (!await this.isOpen()) { + return (await this._trigger()).click(); + } + } + + /** Closes the select's panel. */ + async close(): Promise { + if (await this.isOpen()) { + // This is the most consistent way that works both in both single and multi-select modes, + // but it assumes that only one overlay is open at a time. We should be able to make it + // a bit more precise after #16645 where we can dispatch an ESCAPE press to the host instead. + return (await this._backdrop()).click(); + } + } +} diff --git a/src/material-experimental/mdc-select/index.ts b/src/material-experimental/mdc-select/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/mdc-select/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-select/module.ts b/src/material-experimental/mdc-select/module.ts new file mode 100644 index 000000000000..bf2bea9e0174 --- /dev/null +++ b/src/material-experimental/mdc-select/module.ts @@ -0,0 +1,18 @@ +/** + * @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 {MatCommonModule} from '@angular/material/core'; + +@NgModule({ + imports: [MatCommonModule, CommonModule], + exports: [MatCommonModule], +}) +export class MatSelectModule { +} diff --git a/src/material-experimental/mdc-select/public-api.ts b/src/material-experimental/mdc-select/public-api.ts new file mode 100644 index 000000000000..508adc834fb3 --- /dev/null +++ b/src/material-experimental/mdc-select/public-api.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 './module'; diff --git a/src/material-experimental/mdc-select/select.e2e.spec.ts b/src/material-experimental/mdc-select/select.e2e.spec.ts new file mode 100644 index 000000000000..6efd0de794a7 --- /dev/null +++ b/src/material-experimental/mdc-select/select.e2e.spec.ts @@ -0,0 +1 @@ +// TODO: copy tests from existing mat-select, update as necessary to fix. diff --git a/src/material-experimental/mdc-select/select.scss b/src/material-experimental/mdc-select/select.scss new file mode 100644 index 000000000000..5ce3cd65c02d --- /dev/null +++ b/src/material-experimental/mdc-select/select.scss @@ -0,0 +1 @@ +// TODO: implement MDC-based select diff --git a/src/material-experimental/mdc-select/tsconfig-build.json b/src/material-experimental/mdc-select/tsconfig-build.json new file mode 100644 index 000000000000..a3c7dd479141 --- /dev/null +++ b/src/material-experimental/mdc-select/tsconfig-build.json @@ -0,0 +1,15 @@ +{ + "extends": "../tsconfig-build", + "files": [ + "public-api.ts", + "../typings.d.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@angular/material-experimental/mdc-select", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +}