diff --git a/src/dev-app/mdc-tabs/BUILD.bazel b/src/dev-app/mdc-tabs/BUILD.bazel index 506cdec843a3..e5da8e60e11c 100644 --- a/src/dev-app/mdc-tabs/BUILD.bazel +++ b/src/dev-app/mdc-tabs/BUILD.bazel @@ -12,6 +12,7 @@ ng_module( ], deps = [ "//src/material-experimental/mdc-tabs", + "//src/material/button-toggle", "@npm//@angular/router", ], ) diff --git a/src/dev-app/mdc-tabs/mdc-tabs-demo-module.ts b/src/dev-app/mdc-tabs/mdc-tabs-demo-module.ts index 1924e402fefa..0ac7f5713e92 100644 --- a/src/dev-app/mdc-tabs/mdc-tabs-demo-module.ts +++ b/src/dev-app/mdc-tabs/mdc-tabs-demo-module.ts @@ -9,12 +9,16 @@ import {NgModule} from '@angular/core'; import {MatTabsModule} from '@angular/material-experimental/mdc-tabs'; import {RouterModule} from '@angular/router'; +import {CommonModule} from '@angular/common'; import {MdcTabsDemo} from './mdc-tabs-demo'; +import {MatButtonToggleModule} from '@angular/material/button-toggle'; @NgModule({ imports: [ MatTabsModule, RouterModule.forChild([{path: '', component: MdcTabsDemo}]), + CommonModule, + MatButtonToggleModule, ], declarations: [MdcTabsDemo], }) diff --git a/src/dev-app/mdc-tabs/mdc-tabs-demo.html b/src/dev-app/mdc-tabs/mdc-tabs-demo.html index b390f3f2299a..c20212d256e7 100644 --- a/src/dev-app/mdc-tabs/mdc-tabs-demo.html +++ b/src/dev-app/mdc-tabs/mdc-tabs-demo.html @@ -1,2 +1,113 @@ - -Not yet implemented. +
+

Paginated tabs

+ + Content + + +

Themed tabs

+ + Content 1 + Content 2 + Content 3 + Content 4 + + + + Content 1 + Content 2 + Content 3 + Content 4 + + + + Content 1 + Content 2 + Content 3 + Content 4 + + +

Stretched tabs

+ + Content 1 + Content 2 + Content 3 + + +

Aligned tabs

+ + Content 1 + Content 2 + Content 3 + + + + Content 1 + Content 2 + Content 3 + + + + Content 1 + Content 2 + Content 3 + + +

Inverted tabs

+ + Content 1 + Content 2 + Content 3 + + +

Tabs with background color

+
+ + Primary + Accent + Warn + +
+ + + Content + + +

Template labels

+ + + + One + First tab's content + + + Two + Second tab's content + + + Three + Third tab's content + + + +

Lazy tabs

+ + + Eager + + + +
Lazy
+
+
+
+ +

Tab nav bar

+ +
diff --git a/src/dev-app/mdc-tabs/mdc-tabs-demo.scss b/src/dev-app/mdc-tabs/mdc-tabs-demo.scss index 2ac26e2a6f10..a392063ce466 100644 --- a/src/dev-app/mdc-tabs/mdc-tabs-demo.scss +++ b/src/dev-app/mdc-tabs/mdc-tabs-demo.scss @@ -1 +1,3 @@ -// TODO: copy in demo styles from existing tabs demo. +mat-tab-group { + margin-bottom: 32px; +} diff --git a/src/dev-app/mdc-tabs/mdc-tabs-demo.ts b/src/dev-app/mdc-tabs/mdc-tabs-demo.ts index 9c5ea229aa62..3f97a4cf06b7 100644 --- a/src/dev-app/mdc-tabs/mdc-tabs-demo.ts +++ b/src/dev-app/mdc-tabs/mdc-tabs-demo.ts @@ -15,4 +15,7 @@ import {Component} from '@angular/core'; styleUrls: ['mdc-tabs-demo.css'], }) export class MdcTabsDemo { + links = ['First', 'Second', 'Third']; + lotsOfTabs = new Array(30).fill(0).map((_, index) => `Tab ${index}`); + activeLink = this.links[0]; } diff --git a/src/e2e-app/mdc-tabs/mdc-tabs-e2e-module.ts b/src/e2e-app/mdc-tabs/mdc-tabs-e2e-module.ts index b57ea1cbd09d..23006d7a75b6 100644 --- a/src/e2e-app/mdc-tabs/mdc-tabs-e2e-module.ts +++ b/src/e2e-app/mdc-tabs/mdc-tabs-e2e-module.ts @@ -7,11 +7,13 @@ */ import {NgModule} from '@angular/core'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; import {MatTabsModule} from '@angular/material-experimental/mdc-tabs'; import {MdcTabsE2e} from './mdc-tabs-e2e'; @NgModule({ - imports: [MatTabsModule], + imports: [MatTabsModule, MatFormFieldModule, MatInputModule], declarations: [MdcTabsE2e], }) export class MdcTabsE2eModule { diff --git a/src/e2e-app/mdc-tabs/mdc-tabs-e2e.html b/src/e2e-app/mdc-tabs/mdc-tabs-e2e.html index 6970fa69413d..c0262f133d38 100644 --- a/src/e2e-app/mdc-tabs/mdc-tabs-e2e.html +++ b/src/e2e-app/mdc-tabs/mdc-tabs-e2e.html @@ -1 +1,18 @@ - +
+ + + One + + + + + + Two + Second tab's content + + + Three + Third tab's content + + +
diff --git a/src/e2e-app/mdc-tabs/mdc-tabs-e2e.ts b/src/e2e-app/mdc-tabs/mdc-tabs-e2e.ts index 30123d2c0c4c..d744ee6caa6f 100644 --- a/src/e2e-app/mdc-tabs/mdc-tabs-e2e.ts +++ b/src/e2e-app/mdc-tabs/mdc-tabs-e2e.ts @@ -14,5 +14,4 @@ import {Component} from '@angular/core'; templateUrl: 'mdc-tabs-e2e.html', }) export class MdcTabsE2e { - // TODO: copy implementation from existing tabs e2e page. } diff --git a/src/material-experimental/mdc-tabs/BUILD.bazel b/src/material-experimental/mdc-tabs/BUILD.bazel index f811080b81dc..d5671021f9a7 100644 --- a/src/material-experimental/mdc-tabs/BUILD.bazel +++ b/src/material-experimental/mdc-tabs/BUILD.bazel @@ -2,7 +2,7 @@ package(default_visibility = ["//visibility:public"]) load("@io_bazel_rules_sass//:defs.bzl", "sass_binary", "sass_library") load("//src/e2e-app:test_suite.bzl", "e2e_test_suite") -load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module") +load("//tools:defaults.bzl", "ng_e2e_test_library", "ng_module", "ng_test_library", "ng_web_test_suite") ng_module( name = "mdc-tabs", @@ -10,12 +10,30 @@ ng_module( ["**/*.ts"], exclude = ["**/*.spec.ts"], ), - assets = [":tabs_scss"] + glob(["**/*.html"]), + assets = [ + ":tabs_scss", + ":tab-body.css", + ":tab-header.css", + ":tab-group.css", + ":tab-nav-bar/tab-nav-bar.css", + ":tab-nav-bar/tab-link.css", + ] + glob(["**/*.html"]), module_name = "@angular/material-experimental/mdc-tabs", deps = [ + "//src/cdk/a11y", + "//src/cdk/bidi", + "//src/cdk/coercion", + "//src/cdk/keycodes", + "//src/cdk/observers", + "//src/cdk/platform", + "//src/cdk/portal", + "//src/cdk/scrolling", "//src/material/core", + "//src/material/tabs", + "@npm//@angular/animations", "@npm//@angular/common", "@npm//@angular/core", + "@npm//@material/tab-indicator", ], ) @@ -30,21 +48,85 @@ sass_library( sass_binary( name = "tabs_scss", - src = "tabs.scss", - include_paths = [ - "external/npm/node_modules", - ], + src = "_mdc-tabs.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", ], ) +sass_binary( + name = "mdc_tab_body_scss", + src = "tab-body.scss", + include_paths = ["external/npm/node_modules"], + deps = ["//src/material/core:core_scss_lib"], +) + +sass_binary( + name = "mdc_tab_header_scss", + src = "tab-header.scss", + include_paths = ["external/npm/node_modules"], + deps = [":mdc_tabs_scss_lib"], +) + +sass_binary( + name = "mdc_tab_group_scss", + src = "tab-group.scss", + include_paths = ["external/npm/node_modules"], + deps = [":mdc_tabs_scss_lib"], +) + +sass_binary( + name = "mdc_tab_nav_bar_scss", + src = "tab-nav-bar/tab-nav-bar.scss", + include_paths = ["external/npm/node_modules"], + deps = [":mdc_tabs_scss_lib"], +) + +sass_binary( + name = "mdc_tab_link_scss", + src = "tab-nav-bar/tab-link.scss", + include_paths = ["external/npm/node_modules"], + deps = [":mdc_tabs_scss_lib"], +) + +ng_test_library( + name = "tabs_tests_lib", + srcs = glob( + ["**/*.spec.ts"], + exclude = ["**/*.e2e.spec.ts"], + ), + deps = [ + ":mdc-tabs", + "//src/cdk-experimental/testing", + "//src/cdk-experimental/testing/testbed", + "//src/cdk/bidi", + "//src/cdk/keycodes", + "//src/cdk/observers", + "//src/cdk/portal", + "//src/cdk/scrolling", + "//src/cdk/testing", + "//src/material/core", + "@npm//@angular/common", + "@npm//@angular/platform-browser", + "@npm//rxjs", + ], +) + +ng_web_test_suite( + name = "unit_tests", + static_files = ["@npm//:node_modules/@material/tab-indicator/dist/mdc.tabIndicator.js"], + deps = [ + ":tabs_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", - ], + deps = ["//src/cdk/private/testing/e2e"], ) e2e_test_suite( diff --git a/src/material-experimental/mdc-tabs/README.md b/src/material-experimental/mdc-tabs/README.md index ef59f40277b2..db91ff7c5add 100644 --- a/src/material-experimental/mdc-tabs/README.md +++ b/src/material-experimental/mdc-tabs/README.md @@ -1 +1,94 @@ -This is a placeholder for the MDC-based implementation of tabs. +This is prototype of an alternate version of the Angular Material tabs built on top of +[MDC Web](https://github.com/material-components/material-components-web). It demonstrates how +Angular Material could use MDC Web under the hood while still exposing the same API Angular users as +the existing ``. This component is experimental and should not be used in production. + +## How to use +Assuming your application is already up and running using Angular Material, you can add this +component by following these steps: + +1. Install Angular Material Experimental & MDC WEB: + + ```bash + npm i material-components-web @angular/material-experimental + ``` + +2. In your `angular.json`, make sure `node_modules/` is listed as a Sass include path. This is + needed for the Sass compiler to be able to find the MDC Web Sass files. + + ```json + ... + "styles": [ + "src/styles.scss" + ], + "stylePreprocessorOptions": { + "includePaths": [ + "node_modules/" + ] + }, + ... + ``` + +3. Import the experimental `MatTabsModule` and add it to the module that declares your + component: + + ```ts + import {MatTabsModule} from '@angular/material-experimental/mdc-tabs'; + + @NgModule({ + declarations: [MyComponent], + imports: [MatTabsModule], + }) + export class MyModule {} + ``` + +4. Use `` in your component's template, just like you would the normal + ``: + + ```html + + Content 1 + Content 2 + Content 3 + + ``` + +5. Add the theme and typography mixins to your Sass. (There is currently no pre-built CSS option for + the experimental tabs): + + ```scss + @import '~@angular/material/theming'; + @import '~@angular/material-experimental/mdc-tabs'; + + $my-primary: mat-palette($mat-indigo); + $my-accent: mat-palette($mat-pink, A200, A100, A400); + $my-theme: mat-light-theme($my-primary, $my-accent); + + @include mat-tabs-theme-mdc($my-theme); + @include mat-tabs-typography-mdc(); + ``` + +## API differences +The experimental tabs API closely matches the +[API of the standard tabs](https://material.angular.io/components/tabs/api). +`@angular/material-experimental/mdc-tabs` exports symbols with the same name and public interface +as all of the symbols found under `@angular/material/tabs`, except for the following +differences: + +* `MatTabLink` is defined as a `Component` in the experimental package, + whereas in the current one it's a `Directive`. + +## Replacing the standard tabs in an existing app +Because the experimental API mirrors the API for the standard tabs, 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/tabs['\"]" | xargs sed -i \ + "s/['\"]@angular\/material\/tabs['\"]/'@angular\/material-experimental\/mdc-tabs'/g" +``` + +CSS styles and tests that depend on implementation details of the tabs (such as getting elements +from the template by class name) will need to be manually updated. + diff --git a/src/material-experimental/mdc-tabs/_mdc-tabs.scss b/src/material-experimental/mdc-tabs/_mdc-tabs.scss index 1b909d2609c7..985837e19c7a 100644 --- a/src/material-experimental/mdc-tabs/_mdc-tabs.scss +++ b/src/material-experimental/mdc-tabs/_mdc-tabs.scss @@ -1,13 +1,93 @@ +@import '@material/theme/functions'; +@import '@material/tab-indicator/mixins'; +@import '@material/tab/mixins'; +@import '@material/tab/variables'; @import '../mdc-helpers/mdc-helpers'; @mixin mat-tabs-theme-mdc($theme) { + // Save original values of MDC global variables. We need to save these so we can restore the + // variables to their original values and prevent unintended side effects from using this mixin. + $orig-mdc-tab-text-label-color-active: $mdc-tab-text-label-color-active; + $orig-mdc-tab-icon-color-active: $mdc-tab-icon-color-active; + @include mat-using-mdc-theme($theme) { - // TODO: MDC theme styles here. + @include _mat-mdc-tabs-palette-styles($mdc-tab-text-label-color-active); + + .mat-mdc-tab-group, .mat-mdc-tab-nav-bar { + &.mat-accent { + $mdc-tab-text-label-color-active: secondary !global; + $mdc-tab-icon-color-active: secondary !global; + @include _mat-mdc-tabs-palette-styles($mdc-tab-text-label-color-active); + } + + &.mat-warn { + $mdc-tab-text-label-color-active: error !global; + $mdc-tab-icon-color-active: error !global; + @include _mat-mdc-tabs-palette-styles($mdc-tab-text-label-color-active); + } + } + + .mat-mdc-tab-group { + &.mat-background-primary { + @include _mat-mdc-tabs-background(primary, on-primary); + } + + &.mat-background-accent { + @include _mat-mdc-tabs-background(secondary, on-secondary); + } + + &.mat-background-warn { + @include _mat-mdc-tabs-background(error, on-error); + } + } + + .mat-mdc-tab-header-pagination-chevron { + @include mdc-theme-prop(border-color, on-surface); + } + } + + // Restore original values of MDC global variables. + $mdc-tab-text-label-color-active: $orig-mdc-tab-text-label-color-active !global; + $mdc-tab-icon-color-active: $orig-mdc-tab-icon-color-active !global; +} + +@mixin _mat-mdc-tabs-background($background-color, $foreground-color) { + .mat-mdc-tab-header, .mat-mdc-tab-links, .mat-mdc-tab-header-pagination { + // Set background color for the tab group + @include mdc-theme-prop(background-color, $background-color); + } + + // Set labels to contrast against background + .mdc-tab__text-label, .mat-mdc-tab-link { + @include mdc-theme-prop(color, $foreground-color); + } + + .mat-ripple-element, .mat-mdc-tab::before { + @include mdc-theme-prop(background-color, $foreground-color); + } + + .mdc-tab-indicator__content--underline, .mat-mdc-tab-header-pagination-chevron { + @include mdc-theme-prop(border-color, $foreground-color); + } +} + +@mixin _mat-mdc-tabs-palette-styles($color) { + @include mdc-tab-without-ripple($query: $mat-theme-styles-query); + @include mdc-tab-indicator-underline-color($color, $query: $mat-theme-styles-query); + @include mdc-tab-indicator-icon-color($color, $query: $mat-theme-styles-query); + + .mat-mdc-tab::before, + .mat-mdc-tab-link::before, + .mat-mdc-tab .mat-ripple-element, + .mat-mdc-tab-header-pagination .mat-ripple-element, + .mat-mdc-tab-link .mat-ripple-element { + @include mdc-theme-prop(background-color, $color); } } @mixin mat-tabs-typography-mdc($config) { @include mat-using-mdc-typography($config) { - // TODO: MDC typography styles here. + @include mdc-tab-without-ripple($query: $mat-typography-styles-query); + @include mdc-tab-indicator-core-styles($query: $mat-typography-styles-query); } } diff --git a/src/material-experimental/mdc-tabs/_tabs-common.scss b/src/material-experimental/mdc-tabs/_tabs-common.scss new file mode 100644 index 000000000000..95e60761f674 --- /dev/null +++ b/src/material-experimental/mdc-tabs/_tabs-common.scss @@ -0,0 +1,152 @@ +@import '@material/ripple/variables'; +@import '../../material/core/style/variables'; +@import '../../material/core/style/noop-animation'; +@import '../../material/core/style/vendor-prefixes'; +@import '../../cdk/a11y/a11y'; + +$mat-tab-animation-duration: 500ms !default; + +@mixin mat-mdc-tab { + &.mdc-tab { + // MDC's tabs stretch to fit the header by default, whereas stretching on our current ones + // is an opt-in behavior. Also technically we don't need to combine the two classes, but + // we need the extra specificity to avoid issues with CSS insertion order. + flex-grow: 0; + min-width: 160px; + } + + // Used to render out the background tint when hovered/focused. Usually this is done by + // MDC's ripple styles, however we're using our own ripples due to size concerns. + &::before { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + opacity: 0; + pointer-events: none; + } + + // We need to handle the hover and focus indication ourselves, because we don't use MDC's ripple. + &:hover::before { + opacity: map-get($mdc-ripple-dark-ink-opacities, hover); + } + + &.cdk-program-focused, + &.cdk-keyboard-focused { + &::before { + opacity: map-get($mdc-ripple-dark-ink-opacities, focus); + } + } + + .mat-ripple-element { + opacity: map-get($mdc-ripple-dark-ink-opacities, press); + } +} + +@mixin mat-mdc-tab-ripple { + .mat-mdc-tab-ripple { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + pointer-events: none; + } +} + +// Structural styles for a tab header. Used by both `mat-tab-header` and `mat-tab-nav-bar`. +// We need this styles on top of MDC's, because MDC doesn't support pagination like ours. +@mixin mat-mdc-paginated-tab-header { + .mat-mdc-tab-header { + display: flex; + overflow: hidden; + position: relative; + flex-shrink: 0; + } + + .mat-mdc-tab-header-pagination { + @include user-select(none); + position: relative; + display: none; + justify-content: center; + align-items: center; + min-width: 32px; + cursor: pointer; + z-index: 2; + -webkit-tap-highlight-color: transparent; + touch-action: none; + + .mat-ripple-element { + opacity: map-get($mdc-ripple-dark-ink-opacities, press); + } + + .mat-mdc-tab-header-pagination-controls-enabled & { + display: flex; + } + } + + // The pagination control that is displayed on the left side of the tab header. + .mat-mdc-tab-header-pagination-before, + .mat-mdc-tab-header-rtl .mat-mdc-tab-header-pagination-after { + padding-left: 4px; + .mat-mdc-tab-header-pagination-chevron { + transform: rotate(-135deg); + } + } + + // The pagination control that is displayed on the right side of the tab header. + .mat-mdc-tab-header-rtl .mat-mdc-tab-header-pagination-before, + .mat-mdc-tab-header-pagination-after { + padding-right: 4px; + .mat-mdc-tab-header-pagination-chevron { + transform: rotate(45deg); + } + } + + .mat-mdc-tab-header-pagination-chevron { + border-style: solid; + border-width: 2px 2px 0 0; + content: ''; + height: 8px; + width: 8px; + } + + .mat-mdc-tab-header-pagination-disabled { + box-shadow: none; + cursor: default; + opacity: 0.4; + pointer-events: none; + } + + .mat-mdc-tab-list { + flex-grow: 1; + position: relative; + transition: transform 500ms cubic-bezier(0.35, 0, 0.25, 1); + } +} + +// Structural styles for the element that wraps the paginated header items. +@mixin mat-mdc-paginated-tab-header-item-wrapper { + display: flex; + flex: 1 0 auto; + + // Note that these are used as inputs so they shouldn't be changed to `mat-mdc-`. + [mat-align-tabs='center'] & { + justify-content: center; + } + + [mat-align-tabs='end'] & { + justify-content: flex-end; + } +} + +// Structural styles for the element that wraps the paginated container's content. +@mixin mat-mdc-paginated-tab-header-container { + display: flex; + flex-grow: 1; + overflow: hidden; + z-index: 1; +} diff --git a/src/material-experimental/mdc-tabs/ink-bar.ts b/src/material-experimental/mdc-tabs/ink-bar.ts new file mode 100644 index 000000000000..59292899cd0c --- /dev/null +++ b/src/material-experimental/mdc-tabs/ink-bar.ts @@ -0,0 +1,142 @@ +/** + * @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 {ElementRef, QueryList} from '@angular/core'; +import { + MDCTabIndicatorFoundation, + MDCSlidingTabIndicatorFoundation, + MDCTabIndicatorAdapter +} from '@material/tab-indicator'; + +/** + * Item inside a tab header relative to which the ink bar can be aligned. + * @docs-private + */ +export interface MatInkBarItem { + _foundation: MatInkBarFoundation; + elementRef: ElementRef; +} + +/** + * Abstraction around the MDC tab indicator that manages the ink bar of a tab header. + * @docs-private + */ +export class MatInkBar { + /** Item to which the ink bar is aligned currently. */ + private _currentItem: MatInkBarItem|undefined; + + constructor(private _items: QueryList) {} + + /** Hides the ink bar. */ + hide() { + this._items.forEach(item => item._foundation.deactivate()); + } + + /** Aligns the ink bar to a DOM node. */ + alignToElement(element: HTMLElement) { + const correspondingItem = this._items.find(item => item.elementRef.nativeElement === element); + const currentItem = this._currentItem; + + if (currentItem) { + currentItem._foundation.deactivate(); + } + + if (correspondingItem) { + const clientRect = currentItem ? + currentItem._foundation.computeContentClientRect() : undefined; + + // The MDC indicator won't animate unless we give it the `ClientRect` of the previous item. + correspondingItem._foundation.activate(clientRect); + this._currentItem = correspondingItem; + } + } +} + +/** + * Implementation of MDC's sliding tab indicator foundation. + * @docs-private + */ +export class MatInkBarFoundation { + private _destroyed: boolean; + private _foundation: MDCTabIndicatorFoundation; + private _element: HTMLElement; + private _indicator: HTMLElement; + private _indicatorContent: HTMLElement; + private _adapter: MDCTabIndicatorAdapter = { + addClass: className => { + if (!this._destroyed) { + this._element.classList.add(className); + } + }, + removeClass: className => { + if (!this._destroyed) { + this._element.classList.remove(className); + } + }, + setContentStyleProperty: (propName, value) => { + this._indicatorContent.style.setProperty(propName, value); + }, + computeContentClientRect: () => { + return this._destroyed ? { + width: 0, height: 0, top: 0, left: 0, right: 0, bottom: 0 + } : this._indicatorContent.getBoundingClientRect(); + } + }; + + constructor(elementRef: ElementRef, document: Document) { + this._element = elementRef.nativeElement; + this._foundation = new MDCSlidingTabIndicatorFoundation(this._adapter); + this._createIndicator(document); + } + + /** Aligns the ink bar to the current item. */ + activate(clientRect?: ClientRect) { + this._foundation.activate(clientRect); + } + + /** Removes the ink bar from the current item. */ + deactivate() { + this._foundation.deactivate(); + } + + /** Gets the ClientRect of the indicator. */ + computeContentClientRect() { + return this._foundation.computeContentClientRect(); + } + + /** Initializes the foundation. */ + init() { + this._foundation.init(); + } + + /** Destroys the foundation. */ + destroy() { + const indicator = this._indicator; + + if (indicator.parentNode) { + indicator.parentNode.removeChild(indicator); + } + + this._element = this._indicator = this._indicatorContent = null!; + this._foundation.destroy(); + this._destroyed = true; + } + + private _createIndicator(document: Document) { + if (!this._indicator) { + const indicator = this._indicator = document.createElement('span'); + const content = this._indicatorContent = document.createElement('span'); + + indicator.className = 'mdc-tab-indicator'; + content.className = 'mdc-tab-indicator__content mdc-tab-indicator__content--underline'; + + indicator.appendChild(content); + this._element.appendChild(indicator); + } + } +} diff --git a/src/material-experimental/mdc-tabs/module.ts b/src/material-experimental/mdc-tabs/module.ts index badb8e7d5273..f3820aa209a4 100644 --- a/src/material-experimental/mdc-tabs/module.ts +++ b/src/material-experimental/mdc-tabs/module.ts @@ -8,11 +8,51 @@ import {CommonModule} from '@angular/common'; import {NgModule} from '@angular/core'; -import {MatCommonModule} from '@angular/material/core'; +import {MatCommonModule, MatRippleModule} from '@angular/material/core'; +import {PortalModule} from '@angular/cdk/portal'; +import {ObserversModule} from '@angular/cdk/observers'; +import {A11yModule} from '@angular/cdk/a11y'; +import {MatTabBody, MatTabBodyPortal} from './tab-body'; +import {MatTabContent} from './tab-content'; +import {MatTabLabel} from './tab-label'; +import {MatTabLabelWrapper} from './tab-label-wrapper'; +import {MatTab} from './tab'; +import {MatTabHeader} from './tab-header'; +import {MatTabGroup} from './tab-group'; +import {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar'; @NgModule({ - imports: [MatCommonModule, CommonModule], - exports: [MatCommonModule], + imports: [ + CommonModule, + MatCommonModule, + PortalModule, + MatRippleModule, + ObserversModule, + A11yModule, + ], + exports: [ + MatCommonModule, + MatTabContent, + MatTabLabel, + MatTab, + MatTabGroup, + MatTabNav, + MatTabLink, + ], + declarations: [ + MatTabContent, + MatTabLabel, + MatTab, + MatTabGroup, + MatTabNav, + MatTabLink, + + // Private directives, should not be exported. + MatTabBody, + MatTabBodyPortal, + MatTabLabelWrapper, + MatTabHeader + ] }) export class MatTabsModule { } diff --git a/src/material-experimental/mdc-tabs/public-api.ts b/src/material-experimental/mdc-tabs/public-api.ts index 508adc834fb3..e3a8f5da0ced 100644 --- a/src/material-experimental/mdc-tabs/public-api.ts +++ b/src/material-experimental/mdc-tabs/public-api.ts @@ -7,3 +7,24 @@ */ export * from './module'; +export {MatTabBodyPortal} from './tab-body'; +export {MatTabContent} from './tab-content'; +export {MatTabLabel} from './tab-label'; +export {MatTabLabelWrapper} from './tab-label-wrapper'; +export {MatTab} from './tab'; +export {MatInkBar} from './ink-bar'; +export {MatTabHeader} from './tab-header'; +export {MatTabGroup} from './tab-group'; +export {MatTabNav, MatTabLink} from './tab-nav-bar/tab-nav-bar'; + +export { + MatTabBodyPositionState, + MatTabBodyOriginState, + matTabsAnimations, + _MatInkBarPositioner, + _MAT_INK_BAR_POSITIONER, + MatTabChangeEvent, + MatTabHeaderPosition, + MatTabsConfig, + MAT_TABS_CONFIG, +} from '@angular/material/tabs'; diff --git a/src/material-experimental/mdc-tabs/tab-body.html b/src/material-experimental/mdc-tabs/tab-body.html new file mode 100644 index 000000000000..b15d5940aa02 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-body.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/src/material-experimental/mdc-tabs/tab-body.scss b/src/material-experimental/mdc-tabs/tab-body.scss new file mode 100644 index 000000000000..7a5363348d6d --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-body.scss @@ -0,0 +1,34 @@ +@import '../../material/core/style/vendor-prefixes'; +@import '../../material/core/style/layout-common'; + +// Wraps each tab body. We need to add these styles ourselves, +// because MDC only provides styling for the tab header. +.mat-mdc-tab-body { + @include mat-fill; + display: block; + overflow: hidden; + + // Fix for auto content wrapping in IE11 + flex-basis: 100%; + + &.mat-mdc-tab-body-active { + position: relative; + overflow-x: hidden; + overflow-y: auto; + z-index: 1; + flex-grow: 1; + } + + .mat-mdc-tab-group.mat-mdc-tab-group-dynamic-height &.mat-mdc-tab-body-active { + overflow-y: hidden; + } +} + +.mat-mdc-tab-body-content { + height: 100%; + overflow: auto; + + .mat-mdc-tab-group-dynamic-height & { + overflow: hidden; + } +} diff --git a/src/material-experimental/mdc-tabs/tab-body.spec.ts b/src/material-experimental/mdc-tabs/tab-body.spec.ts new file mode 100644 index 000000000000..0ba0a1cf96be --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-body.spec.ts @@ -0,0 +1,203 @@ +import {Direction, Directionality} from '@angular/cdk/bidi'; +import {PortalModule, TemplatePortal} from '@angular/cdk/portal'; +import {CommonModule} from '@angular/common'; +import {AfterContentInit, Component, TemplateRef, ViewChild, ViewContainerRef} from '@angular/core'; +import {async, ComponentFixture, TestBed} from '@angular/core/testing'; +import {MatRippleModule} from '@angular/material/core'; +import {NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {MatTabBody, MatTabBodyPortal} from './tab-body'; +import {Subject} from 'rxjs'; + + +describe('MatTabBody', () => { + let dir: Direction = 'ltr'; + let dirChange: Subject = new Subject(); + + beforeEach(async(() => { + dir = 'ltr'; + TestBed.configureTestingModule({ + imports: [CommonModule, PortalModule, MatRippleModule, NoopAnimationsModule], + declarations: [ + MatTabBody, + MatTabBodyPortal, + SimpleTabBodyApp, + ], + providers: [ + {provide: Directionality, useFactory: () => ({value: dir, change: dirChange})} + ] + }); + + TestBed.compileComponents(); + })); + + describe('when initialized as center', () => { + let fixture: ComponentFixture; + + it('should be center position if origin is unchanged', () => { + fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.componentInstance.position = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('center'); + }); + + it('should be center position if origin is explicitly set to null', () => { + fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.componentInstance.position = 0; + + // It can happen that the `origin` is explicitly set to null through the Angular input + // binding. This test should ensure that the body does properly such origin value. + // The `MatTab` class sets the origin by default to null. See related issue: #12455 + fixture.componentInstance.origin = null; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('center'); + }); + + describe('in LTR direction', () => { + + beforeEach(() => { + dir = 'ltr'; + fixture = TestBed.createComponent(SimpleTabBodyApp); + }); + it('should be left-origin-center position with negative or zero origin', () => { + fixture.componentInstance.position = 0; + fixture.componentInstance.origin = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left-origin-center'); + }); + + it('should be right-origin-center position with positive nonzero origin', () => { + fixture.componentInstance.position = 0; + fixture.componentInstance.origin = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right-origin-center'); + }); + }); + + describe('in RTL direction', () => { + beforeEach(() => { + dir = 'rtl'; + fixture = TestBed.createComponent(SimpleTabBodyApp); + }); + + it('should be right-origin-center position with negative or zero origin', () => { + fixture.componentInstance.position = 0; + fixture.componentInstance.origin = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right-origin-center'); + }); + + it('should be left-origin-center position with positive nonzero origin', () => { + fixture.componentInstance.position = 0; + fixture.componentInstance.origin = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left-origin-center'); + }); + }); + }); + + describe('should properly set the position in LTR', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + dir = 'ltr'; + fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.detectChanges(); + }); + + it('to be left position with negative position', () => { + fixture.componentInstance.position = -1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left'); + }); + + it('to be center position with zero position', () => { + fixture.componentInstance.position = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('center'); + }); + + it('to be left position with positive position', () => { + fixture.componentInstance.position = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right'); + }); + }); + + describe('should properly set the position in RTL', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + dir = 'rtl'; + fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.detectChanges(); + }); + + it('to be right position with negative position', () => { + fixture.componentInstance.position = -1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right'); + }); + + it('to be center position with zero position', () => { + fixture.componentInstance.position = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('center'); + }); + + it('to be left position with positive position', () => { + fixture.componentInstance.position = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left'); + }); + }); + + it('should update position if direction changed at runtime', () => { + const fixture = TestBed.createComponent(SimpleTabBodyApp); + + fixture.componentInstance.position = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right'); + + dirChange.next('rtl'); + dir = 'rtl'; + + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left'); + }); +}); + + +@Component({ + template: ` + Tab Body Content + + ` +}) +class SimpleTabBodyApp implements AfterContentInit { + content: TemplatePortal; + position: number; + origin: number | null; + + @ViewChild(MatTabBody, {static: false}) tabBody: MatTabBody; + @ViewChild(TemplateRef, {static: true}) template: TemplateRef; + + constructor(private _viewContainerRef: ViewContainerRef) { } + + ngAfterContentInit() { + this.content = new TemplatePortal(this.template, this._viewContainerRef); + } +} diff --git a/src/material-experimental/mdc-tabs/tab-body.ts b/src/material-experimental/mdc-tabs/tab-body.ts new file mode 100644 index 000000000000..a78c78bb967e --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-body.ts @@ -0,0 +1,71 @@ +/** + * @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, + Directive, + ViewEncapsulation, + ChangeDetectionStrategy, + ViewChild, + ComponentFactoryResolver, + ViewContainerRef, + Inject, + forwardRef, + ChangeDetectorRef, + Optional, + ElementRef, +} from '@angular/core'; +import { + MatTabBodyPortal as BaseMatTabBodyPortal, + matTabsAnimations, + _MatTabBodyBase, +} from '@angular/material/tabs'; +import {PortalHostDirective} from '@angular/cdk/portal'; +import {Directionality} from '@angular/cdk/bidi'; + +/** + * The portal host directive for the contents of the tab. + * @docs-private + */ +@Directive({ + selector: '[matTabBodyHost]' +}) +export class MatTabBodyPortal extends BaseMatTabBodyPortal { + constructor( + componentFactoryResolver: ComponentFactoryResolver, + viewContainerRef: ViewContainerRef, + @Inject(forwardRef(() => MatTabBody)) host: MatTabBody) { + super(componentFactoryResolver, viewContainerRef, host); + } +} + +/** + * Wrapper for the contents of a tab. + * @docs-private + */ +@Component({ + moduleId: module.id, + selector: 'mat-tab-body', + templateUrl: 'tab-body.html', + styleUrls: ['tab-body.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [matTabsAnimations.translateTab], + host: { + 'class': 'mat-mdc-tab-body', + }, +}) +export class MatTabBody extends _MatTabBodyBase { + @ViewChild(PortalHostDirective, {static: false}) _portalHost: PortalHostDirective; + + constructor(elementRef: ElementRef, + @Optional() dir: Directionality, + changeDetectorRef: ChangeDetectorRef) { + super(elementRef, dir, changeDetectorRef); + } +} diff --git a/src/material-experimental/mdc-tabs/tab-content.ts b/src/material-experimental/mdc-tabs/tab-content.ts new file mode 100644 index 000000000000..e66963653849 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-content.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive} from '@angular/core'; +import {MatTabContent as BaseMatTabContent} from '@angular/material/tabs'; + +/** Decorates the `ng-template` tags and reads out the template from it. */ +@Directive({selector: '[matTabContent]'}) +export class MatTabContent extends BaseMatTabContent { +} diff --git a/src/material-experimental/mdc-tabs/tab-group.html b/src/material-experimental/mdc-tabs/tab-group.html new file mode 100644 index 000000000000..5f27bbda53c5 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-group.html @@ -0,0 +1,63 @@ + + + + + +
+ + +
diff --git a/src/material-experimental/mdc-tabs/tab-group.scss b/src/material-experimental/mdc-tabs/tab-group.scss new file mode 100644 index 000000000000..b37e676b3dd0 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-group.scss @@ -0,0 +1,47 @@ +@import '@material/tab/mixins'; +@import '@material/ripple/variables'; +@import '../../material/core/style/variables'; +@import '../../material/core/style/noop-animation'; +@import '../mdc-helpers/mdc-helpers'; +@import './tabs-common'; + +@include mdc-tab-without-ripple($query: $mat-base-styles-query); +@include mat-mdc-tab-ripple; + +.mat-mdc-tab { + @include mat-mdc-tab; + + // Note that we only want to target direct descendant tabs. Also note that + // `mat-stretch-tabs` is part of the public API so it should not be changed to `mat-mdc-`. + .mat-mdc-tab-group[mat-stretch-tabs] > .mat-mdc-tab-header & { + flex-grow: 1; + } +} + +// MDC doesn't support disabled tabs so we need to improvise. +.mat-mdc-tab-disabled { + opacity: 0.4; + pointer-events: none; +} + +.mat-mdc-tab-group { + display: flex; + flex-direction: column; + + &.mat-mdc-tab-group-inverted-header { + flex-direction: column-reverse; + + .mdc-tab-indicator__content--underline { + align-self: flex-start; + } + } +} + +// The bottom section of the view; contains the tab bodies +.mat-mdc-tab-body-wrapper { + @include _noop-animation(); + position: relative; + overflow: hidden; + display: flex; + transition: height $mat-tab-animation-duration $ease-in-out-curve-function; +} diff --git a/src/material-experimental/mdc-tabs/tab-group.spec.ts b/src/material-experimental/mdc-tabs/tab-group.spec.ts new file mode 100644 index 000000000000..f3ec0a621f16 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-group.spec.ts @@ -0,0 +1,883 @@ +import {LEFT_ARROW} from '@angular/cdk/keycodes'; +import {dispatchFakeEvent, dispatchKeyboardEvent} from '@angular/cdk/testing'; +import {Component, OnInit, QueryList, ViewChild, ViewChildren} from '@angular/core'; +import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BrowserAnimationsModule, NoopAnimationsModule} from '@angular/platform-browser/animations'; +import {CommonModule} from '@angular/common'; +import {Observable} from 'rxjs'; +import {MatTab, MatTabGroup, MatTabHeaderPosition, MatTabsModule} from './index'; + + +describe('MatTabGroup', () => { + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatTabsModule, CommonModule, NoopAnimationsModule], + declarations: [ + SimpleTabsTestApp, + SimpleDynamicTabsTestApp, + BindedTabsTestApp, + AsyncTabsTestApp, + DisabledTabsTestApp, + TabGroupWithSimpleApi, + TemplateTabs, + TabGroupWithAriaInputs, + TabGroupWithIsActiveBinding, + ], + }); + + TestBed.compileComponents(); + })); + + describe('basic behavior', () => { + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabsTestApp); + element = fixture.nativeElement; + }); + + it('should default to the first tab', () => { + checkSelectedIndex(1, fixture); + }); + + it('will properly load content on first change detection pass', () => { + fixture.detectChanges(); + const tabBodies = element.querySelectorAll('.mat-mdc-tab-body'); + expect(tabBodies[1].querySelectorAll('span').length).toBe(3); + }); + + it('should change selected index on click', () => { + let component = fixture.debugElement.componentInstance; + component.selectedIndex = 0; + checkSelectedIndex(0, fixture); + + // select the second tab + let tabLabel = fixture.debugElement.queryAll(By.css('.mat-mdc-tab'))[1]; + tabLabel.nativeElement.click(); + checkSelectedIndex(1, fixture); + + // select the third tab + tabLabel = fixture.debugElement.queryAll(By.css('.mat-mdc-tab'))[2]; + tabLabel.nativeElement.click(); + checkSelectedIndex(2, fixture); + }); + + it('should support two-way binding for selectedIndex', fakeAsync(() => { + let component = fixture.componentInstance; + component.selectedIndex = 0; + + fixture.detectChanges(); + + let tabLabel = fixture.debugElement.queryAll(By.css('.mat-mdc-tab'))[1]; + tabLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + + expect(component.selectedIndex).toBe(1); + })); + + // Note: needs to be `async` in order to fail when we expect it to. + it('should set to correct tab on fast change', async(() => { + let component = fixture.componentInstance; + component.selectedIndex = 0; + fixture.detectChanges(); + + setTimeout(() => { + component.selectedIndex = 1; + fixture.detectChanges(); + + setTimeout(() => { + component.selectedIndex = 0; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(component.selectedIndex).toBe(0); + }); + }, 1); + }, 1); + })); + + it('should change tabs based on selectedIndex', fakeAsync(() => { + let component = fixture.componentInstance; + let tabComponent = fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; + + spyOn(component, 'handleSelection').and.callThrough(); + + checkSelectedIndex(1, fixture); + + tabComponent.selectedIndex = 2; + + checkSelectedIndex(2, fixture); + tick(); + + expect(component.handleSelection).toHaveBeenCalledTimes(1); + expect(component.selectEvent.index).toBe(2); + })); + + it('should update tab positions when selected index is changed', () => { + fixture.detectChanges(); + const component: MatTabGroup = + fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; + const tabs: MatTab[] = component._tabs.toArray(); + + expect(tabs[0].position).toBeLessThan(0); + expect(tabs[1].position).toBe(0); + expect(tabs[2].position).toBeGreaterThan(0); + + // Move to third tab + component.selectedIndex = 2; + fixture.detectChanges(); + expect(tabs[0].position).toBeLessThan(0); + expect(tabs[1].position).toBeLessThan(0); + expect(tabs[2].position).toBe(0); + + // Move to the first tab + component.selectedIndex = 0; + fixture.detectChanges(); + expect(tabs[0].position).toBe(0); + expect(tabs[1].position).toBeGreaterThan(0); + expect(tabs[2].position).toBeGreaterThan(0); + }); + + it('should clamp the selected index to the size of the number of tabs', () => { + fixture.detectChanges(); + const component: MatTabGroup = + fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; + + // Set the index to be negative, expect first tab selected + fixture.componentInstance.selectedIndex = -1; + fixture.detectChanges(); + expect(component.selectedIndex).toBe(0); + + // Set the index beyond the size of the tabs, expect last tab selected + fixture.componentInstance.selectedIndex = 3; + fixture.detectChanges(); + expect(component.selectedIndex).toBe(2); + }); + + it('should not crash when setting the selected index to NaN', () => { + let component = fixture.debugElement.componentInstance; + + expect(() => { + component.selectedIndex = NaN; + fixture.detectChanges(); + }).not.toThrow(); + }); + + it('should show ripples for tab-group labels', () => { + fixture.detectChanges(); + + const testElement = fixture.nativeElement; + const tabLabel = fixture.debugElement.queryAll(By.css('.mat-mdc-tab'))[1]; + + expect(testElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripples to show up initially.'); + + dispatchFakeEvent(tabLabel.nativeElement, 'mousedown'); + dispatchFakeEvent(tabLabel.nativeElement, 'mouseup'); + + expect(testElement.querySelectorAll('.mat-ripple-element').length) + .toBe(1, 'Expected one ripple to show up on label mousedown.'); + }); + + it('should allow disabling ripples for tab-group labels', () => { + fixture.componentInstance.disableRipple = true; + fixture.detectChanges(); + + const testElement = fixture.nativeElement; + const tabLabel = fixture.debugElement.queryAll(By.css('.mat-mdc-tab'))[1]; + + expect(testElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripples to show up initially.'); + + dispatchFakeEvent(tabLabel.nativeElement, 'mousedown'); + dispatchFakeEvent(tabLabel.nativeElement, 'mouseup'); + + expect(testElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripple to show up on label mousedown.'); + }); + + it('should set the isActive flag on each of the tabs', fakeAsync(() => { + fixture.detectChanges(); + tick(); + + const tabs = fixture.componentInstance.tabs.toArray(); + + expect(tabs[0].isActive).toBe(false); + expect(tabs[1].isActive).toBe(true); + expect(tabs[2].isActive).toBe(false); + + fixture.componentInstance.selectedIndex = 2; + fixture.detectChanges(); + tick(); + + expect(tabs[0].isActive).toBe(false); + expect(tabs[1].isActive).toBe(false); + expect(tabs[2].isActive).toBe(true); + })); + + it('should fire animation done event', fakeAsync(() => { + fixture.detectChanges(); + + spyOn(fixture.componentInstance, 'animationDone'); + let tabLabel = fixture.debugElement.queryAll(By.css('.mat-mdc-tab'))[1]; + tabLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.animationDone).toHaveBeenCalledTimes(1); + })); + + it('should add the proper `aria-setsize` and `aria-posinset`', () => { + fixture.detectChanges(); + + const labels = Array.from(element.querySelectorAll('.mat-mdc-tab')); + + expect(labels.map(label => label.getAttribute('aria-posinset'))).toEqual(['1', '2', '3']); + expect(labels.every(label => label.getAttribute('aria-setsize') === '3')).toBe(true); + }); + + it('should emit focusChange event on click', () => { + spyOn(fixture.componentInstance, 'handleFocus'); + fixture.detectChanges(); + + const tabLabels = fixture.debugElement.queryAll(By.css('.mat-mdc-tab')); + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(0); + + tabLabels[1].nativeElement.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.handleFocus) + .toHaveBeenCalledWith(jasmine.objectContaining({index: 1})); + }); + + it('should emit focusChange on arrow key navigation', () => { + spyOn(fixture.componentInstance, 'handleFocus'); + fixture.detectChanges(); + + const tabLabels = fixture.debugElement.queryAll(By.css('.mat-mdc-tab')); + const tabLabelContainer = fixture.debugElement + .query(By.css('.mat-mdc-tab-label-container')).nativeElement as HTMLElement; + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(0); + + // In order to verify that the `focusChange` event also fires with the correct + // index, we focus the second tab before testing the keyboard navigation. + tabLabels[1].nativeElement.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(1); + + dispatchKeyboardEvent(tabLabelContainer, 'keydown', LEFT_ARROW); + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.handleFocus) + .toHaveBeenCalledWith(jasmine.objectContaining({index: 0})); + }); + + }); + + describe('aria labelling', () => { + let fixture: ComponentFixture; + let tab: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(TabGroupWithAriaInputs); + fixture.detectChanges(); + tick(); + tab = fixture.nativeElement.querySelector('.mat-mdc-tab'); + })); + + it('should not set aria-label or aria-labelledby attributes if they are not passed in', () => { + expect(tab.hasAttribute('aria-label')).toBe(false); + expect(tab.hasAttribute('aria-labelledby')).toBe(false); + }); + + it('should set the aria-label attribute', () => { + fixture.componentInstance.ariaLabel = 'Fruit'; + fixture.detectChanges(); + + expect(tab.getAttribute('aria-label')).toBe('Fruit'); + }); + + it('should set the aria-labelledby attribute', () => { + fixture.componentInstance.ariaLabelledby = 'fruit-label'; + fixture.detectChanges(); + + expect(tab.getAttribute('aria-labelledby')).toBe('fruit-label'); + }); + + it('should not be able to set both an aria-label and aria-labelledby', () => { + fixture.componentInstance.ariaLabel = 'Fruit'; + fixture.componentInstance.ariaLabelledby = 'fruit-label'; + fixture.detectChanges(); + + expect(tab.getAttribute('aria-label')).toBe('Fruit'); + expect(tab.hasAttribute('aria-labelledby')).toBe(false); + }); + }); + + describe('disable tabs', () => { + let fixture: ComponentFixture; + beforeEach(() => { + fixture = TestBed.createComponent(DisabledTabsTestApp); + }); + + it('should have one disabled tab', () => { + fixture.detectChanges(); + const labels = fixture.debugElement.queryAll(By.css('.mat-mdc-tab-disabled')); + expect(labels.length).toBe(1); + expect(labels[0].nativeElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should set the disabled flag on tab', () => { + fixture.detectChanges(); + + const tabs = fixture.componentInstance.tabs.toArray(); + let labels = fixture.debugElement.queryAll(By.css('.mat-mdc-tab-disabled')); + expect(tabs[2].disabled).toBe(false); + expect(labels.length).toBe(1); + expect(labels[0].nativeElement.getAttribute('aria-disabled')).toBe('true'); + + fixture.componentInstance.isDisabled = true; + fixture.detectChanges(); + + expect(tabs[2].disabled).toBe(true); + labels = fixture.debugElement.queryAll(By.css('.mat-mdc-tab-disabled')); + expect(labels.length).toBe(2); + expect(labels.every(label => label.nativeElement.getAttribute('aria-disabled') === 'true')) + .toBe(true); + }); + }); + + describe('dynamic binding tabs', () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(SimpleDynamicTabsTestApp); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should be able to add a new tab, select it, and have correct origin position', + fakeAsync(() => { + const component: MatTabGroup = + fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; + + let tabs: MatTab[] = component._tabs.toArray(); + expect(tabs[0].origin).toBe(null); + expect(tabs[1].origin).toBe(0); + expect(tabs[2].origin).toBe(null); + + // Add a new tab on the right and select it, expect an origin >= than 0 (animate right) + fixture.componentInstance.tabs.push({label: 'New tab', content: 'to right of index'}); + fixture.componentInstance.selectedIndex = 4; + fixture.detectChanges(); + tick(); + + tabs = component._tabs.toArray(); + expect(tabs[3].origin).toBeGreaterThanOrEqual(0); + + // Add a new tab in the beginning and select it, expect an origin < than 0 (animate left) + fixture.componentInstance.selectedIndex = 0; + fixture.detectChanges(); + tick(); + + fixture.componentInstance.tabs.push({label: 'New tab', content: 'to left of index'}); + fixture.detectChanges(); + tick(); + + tabs = component._tabs.toArray(); + expect(tabs[0].origin).toBeLessThan(0); + })); + + + it('should update selected index if the last tab removed while selected', fakeAsync(() => { + const component: MatTabGroup = + fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; + + const numberOfTabs = component._tabs.length; + fixture.componentInstance.selectedIndex = numberOfTabs - 1; + fixture.detectChanges(); + tick(); + + // Remove last tab while last tab is selected, expect next tab over to be selected + fixture.componentInstance.tabs.pop(); + fixture.detectChanges(); + tick(); + + expect(component.selectedIndex).toBe(numberOfTabs - 2); + })); + + + it('should maintain the selected tab if a new tab is added', () => { + fixture.detectChanges(); + const component: MatTabGroup = + fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; + + fixture.componentInstance.selectedIndex = 1; + fixture.detectChanges(); + + // Add a new tab at the beginning. + fixture.componentInstance.tabs.unshift({label: 'New tab', content: 'at the start'}); + fixture.detectChanges(); + + expect(component.selectedIndex).toBe(2); + expect(component._tabs.toArray()[2].isActive).toBe(true); + }); + + + it('should maintain the selected tab if a tab is removed', () => { + // Select the second tab. + fixture.componentInstance.selectedIndex = 1; + fixture.detectChanges(); + + const component: MatTabGroup = + fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; + + // Remove the first tab that is right before the selected one. + fixture.componentInstance.tabs.splice(0, 1); + fixture.detectChanges(); + + // Since the first tab has been removed and the second one was selected before, the selected + // tab moved one position to the right. Meaning that the tab is now the first tab. + expect(component.selectedIndex).toBe(0); + expect(component._tabs.toArray()[0].isActive).toBe(true); + }); + + it('should be able to select a new tab after creation', fakeAsync(() => { + fixture.detectChanges(); + const component: MatTabGroup = + fixture.debugElement.query(By.css('mat-tab-group')).componentInstance; + + fixture.componentInstance.tabs.push({label: 'Last tab', content: 'at the end'}); + fixture.componentInstance.selectedIndex = 3; + + fixture.detectChanges(); + tick(); + + expect(component.selectedIndex).toBe(3); + expect(component._tabs.toArray()[3].isActive).toBe(true); + })); + + it('should not fire `selectedTabChange` when the amount of tabs changes', fakeAsync(() => { + fixture.detectChanges(); + fixture.componentInstance.selectedIndex = 1; + fixture.detectChanges(); + + // Add a new tab at the beginning. + spyOn(fixture.componentInstance, 'handleSelection'); + fixture.componentInstance.tabs.unshift({label: 'New tab', content: 'at the start'}); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(fixture.componentInstance.handleSelection).not.toHaveBeenCalled(); + })); + + }); + + describe('async tabs', () => { + let fixture: ComponentFixture; + + it('should show tabs when they are available', fakeAsync(() => { + fixture = TestBed.createComponent(AsyncTabsTestApp); + + expect(fixture.debugElement.queryAll(By.css('.mat-mdc-tab')).length).toBe(0); + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + tick(); + + expect(fixture.debugElement.queryAll(By.css('.mat-mdc-tab')).length).toBe(2); + })); + }); + + describe('with simple api', () => { + let fixture: ComponentFixture; + let tabGroup: MatTabGroup; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(TabGroupWithSimpleApi); + fixture.detectChanges(); + tick(); + + tabGroup = + fixture.debugElement.query(By.directive(MatTabGroup)).componentInstance as MatTabGroup; + })); + + it('should support a tab-group with the simple api', fakeAsync(() => { + expect(getSelectedLabel(fixture).textContent).toMatch('Junk food'); + expect(getSelectedContent(fixture).textContent).toMatch('Pizza, fries'); + + tabGroup.selectedIndex = 2; + fixture.detectChanges(); + tick(); + + expect(getSelectedLabel(fixture).textContent).toMatch('Fruit'); + expect(getSelectedContent(fixture).textContent).toMatch('Apples, grapes'); + + fixture.componentInstance.otherLabel = 'Chips'; + fixture.componentInstance.otherContent = 'Salt, vinegar'; + fixture.detectChanges(); + + expect(getSelectedLabel(fixture).textContent).toMatch('Chips'); + expect(getSelectedContent(fixture).textContent).toMatch('Salt, vinegar'); + })); + + it('should support @ViewChild in the tab content', () => { + expect(fixture.componentInstance.legumes).toBeTruthy(); + }); + + it('should only have the active tab in the DOM', fakeAsync(() => { + expect(fixture.nativeElement.textContent).toContain('Pizza, fries'); + expect(fixture.nativeElement.textContent).not.toContain('Peanuts'); + + tabGroup.selectedIndex = 3; + fixture.detectChanges(); + tick(); + + expect(fixture.nativeElement.textContent).not.toContain('Pizza, fries'); + expect(fixture.nativeElement.textContent).toContain('Peanuts'); + })); + + it('should support setting the header position', () => { + let tabGroupNode = fixture.debugElement.query(By.css('mat-tab-group')).nativeElement; + + expect(tabGroupNode.classList).not.toContain('mat-mdc-tab-group-inverted-header'); + + tabGroup.headerPosition = 'below'; + fixture.detectChanges(); + + expect(tabGroupNode.classList).toContain('mat-mdc-tab-group-inverted-header'); + }); + }); + + describe('lazy loaded tabs', () => { + it('should lazy load the second tab', fakeAsync(() => { + const fixture = TestBed.createComponent(TemplateTabs); + fixture.detectChanges(); + tick(); + + const secondLabel = fixture.debugElement.queryAll(By.css('.mat-mdc-tab'))[1]; + secondLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const child = fixture.debugElement.query(By.css('.child')); + expect(child.nativeElement).toBeDefined(); + })); + }); + + describe('special cases', () => { + it('should not throw an error when binding isActive to the view', fakeAsync(() => { + const fixture = TestBed.createComponent(TabGroupWithIsActiveBinding); + + expect(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + }).not.toThrow(); + + expect(fixture.nativeElement.textContent).toContain('pizza is active'); + })); + }); + + /** + * Checks that the `selectedIndex` has been updated; checks that the label and body have their + * respective `active` classes + */ + function checkSelectedIndex(expectedIndex: number, fixture: ComponentFixture) { + fixture.detectChanges(); + + let tabComponent: MatTabGroup = fixture.debugElement + .query(By.css('mat-tab-group')).componentInstance; + expect(tabComponent.selectedIndex).toBe(expectedIndex); + + let tabLabelElement = fixture.debugElement + .query(By.css(`.mat-mdc-tab:nth-of-type(${expectedIndex + 1})`)).nativeElement; + expect(tabLabelElement.classList.contains('mdc-tab--active')).toBe(true); + + let tabContentElement = fixture.debugElement + .query(By.css(`mat-tab-body:nth-of-type(${expectedIndex + 1})`)).nativeElement; + expect(tabContentElement.classList.contains('mat-mdc-tab-body-active')).toBe(true); + } + + function getSelectedLabel(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('.mdc-tab--active'); + } + + function getSelectedContent(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('.mat-mdc-tab-body-active'); + } +}); + + +describe('nested MatTabGroup with enabled animations', () => { + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatTabsModule, BrowserAnimationsModule], + declarations: [NestedTabs, TabsWithCustomAnimationDuration] + }); + + TestBed.compileComponents(); + })); + + it('should not throw when creating a component with nested tab groups', fakeAsync(() => { + expect(() => { + let fixture = TestBed.createComponent(NestedTabs); + fixture.detectChanges(); + tick(); + }).not.toThrow(); + })); + + it('should not throw when setting an animationDuration without units', fakeAsync(() => { + expect(() => { + let fixture = TestBed.createComponent(TabsWithCustomAnimationDuration); + fixture.detectChanges(); + tick(); + }).not.toThrow(); + })); +}); + + +@Component({ + template: ` + + + Tab One + Tab one content + + + Tab Two + Tab twocontent + + + Tab Three + Tab three content + + + ` +}) +class SimpleTabsTestApp { + @ViewChildren(MatTab) tabs: QueryList; + selectedIndex: number = 1; + focusEvent: any; + selectEvent: any; + disableRipple: boolean = false; + headerPosition: MatTabHeaderPosition = 'above'; + handleFocus(event: any) { + this.focusEvent = event; + } + handleSelection(event: any) { + this.selectEvent = event; + } + animationDone() { } +} + +@Component({ + template: ` + + + {{tab.label}} + {{tab.content}} + + + ` +}) +class SimpleDynamicTabsTestApp { + tabs = [ + {label: 'Label 1', content: 'Content 1'}, + {label: 'Label 2', content: 'Content 2'}, + {label: 'Label 3', content: 'Content 3'}, + ]; + selectedIndex: number = 1; + focusEvent: any; + selectEvent: any; + handleFocus(event: any) { + this.focusEvent = event; + } + handleSelection(event: any) { + this.selectEvent = event; + } +} + +@Component({ + template: ` + + + {{tab.content}} + + + ` +}) +class BindedTabsTestApp { + tabs = [ + { label: 'one', content: 'one' }, + { label: 'two', content: 'two' } + ]; + selectedIndex = 0; + + addNewActiveTab(): void { + this.tabs.push({ + label: 'new tab', + content: 'new content' + }); + this.selectedIndex = this.tabs.length - 1; + } +} + +@Component({ + selector: 'test-app', + template: ` + + + Tab One + Tab one content + + + Tab Two + Tab two content + + + Tab Three + Tab three content + + + `, +}) +class DisabledTabsTestApp { + @ViewChildren(MatTab) tabs: QueryList; + isDisabled = false; +} + +@Component({ + template: ` + + + {{ tab.label }} + {{ tab.content }} + + + ` +}) +class AsyncTabsTestApp implements OnInit { + private _tabs = [ + { label: 'one', content: 'one' }, + { label: 'two', content: 'two' } + ]; + + tabs: Observable; + + ngOnInit() { + // Use ngOnInit because there is some issue with scheduling the async task in the constructor. + this.tabs = new Observable((observer: any) => { + setTimeout(() => observer.next(this._tabs)); + }); + } +} + + +@Component({ + template: ` + + Pizza, fries + Broccoli, spinach + {{otherContent}} +

Peanuts

+
+ ` +}) +class TabGroupWithSimpleApi { + otherLabel = 'Fruit'; + otherContent = 'Apples, grapes'; + @ViewChild('legumes', {static: false}) legumes: any; +} + + +@Component({ + selector: 'nested-tabs', + template: ` + + Tab one content + + Tab two content + + Inner content one + Inner content two + + + + `, +}) +class NestedTabs {} + +@Component({ + selector: 'template-tabs', + template: ` + + + Eager + + + +
Hi
+
+
+
+ `, + }) + class TemplateTabs {} + + + @Component({ + template: ` + + + + ` +}) +class TabGroupWithAriaInputs { + ariaLabel: string; + ariaLabelledby: string; +} + + +@Component({ + template: ` + + Pizza, fries + Broccoli, spinach + + +
pizza is active
+ ` +}) +class TabGroupWithIsActiveBinding { +} + + +@Component({ + template: ` + + Tab one content + Tab two content + + `, +}) +class TabsWithCustomAnimationDuration {} diff --git a/src/material-experimental/mdc-tabs/tab-group.ts b/src/material-experimental/mdc-tabs/tab-group.ts new file mode 100644 index 000000000000..0d958dee9e6d --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-group.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ChangeDetectionStrategy, + Component, + ContentChildren, + ElementRef, + QueryList, + ViewChild, + ViewEncapsulation, + ChangeDetectorRef, + Inject, + Optional, +} from '@angular/core'; +import {_MatTabGroupBase, MAT_TABS_CONFIG, MatTabsConfig} from '@angular/material/tabs'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {MatTab} from './tab'; +import {MatTabHeader} from './tab-header'; + +/** + * Material design tab-group component. Supports basic tab pairs (label + content) and includes + * animated ink-bar, keyboard navigation, and screen reader. + * See: https://material.io/design/components/tabs.html + */ +@Component({ + moduleId: module.id, + selector: 'mat-tab-group', + exportAs: 'matTabGroup', + templateUrl: 'tab-group.html', + styleUrls: ['tab-group.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + inputs: ['color', 'disableRipple'], + host: { + 'class': 'mat-mdc-tab-group', + '[class.mat-mdc-tab-group-dynamic-height]': 'dynamicHeight', + '[class.mat-mdc-tab-group-inverted-header]': 'headerPosition === "below"', + }, +}) +export class MatTabGroup extends _MatTabGroupBase { + @ContentChildren(MatTab) _tabs: QueryList; + @ViewChild('tabBodyWrapper', {static: false}) _tabBodyWrapper: ElementRef; + @ViewChild('tabHeader', {static: false}) _tabHeader: MatTabHeader; + + constructor(elementRef: ElementRef, + changeDetectorRef: ChangeDetectorRef, + @Inject(MAT_TABS_CONFIG) @Optional() defaultConfig?: MatTabsConfig, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(elementRef, changeDetectorRef, defaultConfig, animationMode); + } +} diff --git a/src/material-experimental/mdc-tabs/tab-header.html b/src/material-experimental/mdc-tabs/tab-header.html new file mode 100644 index 000000000000..115739bee519 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-header.html @@ -0,0 +1,38 @@ + + + +
+
+
+ +
+
+
+ + + diff --git a/src/material-experimental/mdc-tabs/tab-header.scss b/src/material-experimental/mdc-tabs/tab-header.scss new file mode 100644 index 000000000000..1c4cd096a51f --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-header.scss @@ -0,0 +1,21 @@ +@import '@material/tab-indicator/mixins'; +@import '../../material/core/style/noop-animation'; +@import '../mdc-helpers/mdc-helpers'; +@import './tabs-common'; + +@include mdc-tab-indicator-core-styles($query: $mat-base-styles-query); +@include mat-mdc-paginated-tab-header; + +.mat-mdc-tab-label-container { + @include mat-mdc-paginated-tab-header-container; +} + +.mat-mdc-tab-header-pagination-disabled { + box-shadow: none; + cursor: default; + opacity: 0.4; +} + +.mat-mdc-tab-labels { + @include mat-mdc-paginated-tab-header-item-wrapper; +} diff --git a/src/material-experimental/mdc-tabs/tab-header.spec.ts b/src/material-experimental/mdc-tabs/tab-header.spec.ts new file mode 100644 index 000000000000..3800074bd7ec --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-header.spec.ts @@ -0,0 +1,656 @@ +import {Direction, Directionality} from '@angular/cdk/bidi'; +import {END, ENTER, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE} from '@angular/cdk/keycodes'; +import {PortalModule} from '@angular/cdk/portal'; +import {ScrollingModule, ViewportRuler} from '@angular/cdk/scrolling'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + createKeyboardEvent, + dispatchEvent, +} from '@angular/cdk/testing'; +import {CommonModule} from '@angular/common'; +import {Component, ViewChild} from '@angular/core'; +import { + async, + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + TestBed, + tick, +} from '@angular/core/testing'; +import {MatRippleModule} from '@angular/material/core'; +import {By} from '@angular/platform-browser'; +import {MatTabHeader} from './tab-header'; +import {MatTabLabelWrapper} from './tab-label-wrapper'; +import {Subject} from 'rxjs'; +import {ObserversModule, MutationObserverFactory} from '@angular/cdk/observers'; + + +describe('MatTabHeader', () => { + let dir: Direction = 'ltr'; + let change = new Subject(); + let fixture: ComponentFixture; + let appComponent: SimpleTabHeaderApp; + + beforeEach(async(() => { + dir = 'ltr'; + TestBed.configureTestingModule({ + imports: [CommonModule, PortalModule, MatRippleModule, ScrollingModule, ObserversModule], + declarations: [ + MatTabHeader, + MatTabLabelWrapper, + SimpleTabHeaderApp, + ], + providers: [ + ViewportRuler, + {provide: Directionality, useFactory: () => ({value: dir, change: change.asObservable()})}, + ] + }); + + TestBed.compileComponents(); + })); + + describe('focusing', () => { + let tabListContainer: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + appComponent = fixture.componentInstance; + tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement; + }); + + it('should initialize to the selected index', () => { + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(appComponent.selectedIndex); + }); + + it('should send focus change event', () => { + appComponent.tabHeader.focusIndex = 2; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(2); + }); + + it('should not set focus a disabled tab', () => { + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + // Set focus on the disabled tab, but focus should remain 0 + appComponent.tabHeader.focusIndex = appComponent.disabledTabIndex; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + }); + + it('should move focus right and skip disabled tabs', () => { + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + // Move focus right, verify that the disabled tab is 1 and should be skipped + expect(appComponent.disabledTabIndex).toBe(1); + dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(2); + + // Move focus right to index 3 + dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(3); + }); + + it('should move focus left and skip disabled tabs', () => { + appComponent.tabHeader.focusIndex = 3; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(3); + + // Move focus left to index 3 + dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(2); + + // Move focus left, verify that the disabled tab is 1 and should be skipped + expect(appComponent.disabledTabIndex).toBe(1); + dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + }); + + it('should support key down events to move and select focus', () => { + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + // Move focus right to 2 + dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(2); + + // Select the focused index 2 + expect(appComponent.selectedIndex).toBe(0); + const enterEvent = dispatchKeyboardEvent(tabListContainer, 'keydown', ENTER); + fixture.detectChanges(); + expect(appComponent.selectedIndex).toBe(2); + expect(enterEvent.defaultPrevented).toBe(true); + + // Move focus right to 0 + dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + // Select the focused 0 using space. + expect(appComponent.selectedIndex).toBe(2); + const spaceEvent = dispatchKeyboardEvent(tabListContainer, 'keydown', SPACE); + fixture.detectChanges(); + expect(appComponent.selectedIndex).toBe(0); + expect(spaceEvent.defaultPrevented).toBe(true); + }); + + it('should move focus to the first tab when pressing HOME', () => { + appComponent.tabHeader.focusIndex = 3; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(3); + + const event = dispatchKeyboardEvent(tabListContainer, 'keydown', HOME); + fixture.detectChanges(); + + expect(appComponent.tabHeader.focusIndex).toBe(0); + expect(event.defaultPrevented).toBe(true); + }); + + it('should skip disabled items when moving focus using HOME', () => { + appComponent.tabHeader.focusIndex = 3; + appComponent.tabs[0].disabled = true; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(3); + + dispatchKeyboardEvent(tabListContainer, 'keydown', HOME); + fixture.detectChanges(); + + // Note that the second tab is disabled by default already. + expect(appComponent.tabHeader.focusIndex).toBe(2); + }); + + it('should move focus to the last tab when pressing END', () => { + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + const event = dispatchKeyboardEvent(tabListContainer, 'keydown', END); + fixture.detectChanges(); + + expect(appComponent.tabHeader.focusIndex).toBe(3); + expect(event.defaultPrevented).toBe(true); + }); + + it('should skip disabled items when moving focus using END', () => { + appComponent.tabHeader.focusIndex = 0; + appComponent.tabs[3].disabled = true; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + dispatchKeyboardEvent(tabListContainer, 'keydown', END); + fixture.detectChanges(); + + expect(appComponent.tabHeader.focusIndex).toBe(2); + }); + + it('should not do anything if a modifier key is pressed', () => { + const rightArrowEvent = createKeyboardEvent('keydown', RIGHT_ARROW); + const enterEvent = createKeyboardEvent('keydown', ENTER); + + [rightArrowEvent, enterEvent].forEach(event => { + Object.defineProperty(event, 'shiftKey', {get: () => true}); + }); + + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + dispatchEvent(tabListContainer, rightArrowEvent); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + expect(rightArrowEvent.defaultPrevented).toBe(false); + + expect(appComponent.selectedIndex).toBe(0); + dispatchEvent(tabListContainer, enterEvent); + fixture.detectChanges(); + expect(appComponent.selectedIndex).toBe(0); + expect(enterEvent.defaultPrevented).toBe(false); + }); + + }); + + describe('pagination', () => { + describe('ltr', () => { + beforeEach(() => { + dir = 'ltr'; + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + appComponent = fixture.componentInstance; + }); + + it('should show width when tab list width exceeds container', () => { + fixture.detectChanges(); + expect(appComponent.tabHeader._showPaginationControls).toBe(false); + + // Add enough tabs that it will obviously exceed the width + appComponent.addTabsForScrolling(); + fixture.detectChanges(); + + expect(appComponent.tabHeader._showPaginationControls).toBe(true); + }); + + it('should scroll to show the focused tab label', () => { + appComponent.addTabsForScrolling(); + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance).toBe(0); + + // Focus on the last tab, expect this to be the maximum scroll distance. + appComponent.tabHeader.focusIndex = appComponent.tabs.length - 1; + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance) + .toBe(appComponent.tabHeader._getMaxScrollDistance()); + + // Focus on the first tab, expect this to be the maximum scroll distance. + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance).toBe(0); + }); + + it('should show ripples for pagination buttons', () => { + appComponent.addTabsForScrolling(); + fixture.detectChanges(); + + expect(appComponent.tabHeader._showPaginationControls).toBe(true); + + const buttonAfter = + fixture.debugElement.query(By.css('.mat-mdc-tab-header-pagination-after')); + + expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripple to show up initially.'); + + dispatchFakeEvent(buttonAfter.nativeElement, 'mousedown'); + dispatchFakeEvent(buttonAfter.nativeElement, 'mouseup'); + + expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length) + .toBe(1, 'Expected one ripple to show up after mousedown'); + }); + + it('should allow disabling ripples for pagination buttons', () => { + appComponent.addTabsForScrolling(); + appComponent.disableRipple = true; + fixture.detectChanges(); + + expect(appComponent.tabHeader._showPaginationControls).toBe(true); + + const buttonAfter = + fixture.debugElement.query(By.css('.mat-mdc-tab-header-pagination-after')); + + expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripple to show up initially.'); + + dispatchFakeEvent(buttonAfter.nativeElement, 'mousedown'); + dispatchFakeEvent(buttonAfter.nativeElement, 'mouseup'); + + expect(fixture.nativeElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripple to show up after mousedown'); + }); + + }); + + describe('rtl', () => { + beforeEach(() => { + dir = 'rtl'; + fixture = TestBed.createComponent(SimpleTabHeaderApp); + appComponent = fixture.componentInstance; + appComponent.dir = 'rtl'; + + fixture.detectChanges(); + }); + + it('should scroll to show the focused tab label', () => { + appComponent.addTabsForScrolling(); + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance).toBe(0); + + // Focus on the last tab, expect this to be the maximum scroll distance. + appComponent.tabHeader.focusIndex = appComponent.tabs.length - 1; + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance) + .toBe(appComponent.tabHeader._getMaxScrollDistance()); + + // Focus on the first tab, expect this to be the maximum scroll distance. + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance).toBe(0); + }); + }); + + describe('scrolling when holding paginator', () => { + let nextButton: HTMLElement; + let prevButton: HTMLElement; + let header: MatTabHeader; + let headerElement: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.componentInstance.disableRipple = true; + fixture.detectChanges(); + + fixture.componentInstance.addTabsForScrolling(50); + fixture.detectChanges(); + + nextButton = fixture.nativeElement.querySelector('.mat-mdc-tab-header-pagination-after'); + prevButton = fixture.nativeElement.querySelector('.mat-mdc-tab-header-pagination-before'); + header = fixture.componentInstance.tabHeader; + headerElement = fixture.nativeElement.querySelector('.mat-mdc-tab-header'); + }); + + it('should scroll towards the end while holding down the next button using a mouse', + fakeAsync(() => { + assertNextButtonScrolling('mousedown', 'click'); + })); + + it('should scroll towards the start while holding down the prev button using a mouse', + fakeAsync(() => { + assertPrevButtonScrolling('mousedown', 'click'); + })); + + it('should scroll towards the end while holding down the next button using touch', + fakeAsync(() => { + assertNextButtonScrolling('touchstart', 'touchend'); + })); + + it('should scroll towards the start while holding down the prev button using touch', + fakeAsync(() => { + assertPrevButtonScrolling('touchstart', 'touchend'); + })); + + it('should not scroll if the sequence is interrupted quickly', fakeAsync(() => { + expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.'); + + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + + tick(100); + + dispatchFakeEvent(headerElement, 'mouseleave'); + fixture.detectChanges(); + + tick(3000); + + expect(header.scrollDistance).toBe(0, 'Expected not to have scrolled after a while.'); + })); + + it('should clear the timeouts on destroy', fakeAsync(() => { + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + fixture.destroy(); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should clear the timeouts on click', fakeAsync(() => { + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + + dispatchFakeEvent(nextButton, 'click'); + fixture.detectChanges(); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should clear the timeouts on touchend', fakeAsync(() => { + dispatchFakeEvent(nextButton, 'touchstart'); + fixture.detectChanges(); + + dispatchFakeEvent(nextButton, 'touchend'); + fixture.detectChanges(); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should clear the timeouts when reaching the end', fakeAsync(() => { + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + + // Simulate a very long timeout. + tick(60000); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should clear the timeouts when reaching the start', fakeAsync(() => { + header.scrollDistance = Infinity; + fixture.detectChanges(); + + dispatchFakeEvent(prevButton, 'mousedown'); + fixture.detectChanges(); + + // Simulate a very long timeout. + tick(60000); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should stop scrolling if the pointer leaves the header', fakeAsync(() => { + expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.'); + + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + tick(300); + + expect(header.scrollDistance).toBe(0, 'Expected not to scroll after short amount of time.'); + + tick(1000); + + expect(header.scrollDistance).toBeGreaterThan(0, 'Expected to scroll after some time.'); + + let previousDistance = header.scrollDistance; + + dispatchFakeEvent(headerElement, 'mouseleave'); + fixture.detectChanges(); + tick(100); + + expect(header.scrollDistance).toBe(previousDistance); + })); + + /** + * Asserts that auto scrolling using the next button works. + * @param startEventName Name of the event that is supposed to start the scrolling. + * @param endEventName Name of the event that is supposed to end the scrolling. + */ + function assertNextButtonScrolling(startEventName: string, endEventName: string) { + expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.'); + + dispatchFakeEvent(nextButton, startEventName); + fixture.detectChanges(); + tick(300); + + expect(header.scrollDistance).toBe(0, 'Expected not to scroll after short amount of time.'); + + tick(1000); + + expect(header.scrollDistance).toBeGreaterThan(0, 'Expected to scroll after some time.'); + + let previousDistance = header.scrollDistance; + + tick(100); + + expect(header.scrollDistance) + .toBeGreaterThan(previousDistance, 'Expected to scroll again after some more time.'); + + dispatchFakeEvent(nextButton, endEventName); + } + + /** + * Asserts that auto scrolling using the previous button works. + * @param startEventName Name of the event that is supposed to start the scrolling. + * @param endEventName Name of the event that is supposed to end the scrolling. + */ + function assertPrevButtonScrolling(startEventName: string, endEventName: string) { + header.scrollDistance = Infinity; + fixture.detectChanges(); + + let currentScroll = header.scrollDistance; + + expect(currentScroll).toBeGreaterThan(0, 'Expected to start off scrolled.'); + + dispatchFakeEvent(prevButton, startEventName); + fixture.detectChanges(); + tick(300); + + expect(header.scrollDistance) + .toBe(currentScroll, 'Expected not to scroll after short amount of time.'); + + tick(1000); + + expect(header.scrollDistance) + .toBeLessThan(currentScroll, 'Expected to scroll after some time.'); + + currentScroll = header.scrollDistance; + + tick(100); + + expect(header.scrollDistance) + .toBeLessThan(currentScroll, 'Expected to scroll again after some more time.'); + + dispatchFakeEvent(nextButton, endEventName); + } + + }); + + it('should re-align the ink bar when the direction changes', fakeAsync(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + const inkBar = fixture.componentInstance.tabHeader._inkBar; + spyOn(inkBar, 'alignToElement'); + + fixture.detectChanges(); + + change.next(); + fixture.detectChanges(); + tick(20); // Angular turns rAF calls into 16.6ms timeouts in tests. + + expect(inkBar.alignToElement).toHaveBeenCalled(); + })); + + it('should re-align the ink bar when the window is resized', fakeAsync(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + const inkBar = fixture.componentInstance.tabHeader._inkBar; + + spyOn(inkBar, 'alignToElement'); + + dispatchFakeEvent(window, 'resize'); + tick(150); + fixture.detectChanges(); + + expect(inkBar.alignToElement).toHaveBeenCalled(); + discardPeriodicTasks(); + })); + + it('should update arrows when the window is resized', fakeAsync(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + + const header = fixture.componentInstance.tabHeader; + + spyOn(header, '_checkPaginationEnabled'); + + dispatchFakeEvent(window, 'resize'); + tick(10); + fixture.detectChanges(); + + expect(header._checkPaginationEnabled).toHaveBeenCalled(); + discardPeriodicTasks(); + })); + + it('should update the pagination state if the content of the labels changes', () => { + const mutationCallbacks: Function[] = []; + TestBed.overrideProvider(MutationObserverFactory, { + useValue: { + // Stub out the MutationObserver since the native one is async. + create: function(callback: Function) { + mutationCallbacks.push(callback); + return {observe: () => {}, disconnect: () => {}}; + } + } + }); + + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + const tabHeaderElement: HTMLElement = + fixture.nativeElement.querySelector('.mat-mdc-tab-header'); + const labels = + Array.from(fixture.nativeElement.querySelectorAll('.label-content')); + const extraText = new Array(100).fill('w').join(); + const enabledClass = 'mat-mdc-tab-header-pagination-controls-enabled'; + + expect(tabHeaderElement.classList).not.toContain(enabledClass); + + labels.forEach(label => { + label.style.width = ''; + label.textContent += extraText; + }); + + mutationCallbacks.forEach(callback => callback()); + fixture.detectChanges(); + + expect(tabHeaderElement.classList).toContain(enabledClass); + }); + + }); +}); + +interface Tab { + label: string; + disabled?: boolean; +} + +@Component({ + template: ` +
+ +
+ {{tab.label}} +
+
+
+ `, + styles: [` + :host { + width: 130px; + } + `] +}) +class SimpleTabHeaderApp { + disableRipple: boolean = false; + selectedIndex: number = 0; + focusedIndex: number; + disabledTabIndex = 1; + tabs: Tab[] = [{label: 'tab one'}, {label: 'tab one'}, {label: 'tab one'}, {label: 'tab one'}]; + dir: Direction = 'ltr'; + + @ViewChild(MatTabHeader, {static: true}) tabHeader: MatTabHeader; + + constructor() { + this.tabs[this.disabledTabIndex].disabled = true; + } + + addTabsForScrolling(amount = 4) { + for (let i = 0; i < amount; i++) { + this.tabs.push({label: 'new'}); + } + } +} diff --git a/src/material-experimental/mdc-tabs/tab-header.ts b/src/material-experimental/mdc-tabs/tab-header.ts new file mode 100644 index 000000000000..82c5481ad8c3 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-header.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + ContentChildren, + ViewChild, + ElementRef, + QueryList, + AfterContentInit, + Optional, + ChangeDetectorRef, + NgZone, + Inject, +} from '@angular/core'; +import {_MatTabHeaderBase} from '@angular/material/tabs'; +import {ViewportRuler} from '@angular/cdk/scrolling'; +import {Platform} from '@angular/cdk/platform'; +import {Directionality} from '@angular/cdk/bidi'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {MatTabLabelWrapper} from './tab-label-wrapper'; +import {MatInkBar} from './ink-bar'; + +/** + * The header of the tab group which displays a list of all the tabs in the tab group. Includes + * an ink bar that follows the currently selected tab. When the tabs list's width exceeds the + * width of the header container, then arrows will be displayed to allow the user to scroll + * left and right across the header. + * @docs-private + */ +@Component({ + moduleId: module.id, + selector: 'mat-tab-header', + templateUrl: 'tab-header.html', + styleUrls: ['tab-header.css'], + inputs: ['selectedIndex'], + outputs: ['selectFocusedIndex', 'indexFocused'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + 'class': 'mat-mdc-tab-header', + '[class.mat-mdc-tab-header-pagination-controls-enabled]': '_showPaginationControls', + '[class.mat-mdc-tab-header-rtl]': "_getLayoutDirection() == 'rtl'", + }, +}) +export class MatTabHeader extends _MatTabHeaderBase implements AfterContentInit { + @ContentChildren(MatTabLabelWrapper) _items: QueryList; + @ViewChild('tabListContainer', {static: true}) _tabListContainer: ElementRef; + @ViewChild('tabList', {static: true}) _tabList: ElementRef; + @ViewChild('nextPaginator', {static: false}) _nextPaginator: ElementRef; + @ViewChild('previousPaginator', {static: false}) _previousPaginator: ElementRef; + _inkBar: MatInkBar; + + constructor(elementRef: ElementRef, + changeDetectorRef: ChangeDetectorRef, + viewportRuler: ViewportRuler, + @Optional() dir: Directionality, + ngZone: NgZone, + platform: Platform, + // @breaking-change 9.0.0 `_animationMode` parameter to be made required. + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(elementRef, changeDetectorRef, viewportRuler, dir, ngZone, platform, animationMode); + } + + ngAfterContentInit() { + this._inkBar = new MatInkBar(this._items); + super.ngAfterContentInit(); + } +} diff --git a/src/material-experimental/mdc-tabs/tab-label-wrapper.ts b/src/material-experimental/mdc-tabs/tab-label-wrapper.ts new file mode 100644 index 000000000000..79cc82ae5e11 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-label-wrapper.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive, ElementRef, Inject, OnDestroy} from '@angular/core'; +import {DOCUMENT} from '@angular/common'; +import {MatTabLabelWrapper as BaseMatTabLabelWrapper} from '@angular/material/tabs'; +import {MatInkBarFoundation, MatInkBarItem} from './ink-bar'; + +/** + * Used in the `mat-tab-group` view to display tab labels. + * @docs-private + */ +@Directive({ + selector: '[matTabLabelWrapper]', + inputs: ['disabled'], + host: { + '[class.mat-mdc-tab-disabled]': 'disabled', + '[attr.aria-disabled]': '!!disabled', + } +}) +export class MatTabLabelWrapper extends BaseMatTabLabelWrapper implements MatInkBarItem, OnDestroy { + _foundation: MatInkBarFoundation; + + constructor(public elementRef: ElementRef, @Inject(DOCUMENT) _document: any) { + super(elementRef); + this._foundation = new MatInkBarFoundation(elementRef, _document); + this._foundation.init(); + } + + ngOnDestroy() { + this._foundation.destroy(); + } + + /** Sets focus on the wrapper element */ + focus(): void { + this.elementRef.nativeElement.focus(); + } +} diff --git a/src/material-experimental/mdc-tabs/tab-label.ts b/src/material-experimental/mdc-tabs/tab-label.ts new file mode 100644 index 000000000000..d335d9052adb --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-label.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Directive} from '@angular/core'; +import {MatTabLabel as BaseMatTabLabel} from '@angular/material/tabs'; + +/** Used to flag tab labels for use with the portal directive */ +@Directive({ + selector: '[mat-tab-label], [matTabLabel]', +}) +export class MatTabLabel extends BaseMatTabLabel {} diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-link.html b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-link.html new file mode 100644 index 000000000000..6c03e75574e9 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-link.html @@ -0,0 +1,11 @@ +
+ + + + + + diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-link.scss b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-link.scss new file mode 100644 index 000000000000..5c203f41c47c --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-link.scss @@ -0,0 +1,36 @@ +@import '@material/tab/mixins'; +@import '../../../material/core/style/variables'; +@import '../../mdc-helpers/mdc-helpers'; +@import '../tabs-common'; + +@include mdc-tab-without-ripple($query: $mat-base-styles-query); +@include mdc-tab-indicator-core-styles($query: $mat-base-styles-query); +@include mat-mdc-tab-ripple; + +// Wraps each link in the header +.mat-mdc-tab-link { + @include mat-mdc-tab; + + &.mat-mdc-tab-disabled { + // We use `pointer-events` to make the element unclickable when it's disabled, rather than + // preventing the default action through JS, because we can't prevent the action reliably + // due to other directives potentially registering their events earlier. This shouldn't cause + // the user to click through, because we always have a `.mat-tab-links` behind the link. + pointer-events: none; + + // MDC doesn't support disabled tabs so we need to improvise. + opacity: 0.4; + } + + // Note that we only want to target direct descendant tabs. Also note that + // `mat-stretch-tabs` is part of the public API so it should not be changed to `mat-mdc-`. + .mat-mdc-tab-header[mat-stretch-tabs] & { + flex-grow: 1; + } +} + +@media ($mat-xsmall) { + .mat-mdc-tab-link { + min-width: 72px; + } +} diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.html b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.html new file mode 100644 index 000000000000..8b77be104601 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.html @@ -0,0 +1,31 @@ + + + + + + + diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.scss b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.scss new file mode 100644 index 000000000000..3fa3c7997a0b --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.scss @@ -0,0 +1,14 @@ +@import '@material/tab/mixins'; +@import '../tabs-common'; +@import '../../../material/core/style/variables'; +@import '../../mdc-helpers/mdc-helpers'; + +@include mat-mdc-paginated-tab-header; + +.mat-mdc-tab-links { + @include mat-mdc-paginated-tab-header-item-wrapper; +} + +.mat-mdc-tab-link-container { + @include mat-mdc-paginated-tab-header-container; +} diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts new file mode 100644 index 000000000000..9d95ac756271 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -0,0 +1,395 @@ +import {async, ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; +import {Component, ViewChild, ViewChildren, QueryList} from '@angular/core'; +import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core'; +import {By} from '@angular/platform-browser'; +import {dispatchFakeEvent, dispatchMouseEvent} from '@angular/cdk/testing'; +import {Direction, Directionality} from '@angular/cdk/bidi'; +import {Subject} from 'rxjs'; +import {MatTabsModule} from '../module'; +import {MatTabLink, MatTabNav} from './tab-nav-bar'; + + +describe('MatTabNavBar', () => { + let dir: Direction = 'ltr'; + let dirChange = new Subject(); + let globalRippleOptions: RippleGlobalOptions; + + beforeEach(async(() => { + globalRippleOptions = {}; + + TestBed.configureTestingModule({ + imports: [MatTabsModule], + declarations: [ + SimpleTabNavBarTestApp, + TabLinkWithNgIf, + TabLinkWithTabIndexBinding, + TabLinkWithNativeTabindexAttr, + TabBarWithInactiveTabsOnInit, + ], + providers: [ + {provide: MAT_RIPPLE_GLOBAL_OPTIONS, useFactory: () => globalRippleOptions}, + {provide: Directionality, useFactory: () => + ({value: dir, change: dirChange.asObservable()})}, + ] + }); + + TestBed.compileComponents(); + })); + + describe('basic behavior', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabNavBarTestApp); + fixture.detectChanges(); + }); + + it('should change active index on click', () => { + // select the second link + let tabLink = fixture.debugElement.queryAll(By.css('a'))[1]; + tabLink.nativeElement.click(); + expect(fixture.componentInstance.activeIndex).toBe(1); + + // select the third link + tabLink = fixture.debugElement.queryAll(By.css('a'))[2]; + tabLink.nativeElement.click(); + expect(fixture.componentInstance.activeIndex).toBe(2); + }); + + it('should add the active class if active', () => { + let tabLink1 = fixture.debugElement.queryAll(By.css('a'))[0]; + let tabLink2 = fixture.debugElement.queryAll(By.css('a'))[1]; + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map(tabLinkDebugEl => tabLinkDebugEl.nativeElement); + + tabLink1.nativeElement.click(); + fixture.detectChanges(); + expect(tabLinkElements[0].classList.contains('mdc-tab--active')).toBeTruthy(); + expect(tabLinkElements[1].classList.contains('mdc-tab--active')).toBeFalsy(); + + tabLink2.nativeElement.click(); + fixture.detectChanges(); + expect(tabLinkElements[0].classList.contains('mdc-tab--active')).toBeFalsy(); + expect(tabLinkElements[1].classList.contains('mdc-tab--active')).toBeTruthy(); + }); + + it('should toggle aria-current based on active state', () => { + let tabLink1 = fixture.debugElement.queryAll(By.css('a'))[0]; + let tabLink2 = fixture.debugElement.queryAll(By.css('a'))[1]; + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map(tabLinkDebugEl => tabLinkDebugEl.nativeElement); + + tabLink1.nativeElement.click(); + fixture.detectChanges(); + expect(tabLinkElements[0].getAttribute('aria-current')).toEqual('page'); + expect(tabLinkElements[1].hasAttribute('aria-current')).toEqual(false); + + tabLink2.nativeElement.click(); + fixture.detectChanges(); + expect(tabLinkElements[0].hasAttribute('aria-current')).toEqual(false); + expect(tabLinkElements[1].getAttribute('aria-current')).toEqual('page'); + }); + + it('should add the disabled class if disabled', () => { + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map(tabLinkDebugEl => tabLinkDebugEl.nativeElement); + + expect(tabLinkElements.every(tabLinkEl => { + return !tabLinkEl.classList.contains('mat-mdc-tab-disabled'); + })).toBe(true, 'Expected every tab link to not have the disabled class initially'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(tabLinkElements.every(tabLinkEl => { + return tabLinkEl.classList.contains('mat-mdc-tab-disabled'); + })).toBe(true, 'Expected every tab link to have the disabled class if set through binding'); + }); + + it('should update aria-disabled if disabled', () => { + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map(tabLinkDebugEl => tabLinkDebugEl.nativeElement); + + expect(tabLinkElements.every(tabLink => tabLink.getAttribute('aria-disabled') === 'false')) + .toBe(true, 'Expected aria-disabled to be set to "false" by default.'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(tabLinkElements.every(tabLink => tabLink.getAttribute('aria-disabled') === 'true')) + .toBe(true, 'Expected aria-disabled to be set to "true" if link is disabled.'); + }); + + it('should update the tabindex if links are disabled', () => { + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map(tabLinkDebugEl => tabLinkDebugEl.nativeElement); + + expect(tabLinkElements.every(tabLink => tabLink.tabIndex === 0)) + .toBe(true, 'Expected element to be keyboard focusable by default'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(tabLinkElements.every(tabLink => tabLink.tabIndex === -1)) + .toBe(true, 'Expected element to no longer be keyboard focusable if disabled.'); + }); + + it('should mark disabled links', () => { + const tabLinkElement = fixture.debugElement.query(By.css('a')).nativeElement; + + expect(tabLinkElement.classList).not.toContain('mat-mdc-tab-disabled'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(tabLinkElement.classList).toContain('mat-mdc-tab-disabled'); + }); + + it('should re-align the ink bar when the direction changes', () => { + const inkBar = fixture.componentInstance.tabNavBar._inkBar; + + spyOn(inkBar, 'alignToElement'); + + dirChange.next(); + fixture.detectChanges(); + + expect(inkBar.alignToElement).toHaveBeenCalled(); + }); + + it('should re-align the ink bar when the tabs list change', () => { + const inkBar = fixture.componentInstance.tabNavBar._inkBar; + + spyOn(inkBar, 'alignToElement'); + + fixture.componentInstance.tabs = [1, 2, 3, 4]; + fixture.detectChanges(); + + expect(inkBar.alignToElement).toHaveBeenCalled(); + }); + + it('should re-align the ink bar when the tab labels change the width', done => { + const inkBar = fixture.componentInstance.tabNavBar._inkBar; + + const spy = spyOn(inkBar, 'alignToElement').and.callFake(() => { + expect(spy.calls.any()).toBe(true); + done(); + }); + + fixture.componentInstance.label = 'label change'; + fixture.detectChanges(); + + expect(spy.calls.any()).toBe(false); + }); + + it('should re-align the ink bar when the window is resized', fakeAsync(() => { + const inkBar = fixture.componentInstance.tabNavBar._inkBar; + + spyOn(inkBar, 'alignToElement'); + + dispatchFakeEvent(window, 'resize'); + tick(150); + fixture.detectChanges(); + + expect(inkBar.alignToElement).toHaveBeenCalled(); + })); + + it('should hide the ink bar when all the links are inactive', () => { + const inkBar = fixture.componentInstance.tabNavBar._inkBar; + + spyOn(inkBar, 'hide'); + + fixture.componentInstance.tabLinks.forEach(link => link.active = false); + fixture.detectChanges(); + + expect(inkBar.hide).toHaveBeenCalled(); + }); + + }); + + it('should hide the ink bar if no tabs are active on init', fakeAsync(() => { + const fixture = TestBed.createComponent(TabBarWithInactiveTabsOnInit); + fixture.detectChanges(); + tick(20); // Angular turns rAF calls into 16.6ms timeouts in tests. + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelectorAll('.mdc-tab-indicator--active').length).toBe(0); + })); + + it('should clean up the ripple event handlers on destroy', () => { + let fixture: ComponentFixture = TestBed.createComponent(TabLinkWithNgIf); + fixture.detectChanges(); + + let link = fixture.debugElement.nativeElement.querySelector('.mat-mdc-tab-link'); + + fixture.componentInstance.isDestroyed = true; + fixture.detectChanges(); + + dispatchMouseEvent(link, 'mousedown'); + + expect(link.querySelector('.mat-ripple-element')) + .toBeFalsy('Expected no ripple to be created when ripple target is destroyed.'); + }); + + it('should support the native tabindex attribute', () => { + const fixture = TestBed.createComponent(TabLinkWithNativeTabindexAttr); + fixture.detectChanges(); + + const tabLink = fixture.debugElement.query(By.directive(MatTabLink)) + .injector.get(MatTabLink); + + expect(tabLink.tabIndex) + .toBe(5, 'Expected the tabIndex to be set from the native tabindex attribute.'); + }); + + it('should support binding to the tabIndex', () => { + const fixture = TestBed.createComponent(TabLinkWithTabIndexBinding); + fixture.detectChanges(); + + const tabLink = fixture.debugElement.query(By.directive(MatTabLink)) + .injector.get(MatTabLink); + + expect(tabLink.tabIndex).toBe(0, 'Expected the tabIndex to be set to 0 by default.'); + + fixture.componentInstance.tabIndex = 3; + fixture.detectChanges(); + + expect(tabLink.tabIndex).toBe(3, 'Expected the tabIndex to be have been set to 3.'); + }); + + describe('ripples', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabNavBarTestApp); + fixture.detectChanges(); + }); + + it('should be disabled on all tab links when they are disabled on the nav bar', () => { + expect(fixture.componentInstance.tabLinks.toArray().every(tabLink => !tabLink.rippleDisabled)) + .toBe(true, 'Expected every tab link to have ripples enabled'); + + fixture.componentInstance.disableRippleOnBar = true; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabLinks.toArray().every(tabLink => tabLink.rippleDisabled)) + .toBe(true, 'Expected every tab link to have ripples disabled'); + }); + + it('should have the `disableRipple` from the tab take precedence over the nav bar', () => { + const firstTab = fixture.componentInstance.tabLinks.first; + + expect(firstTab.rippleDisabled).toBe(false, 'Expected ripples to be enabled on first tab'); + + firstTab.disableRipple = true; + fixture.componentInstance.disableRippleOnBar = false; + fixture.detectChanges(); + + expect(firstTab.rippleDisabled).toBe(true, 'Expected ripples to be disabled on first tab'); + }); + + it('should show up for tab link elements on mousedown', () => { + const tabLink = fixture.debugElement.nativeElement.querySelector('.mat-mdc-tab-link'); + + dispatchMouseEvent(tabLink, 'mousedown'); + dispatchMouseEvent(tabLink, 'mouseup'); + + expect(tabLink.querySelectorAll('.mat-ripple-element').length) + .toBe(1, 'Expected one ripple to show up if user clicks on tab link.'); + }); + + it('should be able to disable ripples on an individual tab link', () => { + const tabLinkDebug = fixture.debugElement.query(By.css('a')); + const tabLinkElement = tabLinkDebug.nativeElement; + + fixture.componentInstance.disableRippleOnLink = true; + fixture.detectChanges(); + + dispatchMouseEvent(tabLinkElement, 'mousedown'); + dispatchMouseEvent(tabLinkElement, 'mouseup'); + + expect(tabLinkElement.querySelectorAll('.mat-ripple-element').length) + .toBe(0, 'Expected no ripple to show up if ripples are disabled.'); + }); + + it('should be able to disable ripples through global options at runtime', () => { + expect(fixture.componentInstance.tabLinks.toArray().every(tabLink => !tabLink.rippleDisabled)) + .toBe(true, 'Expected every tab link to have ripples enabled'); + + globalRippleOptions.disabled = true; + + expect(fixture.componentInstance.tabLinks.toArray().every(tabLink => tabLink.rippleDisabled)) + .toBe(true, 'Expected every tab link to have ripples disabled'); + }); + }); +}); + +@Component({ + selector: 'test-app', + template: ` + + ` +}) +class SimpleTabNavBarTestApp { + @ViewChild(MatTabNav, {static: false}) tabNavBar: MatTabNav; + @ViewChildren(MatTabLink) tabLinks: QueryList; + + label = ''; + disabled = false; + disableRippleOnBar = false; + disableRippleOnLink = false; + tabs = [0, 1, 2]; + + activeIndex = 0; +} + +@Component({ + template: ` + + ` +}) +class TabLinkWithNgIf { + isDestroyed = false; +} + +@Component({ + template: ` + + ` +}) +class TabLinkWithTabIndexBinding { + tabIndex = 0; +} + +@Component({ + template: ` + + ` +}) +class TabLinkWithNativeTabindexAttr {} + + +@Component({ + template: ` + + ` +}) +class TabBarWithInactiveTabsOnInit { + tabs = [0, 1, 2]; +} diff --git a/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts new file mode 100644 index 000000000000..90cf2225b471 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab-nav-bar/tab-nav-bar.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { + ChangeDetectionStrategy, + Component, + ContentChildren, + ElementRef, + forwardRef, + QueryList, + ViewChild, + ViewEncapsulation, + Optional, + Inject, + Attribute, + OnDestroy, + AfterContentInit, + NgZone, + ChangeDetectorRef, +} from '@angular/core'; +import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; +import {MAT_RIPPLE_GLOBAL_OPTIONS, RippleGlobalOptions} from '@angular/material/core'; +import {FocusMonitor} from '@angular/cdk/a11y'; +import {_MatTabNavBase, _MatTabLinkBase} from '@angular/material/tabs'; +import {DOCUMENT} from '@angular/common'; +import {Directionality} from '@angular/cdk/bidi'; +import {ViewportRuler} from '@angular/cdk/scrolling'; +import {Platform} from '@angular/cdk/platform'; +import {MatInkBar, MatInkBarItem, MatInkBarFoundation} from '../ink-bar'; + + +/** + * Navigation component matching the styles of the tab group header. + * Provides anchored navigation with animated ink bar. + */ +@Component({ + moduleId: module.id, + selector: '[mat-tab-nav-bar]', + exportAs: 'matTabNavBar, matTabNav', + inputs: ['color'], + templateUrl: 'tab-nav-bar.html', + styleUrls: ['tab-nav-bar.css'], + host: { + 'class': 'mat-mdc-tab-nav-bar mat-mdc-tab-header', + '[class.mat-mdc-tab-header-pagination-controls-enabled]': '_showPaginationControls', + '[class.mat-mdc-tab-header-rtl]': "_getLayoutDirection() == 'rtl'", + '[class.mat-primary]': 'color !== "warn" && color !== "accent"', + '[class.mat-accent]': 'color === "accent"', + '[class.mat-warn]': 'color === "warn"', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MatTabNav extends _MatTabNavBase implements AfterContentInit { + @ContentChildren(forwardRef(() => MatTabLink), {descendants: true}) _items: QueryList; + @ViewChild('tabListContainer', {static: true}) _tabListContainer: ElementRef; + @ViewChild('tabList', {static: true}) _tabList: ElementRef; + @ViewChild('nextPaginator', {static: false}) _nextPaginator: ElementRef; + @ViewChild('previousPaginator', {static: false}) _previousPaginator: ElementRef; + _inkBar: MatInkBar; + + constructor(elementRef: ElementRef, + @Optional() dir: Directionality, + ngZone: NgZone, + changeDetectorRef: ChangeDetectorRef, + viewportRuler: ViewportRuler, + /** + * @deprecated @breaking-change 9.0.0 `platform` parameter to become required. + */ + @Optional() platform?: Platform, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(elementRef, dir, ngZone, changeDetectorRef, viewportRuler, platform, animationMode); + } + + ngAfterContentInit() { + this._inkBar = new MatInkBar(this._items); + super.ngAfterContentInit(); + } +} + +/** + * Link inside of a `mat-tab-nav-bar`. + */ +@Component({ + moduleId: module.id, + selector: '[mat-tab-link], [matTabLink]', + exportAs: 'matTabLink', + inputs: ['disabled', 'disableRipple', 'tabIndex'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + templateUrl: 'tab-link.html', + styleUrls: ['tab-link.css'], + host: { + 'class': 'mdc-tab mat-mdc-tab-link', + '[attr.aria-current]': 'active ? "page" : null', + '[attr.aria-disabled]': 'disabled', + '[attr.tabIndex]': 'tabIndex', + '[class.mat-mdc-tab-disabled]': 'disabled', + '[class.mdc-tab--active]': 'active', + } +}) +export class MatTabLink extends _MatTabLinkBase implements MatInkBarItem, OnDestroy { + _foundation: MatInkBarFoundation; + + constructor( + tabNavBar: MatTabNav, + elementRef: ElementRef, + @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) globalRippleOptions: RippleGlobalOptions|null, + @Attribute('tabindex') tabIndex: string, focusMonitor: FocusMonitor, + @Inject(DOCUMENT) _document: any, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(tabNavBar, elementRef, globalRippleOptions, tabIndex, focusMonitor, animationMode); + this._foundation = new MatInkBarFoundation(elementRef, _document); + this._foundation.init(); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this._foundation.destroy(); + } +} diff --git a/src/material-experimental/mdc-tabs/tab.html b/src/material-experimental/mdc-tabs/tab.html new file mode 100644 index 000000000000..99442c1f1e37 --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab.html @@ -0,0 +1,4 @@ + + diff --git a/src/material-experimental/mdc-tabs/tab.ts b/src/material-experimental/mdc-tabs/tab.ts new file mode 100644 index 000000000000..c354577f808c --- /dev/null +++ b/src/material-experimental/mdc-tabs/tab.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { + ChangeDetectionStrategy, + Component, + ViewEncapsulation, + TemplateRef, + ContentChild, +} from '@angular/core'; +import {MatTab as BaseMatTab} from '@angular/material/tabs'; +import {MatTabContent} from './tab-content'; +import {MatTabLabel} from './tab-label'; + +@Component({ + moduleId: module.id, + selector: 'mat-tab', + + // Note that usually we'd go through a bit more trouble and set up another class so that + // the inlined template of `MatTab` isn't duplicated, however the template is small enough + // that creating the extra class will generate more code than just duplicating the template. + templateUrl: 'tab.html', + inputs: ['disabled'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + exportAs: 'matTab', +}) +export class MatTab extends BaseMatTab { + /** + * Template provided in the tab content that will be used if present, used to enable lazy-loading + */ + @ContentChild(MatTabContent, {read: TemplateRef, static: true}) + _explicitContent: TemplateRef; + + /** Content for the tab label given by ``. */ + @ContentChild(MatTabLabel, {static: false}) templateLabel: MatTabLabel; +} diff --git a/src/material-experimental/mdc-tabs/tabs.e2e.spec.ts b/src/material-experimental/mdc-tabs/tabs.e2e.spec.ts index f98bacfad40b..e87348a532f4 100644 --- a/src/material-experimental/mdc-tabs/tabs.e2e.spec.ts +++ b/src/material-experimental/mdc-tabs/tabs.e2e.spec.ts @@ -1 +1,91 @@ -// TODO: copy tests from existing tabs, update as necessary to fix. +import { + browser, + by, + element, + ElementArrayFinder, + Key, + ExpectedConditions +} from 'protractor'; +import {pressKeys} from '@angular/cdk/private/testing/e2e'; + +describe('MDC tabs', () => { + describe('basic behavior', () => { + let tabLabels: ElementArrayFinder; + let tabBodies: ElementArrayFinder; + + beforeEach(async () => { + await browser.get('/mdc-tabs'); + tabLabels = element.all(by.css('.mat-mdc-tab')); + tabBodies = element.all(by.css('mat-tab-body')); + }); + + it('should change tabs when the label is clicked', async () => { + await tabLabels.get(1).click(); + expect(await getLabelActiveStates(tabLabels)).toEqual([false, true, false]); + expect(await getBodyActiveStates(tabBodies)).toEqual([false, true, false]); + + await browser.wait(ExpectedConditions.not( + ExpectedConditions.presenceOf(element(by.css('div.mat-ripple-element'))))); + + await tabLabels.get(0).click(); + expect(await getLabelActiveStates(tabLabels)).toEqual([true, false, false]); + expect(await getBodyActiveStates(tabBodies)).toEqual([true, false, false]); + + await browser.wait(ExpectedConditions.not( + ExpectedConditions.presenceOf(element(by.css('div.mat-ripple-element'))))); + }); + + it('should change focus with keyboard interaction', async () => { + const right = Key.RIGHT; + const left = Key.LEFT; + + await tabLabels.get(0).click(); + expect(await getFocusStates(tabLabels)).toEqual([true, false, false]); + + await pressKeys(right); + expect(await getFocusStates(tabLabels)).toEqual([false, true, false]); + + await pressKeys(right); + expect(await getFocusStates(tabLabels)).toEqual([false, false, true]); + + await pressKeys(left); + expect(await getFocusStates(tabLabels)).toEqual([false, true, false]); + + await pressKeys(left); + expect(await getFocusStates(tabLabels)).toEqual([true, false, false]); + }); + }); +}); + +/** + * Returns an array of true/false that represents the focus states of the provided elements. + */ +async function getFocusStates(elements: ElementArrayFinder) { + return elements.map(async el => { + const elementText = await el!.getText(); + const activeText = await browser.driver.switchTo().activeElement().getText(); + + return activeText === elementText; + }); +} + +/** Returns an array of true/false that represents the active states for the provided elements. */ +function getLabelActiveStates(elements: ElementArrayFinder) { + return getClassStates(elements, 'mdc-tab--active'); +} + +/** Returns an array of true/false that represents the active states for the provided elements */ +function getBodyActiveStates(elements: ElementArrayFinder) { + return getClassStates(elements, 'mat-mdc-tab-body-active'); +} + +/** + * Returns an array of true/false values that represents whether the provided className is on + * each element. + */ +async function getClassStates(elements: ElementArrayFinder, className: string) { + return elements.map(async el => { + const classes = await el!.getAttribute('class'); + return classes.split(/ +/g).indexOf(className) > -1; + }); +} diff --git a/src/material-experimental/mdc-tabs/tabs.scss b/src/material-experimental/mdc-tabs/tabs.scss deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/src/material-experimental/mdc-tabs/tabs.spec.ts b/src/material-experimental/mdc-tabs/tabs.spec.ts deleted file mode 100644 index f98bacfad40b..000000000000 --- a/src/material-experimental/mdc-tabs/tabs.spec.ts +++ /dev/null @@ -1 +0,0 @@ -// TODO: copy tests from existing tabs, update as necessary to fix. diff --git a/src/material/tabs/paginated-tab-header.ts b/src/material/tabs/paginated-tab-header.ts index 8c539eee66e0..1b1200443513 100644 --- a/src/material/tabs/paginated-tab-header.ts +++ b/src/material/tabs/paginated-tab-header.ts @@ -25,7 +25,6 @@ import {FocusKeyManager, FocusableOption} from '@angular/cdk/a11y'; import {END, ENTER, HOME, SPACE, hasModifierKey} from '@angular/cdk/keycodes'; import {merge, of as observableOf, Subject, timer, fromEvent} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; -import {MatInkBar} from './ink-bar'; import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; @@ -59,7 +58,7 @@ const HEADER_SCROLL_DELAY = 650; const HEADER_SCROLL_INTERVAL = 100; /** Item inside a paginated tab header. */ -type MatPaginatedTabHeaderItem = FocusableOption & {elementRef: ElementRef}; +export type MatPaginatedTabHeaderItem = FocusableOption & {elementRef: ElementRef}; /** * Base class for a tab header that supported pagination. @@ -67,7 +66,7 @@ type MatPaginatedTabHeaderItem = FocusableOption & {elementRef: ElementRef}; export abstract class MatPaginatedTabHeader implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy { abstract _items: QueryList; - abstract _inkBar: MatInkBar; + abstract _inkBar: {hide: () => void, alignToElement: (element: HTMLElement) => void}; abstract _tabListContainer: ElementRef; abstract _tabList: ElementRef; abstract _nextPaginator: ElementRef; diff --git a/src/material/tabs/public-api.ts b/src/material/tabs/public-api.ts index b8865e5539a7..cfa2f2a09080 100644 --- a/src/material/tabs/public-api.ts +++ b/src/material/tabs/public-api.ts @@ -11,15 +11,16 @@ export * from './tab-group'; export {MatInkBar, _MatInkBarPositioner, _MAT_INK_BAR_POSITIONER} from './ink-bar'; export { MatTabBody, + _MatTabBodyBase, MatTabBodyOriginState, MatTabBodyPositionState, MatTabBodyPortal } from './tab-body'; -export {MatTabHeader} from './tab-header'; +export {MatTabHeader, _MatTabHeaderBase} from './tab-header'; export {MatTabLabelWrapper} from './tab-label-wrapper'; export {MatTab} from './tab'; export {MatTabLabel} from './tab-label'; -export {MatTabNav, MatTabLink} from './tab-nav-bar/index'; +export {MatTabNav, MatTabLink, _MatTabNavBase, _MatTabLinkBase} from './tab-nav-bar/index'; export {MatTabContent} from './tab-content'; export {ScrollDirection} from './paginated-tab-header'; export * from './tabs-animations'; diff --git a/src/material/tabs/tab-body.ts b/src/material/tabs/tab-body.ts index 521f0767e9b4..4ec1bb65089c 100644 --- a/src/material/tabs/tab-body.ts +++ b/src/material/tabs/tab-body.ts @@ -98,24 +98,9 @@ export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestr } } -/** - * Wrapper for the contents of a tab. - * @docs-private - */ -@Component({ - moduleId: module.id, - selector: 'mat-tab-body', - templateUrl: 'tab-body.html', - styleUrls: ['tab-body.css'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - animations: [matTabsAnimations.translateTab], - host: { - 'class': 'mat-tab-body', - }, -}) -export class MatTabBody implements OnInit, OnDestroy { - +/** Base class with all of the `MatTabBody` functionality. */ +// tslint:disable-next-line:class-name +export abstract class _MatTabBodyBase implements OnInit, OnDestroy { /** Current position of the tab-body in the tab-group. Zero means that the tab is visible. */ private _positionIndex: number; @@ -141,7 +126,7 @@ export class MatTabBody implements OnInit, OnDestroy { @Output() readonly _onCentered: EventEmitter = new EventEmitter(true); /** The portal host inside of this container into which the tab body content will be loaded. */ - @ViewChild(PortalHostDirective, {static: false}) _portalHost: PortalHostDirective; + abstract _portalHost: PortalHostDirective; /** The tab body content to display. */ @Input('content') _content: TemplatePortal; @@ -248,3 +233,29 @@ export class MatTabBody implements OnInit, OnDestroy { return 'right-origin-center'; } } + +/** + * Wrapper for the contents of a tab. + * @docs-private + */ +@Component({ + moduleId: module.id, + selector: 'mat-tab-body', + templateUrl: 'tab-body.html', + styleUrls: ['tab-body.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [matTabsAnimations.translateTab], + host: { + 'class': 'mat-tab-body', + } +}) +export class MatTabBody extends _MatTabBodyBase { + @ViewChild(PortalHostDirective, {static: false}) _portalHost: PortalHostDirective; + + constructor(elementRef: ElementRef, + @Optional() dir: Directionality, + changeDetectorRef: ChangeDetectorRef) { + super(elementRef, dir, changeDetectorRef); + } +} diff --git a/src/material/tabs/tab-group.ts b/src/material/tabs/tab-group.ts index b4c0195a2ce2..38978cd0b64b 100644 --- a/src/material/tabs/tab-group.ts +++ b/src/material/tabs/tab-group.ts @@ -37,7 +37,6 @@ import { } from '@angular/material/core'; import {merge, Subscription} from 'rxjs'; import {MatTab} from './tab'; -import {MatTabHeader} from './tab-header'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; @@ -66,40 +65,24 @@ export const MAT_TABS_CONFIG = new InjectionToken('MAT_TABS_CONFI // Boilerplate for applying mixins to MatTabGroup. /** @docs-private */ -class MatTabGroupBase { +class MatTabGroupMixinBase { constructor(public _elementRef: ElementRef) {} } -const _MatTabGroupMixinBase: CanColorCtor & CanDisableRippleCtor & typeof MatTabGroupBase = - mixinColor(mixinDisableRipple(MatTabGroupBase), 'primary'); +const _MatTabGroupMixinBase: CanColorCtor & CanDisableRippleCtor & typeof MatTabGroupMixinBase = + mixinColor(mixinDisableRipple(MatTabGroupMixinBase), 'primary'); -/** - * Material design tab-group component. Supports basic tab pairs (label + content) and includes - * animated ink-bar, keyboard navigation, and screen reader. - * See: https://material.io/design/components/tabs.html - */ -@Component({ - moduleId: module.id, - selector: 'mat-tab-group', - exportAs: 'matTabGroup', - templateUrl: 'tab-group.html', - styleUrls: ['tab-group.css'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, - inputs: ['color', 'disableRipple'], - host: { - 'class': 'mat-tab-group', - '[class.mat-tab-group-dynamic-height]': 'dynamicHeight', - '[class.mat-tab-group-inverted-header]': 'headerPosition === "below"', - }, -}) -export class MatTabGroup extends _MatTabGroupMixinBase implements AfterContentInit, - AfterContentChecked, OnDestroy, CanColor, CanDisableRipple { - - @ContentChildren(MatTab) _tabs: QueryList; - - @ViewChild('tabBodyWrapper', {static: false}) _tabBodyWrapper: ElementRef; +interface MatTabGroupBaseHeader { + _alignInkBarToSelectedTab: () => void; + focusIndex: number; +} - @ViewChild('tabHeader', {static: false}) _tabHeader: MatTabHeader; +/** Base class with all of the `MatTabGroupBase` functionality. */ +// tslint:disable-next-line:class-name +export abstract class _MatTabGroupBase extends _MatTabGroupMixinBase implements AfterContentInit, + AfterContentChecked, OnDestroy, CanColor, CanDisableRipple { + abstract _tabs: QueryList; + abstract _tabBodyWrapper: ElementRef; + abstract _tabHeader: MatTabGroupBaseHeader; /** The tab index that should be selected after the content has been checked. */ private _indexToSelect: number | null = 0; @@ -342,7 +325,7 @@ export class MatTabGroup extends _MatTabGroupMixinBase implements AfterContentIn } /** Handle click events, setting new selected index if appropriate. */ - _handleClick(tab: MatTab, tabHeader: MatTabHeader, index: number) { + _handleClick(tab: MatTab, tabHeader: MatTabGroupBaseHeader, index: number) { if (!tab.disabled) { this.selectedIndex = tabHeader.focusIndex = index; } @@ -356,3 +339,36 @@ export class MatTabGroup extends _MatTabGroupMixinBase implements AfterContentIn return this.selectedIndex === idx ? 0 : -1; } } + +/** + * Material design tab-group component. Supports basic tab pairs (label + content) and includes + * animated ink-bar, keyboard navigation, and screen reader. + * See: https://material.io/design/components/tabs.html + */ +@Component({ + moduleId: module.id, + selector: 'mat-tab-group', + exportAs: 'matTabGroup', + templateUrl: 'tab-group.html', + styleUrls: ['tab-group.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + inputs: ['color', 'disableRipple'], + host: { + 'class': 'mat-tab-group', + '[class.mat-tab-group-dynamic-height]': 'dynamicHeight', + '[class.mat-tab-group-inverted-header]': 'headerPosition === "below"', + }, +}) +export class MatTabGroup extends _MatTabGroupBase { + @ContentChildren(MatTab) _tabs: QueryList; + @ViewChild('tabBodyWrapper', {static: false}) _tabBodyWrapper: ElementRef; + @ViewChild('tabHeader', {static: false}) _tabHeader: MatTabGroupBaseHeader; + + constructor(elementRef: ElementRef, + changeDetectorRef: ChangeDetectorRef, + @Inject(MAT_TABS_CONFIG) @Optional() defaultConfig?: MatTabsConfig, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(elementRef, changeDetectorRef, defaultConfig, animationMode); + } +} diff --git a/src/material/tabs/tab-header.ts b/src/material/tabs/tab-header.ts index 52676efdd59f..3ea9725325e7 100644 --- a/src/material/tabs/tab-header.ts +++ b/src/material/tabs/tab-header.ts @@ -33,6 +33,33 @@ import {MatTabLabelWrapper} from './tab-label-wrapper'; import {Platform} from '@angular/cdk/platform'; import {MatPaginatedTabHeader} from './paginated-tab-header'; +/** Base class with all of the `MatTabHeader` functionality. */ +// tslint:disable-next-line:class-name +export abstract class _MatTabHeaderBase extends MatPaginatedTabHeader implements + AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy { + + /** Whether the ripple effect is disabled or not. */ + @Input() + get disableRipple() { return this._disableRipple; } + set disableRipple(value: any) { this._disableRipple = coerceBooleanProperty(value); } + private _disableRipple: boolean = false; + + constructor(elementRef: ElementRef, + changeDetectorRef: ChangeDetectorRef, + viewportRuler: ViewportRuler, + @Optional() dir: Directionality, + ngZone: NgZone, + platform: Platform, + // @breaking-change 9.0.0 `_animationMode` parameter to be made required. + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(elementRef, changeDetectorRef, viewportRuler, dir, ngZone, platform, animationMode); + } + + protected _itemSelected(event: KeyboardEvent) { + event.preventDefault(); + } +} + /** * The header of the tab group which displays a list of all the tabs in the tab group. Includes * an ink bar that follows the currently selected tab. When the tabs list's width exceeds the @@ -55,9 +82,7 @@ import {MatPaginatedTabHeader} from './paginated-tab-header'; '[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'", }, }) -export class MatTabHeader extends MatPaginatedTabHeader implements AfterContentChecked, - AfterContentInit, AfterViewInit, OnDestroy { - +export class MatTabHeader extends _MatTabHeaderBase { @ContentChildren(MatTabLabelWrapper) _items: QueryList; @ViewChild(MatInkBar, {static: true}) _inkBar: MatInkBar; @ViewChild('tabListContainer', {static: true}) _tabListContainer: ElementRef; @@ -65,12 +90,6 @@ export class MatTabHeader extends MatPaginatedTabHeader implements AfterContentC @ViewChild('nextPaginator', {static: false}) _nextPaginator: ElementRef; @ViewChild('previousPaginator', {static: false}) _previousPaginator: ElementRef; - /** Whether the ripple effect is disabled or not. */ - @Input() - get disableRipple() { return this._disableRipple; } - set disableRipple(value: any) { this._disableRipple = coerceBooleanProperty(value); } - private _disableRipple: boolean = false; - constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, viewportRuler: ViewportRuler, @@ -81,8 +100,4 @@ export class MatTabHeader extends MatPaginatedTabHeader implements AfterContentC @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { super(elementRef, changeDetectorRef, viewportRuler, dir, ngZone, platform, animationMode); } - - protected _itemSelected(event: KeyboardEvent) { - event.preventDefault(); - } } diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts index a4e536b04834..7f9d89c66d17 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts @@ -45,42 +45,16 @@ import {coerceBooleanProperty} from '@angular/cdk/coercion'; import {FocusMonitor, FocusableOption} from '@angular/cdk/a11y'; import {ANIMATION_MODULE_TYPE} from '@angular/platform-browser/animations'; import {MatInkBar} from '../ink-bar'; -import {MatPaginatedTabHeader} from '../paginated-tab-header'; +import {MatPaginatedTabHeader, MatPaginatedTabHeaderItem} from '../paginated-tab-header'; import {startWith, takeUntil} from 'rxjs/operators'; - -/** - * Navigation component matching the styles of the tab group header. - * Provides anchored navigation with animated ink bar. - */ -@Component({ - moduleId: module.id, - selector: '[mat-tab-nav-bar]', - exportAs: 'matTabNavBar, matTabNav', - inputs: ['color'], - templateUrl: 'tab-nav-bar.html', - styleUrls: ['tab-nav-bar.css'], - host: { - 'class': 'mat-tab-nav-bar mat-tab-header', - '[class.mat-tab-header-pagination-controls-enabled]': '_showPaginationControls', - '[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'", - '[class.mat-primary]': 'color !== "warn" && color !== "accent"', - '[class.mat-accent]': 'color === "accent"', - '[class.mat-warn]': 'color === "warn"', - }, - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class MatTabNav extends MatPaginatedTabHeader implements AfterContentChecked, +/** Base class with all of the `MatTabNav` functionality. */ +// tslint:disable-next-line:class-name +export abstract class _MatTabNavBase extends MatPaginatedTabHeader implements AfterContentChecked, AfterContentInit, OnDestroy { /** Query list of all tab links of the tab navigation. */ - @ContentChildren(forwardRef(() => MatTabLink), {descendants: true}) _items: QueryList; - @ViewChild(MatInkBar, {static: true}) _inkBar: MatInkBar; - @ViewChild('tabListContainer', {static: true}) _tabListContainer: ElementRef; - @ViewChild('tabList', {static: true}) _tabList: ElementRef; - @ViewChild('nextPaginator', {static: false}) _nextPaginator: ElementRef; - @ViewChild('previousPaginator', {static: false}) _previousPaginator: ElementRef; + abstract _items: QueryList; /** Background color of the tab nav. */ @Input() @@ -159,37 +133,64 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentChec } -// Boilerplate for applying mixins to MatTabLink. -class MatTabLinkBase {} -const _MatTabLinkMixinBase: - HasTabIndexCtor & CanDisableRippleCtor & CanDisableCtor & typeof MatTabLinkBase = - mixinTabIndex(mixinDisableRipple(mixinDisabled(MatTabLinkBase))); - /** - * Link inside of a `mat-tab-nav-bar`. + * Navigation component matching the styles of the tab group header. + * Provides anchored navigation with animated ink bar. */ -@Directive({ - selector: '[mat-tab-link], [matTabLink]', - exportAs: 'matTabLink', - inputs: ['disabled', 'disableRipple', 'tabIndex'], +@Component({ + moduleId: module.id, + selector: '[mat-tab-nav-bar]', + exportAs: 'matTabNavBar, matTabNav', + inputs: ['color'], + templateUrl: 'tab-nav-bar.html', + styleUrls: ['tab-nav-bar.css'], host: { - 'class': 'mat-tab-link', - '[attr.aria-current]': 'active ? "page" : null', - '[attr.aria-disabled]': 'disabled', - '[attr.tabIndex]': 'tabIndex', - '[class.mat-tab-disabled]': 'disabled', - '[class.mat-tab-label-active]': 'active', - } + 'class': 'mat-tab-nav-bar mat-tab-header', + '[class.mat-tab-header-pagination-controls-enabled]': '_showPaginationControls', + '[class.mat-tab-header-rtl]': "_getLayoutDirection() == 'rtl'", + '[class.mat-primary]': 'color !== "warn" && color !== "accent"', + '[class.mat-accent]': 'color === "accent"', + '[class.mat-warn]': 'color === "warn"', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MatTabLink extends _MatTabLinkMixinBase implements OnDestroy, CanDisable, +export class MatTabNav extends _MatTabNavBase { + @ContentChildren(forwardRef(() => MatTabLink), {descendants: true}) _items: QueryList; + @ViewChild(MatInkBar, {static: true}) _inkBar: MatInkBar; + @ViewChild('tabListContainer', {static: true}) _tabListContainer: ElementRef; + @ViewChild('tabList', {static: true}) _tabList: ElementRef; + @ViewChild('nextPaginator', {static: false}) _nextPaginator: ElementRef; + @ViewChild('previousPaginator', {static: false}) _previousPaginator: ElementRef; + + constructor(elementRef: ElementRef, + @Optional() dir: Directionality, + ngZone: NgZone, + changeDetectorRef: ChangeDetectorRef, + viewportRuler: ViewportRuler, + /** + * @deprecated @breaking-change 9.0.0 `platform` parameter to become required. + */ + @Optional() platform?: Platform, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(elementRef, dir, ngZone, changeDetectorRef, viewportRuler, platform, animationMode); + } +} + +// Boilerplate for applying mixins to MatTabLink. +class MatTabLinkMixinBase {} +const _MatTabLinkMixinBase: + HasTabIndexCtor & CanDisableRippleCtor & CanDisableCtor & typeof MatTabLinkMixinBase = + mixinTabIndex(mixinDisableRipple(mixinDisabled(MatTabLinkMixinBase))); + +/** Base class with all of the `MatTabLink` functionality. */ +// tslint:disable-next-line:class-name +export class _MatTabLinkBase extends _MatTabLinkMixinBase implements OnDestroy, CanDisable, CanDisableRipple, HasTabIndex, RippleTarget, FocusableOption { /** Whether the tab link is active or not. */ protected _isActive: boolean = false; - /** Reference to the RippleRenderer for the tab-link. */ - protected _tabLinkRipple: RippleRenderer; - /** Whether the link is active. */ @Input() get active(): boolean { return this._isActive; } @@ -218,15 +219,12 @@ export class MatTabLink extends _MatTabLinkMixinBase implements OnDestroy, CanDi } constructor( - private _tabNavBar: MatTabNav, public elementRef: ElementRef, ngZone: NgZone, - platform: Platform, + private _tabNavBar: _MatTabNavBase, public elementRef: ElementRef, @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) globalRippleOptions: RippleGlobalOptions|null, @Attribute('tabindex') tabIndex: string, private _focusMonitor: FocusMonitor, @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { super(); - this._tabLinkRipple = new RippleRenderer(this, ngZone, elementRef, platform); - this._tabLinkRipple.setupTriggerEvents(elementRef); this.rippleConfig = globalRippleOptions || {}; this.tabIndex = parseInt(tabIndex) || 0; @@ -242,7 +240,44 @@ export class MatTabLink extends _MatTabLinkMixinBase implements OnDestroy, CanDi } ngOnDestroy() { - this._tabLinkRipple._removeTriggerEvents(); this._focusMonitor.stopMonitoring(this.elementRef); } } + + +/** + * Link inside of a `mat-tab-nav-bar`. + */ +@Directive({ + selector: '[mat-tab-link], [matTabLink]', + exportAs: 'matTabLink', + inputs: ['disabled', 'disableRipple', 'tabIndex'], + host: { + 'class': 'mat-tab-link', + '[attr.aria-current]': 'active ? "page" : null', + '[attr.aria-disabled]': 'disabled', + '[attr.tabIndex]': 'tabIndex', + '[class.mat-tab-disabled]': 'disabled', + '[class.mat-tab-label-active]': 'active', + } +}) +export class MatTabLink extends _MatTabLinkBase implements OnDestroy { + /** Reference to the RippleRenderer for the tab-link. */ + private _tabLinkRipple: RippleRenderer; + + constructor( + tabNavBar: MatTabNav, elementRef: ElementRef, ngZone: NgZone, + platform: Platform, + @Optional() @Inject(MAT_RIPPLE_GLOBAL_OPTIONS) globalRippleOptions: RippleGlobalOptions|null, + @Attribute('tabindex') tabIndex: string, focusMonitor: FocusMonitor, + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationMode?: string) { + super(tabNavBar, elementRef, globalRippleOptions, tabIndex, focusMonitor, animationMode); + this._tabLinkRipple = new RippleRenderer(this, ngZone, elementRef, platform); + this._tabLinkRipple.setupTriggerEvents(elementRef.nativeElement); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this._tabLinkRipple._removeTriggerEvents(); + } +} diff --git a/tools/public_api_guard/material/tabs.d.ts b/tools/public_api_guard/material/tabs.d.ts index 02dc5e54594e..2652c67ff79e 100644 --- a/tools/public_api_guard/material/tabs.d.ts +++ b/tools/public_api_guard/material/tabs.d.ts @@ -7,6 +7,85 @@ export interface _MatInkBarPositioner { }; } +export declare abstract class _MatTabBodyBase implements OnInit, OnDestroy { + readonly _afterLeavingCenter: EventEmitter; + readonly _beforeCentering: EventEmitter; + _content: TemplatePortal; + readonly _onCentered: EventEmitter; + readonly _onCentering: EventEmitter; + abstract _portalHost: PortalHostDirective; + _position: MatTabBodyPositionState; + _translateTabComplete: Subject; + animationDuration: string; + origin: number; + position: number; + constructor(_elementRef: ElementRef, _dir: Directionality, changeDetectorRef: ChangeDetectorRef); + _getLayoutDirection(): Direction; + _isCenterPosition(position: MatTabBodyPositionState | string): boolean; + _onTranslateTabStarted(event: AnimationEvent): void; + ngOnDestroy(): void; + ngOnInit(): void; +} + +export declare abstract class _MatTabGroupBase extends _MatTabGroupMixinBase implements AfterContentInit, AfterContentChecked, OnDestroy, CanColor, CanDisableRipple { + _animationMode?: string | undefined; + abstract _tabBodyWrapper: ElementRef; + abstract _tabHeader: MatTabGroupBaseHeader; + abstract _tabs: QueryList; + readonly animationDone: EventEmitter; + animationDuration: string; + backgroundColor: ThemePalette; + dynamicHeight: boolean; + readonly focusChange: EventEmitter; + headerPosition: MatTabHeaderPosition; + selectedIndex: number | null; + readonly selectedIndexChange: EventEmitter; + readonly selectedTabChange: EventEmitter; + constructor(elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, defaultConfig?: MatTabsConfig, _animationMode?: string | undefined); + _focusChanged(index: number): void; + _getTabContentId(i: number): string; + _getTabIndex(tab: MatTab, idx: number): number | null; + _getTabLabelId(i: number): string; + _handleClick(tab: MatTab, tabHeader: MatTabGroupBaseHeader, index: number): void; + _removeTabBodyWrapperHeight(): void; + _setTabBodyWrapperHeight(tabHeight: number): void; + ngAfterContentChecked(): void; + ngAfterContentInit(): void; + ngOnDestroy(): void; + realignInkBar(): void; +} + +export declare abstract class _MatTabHeaderBase extends MatPaginatedTabHeader implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy { + disableRipple: any; + constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, viewportRuler: ViewportRuler, dir: Directionality, ngZone: NgZone, platform: Platform, animationMode?: string); + protected _itemSelected(event: KeyboardEvent): void; +} + +export declare class _MatTabLinkBase extends _MatTabLinkMixinBase implements OnDestroy, CanDisable, CanDisableRipple, HasTabIndex, RippleTarget, FocusableOption { + protected _isActive: boolean; + active: boolean; + elementRef: ElementRef; + rippleConfig: RippleConfig & RippleGlobalOptions; + readonly rippleDisabled: boolean; + constructor(_tabNavBar: _MatTabNavBase, elementRef: ElementRef, globalRippleOptions: RippleGlobalOptions | null, tabIndex: string, _focusMonitor: FocusMonitor, animationMode?: string); + focus(): void; + ngOnDestroy(): void; +} + +export declare abstract class _MatTabNavBase extends MatPaginatedTabHeader implements AfterContentChecked, AfterContentInit, OnDestroy { + abstract _items: QueryList; + backgroundColor: ThemePalette; + color: ThemePalette; + disableRipple: any; + constructor(elementRef: ElementRef, dir: Directionality, ngZone: NgZone, changeDetectorRef: ChangeDetectorRef, viewportRuler: ViewportRuler, + platform?: Platform, animationMode?: string); + protected _itemSelected(): void; + ngAfterContentInit(): void; + updateActiveLink(_element?: ElementRef): void; +} + export declare const MAT_TABS_CONFIG: InjectionToken; export declare class MatInkBar { @@ -35,24 +114,9 @@ export declare class MatTab extends _MatTabMixinBase implements OnInit, CanDisab ngOnInit(): void; } -export declare class MatTabBody implements OnInit, OnDestroy { - readonly _afterLeavingCenter: EventEmitter; - readonly _beforeCentering: EventEmitter; - _content: TemplatePortal; - readonly _onCentered: EventEmitter; - readonly _onCentering: EventEmitter; +export declare class MatTabBody extends _MatTabBodyBase { _portalHost: PortalHostDirective; - _position: MatTabBodyPositionState; - _translateTabComplete: Subject; - animationDuration: string; - origin: number; - position: number; - constructor(_elementRef: ElementRef, _dir: Directionality, changeDetectorRef: ChangeDetectorRef); - _getLayoutDirection(): Direction; - _isCenterPosition(position: MatTabBodyPositionState | string): boolean; - _onTranslateTabStarted(event: AnimationEvent): void; - ngOnDestroy(): void; - ngOnInit(): void; + constructor(elementRef: ElementRef, dir: Directionality, changeDetectorRef: ChangeDetectorRef); } export declare type MatTabBodyOriginState = 'left' | 'right'; @@ -75,44 +139,21 @@ export declare class MatTabContent { constructor(template: TemplateRef); } -export declare class MatTabGroup extends _MatTabGroupMixinBase implements AfterContentInit, AfterContentChecked, OnDestroy, CanColor, CanDisableRipple { - _animationMode?: string | undefined; +export declare class MatTabGroup extends _MatTabGroupBase { _tabBodyWrapper: ElementRef; - _tabHeader: MatTabHeader; + _tabHeader: MatTabGroupBaseHeader; _tabs: QueryList; - readonly animationDone: EventEmitter; - animationDuration: string; - backgroundColor: ThemePalette; - dynamicHeight: boolean; - readonly focusChange: EventEmitter; - headerPosition: MatTabHeaderPosition; - selectedIndex: number | null; - readonly selectedIndexChange: EventEmitter; - readonly selectedTabChange: EventEmitter; - constructor(elementRef: ElementRef, _changeDetectorRef: ChangeDetectorRef, defaultConfig?: MatTabsConfig, _animationMode?: string | undefined); - _focusChanged(index: number): void; - _getTabContentId(i: number): string; - _getTabIndex(tab: MatTab, idx: number): number | null; - _getTabLabelId(i: number): string; - _handleClick(tab: MatTab, tabHeader: MatTabHeader, index: number): void; - _removeTabBodyWrapperHeight(): void; - _setTabBodyWrapperHeight(tabHeight: number): void; - ngAfterContentChecked(): void; - ngAfterContentInit(): void; - ngOnDestroy(): void; - realignInkBar(): void; + constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, defaultConfig?: MatTabsConfig, animationMode?: string); } -export declare class MatTabHeader extends MatPaginatedTabHeader implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy { +export declare class MatTabHeader extends _MatTabHeaderBase { _inkBar: MatInkBar; _items: QueryList; _nextPaginator: ElementRef; _previousPaginator: ElementRef; _tabList: ElementRef; _tabListContainer: ElementRef; - disableRipple: any; constructor(elementRef: ElementRef, changeDetectorRef: ChangeDetectorRef, viewportRuler: ViewportRuler, dir: Directionality, ngZone: NgZone, platform: Platform, animationMode?: string); - protected _itemSelected(event: KeyboardEvent): void; } export declare type MatTabHeaderPosition = 'above' | 'below'; @@ -128,33 +169,20 @@ export declare class MatTabLabelWrapper extends _MatTabLabelWrapperMixinBase imp getOffsetWidth(): number; } -export declare class MatTabLink extends _MatTabLinkMixinBase implements OnDestroy, CanDisable, CanDisableRipple, HasTabIndex, RippleTarget, FocusableOption { - protected _isActive: boolean; - protected _tabLinkRipple: RippleRenderer; - active: boolean; - elementRef: ElementRef; - rippleConfig: RippleConfig & RippleGlobalOptions; - readonly rippleDisabled: boolean; - constructor(_tabNavBar: MatTabNav, elementRef: ElementRef, ngZone: NgZone, platform: Platform, globalRippleOptions: RippleGlobalOptions | null, tabIndex: string, _focusMonitor: FocusMonitor, animationMode?: string); - focus(): void; +export declare class MatTabLink extends _MatTabLinkBase implements OnDestroy { + constructor(tabNavBar: MatTabNav, elementRef: ElementRef, ngZone: NgZone, platform: Platform, globalRippleOptions: RippleGlobalOptions | null, tabIndex: string, focusMonitor: FocusMonitor, animationMode?: string); ngOnDestroy(): void; } -export declare class MatTabNav extends MatPaginatedTabHeader implements AfterContentChecked, AfterContentInit, OnDestroy { +export declare class MatTabNav extends _MatTabNavBase { _inkBar: MatInkBar; _items: QueryList; _nextPaginator: ElementRef; _previousPaginator: ElementRef; _tabList: ElementRef; _tabListContainer: ElementRef; - backgroundColor: ThemePalette; - color: ThemePalette; - disableRipple: any; constructor(elementRef: ElementRef, dir: Directionality, ngZone: NgZone, changeDetectorRef: ChangeDetectorRef, viewportRuler: ViewportRuler, platform?: Platform, animationMode?: string); - protected _itemSelected(): void; - ngAfterContentInit(): void; - updateActiveLink(_element?: ElementRef): void; } export declare const matTabsAnimations: {