From 1510097b18b244aef171c3cc85d3f4d39a1767f0 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 29 Jan 2021 19:38:43 +0100 Subject: [PATCH] feat(material-experimental/mdc-tooltip): implement MDC-based tooltip Adds a tooltip directive based on top of MDC's styles. --- .github/CODEOWNERS | 2 + .ng-dev/commit-message.ts | 1 + rollup-globals.bzl | 1 + scripts/check-mdc-exports-config.ts | 5 + src/dev-app/BUILD.bazel | 2 + src/dev-app/dev-app/dev-app-layout.ts | 1 + src/dev-app/dev-app/routes.ts | 1 + src/dev-app/mdc-tooltip/BUILD.bazel | 27 + .../mdc-tooltip/mdc-tooltip-demo-module.ts | 34 + src/dev-app/mdc-tooltip/mdc-tooltip-demo.html | 36 + src/dev-app/mdc-tooltip/mdc-tooltip-demo.scss | 15 + src/dev-app/mdc-tooltip/mdc-tooltip-demo.ts | 24 + src/e2e-app/devserver-configure.js | 1 + src/material-experimental/config.bzl | 1 + .../mdc-theming/BUILD.bazel | 1 + .../mdc-theming/_all-theme.scss | 2 + .../mdc-tooltip/BUILD.bazel | 80 + .../mdc-tooltip/README.md | 87 ++ .../mdc-tooltip/_tooltip-theme.scss | 39 + .../mdc-tooltip/index.ts | 9 + .../mdc-tooltip/module.ts | 31 + .../mdc-tooltip/public-api.ts | 26 + .../mdc-tooltip/tooltip-animations.ts | 33 + .../mdc-tooltip/tooltip.html | 8 + .../mdc-tooltip/tooltip.scss | 14 + .../mdc-tooltip/tooltip.spec.ts | 1325 +++++++++++++++++ .../mdc-tooltip/tooltip.ts | 116 ++ .../mdc_require_config.js | 1 + src/material/tooltip/tooltip.ts | 140 +- tools/public_api_guard/material/tooltip.d.ts | 94 +- tools/system-config-tmpl.js | 1 + 31 files changed, 2073 insertions(+), 85 deletions(-) create mode 100644 src/dev-app/mdc-tooltip/BUILD.bazel create mode 100644 src/dev-app/mdc-tooltip/mdc-tooltip-demo-module.ts create mode 100644 src/dev-app/mdc-tooltip/mdc-tooltip-demo.html create mode 100644 src/dev-app/mdc-tooltip/mdc-tooltip-demo.scss create mode 100644 src/dev-app/mdc-tooltip/mdc-tooltip-demo.ts create mode 100644 src/material-experimental/mdc-tooltip/BUILD.bazel create mode 100644 src/material-experimental/mdc-tooltip/README.md create mode 100644 src/material-experimental/mdc-tooltip/_tooltip-theme.scss create mode 100644 src/material-experimental/mdc-tooltip/index.ts create mode 100644 src/material-experimental/mdc-tooltip/module.ts create mode 100644 src/material-experimental/mdc-tooltip/public-api.ts create mode 100644 src/material-experimental/mdc-tooltip/tooltip-animations.ts create mode 100644 src/material-experimental/mdc-tooltip/tooltip.html create mode 100644 src/material-experimental/mdc-tooltip/tooltip.scss create mode 100644 src/material-experimental/mdc-tooltip/tooltip.spec.ts create mode 100644 src/material-experimental/mdc-tooltip/tooltip.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 99c7129eccba..3ab054d73603 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -117,6 +117,7 @@ /src/material-experimental/mdc-slide-toggle/** @crisbeto /src/material-experimental/mdc-slider/** @devversion /src/material-experimental/mdc-tabs/** @crisbeto +/src/material-experimental/mdc-tooltip/** @crisbeto /src/material-experimental/mdc-sidenav/** @crisbeto /src/material-experimental/mdc-table/** @andrewseguin /src/material-experimental/mdc-theming/** @mmalerba @@ -197,6 +198,7 @@ /src/dev-app/mdc-slider/** @devversion /src/dev-app/mdc-table/** @andrewseguin /src/dev-app/mdc-tabs/** @crisbeto +/src/dev-app/mdc-tooltip/** @crisbeto /src/dev-app/menu/** @crisbeto /src/dev-app/menubar/** @jelbourn @andy9775 /src/dev-app/overlay/** @jelbourn @crisbeto diff --git a/.ng-dev/commit-message.ts b/.ng-dev/commit-message.ts index e786674462b1..4135abef19b9 100644 --- a/.ng-dev/commit-message.ts +++ b/.ng-dev/commit-message.ts @@ -64,6 +64,7 @@ export const commitMessage: CommitMessageConfig = { 'material-experimental/mdc-snack-bar', 'material-experimental/mdc-table', 'material-experimental/mdc-tabs', + 'material-experimental/mdc-tooltip', 'material-experimental/mdc-theming', 'material-experimental/mdc-typography', 'material-experimental/menubar', diff --git a/rollup-globals.bzl b/rollup-globals.bzl index ca76ac87d5be..e56b94b6c287 100644 --- a/rollup-globals.bzl +++ b/rollup-globals.bzl @@ -67,6 +67,7 @@ ROLLUP_GLOBALS = { "@material/tab-indicator": "mdc.tabIndicator", "@material/tab-scroller": "mdc.tabScroller", "@material/textfield": "mdc.textfield", + "@material/tooltip": "mdc.tooltip", "@material/top-app-bar": "mdc.topAppBar", # Third-party libraries. diff --git a/scripts/check-mdc-exports-config.ts b/scripts/check-mdc-exports-config.ts index 3392dd9c9daf..f6946185e9b7 100644 --- a/scripts/check-mdc-exports-config.ts +++ b/scripts/check-mdc-exports-config.ts @@ -89,6 +89,11 @@ export const config = { '_MatTableDataSource', '_MAT_TEXT_COLUMN_TEMPLATE' ], + 'mdc-tooltip': [ + // Private symbols that are only exported for MDC. + '_MatTooltipBase', + '_TooltipComponentBase' + ], 'mdc-checkbox/testing': [ // Private symbols that are only exported for MDC. '_MatCheckboxHarnessBase' diff --git a/src/dev-app/BUILD.bazel b/src/dev-app/BUILD.bazel index 0ba4c94d89ab..90de4581363b 100644 --- a/src/dev-app/BUILD.bazel +++ b/src/dev-app/BUILD.bazel @@ -67,6 +67,7 @@ ng_module( "//src/dev-app/mdc-snack-bar", "//src/dev-app/mdc-table", "//src/dev-app/mdc-tabs", + "//src/dev-app/mdc-tooltip", "//src/dev-app/menu", "//src/dev-app/menubar", "//src/dev-app/paginator", @@ -163,6 +164,7 @@ filegroup( "@npm//:node_modules/@material/tab-scroller/dist/mdc.tabScroller.js", "@npm//:node_modules/@material/tab/dist/mdc.tab.js", "@npm//:node_modules/@material/textfield/dist/mdc.textfield.js", + "@npm//:node_modules/@material/tooltip/dist/mdc.tooltip.js", "@npm//:node_modules/@material/top-app-bar/dist/mdc.topAppBar.js", "@npm//:node_modules/@webcomponents/custom-elements/custom-elements.min.js", "@npm//:node_modules/core-js-bundle/index.js", diff --git a/src/dev-app/dev-app/dev-app-layout.ts b/src/dev-app/dev-app/dev-app-layout.ts index 985c4e00f19b..7d22e8d3651e 100644 --- a/src/dev-app/dev-app/dev-app-layout.ts +++ b/src/dev-app/dev-app/dev-app-layout.ts @@ -96,6 +96,7 @@ export class DevAppLayout { {name: 'MDC Progress Bar', route: '/mdc-progress-bar'}, {name: 'MDC Progress Spinner', route: '/mdc-progress-spinner'}, {name: 'MDC Tabs', route: '/mdc-tabs'}, + {name: 'MDC Tooltip', route: '/mdc-tooltip'}, {name: 'MDC Select', route: '/mdc-select'}, {name: 'MDC Sidenav', route: '/mdc-sidenav'}, {name: 'MDC Slide Toggle', route: '/mdc-slide-toggle'}, diff --git a/src/dev-app/dev-app/routes.ts b/src/dev-app/dev-app/routes.ts index 1f001264fe43..fb5765ed72b0 100644 --- a/src/dev-app/dev-app/routes.ts +++ b/src/dev-app/dev-app/routes.ts @@ -112,6 +112,7 @@ export const DEV_APP_ROUTES: Routes = [ {path: 'mdc-slider', loadChildren: 'mdc-slider/mdc-slider-demo-module#MdcSliderDemoModule'}, {path: 'mdc-table', loadChildren: 'mdc-table/mdc-table-demo-module#MdcTableDemoModule'}, {path: 'mdc-tabs', loadChildren: 'mdc-tabs/mdc-tabs-demo-module#MdcTabsDemoModule'}, + {path: 'mdc-tooltip', loadChildren: 'mdc-tooltip/mdc-tooltip-demo-module#MdcTooltipDemoModule'}, {path: 'menu', loadChildren: 'menu/menu-demo-module#MenuDemoModule'}, {path: 'paginator', loadChildren: 'paginator/paginator-demo-module#PaginatorDemoModule'}, {path: 'platform', loadChildren: 'platform/platform-demo-module#PlatformDemoModule'}, diff --git a/src/dev-app/mdc-tooltip/BUILD.bazel b/src/dev-app/mdc-tooltip/BUILD.bazel new file mode 100644 index 000000000000..5e6d20b2a192 --- /dev/null +++ b/src/dev-app/mdc-tooltip/BUILD.bazel @@ -0,0 +1,27 @@ +load("//tools:defaults.bzl", "ng_module", "sass_binary") + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "mdc-tooltip", + srcs = glob(["**/*.ts"]), + assets = [ + "mdc-tooltip-demo.html", + ":mdc_tooltip_demo_scss", + ], + deps = [ + "//src/material-experimental/mdc-button", + "//src/material-experimental/mdc-form-field", + "//src/material-experimental/mdc-input", + "//src/material-experimental/mdc-select", + "//src/material-experimental/mdc-tooltip", + "@npm//@angular/common", + "@npm//@angular/forms", + "@npm//@angular/router", + ], +) + +sass_binary( + name = "mdc_tooltip_demo_scss", + src = "mdc-tooltip-demo.scss", +) diff --git a/src/dev-app/mdc-tooltip/mdc-tooltip-demo-module.ts b/src/dev-app/mdc-tooltip/mdc-tooltip-demo-module.ts new file mode 100644 index 000000000000..5799dbab44a2 --- /dev/null +++ b/src/dev-app/mdc-tooltip/mdc-tooltip-demo-module.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {ReactiveFormsModule} from '@angular/forms'; +import {MatButtonModule} from '@angular/material-experimental/mdc-button'; +import {MatFormFieldModule} from '@angular/material-experimental/mdc-form-field'; +import {MatInputModule} from '@angular/material-experimental/mdc-input'; +import {MatSelectModule} from '@angular/material-experimental/mdc-select'; +import {MatTooltipModule} from '@angular/material-experimental/mdc-tooltip'; +import {RouterModule} from '@angular/router'; +import {MdcTooltipDemo} from './mdc-tooltip-demo'; + +@NgModule({ + imports: [ + CommonModule, + ReactiveFormsModule, + MatTooltipModule, + MatButtonModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + RouterModule.forChild([{path: '', component: MdcTooltipDemo}]), + ], + declarations: [MdcTooltipDemo], +}) +export class MdcTooltipDemoModule { +} diff --git a/src/dev-app/mdc-tooltip/mdc-tooltip-demo.html b/src/dev-app/mdc-tooltip/mdc-tooltip-demo.html new file mode 100644 index 000000000000..4f4af17f339c --- /dev/null +++ b/src/dev-app/mdc-tooltip/mdc-tooltip-demo.html @@ -0,0 +1,36 @@ +
+
+ + Message + + + + + Show delay + + milliseconds + + + + Hide delay + + milliseconds + + + + Tooltip position + + + {{positionOption}} + + + +
+ + +
diff --git a/src/dev-app/mdc-tooltip/mdc-tooltip-demo.scss b/src/dev-app/mdc-tooltip/mdc-tooltip-demo.scss new file mode 100644 index 000000000000..d1298121ea59 --- /dev/null +++ b/src/dev-app/mdc-tooltip/mdc-tooltip-demo.scss @@ -0,0 +1,15 @@ +.demo-wrapper { + // Prevent the container from stretching to 100%. + display: inline-block; + + .mat-mdc-tooltip-trigger { + // Center the trigger relative to the content so that it's not flush against one of the + // viewport edges. This will guarantee that we always get the specified demo position. + margin: 32px auto; + display: block; + } +} + +.demo-form-field { + margin: 0 8px; +} diff --git a/src/dev-app/mdc-tooltip/mdc-tooltip-demo.ts b/src/dev-app/mdc-tooltip/mdc-tooltip-demo.ts new file mode 100644 index 000000000000..fcb813b8a41a --- /dev/null +++ b/src/dev-app/mdc-tooltip/mdc-tooltip-demo.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component} from '@angular/core'; +import {FormControl} from '@angular/forms'; +import {TooltipPosition} from '@angular/material-experimental/mdc-tooltip'; + +@Component({ + selector: 'mdc-tooltip-demo', + templateUrl: 'mdc-tooltip-demo.html', + styleUrls: ['mdc-tooltip-demo.css'], +}) +export class MdcTooltipDemo { + message = new FormControl('Info about the action'); + showDelay = new FormControl(0); + hideDelay = new FormControl(0); + positionOptions: TooltipPosition[] = ['below', 'after', 'before', 'above', 'left', 'right']; + position = new FormControl(this.positionOptions[0]); +} diff --git a/src/e2e-app/devserver-configure.js b/src/e2e-app/devserver-configure.js index 4b5b619a7d11..37562c29178d 100644 --- a/src/e2e-app/devserver-configure.js +++ b/src/e2e-app/devserver-configure.js @@ -38,6 +38,7 @@ require.config({ '@material/tab-indicator': '@material/tab-indicator/dist/mdc.tabIndicator', '@material/tab-scroller': '@material/tab-scroller/dist/mdc.tabScroller', '@material/textfield': '@material/textfield/dist/mdc.textfield', + '@material/tooltip': '@material/tooltip/dist/mdc.tooltip', '@material/top-app-bar': '@material/top-app-bar/dist/mdc.topAppBar', } }); diff --git a/src/material-experimental/config.bzl b/src/material-experimental/config.bzl index 1006febdc775..3a3729896ab5 100644 --- a/src/material-experimental/config.bzl +++ b/src/material-experimental/config.bzl @@ -43,6 +43,7 @@ entryPoints = [ "mdc-table/testing", "mdc-tabs", "mdc-tabs/testing", + "mdc-tooltip", "menubar", "popover-edit", "selection", diff --git a/src/material-experimental/mdc-theming/BUILD.bazel b/src/material-experimental/mdc-theming/BUILD.bazel index 8d16bd59be25..cb8ffe0b090b 100644 --- a/src/material-experimental/mdc-theming/BUILD.bazel +++ b/src/material-experimental/mdc-theming/BUILD.bazel @@ -37,6 +37,7 @@ sass_library( "//src/material-experimental/mdc-snack-bar:mdc_snack_bar_scss_lib", "//src/material-experimental/mdc-table:mdc_table_scss_lib", "//src/material-experimental/mdc-tabs:mdc_tabs_scss_lib", + "//src/material-experimental/mdc-tooltip:mdc_tooltip_scss_lib", "//src/material/core:core_scss_lib", "//src/material/core:theming_scss_lib", ], diff --git a/src/material-experimental/mdc-theming/_all-theme.scss b/src/material-experimental/mdc-theming/_all-theme.scss index 65e5ba07c444..f979dce9cc02 100644 --- a/src/material-experimental/mdc-theming/_all-theme.scss +++ b/src/material-experimental/mdc-theming/_all-theme.scss @@ -13,6 +13,7 @@ @import '../mdc-snack-bar/snack-bar-theme'; @import '../mdc-tabs/tabs-theme'; @import '../mdc-table/table-theme'; +@import '../mdc-tooltip/tooltip-theme'; @import '../mdc-paginator/paginator-theme'; @import '../mdc-progress-bar/progress-bar-theme'; @import '../mdc-progress-spinner/progress-spinner-theme'; @@ -46,5 +47,6 @@ @include mat-mdc-form-field-theme($theme-or-color-config); @include mat-mdc-input-theme($theme-or-color-config); @include mat-mdc-tabs-theme($theme-or-color-config); + @include mat-mdc-tooltip-theme($theme-or-color-config); } } diff --git a/src/material-experimental/mdc-tooltip/BUILD.bazel b/src/material-experimental/mdc-tooltip/BUILD.bazel new file mode 100644 index 000000000000..fee31b478824 --- /dev/null +++ b/src/material-experimental/mdc-tooltip/BUILD.bazel @@ -0,0 +1,80 @@ +load( + "//tools:defaults.bzl", + "ng_module", + "ng_test_library", + "ng_web_test_suite", + "sass_binary", + "sass_library", +) + +package(default_visibility = ["//visibility:public"]) + +ng_module( + name = "mdc-tooltip", + srcs = glob( + ["**/*.ts"], + exclude = [ + "**/*.spec.ts", + ], + ), + assets = [ + ":tooltip_scss", + ] + glob(["**/*.html"]), + module_name = "@angular/material-experimental/mdc-tooltip", + deps = [ + "//src/material-experimental/mdc-core", + "//src/material/tooltip", + "@npm//@material/tooltip", + ], +) + +sass_library( + name = "mdc_tooltip_scss_lib", + srcs = glob(["**/_*.scss"]), + deps = [ + "//src/material-experimental/mdc-helpers:mdc_helpers_scss_lib", + "//src/material-experimental/mdc-helpers:mdc_scss_deps_lib", + ], +) + +sass_binary( + name = "tooltip_scss", + src = "tooltip.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", + ], +) + +ng_test_library( + name = "tooltip_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = [ + "**/*.e2e.spec.ts", + ], + ), + deps = [ + ":mdc-tooltip", + "//src/cdk/a11y", + "//src/cdk/bidi", + "//src/cdk/keycodes", + "//src/cdk/overlay", + "//src/cdk/platform", + "//src/cdk/testing/private", + "@npm//@angular/animations", + "@npm//@angular/platform-browser", + ], +) + +ng_web_test_suite( + name = "unit_tests", + static_files = ["@npm//:node_modules/@material/tooltip/dist/mdc.tooltip.js"], + deps = [ + ":tooltip_tests_lib", + "//src/material-experimental:mdc_require_config.js", + ], +) diff --git a/src/material-experimental/mdc-tooltip/README.md b/src/material-experimental/mdc-tooltip/README.md new file mode 100644 index 000000000000..3d071b2234c0 --- /dev/null +++ b/src/material-experimental/mdc-tooltip/README.md @@ -0,0 +1,87 @@ +This is prototype of an alternate version of `MatTooltip` 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 `MatTooltip`. 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 `MatTooltipModule` and add it to the module that declares your + component: + + ```ts + import {MatTooltipModule} from '@angular/material-experimental/mdc-tooltip'; + + @NgModule({ + declarations: [MyComponent], + imports: [MatTooltipModule], + }) + export class MyModule {} + ``` + +4. Use `matTooltip` in your component's template, just like you would the normal + `matTooltip`: + + ```html + + ``` + +5. Add the theme and typography mixins to your Sass. (There is currently no pre-built CSS option for + the experimental `matTooltip`): + + ```scss + @import '~@angular/material/theming'; + @import '~@angular/material-experimental/mdc-tooltip/tooltip-theme'; + + $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-tooltip-theme($my-theme); + @include mat-mdc-tooltip-typography($my-theme); + ``` + +## API differences +There are no API differences between the experimental and standard `matTooltip`. + +## Replacing the standard tooltip in an existing app +Because the experimental API mirrors the API for the standard tooltip, 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/tooltip['\"]" | xargs sed -i \ + "s/['\"]@angular\/material\/tooltip['\"]/'@angular\/material-experimental\/mdc-tooltip'/g" +``` + +CSS styles and tests that depend on implementation details of `matTooltip` (such as getting +elements from the template by class name) will need to be manually updated. diff --git a/src/material-experimental/mdc-tooltip/_tooltip-theme.scss b/src/material-experimental/mdc-tooltip/_tooltip-theme.scss new file mode 100644 index 000000000000..dd7887a692c5 --- /dev/null +++ b/src/material-experimental/mdc-tooltip/_tooltip-theme.scss @@ -0,0 +1,39 @@ +@use '@material/tooltip/tooltip'; +@import '../mdc-helpers/mdc-helpers'; + +@mixin mat-mdc-tooltip-color($config-or-theme) { + $config: mat-get-color-config($config-or-theme); + @include mat-using-mdc-theme($config) { + @include tooltip.core-styles($query: $mat-theme-styles-query); + } +} + +@mixin mat-mdc-tooltip-typography($config-or-theme) { + $config: mat-get-typography-config($config-or-theme); + @include mat-using-mdc-typography($config) { + @include tooltip.core-styles($query: $mat-typography-styles-query); + } +} + +@mixin mat-mdc-tooltip-density($config-or-theme) { + $density-scale: mat-get-density-config($config-or-theme); +} + +@mixin mat-mdc-tooltip-theme($theme-or-color-config) { + $theme: mat-private-legacy-get-theme($theme-or-color-config); + @include mat-private-check-duplicate-theme-styles($theme, 'mat-mdc-tooltip') { + $color: mat-get-color-config($theme); + $density: mat-get-density-config($theme); + $typography: mat-get-typography-config($theme); + + @if $color != null { + @include mat-mdc-tooltip-color($color); + } + @if $density != null { + @include mat-mdc-tooltip-density($density); + } + @if $typography != null { + @include mat-mdc-tooltip-typography($typography); + } + } +} diff --git a/src/material-experimental/mdc-tooltip/index.ts b/src/material-experimental/mdc-tooltip/index.ts new file mode 100644 index 000000000000..676ca90f1ffa --- /dev/null +++ b/src/material-experimental/mdc-tooltip/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-tooltip/module.ts b/src/material-experimental/mdc-tooltip/module.ts new file mode 100644 index 000000000000..8039d99ca91d --- /dev/null +++ b/src/material-experimental/mdc-tooltip/module.ts @@ -0,0 +1,31 @@ +/** + * @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 {NgModule} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {A11yModule} from '@angular/cdk/a11y'; +import {OverlayModule} from '@angular/cdk/overlay'; +import {CdkScrollableModule} from '@angular/cdk/scrolling'; +import {MatCommonModule} from '@angular/material-experimental/mdc-core'; +import {MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER} from '@angular/material/tooltip'; +import {MatTooltip, TooltipComponent} from './tooltip'; + +@NgModule({ + imports: [ + A11yModule, + CommonModule, + OverlayModule, + MatCommonModule, + ], + exports: [MatTooltip, TooltipComponent, MatCommonModule, CdkScrollableModule], + declarations: [MatTooltip, TooltipComponent], + entryComponents: [TooltipComponent], + providers: [MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER] +}) +export class MatTooltipModule { +} diff --git a/src/material-experimental/mdc-tooltip/public-api.ts b/src/material-experimental/mdc-tooltip/public-api.ts new file mode 100644 index 000000000000..7d68c4668005 --- /dev/null +++ b/src/material-experimental/mdc-tooltip/public-api.ts @@ -0,0 +1,26 @@ +/** + * @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 './tooltip'; +export * from './tooltip-animations'; +export * from './module'; + +export { + getMatTooltipInvalidPositionError, + MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY, + MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY, + TooltipPosition, + TooltipTouchGestures, + TooltipVisibility, + SCROLL_THROTTLE_MS, + TOOLTIP_PANEL_CLASS, + MAT_TOOLTIP_SCROLL_STRATEGY, + MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER, + MatTooltipDefaultOptions, + MAT_TOOLTIP_DEFAULT_OPTIONS +} from '@angular/material/tooltip'; diff --git a/src/material-experimental/mdc-tooltip/tooltip-animations.ts b/src/material-experimental/mdc-tooltip/tooltip-animations.ts new file mode 100644 index 000000000000..01319370e09d --- /dev/null +++ b/src/material-experimental/mdc-tooltip/tooltip-animations.ts @@ -0,0 +1,33 @@ +/** + * @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 { + animate, + AnimationTriggerMetadata, + state, + style, + transition, + trigger, +} from '@angular/animations'; + +/** + * Animations used by MatTooltip. + * @docs-private + */ +export const matTooltipAnimations: { + readonly tooltipState: AnimationTriggerMetadata; +} = { + /** Animation that transitions a tooltip in and out. */ + tooltipState: trigger('state', [ + // TODO(crisbeto): these values are based on MDC's CSS. + // We should be able to use their styles directly once we land #19432. + state('initial, void, hidden', style({opacity: 0, transform: 'scale(0.8)'})), + state('visible', style({transform: 'scale(1)'})), + transition('* => visible', animate('150ms cubic-bezier(0, 0, 0.2, 1)')), + transition('* => hidden', animate('75ms cubic-bezier(0.4, 0, 1, 1)')), + ]) +}; diff --git a/src/material-experimental/mdc-tooltip/tooltip.html b/src/material-experimental/mdc-tooltip/tooltip.html new file mode 100644 index 000000000000..f5643e7221e9 --- /dev/null +++ b/src/material-experimental/mdc-tooltip/tooltip.html @@ -0,0 +1,8 @@ +
+
{{message}}
+
diff --git a/src/material-experimental/mdc-tooltip/tooltip.scss b/src/material-experimental/mdc-tooltip/tooltip.scss new file mode 100644 index 000000000000..10c9cb2f2712 --- /dev/null +++ b/src/material-experimental/mdc-tooltip/tooltip.scss @@ -0,0 +1,14 @@ +@use '@material/tooltip/tooltip'; + +// Only include the structural styles, because we handle the animation ourselves. +@include tooltip.core-styles($query: structure); + +.mat-mdc-tooltip { + // We don't use MDC's positioning so this has to be static. + position: static; + + // The overlay reference updates the pointer-events style property directly on the HTMLElement + // depending on the state of the overlay. For tooltips the overlay panel should never enable + // pointer events. To overwrite the inline CSS from the overlay reference `!important` is needed. + pointer-events: none !important; +} diff --git a/src/material-experimental/mdc-tooltip/tooltip.spec.ts b/src/material-experimental/mdc-tooltip/tooltip.spec.ts new file mode 100644 index 000000000000..2bde17aa835c --- /dev/null +++ b/src/material-experimental/mdc-tooltip/tooltip.spec.ts @@ -0,0 +1,1325 @@ +import { + waitForAsync, + ComponentFixture, + fakeAsync, + flush, + flushMicrotasks, + inject, + TestBed, + tick +} from '@angular/core/testing'; +import { + ChangeDetectionStrategy, + Component, + DebugElement, + ElementRef, + ViewChild, + NgZone, +} from '@angular/core'; +import {AnimationEvent} from '@angular/animations'; +import {By} from '@angular/platform-browser'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {Direction, Directionality} from '@angular/cdk/bidi'; +import {OverlayContainer, OverlayModule, CdkScrollable} from '@angular/cdk/overlay'; +import {Platform} from '@angular/cdk/platform'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + patchElementFocus, + dispatchMouseEvent, + createKeyboardEvent, + dispatchEvent, + createFakeEvent, +} from '@angular/cdk/testing/private'; +import {ESCAPE} from '@angular/cdk/keycodes'; +import {FocusMonitor} from '@angular/cdk/a11y'; +import { + MatTooltip, + MatTooltipModule, + SCROLL_THROTTLE_MS, + TOOLTIP_PANEL_CLASS, + MAT_TOOLTIP_DEFAULT_OPTIONS, + TooltipTouchGestures, +} from './index'; + + +const initialTooltipMessage = 'initial tooltip message'; + +describe('MDC-based MatTooltip', () => { + let overlayContainer: OverlayContainer; + let overlayContainerElement: HTMLElement; + let dir: {value: Direction}; + let platform: Platform; + let focusMonitor: FocusMonitor; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + declarations: [ + BasicTooltipDemo, + ScrollableTooltipDemo, + OnPushTooltipDemo, + DynamicTooltipsDemo, + TooltipOnTextFields, + TooltipOnDraggableElement, + DataBoundAriaLabelTooltip, + ], + providers: [ + {provide: Directionality, useFactory: () => { + return dir = {value: 'ltr'}; + }} + ] + }); + + TestBed.compileComponents(); + + inject([OverlayContainer, FocusMonitor, Platform], + (oc: OverlayContainer, fm: FocusMonitor, pl: Platform) => { + overlayContainer = oc; + overlayContainerElement = oc.getContainerElement(); + focusMonitor = fm; + platform = pl; + })(); + })); + + 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('basic usage', () => { + let fixture: ComponentFixture; + let buttonDebugElement: DebugElement; + let buttonElement: HTMLButtonElement; + let tooltipDirective: MatTooltip; + + beforeEach(() => { + fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + buttonDebugElement = fixture.debugElement.query(By.css('button'))!; + buttonElement = buttonDebugElement.nativeElement; + tooltipDirective = buttonDebugElement.injector.get(MatTooltip); + }); + + it('should show and hide the tooltip', fakeAsync(() => { + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.show(); + tick(0); // Tick for the show delay (default is 0) + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + fixture.detectChanges(); + + // wait till animation has finished + tick(500); + + // Make sure tooltip is shown to the user and animation has finished + const tooltipElement = + overlayContainerElement.querySelector('.mat-mdc-tooltip') as HTMLElement; + expect(tooltipElement instanceof HTMLElement).toBe(true); + expect(tooltipElement.style.transform).toBe('scale(1)'); + + expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); + + // After hide called, a timeout delay is created that will to hide the tooltip. + const tooltipDelay = 1000; + tooltipDirective.hide(tooltipDelay); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + // After the tooltip delay elapses, expect that the tooltip is not visible. + tick(tooltipDelay); + fixture.detectChanges(); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + + // On animation complete, should expect that the tooltip has been detached. + flushMicrotasks(); + assertTooltipInstance(tooltipDirective, false); + })); + + it('should be able to re-open a tooltip if it was closed by detaching the overlay', + fakeAsync(() => { + tooltipDirective.show(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + fixture.detectChanges(); + tick(500); + + tooltipDirective._overlayRef!.detach(); + tick(0); + fixture.detectChanges(); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + flushMicrotasks(); + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.show(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + })); + + it('should show with delay', fakeAsync(() => { + assertTooltipInstance(tooltipDirective, false); + + const tooltipDelay = 1000; + tooltipDirective.show(tooltipDelay); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + + fixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain(''); + + tick(tooltipDelay); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); + })); + + it('should be able to override the default show and hide delays', fakeAsync(() => { + TestBed + .resetTestingModule() + .configureTestingModule({ + imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + declarations: [BasicTooltipDemo], + providers: [{ + provide: MAT_TOOLTIP_DEFAULT_OPTIONS, + useValue: {showDelay: 1337, hideDelay: 7331} + }] + }) + .compileComponents(); + + fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + tooltipDirective = fixture.debugElement.query(By.css('button'))! + .injector.get(MatTooltip); + + tooltipDirective.show(); + fixture.detectChanges(); + tick(); + + expect(tooltipDirective._isTooltipVisible()).toBe(false); + tick(1337); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + tooltipDirective.hide(); + fixture.detectChanges(); + tick(); + + expect(tooltipDirective._isTooltipVisible()).toBe(true); + tick(7331); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + })); + + it('should be able to override the default position', fakeAsync(() => { + TestBed + .resetTestingModule() + .configureTestingModule({ + imports: [MatTooltipModule, OverlayModule, NoopAnimationsModule], + declarations: [TooltipDemoWithoutPositionBinding], + providers: [{ + provide: MAT_TOOLTIP_DEFAULT_OPTIONS, + useValue: {position: 'right'} + }] + }) + .compileComponents(); + + const newFixture = TestBed.createComponent(TooltipDemoWithoutPositionBinding); + newFixture.detectChanges(); + tooltipDirective = newFixture.debugElement.query(By.css('button'))! + .injector.get(MatTooltip); + + tooltipDirective.show(); + newFixture.detectChanges(); + tick(); + + expect(tooltipDirective.position).toBe('right'); + expect(tooltipDirective._getOverlayPosition().main.overlayX).toBe('start'); + expect(tooltipDirective._getOverlayPosition().fallback.overlayX).toBe('end'); + })); + + it('should set a css class on the overlay panel element', fakeAsync(() => { + tooltipDirective.show(); + fixture.detectChanges(); + tick(0); + + const overlayRef = tooltipDirective._overlayRef; + + expect(!!overlayRef).toBeTruthy(); + expect(overlayRef!.overlayElement.classList).toContain(TOOLTIP_PANEL_CLASS, + 'Expected the overlay panel element to have the tooltip panel class set.'); + })); + + it('should not show if disabled', fakeAsync(() => { + // Test that disabling the tooltip will not set the tooltip visible + tooltipDirective.disabled = true; + tooltipDirective.show(); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + + // Test to make sure setting disabled to false will show the tooltip + // Sanity check to make sure everything was correct before (detectChanges, tick) + tooltipDirective.disabled = false; + tooltipDirective.show(); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + })); + + it('should hide if disabled while visible', fakeAsync(() => { + // Display the tooltip with a timeout before hiding. + tooltipDirective.hideDelay = 1000; + tooltipDirective.show(); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + // Set tooltip to be disabled and verify that the tooltip hides. + tooltipDirective.disabled = true; + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + })); + + it('should hide if the message is cleared while the tooltip is open', fakeAsync(() => { + tooltipDirective.show(); + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + fixture.componentInstance.message = ''; + fixture.detectChanges(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + })); + + it('should not show if hide is called before delay finishes', waitForAsync(() => { + assertTooltipInstance(tooltipDirective, false); + + const tooltipDelay = 1000; + + tooltipDirective.show(tooltipDelay); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + + fixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain(''); + tooltipDirective.hide(); + + fixture.whenStable().then(() => { + expect(tooltipDirective._isTooltipVisible()).toBe(false); + }); + })); + + it('should not show tooltip if message is not present or empty', () => { + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.message = undefined!; + fixture.detectChanges(); + tooltipDirective.show(); + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.message = null!; + fixture.detectChanges(); + tooltipDirective.show(); + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.message = ''; + fixture.detectChanges(); + tooltipDirective.show(); + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.message = ' '; + fixture.detectChanges(); + tooltipDirective.show(); + assertTooltipInstance(tooltipDirective, false); + }); + + it('should not follow through with hide if show is called after', fakeAsync(() => { + tooltipDirective.show(); + tick(0); // Tick for the show delay (default is 0) + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + // After hide called, a timeout delay is created that will to hide the tooltip. + const tooltipDelay = 1000; + tooltipDirective.hide(tooltipDelay); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + // Before delay time has passed, call show which should cancel intent to hide tooltip. + tooltipDirective.show(); + tick(tooltipDelay); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + })); + + it('should be able to update the tooltip position while open', fakeAsync(() => { + tooltipDirective.position = 'below'; + tooltipDirective.show(); + tick(); + + assertTooltipInstance(tooltipDirective, true); + + tooltipDirective.position = 'above'; + spyOn(tooltipDirective._overlayRef!, 'updatePosition').and.callThrough(); + fixture.detectChanges(); + tick(); + + assertTooltipInstance(tooltipDirective, true); + expect(tooltipDirective._overlayRef!.updatePosition).toHaveBeenCalled(); + })); + + it('should not throw when updating the position for a closed tooltip', fakeAsync(() => { + tooltipDirective.position = 'left'; + tooltipDirective.show(0); + fixture.detectChanges(); + tick(); + + tooltipDirective.hide(0); + fixture.detectChanges(); + tick(); + + // At this point the animation should be able to complete itself and trigger the + // _animationDone function, but for unknown reasons in the test infrastructure, + // this does not occur. Manually call the hook so the animation subscriptions get invoked. + tooltipDirective._tooltipInstance!._animationDone({ + fromState: 'visible', + toState: 'hidden', + totalTime: 150, + phaseName: 'done', + } as AnimationEvent); + + expect(() => { + tooltipDirective.position = 'right'; + fixture.detectChanges(); + tick(); + }).not.toThrow(); + })); + + it('should be able to modify the tooltip message', fakeAsync(() => { + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.show(); + tick(0); // Tick for the show delay (default is 0) + expect(tooltipDirective._tooltipInstance!._visibility).toBe('visible'); + + fixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); + + const newMessage = 'new tooltip message'; + tooltipDirective.message = newMessage; + + fixture.detectChanges(); + expect(overlayContainerElement.textContent).toContain(newMessage); + })); + + it('should allow extra classes to be set on the tooltip', fakeAsync(() => { + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.show(); + tick(0); // Tick for the show delay (default is 0) + fixture.detectChanges(); + + // Make sure classes aren't prematurely added + let tooltipElement = overlayContainerElement.querySelector('.mat-mdc-tooltip') as HTMLElement; + expect(tooltipElement.classList).not.toContain('custom-one', + 'Expected to not have the class before enabling matTooltipClass'); + expect(tooltipElement.classList).not.toContain('custom-two', + 'Expected to not have the class before enabling matTooltipClass'); + + // Enable the classes via ngClass syntax + fixture.componentInstance.showTooltipClass = true; + fixture.detectChanges(); + + // Make sure classes are correctly added + tooltipElement = overlayContainerElement.querySelector('.mat-mdc-tooltip') as HTMLElement; + expect(tooltipElement.classList).toContain('custom-one', + 'Expected to have the class after enabling matTooltipClass'); + expect(tooltipElement.classList).toContain('custom-two', + 'Expected to have the class after enabling matTooltipClass'); + })); + + it('should be removed after parent destroyed', fakeAsync(() => { + tooltipDirective.show(); + tick(0); // Tick for the show delay (default is 0) + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + fixture.destroy(); + expect(overlayContainerElement.childNodes.length).toBe(0); + expect(overlayContainerElement.textContent).toBe(''); + })); + + it('should have an aria-described element with the tooltip message', fakeAsync(() => { + const dynamicTooltipsDemoFixture = TestBed.createComponent(DynamicTooltipsDemo); + const dynamicTooltipsComponent = dynamicTooltipsDemoFixture.componentInstance; + + dynamicTooltipsComponent.tooltips = ['Tooltip One', 'Tooltip Two']; + dynamicTooltipsDemoFixture.detectChanges(); + tick(); + + const buttons = dynamicTooltipsDemoFixture.nativeElement.querySelectorAll('button'); + const firstButtonAria = buttons[0].getAttribute('aria-describedby'); + expect(document.querySelector(`#${firstButtonAria}`)!.textContent).toBe('Tooltip One'); + + const secondButtonAria = buttons[1].getAttribute('aria-describedby'); + expect(document.querySelector(`#${secondButtonAria}`)!.textContent).toBe('Tooltip Two'); + })); + + it('should not add an ARIA description for elements that have the same text as a' + + 'data-bound aria-label', fakeAsync(() => { + const ariaLabelFixture = TestBed.createComponent(DataBoundAriaLabelTooltip); + ariaLabelFixture.detectChanges(); + tick(); + + const button = ariaLabelFixture.nativeElement.querySelector('button'); + expect(button.getAttribute('aria-describedby')).toBeFalsy(); + })); + + it('should not try to dispose the tooltip when destroyed and done hiding', fakeAsync(() => { + tooltipDirective.show(); + fixture.detectChanges(); + tick(150); + + const tooltipDelay = 1000; + tooltipDirective.hide(); + tick(tooltipDelay); // Change the tooltip state to hidden and trigger animation start + + // Store the tooltip instance, which will be set to null after the button is hidden. + const tooltipInstance = tooltipDirective._tooltipInstance!; + fixture.componentInstance.showButton = false; + fixture.detectChanges(); + + // At this point the animation should be able to complete itself and trigger the + // _animationDone function, but for unknown reasons in the test infrastructure, + // this does not occur. Manually call this and verify that doing so does not + // throw an error. + tooltipInstance._animationDone({ + fromState: 'visible', + toState: 'hidden', + totalTime: 150, + phaseName: 'done', + } as AnimationEvent); + })); + + it('should complete the afterHidden stream when tooltip is destroyed', fakeAsync(() => { + tooltipDirective.show(); + fixture.detectChanges(); + tick(150); + + const spy = jasmine.createSpy('complete spy'); + const subscription = tooltipDirective._tooltipInstance!.afterHidden() + .subscribe({complete: spy}); + + tooltipDirective.hide(0); + tick(0); + fixture.detectChanges(); + tick(500); + + expect(spy).toHaveBeenCalled(); + subscription.unsubscribe(); + })); + + it('should consistently position before and after overlay origin in ltr and rtl dir', () => { + tooltipDirective.position = 'left'; + const leftOrigin = tooltipDirective._getOrigin().main; + tooltipDirective.position = 'right'; + const rightOrigin = tooltipDirective._getOrigin().main; + + // Test expectations in LTR + tooltipDirective.position = 'before'; + expect(tooltipDirective._getOrigin().main).toEqual(leftOrigin); + tooltipDirective.position = 'after'; + expect(tooltipDirective._getOrigin().main).toEqual(rightOrigin); + + // Test expectations in RTL + dir.value = 'rtl'; + tooltipDirective.position = 'before'; + expect(tooltipDirective._getOrigin().main).toEqual(leftOrigin); + tooltipDirective.position = 'after'; + expect(tooltipDirective._getOrigin().main).toEqual(rightOrigin); + }); + + it('should consistently position before and after overlay position in ltr and rtl dir', () => { + tooltipDirective.position = 'left'; + const leftOverlayPosition = tooltipDirective._getOverlayPosition().main; + tooltipDirective.position = 'right'; + const rightOverlayPosition = tooltipDirective._getOverlayPosition().main; + + // Test expectations in LTR + tooltipDirective.position = 'before'; + expect(tooltipDirective._getOverlayPosition().main).toEqual(leftOverlayPosition); + tooltipDirective.position = 'after'; + expect(tooltipDirective._getOverlayPosition().main).toEqual(rightOverlayPosition); + + // Test expectations in RTL + dir.value = 'rtl'; + tooltipDirective.position = 'before'; + expect(tooltipDirective._getOverlayPosition().main).toEqual(leftOverlayPosition); + tooltipDirective.position = 'after'; + expect(tooltipDirective._getOverlayPosition().main).toEqual(rightOverlayPosition); + }); + + it('should throw when trying to assign an invalid position', () => { + expect(() => { + fixture.componentInstance.position = 'everywhere'; + fixture.detectChanges(); + tooltipDirective.show(); + }).toThrowError('Tooltip position "everywhere" is invalid.'); + }); + + it('should pass the layout direction to the tooltip', fakeAsync(() => { + dir.value = 'rtl'; + + tooltipDirective.show(); + tick(0); + fixture.detectChanges(); + + const tooltipWrapper = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + + expect(tooltipWrapper).toBeTruthy('Expected tooltip to be shown.'); + expect(tooltipWrapper.getAttribute('dir')).toBe('rtl', 'Expected tooltip to be in RTL mode.'); + })); + + it('should keep the overlay direction in sync with the trigger direction', fakeAsync(() => { + dir.value = 'rtl'; + tooltipDirective.show(); + tick(0); + fixture.detectChanges(); + tick(500); + + let tooltipWrapper = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + expect(tooltipWrapper.getAttribute('dir')).toBe('rtl', 'Expected tooltip to be in RTL.'); + + tooltipDirective.hide(0); + tick(0); + fixture.detectChanges(); + tick(500); + + dir.value = 'ltr'; + tooltipDirective.show(); + tick(0); + fixture.detectChanges(); + tick(500); + + tooltipWrapper = + overlayContainerElement.querySelector('.cdk-overlay-connected-position-bounding-box')!; + expect(tooltipWrapper.getAttribute('dir')).toBe('ltr', 'Expected tooltip to be in LTR.'); + })); + + it('should be able to set the tooltip message as a number', fakeAsync(() => { + fixture.componentInstance.message = 100; + fixture.detectChanges(); + + expect(tooltipDirective.message).toBe('100'); + })); + + it('should hide when clicking away', fakeAsync(() => { + tooltipDirective.show(); + tick(0); + fixture.detectChanges(); + tick(500); + + expect(tooltipDirective._isTooltipVisible()).toBe(true); + expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); + + document.body.click(); + tick(0); + fixture.detectChanges(); + tick(500); + fixture.detectChanges(); + + expect(tooltipDirective._isTooltipVisible()).toBe(false); + expect(overlayContainerElement.textContent).toBe(''); + })); + + it('should hide when clicking away with an auxilliary button', fakeAsync(() => { + tooltipDirective.show(); + tick(0); + fixture.detectChanges(); + tick(500); + + expect(tooltipDirective._isTooltipVisible()).toBe(true); + expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); + + dispatchFakeEvent(document.body, 'auxclick'); + tick(0); + fixture.detectChanges(); + tick(500); + fixture.detectChanges(); + + expect(tooltipDirective._isTooltipVisible()).toBe(false); + expect(overlayContainerElement.textContent).toBe(''); + })); + + it('should not hide immediately if a click fires while animating', fakeAsync(() => { + tooltipDirective.show(); + tick(0); + fixture.detectChanges(); + + document.body.click(); + fixture.detectChanges(); + tick(500); + + expect(overlayContainerElement.textContent).toContain(initialTooltipMessage); + })); + + it('should not throw when pressing ESCAPE', fakeAsync(() => { + expect(() => { + dispatchKeyboardEvent(buttonElement, 'keydown', ESCAPE); + fixture.detectChanges(); + }).not.toThrow(); + + // Flush due to the additional tick that is necessary for the FocusMonitor. + flush(); + })); + + it('should preventDefault when pressing ESCAPE', fakeAsync(() => { + tooltipDirective.show(); + tick(0); + fixture.detectChanges(); + + const event = dispatchKeyboardEvent(buttonElement, 'keydown', ESCAPE); + fixture.detectChanges(); + flush(); + + expect(event.defaultPrevented).toBe(true); + })); + + it('should not preventDefault when pressing ESCAPE with a modifier', fakeAsync(() => { + tooltipDirective.show(); + tick(0); + fixture.detectChanges(); + + const event = createKeyboardEvent('keydown', ESCAPE, undefined, {alt: true}); + dispatchEvent(buttonElement, event); + fixture.detectChanges(); + flush(); + + expect(event.defaultPrevented).toBe(false); + })); + + it('should not show the tooltip on progammatic focus', fakeAsync(() => { + patchElementFocus(buttonElement); + assertTooltipInstance(tooltipDirective, false); + + focusMonitor.focusVia(buttonElement, 'program'); + tick(0); + fixture.detectChanges(); + tick(500); + + expect(overlayContainerElement.querySelector('.mat-mdc-tooltip')).toBeNull(); + })); + + it('should not show the tooltip on mouse focus', fakeAsync(() => { + patchElementFocus(buttonElement); + assertTooltipInstance(tooltipDirective, false); + + focusMonitor.focusVia(buttonElement, 'mouse'); + tick(0); + fixture.detectChanges(); + tick(500); + + expect(overlayContainerElement.querySelector('.mat-mdc-tooltip')).toBeNull(); + })); + + it('should not show the tooltip on touch focus', fakeAsync(() => { + patchElementFocus(buttonElement); + assertTooltipInstance(tooltipDirective, false); + + focusMonitor.focusVia(buttonElement, 'touch'); + tick(0); + fixture.detectChanges(); + tick(500); + + expect(overlayContainerElement.querySelector('.mat-mdc-tooltip')).toBeNull(); + })); + + it('should not hide the tooltip when calling `show` twice in a row', fakeAsync(() => { + tooltipDirective.show(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + fixture.detectChanges(); + tick(500); + + const overlayRef = tooltipDirective._overlayRef!; + + spyOn(overlayRef, 'detach').and.callThrough(); + + tooltipDirective.show(); + tick(0); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + fixture.detectChanges(); + tick(500); + + expect(overlayRef.detach).not.toHaveBeenCalled(); + })); + + }); + + describe('fallback positions', () => { + let fixture: ComponentFixture; + let tooltip: MatTooltip; + + beforeEach(() => { + fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + tooltip = fixture.debugElement.query(By.css('button'))!.injector.get(MatTooltip); + }); + + it('should set a fallback origin position by inverting the main origin position', () => { + tooltip.position = 'left'; + expect(tooltip._getOrigin().main.originX).toBe('start'); + expect(tooltip._getOrigin().fallback.originX).toBe('end'); + + tooltip.position = 'right'; + expect(tooltip._getOrigin().main.originX).toBe('end'); + expect(tooltip._getOrigin().fallback.originX).toBe('start'); + + tooltip.position = 'above'; + expect(tooltip._getOrigin().main.originY).toBe('top'); + expect(tooltip._getOrigin().fallback.originY).toBe('bottom'); + + tooltip.position = 'below'; + expect(tooltip._getOrigin().main.originY).toBe('bottom'); + expect(tooltip._getOrigin().fallback.originY).toBe('top'); + }); + + it('should set a fallback overlay position by inverting the main overlay position', () => { + tooltip.position = 'left'; + expect(tooltip._getOverlayPosition().main.overlayX).toBe('end'); + expect(tooltip._getOverlayPosition().fallback.overlayX).toBe('start'); + + tooltip.position = 'right'; + expect(tooltip._getOverlayPosition().main.overlayX).toBe('start'); + expect(tooltip._getOverlayPosition().fallback.overlayX).toBe('end'); + + tooltip.position = 'above'; + expect(tooltip._getOverlayPosition().main.overlayY).toBe('bottom'); + expect(tooltip._getOverlayPosition().fallback.overlayY).toBe('top'); + + tooltip.position = 'below'; + expect(tooltip._getOverlayPosition().main.overlayY).toBe('top'); + expect(tooltip._getOverlayPosition().fallback.overlayY).toBe('bottom'); + }); + }); + + describe('scrollable usage', () => { + let fixture: ComponentFixture; + let buttonDebugElement: DebugElement; + let tooltipDirective: MatTooltip; + + beforeEach(() => { + fixture = TestBed.createComponent(ScrollableTooltipDemo); + fixture.detectChanges(); + buttonDebugElement = fixture.debugElement.query(By.css('button'))!; + tooltipDirective = buttonDebugElement.injector.get(MatTooltip); + }); + + it('should hide tooltip if clipped after changing positions', fakeAsync(() => { + assertTooltipInstance(tooltipDirective, false); + + // Show the tooltip and tick for the show delay (default is 0) + tooltipDirective.show(); + fixture.detectChanges(); + tick(0); + + // Expect that the tooltip is displayed + expect(tooltipDirective._isTooltipVisible()) + .toBe(true, 'Expected tooltip to be initially visible'); + + // Scroll the page but tick just before the default throttle should update. + fixture.componentInstance.scrollDown(); + tick(SCROLL_THROTTLE_MS - 1); + expect(tooltipDirective._isTooltipVisible()) + .toBe(true, 'Expected tooltip to be visible when scrolling, before throttle limit'); + + // Finish ticking to the throttle's limit and check that the scroll event notified the + // tooltip and it was hidden. + tick(100); + fixture.detectChanges(); + expect(tooltipDirective._isTooltipVisible()) + .toBe(false, 'Expected tooltip hidden when scrolled out of view, after throttle limit'); + })); + + it('should execute the `hide` call, after scrolling away, inside the NgZone', fakeAsync(() => { + const inZoneSpy = jasmine.createSpy('in zone spy'); + + tooltipDirective.show(); + fixture.detectChanges(); + tick(0); + + spyOn(tooltipDirective._tooltipInstance!, 'hide').and.callFake(() => { + inZoneSpy(NgZone.isInAngularZone()); + }); + + fixture.componentInstance.scrollDown(); + tick(100); + fixture.detectChanges(); + + expect(inZoneSpy).toHaveBeenCalled(); + expect(inZoneSpy).toHaveBeenCalledWith(true); + })); + + }); + + describe('with OnPush', () => { + let fixture: ComponentFixture; + let buttonDebugElement: DebugElement; + let buttonElement: HTMLButtonElement; + let tooltipDirective: MatTooltip; + + beforeEach(() => { + fixture = TestBed.createComponent(OnPushTooltipDemo); + fixture.detectChanges(); + buttonDebugElement = fixture.debugElement.query(By.css('button'))!; + buttonElement = buttonDebugElement.nativeElement; + tooltipDirective = buttonDebugElement.injector.get(MatTooltip); + }); + + it('should show and hide the tooltip', fakeAsync(() => { + assertTooltipInstance(tooltipDirective, false); + + tooltipDirective.show(); + tick(0); // Tick for the show delay (default is 0) + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + fixture.detectChanges(); + + // wait until animation has finished + tick(500); + + // Make sure tooltip is shown to the user and animation has finished + const tooltipElement = + overlayContainerElement.querySelector('.mat-mdc-tooltip') as HTMLElement; + expect(tooltipElement instanceof HTMLElement).toBe(true); + expect(tooltipElement.style.transform).toBe('scale(1)'); + + // After hide called, a timeout delay is created that will to hide the tooltip. + const tooltipDelay = 1000; + tooltipDirective.hide(tooltipDelay); + expect(tooltipDirective._isTooltipVisible()).toBe(true); + + // After the tooltip delay elapses, expect that the tooltip is not visible. + tick(tooltipDelay); + fixture.detectChanges(); + expect(tooltipDirective._isTooltipVisible()).toBe(false); + + // On animation complete, should expect that the tooltip has been detached. + flushMicrotasks(); + assertTooltipInstance(tooltipDirective, false); + })); + + it('should have rendered the tooltip text on init', fakeAsync(() => { + // We don't bind mouse events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + dispatchFakeEvent(buttonElement, 'mouseenter'); + fixture.detectChanges(); + tick(0); + + const tooltipElement = + overlayContainerElement.querySelector('.mat-mdc-tooltip') as HTMLElement; + expect(tooltipElement.textContent).toContain('initial tooltip message'); + })); + }); + + describe('touch gestures', () => { + beforeEach(() => { + platform.ANDROID = true; + }); + + it('should have a delay when showing on touchstart', fakeAsync(() => { + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + + dispatchFakeEvent(button, 'touchstart'); + fixture.detectChanges(); + tick(250); // Halfway through the delay. + + assertTooltipInstance(fixture.componentInstance.tooltip, false); + + tick(250); // Finish the delay. + fixture.detectChanges(); + tick(500); // Finish the animation. + + assertTooltipInstance(fixture.componentInstance.tooltip, true); + })); + + it('should be able to disable opening on touch', fakeAsync(() => { + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.componentInstance.touchGestures = 'off'; + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + + dispatchFakeEvent(button, 'touchstart'); + fixture.detectChanges(); + tick(500); // Finish the delay. + fixture.detectChanges(); + tick(500); // Finish the animation. + + assertTooltipInstance(fixture.componentInstance.tooltip, false); + })); + + it('should not prevent the default action on touchstart', () => { + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + const event = dispatchFakeEvent(button, 'touchstart'); + fixture.detectChanges(); + + expect(event.defaultPrevented).toBe(false); + }); + + it('should close on touchend with a delay', fakeAsync(() => { + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + + dispatchFakeEvent(button, 'touchstart'); + fixture.detectChanges(); + tick(500); // Finish the open delay. + fixture.detectChanges(); + tick(500); // Finish the animation. + assertTooltipInstance(fixture.componentInstance.tooltip, true); + + dispatchFakeEvent(button, 'touchend'); + fixture.detectChanges(); + tick(1000); // 2/3 through the delay + assertTooltipInstance(fixture.componentInstance.tooltip, true); + + tick(500); // Finish the delay. + fixture.detectChanges(); + tick(500); // Finish the exit animation. + + assertTooltipInstance(fixture.componentInstance.tooltip, false); + })); + + it('should close on touchcancel with a delay', fakeAsync(() => { + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + + dispatchFakeEvent(button, 'touchstart'); + fixture.detectChanges(); + tick(500); // Finish the open delay. + fixture.detectChanges(); + tick(500); // Finish the animation. + assertTooltipInstance(fixture.componentInstance.tooltip, true); + + dispatchFakeEvent(button, 'touchcancel'); + fixture.detectChanges(); + tick(1000); // 2/3 through the delay + assertTooltipInstance(fixture.componentInstance.tooltip, true); + + tick(500); // Finish the delay. + fixture.detectChanges(); + tick(500); // Finish the exit animation. + + assertTooltipInstance(fixture.componentInstance.tooltip, false); + })); + + it('should disable native touch interactions', () => { + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + + const styles = fixture.nativeElement.querySelector('button').style; + expect(styles.touchAction || (styles as any).webkitUserDrag).toBe('none'); + }); + + it('should allow native touch interactions if touch gestures are turned off', () => { + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.componentInstance.touchGestures = 'off'; + fixture.detectChanges(); + + const styles = fixture.nativeElement.querySelector('button').style; + expect(styles.touchAction || (styles as any).webkitUserDrag).toBeFalsy(); + }); + + it('should allow text selection on inputs when gestures are set to auto', () => { + const fixture = TestBed.createComponent(TooltipOnTextFields); + fixture.detectChanges(); + + const inputStyle = fixture.componentInstance.input.nativeElement.style; + const textareaStyle = fixture.componentInstance.textarea.nativeElement.style; + + expect(inputStyle.userSelect).toBeFalsy(); + expect(inputStyle.webkitUserSelect).toBeFalsy(); + expect((inputStyle as any).msUserSelect).toBeFalsy(); + expect((inputStyle as any).MozUserSelect).toBeFalsy(); + + expect(textareaStyle.userSelect).toBeFalsy(); + expect(textareaStyle.webkitUserSelect).toBeFalsy(); + expect((textareaStyle as any).msUserSelect).toBeFalsy(); + expect((textareaStyle as any).MozUserSelect).toBeFalsy(); + }); + + it('should disable text selection on inputs when gestures are set to on', () => { + const fixture = TestBed.createComponent(TooltipOnTextFields); + fixture.componentInstance.touchGestures = 'on'; + fixture.detectChanges(); + + const inputStyle = fixture.componentInstance.input.nativeElement.style; + const inputUserSelect = inputStyle.userSelect || inputStyle.webkitUserSelect || + (inputStyle as any).msUserSelect || (inputStyle as any).MozUserSelect; + const textareaStyle = fixture.componentInstance.textarea.nativeElement.style; + const textareaUserSelect = textareaStyle.userSelect || textareaStyle.webkitUserSelect || + (textareaStyle as any).msUserSelect || + (textareaStyle as any).MozUserSelect; + + expect(inputUserSelect).toBe('none'); + expect(textareaUserSelect).toBe('none'); + }); + + it('should allow native dragging on draggable elements when gestures are set to auto', () => { + const fixture = TestBed.createComponent(TooltipOnDraggableElement); + fixture.detectChanges(); + + expect(fixture.componentInstance.button.nativeElement.style.webkitUserDrag).toBeFalsy(); + }); + + it('should disable native dragging on draggable elements when gestures are set to on', () => { + const fixture = TestBed.createComponent(TooltipOnDraggableElement); + fixture.componentInstance.touchGestures = 'on'; + fixture.detectChanges(); + + const styles = fixture.componentInstance.button.nativeElement.style; + + if ('webkitUserDrag' in styles) { + expect(styles.webkitUserDrag).toBe('none'); + } + }); + + it('should not open on `mouseenter` on iOS', () => { + platform.IOS = true; + platform.ANDROID = false; + + const fixture = TestBed.createComponent(BasicTooltipDemo); + + fixture.detectChanges(); + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + + assertTooltipInstance(fixture.componentInstance.tooltip, false); + }); + + it('should not open on `mouseenter` on Android', () => { + platform.ANDROID = true; + platform.IOS = false; + + const fixture = TestBed.createComponent(BasicTooltipDemo); + + fixture.detectChanges(); + dispatchMouseEvent(fixture.componentInstance.button.nativeElement, 'mouseenter'); + fixture.detectChanges(); + + assertTooltipInstance(fixture.componentInstance.tooltip, false); + }); + }); + + describe('mouse wheel handling', () => { + it('should close when a wheel event causes the cursor to leave the trigger', fakeAsync(() => { + // We don't bind wheel events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + + dispatchFakeEvent(button, 'mouseenter'); + fixture.detectChanges(); + tick(500); // Finish the open delay. + fixture.detectChanges(); + tick(500); // Finish the animation. + assertTooltipInstance(fixture.componentInstance.tooltip, true); + + // Simulate the pointer at the bottom/right of the page. + const wheelEvent = createFakeEvent('wheel'); + Object.defineProperties(wheelEvent, { + clientX: {get: () => window.innerWidth}, + clientY: {get: () => window.innerHeight} + }); + + dispatchEvent(button, wheelEvent); + fixture.detectChanges(); + tick(1500); // Finish the delay. + fixture.detectChanges(); + tick(500); // Finish the exit animation. + + assertTooltipInstance(fixture.componentInstance.tooltip, false); + })); + + it('should not close if the cursor is over the trigger after a wheel event', fakeAsync(() => { + // We don't bind wheel events on mobile devices. + if (platform.IOS || platform.ANDROID) { + return; + } + + const fixture = TestBed.createComponent(BasicTooltipDemo); + fixture.detectChanges(); + const button: HTMLButtonElement = fixture.nativeElement.querySelector('button'); + + dispatchFakeEvent(button, 'mouseenter'); + fixture.detectChanges(); + tick(500); // Finish the open delay. + fixture.detectChanges(); + tick(500); // Finish the animation. + assertTooltipInstance(fixture.componentInstance.tooltip, true); + + // Simulate the pointer over the trigger. + const triggerRect = button.getBoundingClientRect(); + const wheelEvent = createFakeEvent('wheel'); + Object.defineProperties(wheelEvent, { + clientX: {get: () => triggerRect.left + 1}, + clientY: {get: () => triggerRect.top + 1} + }); + + dispatchEvent(button, wheelEvent); + fixture.detectChanges(); + tick(1500); // Finish the delay. + fixture.detectChanges(); + tick(500); // Finish the exit animation. + + assertTooltipInstance(fixture.componentInstance.tooltip, true); + })); + }); + +}); + +@Component({ + selector: 'app', + template: ` + ` +}) +class BasicTooltipDemo { + position: string = 'below'; + message: any = initialTooltipMessage; + showButton: boolean = true; + showTooltipClass = false; + touchGestures: TooltipTouchGestures = 'auto'; + @ViewChild(MatTooltip) tooltip: MatTooltip; + @ViewChild('button') button: ElementRef; +} + +@Component({ + selector: 'app', + template: ` +
+ +
` +}) +class ScrollableTooltipDemo { + position: string = 'below'; + message: string = initialTooltipMessage; + showButton: boolean = true; + + @ViewChild(CdkScrollable) scrollingContainer: CdkScrollable; + + scrollDown() { + const scrollingContainerEl = this.scrollingContainer.getElementRef().nativeElement; + scrollingContainerEl.scrollTop = 250; + + // Emit a scroll event from the scrolling element in our component. + // This event should be picked up by the scrollable directive and notify. + // The notification should be picked up by the service. + dispatchFakeEvent(scrollingContainerEl, 'scroll'); + } +} + +@Component({ + selector: 'app', + template: ` + `, + changeDetection: ChangeDetectionStrategy.OnPush +}) +class OnPushTooltipDemo { + position: string = 'below'; + message: string = initialTooltipMessage; +} + + +@Component({ + selector: 'app', + template: ` + `, +}) +class DynamicTooltipsDemo { + tooltips: string[] = []; +} + +@Component({ + template: ``, +}) +class DataBoundAriaLabelTooltip { + message = 'Hello there'; +} + + +@Component({ + template: ` + + + + `, +}) +class TooltipOnTextFields { + @ViewChild('input') input: ElementRef; + @ViewChild('textarea') textarea: ElementRef; + touchGestures: TooltipTouchGestures = 'auto'; +} + +@Component({ + template: ` + + `, +}) +class TooltipOnDraggableElement { + @ViewChild('button') button: ElementRef; + touchGestures: TooltipTouchGestures = 'auto'; +} + +@Component({ + selector: 'app', + template: `` +}) +class TooltipDemoWithoutPositionBinding { + message: any = initialTooltipMessage; + @ViewChild(MatTooltip) tooltip: MatTooltip; + @ViewChild('button') button: ElementRef; +} + +/** Asserts whether a tooltip directive has a tooltip instance. */ +function assertTooltipInstance(tooltip: MatTooltip, shouldExist: boolean): void { + // Note that we have to cast this to a boolean, because Jasmine will go into an infinite loop + // if it tries to stringify the `_tooltipInstance` when an assertion fails. The infinite loop + // happens due to the `_tooltipInstance` having a circular structure. + expect(!!tooltip._tooltipInstance).toBe(shouldExist); +} diff --git a/src/material-experimental/mdc-tooltip/tooltip.ts b/src/material-experimental/mdc-tooltip/tooltip.ts new file mode 100644 index 000000000000..10d9c145e05b --- /dev/null +++ b/src/material-experimental/mdc-tooltip/tooltip.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Directive, + ElementRef, + Inject, + NgZone, + Optional, + ViewContainerRef, + ViewEncapsulation, +} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {Platform} from '@angular/cdk/platform'; +import {AriaDescriber, FocusMonitor} from '@angular/cdk/a11y'; +import {Directionality} from '@angular/cdk/bidi'; +import {ConnectedPosition, Overlay, ScrollDispatcher} from '@angular/cdk/overlay'; +import { + MatTooltipDefaultOptions, + MAT_TOOLTIP_DEFAULT_OPTIONS, + MAT_TOOLTIP_SCROLL_STRATEGY, + _MatTooltipBase, + _TooltipComponentBase, +} from '@angular/material/tooltip'; +import {numbers} from '@material/tooltip'; +import {matTooltipAnimations} from './tooltip-animations'; + +/** + * Directive that attaches a material design tooltip to the host element. Animates the showing and + * hiding of a tooltip provided position (defaults to below the element). + * + * https://material.io/design/components/tooltips.html + */ +@Directive({ + selector: '[matTooltip]', + exportAs: 'matTooltip', + host: { + 'class': 'mat-mdc-tooltip-trigger' + } +}) +export class MatTooltip extends _MatTooltipBase { + protected readonly _tooltipComponent = TooltipComponent; + protected readonly _transformOriginSelector = '.mat-mdc-tooltip'; + + constructor( + overlay: Overlay, + elementRef: ElementRef, + scrollDispatcher: ScrollDispatcher, + viewContainerRef: ViewContainerRef, + ngZone: NgZone, + platform: Platform, + ariaDescriber: AriaDescriber, + focusMonitor: FocusMonitor, + @Inject(MAT_TOOLTIP_SCROLL_STRATEGY) scrollStrategy: any, + @Optional() dir: Directionality, + @Optional() @Inject(MAT_TOOLTIP_DEFAULT_OPTIONS) defaultOptions: MatTooltipDefaultOptions, + + /** @breaking-change 11.0.0 _document argument to become required. */ + @Inject(DOCUMENT) _document: any) { + + super(overlay, elementRef, scrollDispatcher, viewContainerRef, ngZone, platform, ariaDescriber, + focusMonitor, scrollStrategy, dir, defaultOptions, _document); + this._viewportMargin = numbers.MIN_VIEWPORT_TOOLTIP_THRESHOLD; + } + + protected _addOffset(position: ConnectedPosition): ConnectedPosition { + const offset = numbers.UNBOUNDED_ANCHOR_GAP; + const isLtr = !this._dir || this._dir.value == 'ltr'; + + if (position.originY === 'top') { + position.offsetY = -offset; + } else if (position.originY === 'bottom') { + position.offsetY = offset; + } else if (position.originX === 'start') { + position.offsetX = isLtr ? -offset : offset; + } else if (position.originX === 'end') { + position.offsetX = isLtr ? offset : -offset; + } + + return position; + } +} + +/** + * Internal component that wraps the tooltip's content. + * @docs-private + */ +@Component({ + selector: 'mat-tooltip-component', + templateUrl: 'tooltip.html', + styleUrls: ['tooltip.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [matTooltipAnimations.tooltipState], + host: { + // Forces the element to have a layout in IE and Edge. This fixes issues where the element + // won't be rendered if the animations are disabled or there is no web animations polyfill. + '[style.zoom]': '_visibility === "visible" ? 1 : null', + '(body:click)': 'this._handleBodyInteraction()', + '(body:auxclick)': 'this._handleBodyInteraction()', + 'aria-hidden': 'true', + } +}) +export class TooltipComponent extends _TooltipComponentBase { + constructor(changeDetectorRef: ChangeDetectorRef) { + super(changeDetectorRef); + } +} diff --git a/src/material-experimental/mdc_require_config.js b/src/material-experimental/mdc_require_config.js index f86b99b8cef0..ab6d9e12b0bb 100644 --- a/src/material-experimental/mdc_require_config.js +++ b/src/material-experimental/mdc_require_config.js @@ -33,6 +33,7 @@ require.config({ '@material/tab-scroller': '/base/npm/node_modules/@material/tab-scroller/dist/mdc.tabScroller', '@material/data-table': '/base/npm/node_modules/@material/data-table/dist/mdc.dataTable', '@material/textfield': '/base/npm/node_modules/@material/textfield/dist/mdc.textfield', + '@material/tooltip': '/base/npm/node_modules/@material/tooltip/dist/mdc.tooltip', '@material/top-app-bar': '/base/npm/node_modules/@material/top-app-bar/dist/mdc.topAppBar', } }); diff --git a/src/material/tooltip/tooltip.ts b/src/material/tooltip/tooltip.ts index 54c26086bbe3..64c1ac6dbe2c 100644 --- a/src/material/tooltip/tooltip.ts +++ b/src/material/tooltip/tooltip.ts @@ -12,6 +12,7 @@ import {BooleanInput, coerceBooleanProperty, NumberInput} from '@angular/cdk/coe import {ESCAPE, hasModifierKey} from '@angular/cdk/keycodes'; import {BreakpointObserver, Breakpoints, BreakpointState} from '@angular/cdk/layout'; import { + ConnectedPosition, FlexibleConnectedPositionStrategy, HorizontalConnectionPos, OriginConnectionPosition, @@ -22,7 +23,7 @@ import { VerticalConnectionPos, } from '@angular/cdk/overlay'; import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; -import {ComponentPortal} from '@angular/cdk/portal'; +import {ComponentPortal, ComponentType} from '@angular/cdk/portal'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { ChangeDetectionStrategy, @@ -123,30 +124,23 @@ export function MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY(): MatTooltipDefaultOptions }; } -/** - * Directive that attaches a material design tooltip to the host element. Animates the showing and - * hiding of a tooltip provided position (defaults to below the element). - * - * https://material.io/design/components/tooltips.html - */ -@Directive({ - selector: '[matTooltip]', - exportAs: 'matTooltip', - host: { - 'class': 'mat-tooltip-trigger' - } -}) -export class MatTooltip implements OnDestroy, AfterViewInit { + +@Directive() +export abstract class _MatTooltipBase implements OnDestroy, + AfterViewInit { _overlayRef: OverlayRef | null; - _tooltipInstance: TooltipComponent | null; + _tooltipInstance: T | null; - private _portal: ComponentPortal; + private _portal: ComponentPortal; private _position: TooltipPosition = 'below'; private _disabled: boolean = false; private _tooltipClass: string|string[]|Set|{[key: string]: any}; private _scrollStrategy: () => ScrollStrategy; private _viewInitialized = false; private _pointerExitEventsInitialized = false; + protected abstract readonly _tooltipComponent: ComponentType; + protected abstract readonly _transformOriginSelector: string; + protected _viewportMargin = 8; /** Allows the user to define the position of the tooltip relative to the parent element */ @Input('matTooltipPosition') @@ -267,10 +261,9 @@ export class MatTooltip implements OnDestroy, AfterViewInit { private _platform: Platform, private _ariaDescriber: AriaDescriber, private _focusMonitor: FocusMonitor, - @Inject(MAT_TOOLTIP_SCROLL_STRATEGY) scrollStrategy: any, - @Optional() private _dir: Directionality, - @Optional() @Inject(MAT_TOOLTIP_DEFAULT_OPTIONS) - private _defaultOptions: MatTooltipDefaultOptions, + scrollStrategy: any, + protected _dir: Directionality, + private _defaultOptions: MatTooltipDefaultOptions, /** @breaking-change 11.0.0 _document argument to become required. */ @Inject(DOCUMENT) _document: any) { @@ -345,7 +338,8 @@ export class MatTooltip implements OnDestroy, AfterViewInit { const overlayRef = this._createOverlay(); this._detach(); - this._portal = this._portal || new ComponentPortal(TooltipComponent, this._viewContainerRef); + this._portal = this._portal || + new ComponentPortal(this._tooltipComponent, this._viewContainerRef); this._tooltipInstance = overlayRef.attach(this._portal).instance; this._tooltipInstance.afterHidden() .pipe(takeUntil(this._destroyed)) @@ -396,9 +390,9 @@ export class MatTooltip implements OnDestroy, AfterViewInit { // Create connected position strategy that listens for scroll events to reposition. const strategy = this._overlay.position() .flexibleConnectedTo(this._elementRef) - .withTransformOriginOn('.mat-tooltip') + .withTransformOriginOn(this._transformOriginSelector) .withFlexibleDimensions(false) - .withViewportMargin(8) + .withViewportMargin(this._viewportMargin) .withScrollableContainers(scrollableAncestors); strategy.positionChanges.pipe(takeUntil(this._destroyed)).subscribe(change => { @@ -444,11 +438,16 @@ export class MatTooltip implements OnDestroy, AfterViewInit { const overlay = this._getOverlayPosition(); position.withPositions([ - {...origin.main, ...overlay.main}, - {...origin.fallback, ...overlay.fallback} + this._addOffset({...origin.main, ...overlay.main}), + this._addOffset({...origin.fallback, ...overlay.fallback}) ]); } + /** Adds the configured offset to a position. Used as a hook for child classes. */ + protected _addOffset(position: ConnectedPosition): ConnectedPosition { + return position; + } + /** * Returns the origin position and a fallback position based on the user's position preference. * The fallback position is the inverse of the origin (e.g. `'below' -> 'above'`). @@ -682,26 +681,45 @@ export class MatTooltip implements OnDestroy, AfterViewInit { } /** - * Internal component that wraps the tooltip's content. - * @docs-private + * Directive that attaches a material design tooltip to the host element. Animates the showing and + * hiding of a tooltip provided position (defaults to below the element). + * + * https://material.io/design/components/tooltips.html */ -@Component({ - selector: 'mat-tooltip-component', - templateUrl: 'tooltip.html', - styleUrls: ['tooltip.css'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [matTooltipAnimations.tooltipState], +@Directive({ + selector: '[matTooltip]', + exportAs: 'matTooltip', host: { - // Forces the element to have a layout in IE and Edge. This fixes issues where the element - // won't be rendered if the animations are disabled or there is no web animations polyfill. - '[style.zoom]': '_visibility === "visible" ? 1 : null', - '(body:click)': 'this._handleBodyInteraction()', - '(body:auxclick)': 'this._handleBodyInteraction()', - 'aria-hidden': 'true', + 'class': 'mat-tooltip-trigger' } }) -export class TooltipComponent implements OnDestroy { +export class MatTooltip extends _MatTooltipBase { + protected readonly _tooltipComponent = TooltipComponent; + protected readonly _transformOriginSelector = '.mat-tooltip'; + + constructor( + overlay: Overlay, + elementRef: ElementRef, + scrollDispatcher: ScrollDispatcher, + viewContainerRef: ViewContainerRef, + ngZone: NgZone, + platform: Platform, + ariaDescriber: AriaDescriber, + focusMonitor: FocusMonitor, + @Inject(MAT_TOOLTIP_SCROLL_STRATEGY) scrollStrategy: any, + @Optional() dir: Directionality, + @Optional() @Inject(MAT_TOOLTIP_DEFAULT_OPTIONS) defaultOptions: MatTooltipDefaultOptions, + + /** @breaking-change 11.0.0 _document argument to become required. */ + @Inject(DOCUMENT) _document: any) { + + super(overlay, elementRef, scrollDispatcher, viewContainerRef, ngZone, platform, ariaDescriber, + focusMonitor, scrollStrategy, dir, defaultOptions, _document); + } +} + +@Directive() +export abstract class _TooltipComponentBase implements OnDestroy { /** Message to display in the tooltip */ message: string; @@ -723,12 +741,7 @@ export class TooltipComponent implements OnDestroy { /** Subject for notifying that the tooltip has been hidden from the view */ private readonly _onHide: Subject = new Subject(); - /** Stream that emits whether the user has a handset-sized display. */ - _isHandset: Observable = this._breakpointObserver.observe(Breakpoints.Handset); - - constructor( - private _changeDetectorRef: ChangeDetectorRef, - private _breakpointObserver: BreakpointObserver) {} + constructor(private _changeDetectorRef: ChangeDetectorRef) {} /** * Shows the tooltip with an animation originating from the provided origin @@ -824,3 +837,34 @@ export class TooltipComponent implements OnDestroy { this._changeDetectorRef.markForCheck(); } } + +/** + * Internal component that wraps the tooltip's content. + * @docs-private + */ +@Component({ + selector: 'mat-tooltip-component', + templateUrl: 'tooltip.html', + styleUrls: ['tooltip.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [matTooltipAnimations.tooltipState], + host: { + // Forces the element to have a layout in IE and Edge. This fixes issues where the element + // won't be rendered if the animations are disabled or there is no web animations polyfill. + '[style.zoom]': '_visibility === "visible" ? 1 : null', + '(body:click)': 'this._handleBodyInteraction()', + '(body:auxclick)': 'this._handleBodyInteraction()', + 'aria-hidden': 'true', + } +}) +export class TooltipComponent extends _TooltipComponentBase { + /** Stream that emits whether the user has a handset-sized display. */ + _isHandset: Observable = this._breakpointObserver.observe(Breakpoints.Handset); + + constructor( + changeDetectorRef: ChangeDetectorRef, + private _breakpointObserver: BreakpointObserver) { + super(changeDetectorRef); + } +} diff --git a/tools/public_api_guard/material/tooltip.d.ts b/tools/public_api_guard/material/tooltip.d.ts index b1750c34a8a2..89ef9d0e7784 100644 --- a/tools/public_api_guard/material/tooltip.d.ts +++ b/tools/public_api_guard/material/tooltip.d.ts @@ -1,22 +1,10 @@ -export declare function getMatTooltipInvalidPositionError(position: string): Error; - -export declare const MAT_TOOLTIP_DEFAULT_OPTIONS: InjectionToken; - -export declare function MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY(): MatTooltipDefaultOptions; - -export declare const MAT_TOOLTIP_SCROLL_STRATEGY: InjectionToken<() => ScrollStrategy>; - -export declare function MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY(overlay: Overlay): () => ScrollStrategy; - -export declare const MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER: { - provide: InjectionToken<() => ScrollStrategy>; - deps: (typeof Overlay)[]; - useFactory: typeof MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY; -}; - -export declare class MatTooltip implements OnDestroy, AfterViewInit { +export declare abstract class _MatTooltipBase implements OnDestroy, AfterViewInit { + protected _dir: Directionality; _overlayRef: OverlayRef | null; - _tooltipInstance: TooltipComponent | null; + protected abstract readonly _tooltipComponent: ComponentType; + _tooltipInstance: T | null; + protected abstract readonly _transformOriginSelector: string; + protected _viewportMargin: number; get disabled(): boolean; set disabled(value: boolean); hideDelay: number; @@ -34,6 +22,7 @@ export declare class MatTooltip implements OnDestroy, AfterViewInit { touchGestures: TooltipTouchGestures; constructor(_overlay: Overlay, _elementRef: ElementRef, _scrollDispatcher: ScrollDispatcher, _viewContainerRef: ViewContainerRef, _ngZone: NgZone, _platform: Platform, _ariaDescriber: AriaDescriber, _focusMonitor: FocusMonitor, scrollStrategy: any, _dir: Directionality, _defaultOptions: MatTooltipDefaultOptions, _document: any); + protected _addOffset(position: ConnectedPosition): ConnectedPosition; _getOrigin(): { main: OriginConnectionPosition; fallback: OriginConnectionPosition; @@ -51,7 +40,54 @@ export declare class MatTooltip implements OnDestroy, AfterViewInit { static ngAcceptInputType_disabled: BooleanInput; static ngAcceptInputType_hideDelay: NumberInput; static ngAcceptInputType_showDelay: NumberInput; - static ɵdir: i0.ɵɵDirectiveDefWithMeta; + static ɵdir: i0.ɵɵDirectiveDefWithMeta<_MatTooltipBase, never, never, { "position": "matTooltipPosition"; "disabled": "matTooltipDisabled"; "showDelay": "matTooltipShowDelay"; "hideDelay": "matTooltipHideDelay"; "touchGestures": "matTooltipTouchGestures"; "message": "matTooltip"; "tooltipClass": "matTooltipClass"; }, {}, never>; + static ɵfac: i0.ɵɵFactoryDef<_MatTooltipBase, never>; +} + +export declare abstract class _TooltipComponentBase implements OnDestroy { + _hideTimeoutId: number | null; + _showTimeoutId: number | null; + _visibility: TooltipVisibility; + message: string; + tooltipClass: string | string[] | Set | { + [key: string]: any; + }; + constructor(_changeDetectorRef: ChangeDetectorRef); + _animationDone(event: AnimationEvent): void; + _animationStart(): void; + _handleBodyInteraction(): void; + _markForCheck(): void; + afterHidden(): Observable; + hide(delay: number): void; + isVisible(): boolean; + ngOnDestroy(): void; + show(delay: number): void; + static ɵdir: i0.ɵɵDirectiveDefWithMeta<_TooltipComponentBase, never, never, {}, {}, never>; + static ɵfac: i0.ɵɵFactoryDef<_TooltipComponentBase, never>; +} + +export declare function getMatTooltipInvalidPositionError(position: string): Error; + +export declare const MAT_TOOLTIP_DEFAULT_OPTIONS: InjectionToken; + +export declare function MAT_TOOLTIP_DEFAULT_OPTIONS_FACTORY(): MatTooltipDefaultOptions; + +export declare const MAT_TOOLTIP_SCROLL_STRATEGY: InjectionToken<() => ScrollStrategy>; + +export declare function MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY(overlay: Overlay): () => ScrollStrategy; + +export declare const MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY_PROVIDER: { + provide: InjectionToken<() => ScrollStrategy>; + deps: (typeof Overlay)[]; + useFactory: typeof MAT_TOOLTIP_SCROLL_STRATEGY_FACTORY; +}; + +export declare class MatTooltip extends _MatTooltipBase { + protected readonly _tooltipComponent: typeof TooltipComponent; + protected readonly _transformOriginSelector = ".mat-tooltip"; + constructor(overlay: Overlay, elementRef: ElementRef, scrollDispatcher: ScrollDispatcher, viewContainerRef: ViewContainerRef, ngZone: NgZone, platform: Platform, ariaDescriber: AriaDescriber, focusMonitor: FocusMonitor, scrollStrategy: any, dir: Directionality, defaultOptions: MatTooltipDefaultOptions, + _document: any); + static ɵdir: i0.ɵɵDirectiveDefWithMeta; static ɵfac: i0.ɵɵFactoryDef; } @@ -76,25 +112,9 @@ export declare const SCROLL_THROTTLE_MS = 20; export declare const TOOLTIP_PANEL_CLASS = "mat-tooltip-panel"; -export declare class TooltipComponent implements OnDestroy { - _hideTimeoutId: number | null; +export declare class TooltipComponent extends _TooltipComponentBase { _isHandset: Observable; - _showTimeoutId: number | null; - _visibility: TooltipVisibility; - message: string; - tooltipClass: string | string[] | Set | { - [key: string]: any; - }; - constructor(_changeDetectorRef: ChangeDetectorRef, _breakpointObserver: BreakpointObserver); - _animationDone(event: AnimationEvent): void; - _animationStart(): void; - _handleBodyInteraction(): void; - _markForCheck(): void; - afterHidden(): Observable; - hide(delay: number): void; - isVisible(): boolean; - ngOnDestroy(): void; - show(delay: number): void; + constructor(changeDetectorRef: ChangeDetectorRef, _breakpointObserver: BreakpointObserver); static ɵcmp: i0.ɵɵComponentDefWithMeta; static ɵfac: i0.ɵɵFactoryDef; } diff --git a/tools/system-config-tmpl.js b/tools/system-config-tmpl.js index ca531ffe7962..920745ced6e0 100644 --- a/tools/system-config-tmpl.js +++ b/tools/system-config-tmpl.js @@ -66,6 +66,7 @@ var pathMapping = { '@material/tab-indicator': 'node:@material/tab-indicator/dist/mdc.tabIndicator.js', '@material/tab-scroller': 'node:@material/tab-scroller/dist/mdc.tabScroller.js', '@material/textfield': 'node:@material/textfield/dist/mdc.textfield.js', + '@material/tooltip': 'node:@material/tooltip/dist/mdc.tooltip.js', '@material/top-app-bar': 'node:@material/top-app-bar/dist/mdc.topAppBar.js' };