diff --git a/demos/tab-bar.html b/demos/tab-bar.html new file mode 100644 index 00000000000..691c4a563c8 --- /dev/null +++ b/demos/tab-bar.html @@ -0,0 +1,320 @@ + + + + + + Tab Bar - Material Components Catalog + + + + + + + + + + +
+
+
+ + + + Tab +
+
+
+ +
+
+
+
+
+
+ + + + + + + + +
+
+
+
+
+ +
+

RTL

+
+ + +
+
+ +
+

Tab Bar

+ +

Start Alignment

+
+
+
+
+
+ + + +
+
+
+
+
+ +

Center Alignment

+
+
+
+
+
+ + + +
+
+
+
+
+ +

End Alignment

+
+
+
+
+
+ + + +
+
+
+
+
+
+ +
+

Customization

+ +
+
+
+
+
+ + + +
+
+
+
+
+
+
+ + + + + + diff --git a/demos/tab-bar.scss b/demos/tab-bar.scss new file mode 100644 index 00000000000..e86e7f64608 --- /dev/null +++ b/demos/tab-bar.scss @@ -0,0 +1,68 @@ +// +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@import "./common"; +@import "../packages/mdc-tab/mdc-tab"; +@import "../packages/mdc-tab/mixins"; +@import "../packages/mdc-tab-indicator/mdc-tab-indicator"; +@import "../packages/mdc-tab-indicator/mixins"; +@import "../packages/mdc-tab-scroller/mdc-tab-scroller"; +@import "../packages/mdc-tab-bar/mdc-tab-bar"; +@import "../packages/mdc-tab-bar/mixins"; +@import "../packages/mdc-elevation/mixins"; +@import "../packages/mdc-ripple/mixins"; +@import "../packages/mdc-theme/color-palette"; + +.demo { + display: flex; + align-items: center; +} + +.hero-demo-tab-bar { + @include mdc-tab-bar-width(420px); +} + +.custom-demo-tab-bar { + .mdc-tab { + @include mdc-typography(subtitle1); + @include mdc-tab-fixed-width(120px); + @include mdc-tab-text-label-color($material-color-blue-600); + @include mdc-tab-icon-color($material-color-blue-600); + } + + .mdc-tab__ripple { + @include mdc-states($material-color-yellow-700); + } + + .mdc-tab--active { + @include mdc-tab-text-label-color($material-color-blue-900); + @include mdc-tab-icon-color($material-color-blue-900); + } + + .mdc-tab-indicator { + @include mdc-tab-indicator-underline-height(5px); + @include mdc-tab-indicator-underline-color($material-color-yellow-700); + } +} + +.demo-controls { + padding: 0 16px; +} + +.rtl-container { + margin: 24px; + padding: 24px; +} diff --git a/demos/tab-indicator.html b/demos/tab-indicator.html new file mode 100644 index 00000000000..0ad65cf9d15 --- /dev/null +++ b/demos/tab-indicator.html @@ -0,0 +1,269 @@ + + + + + + Tab Indicator - Material Components Catalog + + + + + + + + + + +
+
+
+ + + + Tab Indicator +
+
+
+ +
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+

Sliding Underline Indicator

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ +

Custom Color

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ +

Custom Height

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ +
+ +

Custom Border Radius

+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ +
+

Fading Icon Indicator

+
+
+ + favorite + +
+
+ + star + +
+
+ + lens + +
+
+ +
+ +

Custom Color

+
+
+ + favorite + +
+
+ + star + +
+
+ + lens + +
+
+ +
+ +

Custom Height

+
+
+ + favorite + +
+
+ + star + +
+
+ + lens + +
+
+
+ +
+

Mix and Match

+

This demo shows how to use a sliding indicator with custom SVG icon content.

+
+
+ + + + + + +
+
+ + + + + + +
+
+ + + + + + +
+
+
+
+ + + + + diff --git a/demos/tab-indicator.scss b/demos/tab-indicator.scss new file mode 100644 index 00000000000..ac4c2344d57 --- /dev/null +++ b/demos/tab-indicator.scss @@ -0,0 +1,85 @@ +@import "./common"; +@import "../packages/mdc-tab-indicator/mixins"; +@import "../packages/mdc-tab-indicator/mdc-tab-indicator"; +@import "../packages/mdc-elevation/mixins"; +@import "../packages/mdc-theme/color-palette"; + +.hero { + flex-direction: column; +} + +.demo { + @include mdc-elevation(1); + + display: flex; + width: 400px; + height: 50px; + margin: 2rem 0; +} + +.demo-item { + @include mdc-tab-indicator-surface; + + flex: 2 0 auto; + height: inherit; + background-color: $material-color-grey-50; + cursor: pointer; +} + +.demo-item--narrow { + flex: 1 0 auto; +} + +.demo-item--wide { + flex: 3 0 auto; +} + +.demo-item:hover { + background-color: white; +} + +.demo > .demo-item:nth-child(n + 2) { + border-left: 1px solid $material-color-grey-300; +} + +.demo-label { + position: relative; + z-index: 2; +} + +.custom-color-underline { + @include mdc-tab-indicator-underline-color($material-color-orange-a400); +} + +.custom-color-icon { + @include mdc-tab-indicator-icon-color($material-color-orange-a400); +} + +.custom-height-underline { + @include mdc-tab-indicator-underline-height(8px); +} + +.custom-height-icon { + @include mdc-tab-indicator-icon-height(44px); +} + +.custom-border-radius { + @include mdc-tab-indicator-underline-height(5px); + @include mdc-tab-indicator-underline-top-corner-radius(5px); +} + +.custom-fading-underline { + @include mdc-tab-indicator-underline-height(4px); + @include mdc-tab-indicator-underline-color($material-color-green-a400); + @include mdc-tab-indicator-underline-top-corner-radius(4px); +} + +.custom-sliding-icon { + @include mdc-tab-indicator-icon-height(30px); + @include mdc-tab-indicator-icon-color($material-color-pink-a400); +} + +.custom-icon { + height: inherit; + fill: currentColor; +} diff --git a/demos/tab-scroller.html b/demos/tab-scroller.html new file mode 100644 index 00000000000..4a5e6ee8964 --- /dev/null +++ b/demos/tab-scroller.html @@ -0,0 +1,142 @@ + + + + + + Tab Scroller - Material Components Catalog + + + + + + + + + + +
+
+
+ + + + Tab Scroller +
+
+
+ +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + +
+
+ + +
+
+
+
+
+
+

RTL

+
+ + +
+
+
+
+ + + + + diff --git a/demos/tab-scroller.scss b/demos/tab-scroller.scss new file mode 100644 index 00000000000..d46fff87e48 --- /dev/null +++ b/demos/tab-scroller.scss @@ -0,0 +1,28 @@ +@import "./common"; +@import "../packages/mdc-theme/color-palette"; +@import "../packages/mdc-tab-scroller/mdc-tab-scroller"; + +.demo-scroller { + width: 360px; + outline: 1px solid $material-color-grey-400; +} + +.demo-cube { + width: 43px; + height: 43px; + margin: 10px; +} + +@for $i from 1 through 34 { + .demo-cube:nth-child(#{$i}) { + background-color: rgb(10 + random(230), 10 + random(230), 10 + random(230)); + } +} + +.demo-controller { + padding: 10px 0; +} + +.demo-controller-row { + padding: 5px 0; +} diff --git a/demos/tab.html b/demos/tab.html new file mode 100644 index 00000000000..91b2a27d1df --- /dev/null +++ b/demos/tab.html @@ -0,0 +1,358 @@ + + + + + + Tab - Material Components Catalog + + + + + + + + + + +
+
+
+ + + + Tab +
+
+
+ +
+
+ + + +
+ +
+

RTL

+
+ + +
+
+ +
+

Tabs

+ +

Text Label

+
+ + + +
+ +
+

Icon

+
+ + + +
+ +
+

Text Label and Icon

+
+ + + +
+ +
+

Stacked Text Label and Icon

+
+ + + +
+ +
+

Text Label Width-Matching Indicator

+
+ + + +
+ +
+

Text Label with Icon Indicator

+
+ + + +
+
+ +
+

Customization

+
+ + + +
+
+
+ + + + + + diff --git a/demos/tab.scss b/demos/tab.scss new file mode 100644 index 00000000000..0be33f01451 --- /dev/null +++ b/demos/tab.scss @@ -0,0 +1,62 @@ +// +// Copyright 2018 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +@import "./common"; +@import "../packages/mdc-tab/mixins"; +@import "../packages/mdc-tab/mdc-tab"; +@import "../packages/mdc-tab-indicator/mdc-tab-indicator"; +@import "../packages/mdc-tab-indicator/mixins"; +@import "../packages/mdc-elevation/mixins"; +@import "../packages/mdc-ripple/mixins"; +@import "../packages/mdc-theme/color-palette"; + +.demo { + display: flex; + align-items: center; +} + +.demo-tab { + flex: 0 1 auto; +} + +.demo-controls { + padding: 0 16px; +} + +.rtl-container { + margin: 24px; + padding: 24px; +} + +.custom-tab { + @include mdc-tab-text-label-color($material-color-blue-300); + @include mdc-tab-icon-color($material-color-orange-300); + + .custom-tab-indicator { + @include mdc-tab-indicator-underline-color($material-color-pink-a700); + @include mdc-tab-indicator-underline-height(5px); + @include mdc-tab-indicator-underline-top-corner-radius(5px); + } + + .mdc-tab__ripple { + @include mdc-states($material-color-pink-100); + } + + &.mdc-tab--active { + @include mdc-tab-text-label-color($material-color-blue-900); + @include mdc-tab-icon-color($material-color-orange-900); + } +} diff --git a/docs/docsite-tabs.md b/docs/docsite-tabs.md new file mode 100644 index 00000000000..1c20ce1cb51 --- /dev/null +++ b/docs/docsite-tabs.md @@ -0,0 +1,13 @@ +--- +# This file exists describes the directory housing the tab components. If the +# URL is visited, it will immediately redirect to the Tab component. +title: "Tabs" +layout: detail +section: components +excerpt: "Components for tabbed navigation." +iconId: tabs +path: /catalog/tabs/ +redirect_path: /catalog/tabs/tab/ +--- + +{% include redirect-page.html %} diff --git a/package.json b/package.json index a5db57c6fda..23bf0987d42 100644 --- a/package.json +++ b/package.json @@ -192,6 +192,9 @@ "switch", "tabs", "tab", + "tab-bar", + "tab-indicator", + "tab-scroller", "text-field", "theme", "toolbar", @@ -227,6 +230,9 @@ "mdc-slider", "mdc-switch", "mdc-tab", + "mdc-tab-indicator", + "mdc-tab-scroller", + "mdc-tab-bar", "mdc-textfield", "mdc-top-app-bar" ] diff --git a/packages/material-components-web/index.js b/packages/material-components-web/index.js index 8f19259ccc5..3074d891668 100644 --- a/packages/material-components-web/index.js +++ b/packages/material-components-web/index.js @@ -37,7 +37,10 @@ import * as selectionControl from '@material/selection-control/index'; import * as slider from '@material/slider/index'; import * as snackbar from '@material/snackbar/index'; import * as switchControl from '@material/switch/index'; -import * as tabs from '@material/tabs/index'; +import * as tab from '@material/tab/index'; +import * as tabBar from '@material/tab-bar/index'; +import * as tabIndicator from '@material/tab-indicator/index'; +import * as tabScroller from '@material/tab-scroller/index'; import * as textField from '@material/textfield/index'; import * as toolbar from '@material/toolbar/index'; import * as topAppBar from '@material/top-app-bar/index'; @@ -61,8 +64,7 @@ autoInit.register('MDCList', list.MDCList); autoInit.register('MDCNotchedOutline', notchedOutline.MDCNotchedOutline); autoInit.register('MDCRadio', radio.MDCRadio); autoInit.register('MDCSnackbar', snackbar.MDCSnackbar); -autoInit.register('MDCTab', tabs.MDCTab); -autoInit.register('MDCTabBar', tabs.MDCTabBar); +autoInit.register('MDCTabBar', tabBar.MDCTabBar); autoInit.register('MDCTextField', textField.MDCTextField); autoInit.register('MDCMenu', menu.MDCMenu); autoInit.register('MDCSelect', select.MDCSelect); @@ -96,7 +98,10 @@ export { slider, switchControl, snackbar, - tabs, + tab, + tabBar, + tabIndicator, + tabScroller, textField, toolbar, topAppBar, diff --git a/packages/material-components-web/material-components-web.scss b/packages/material-components-web/material-components-web.scss index 7cdfe3e104a..629a889a12e 100644 --- a/packages/material-components-web/material-components-web.scss +++ b/packages/material-components-web/material-components-web.scss @@ -41,7 +41,10 @@ @import "@material/slider/mdc-slider"; @import "@material/snackbar/mdc-snackbar"; @import "@material/switch/mdc-switch"; -@import "@material/tabs/mdc-tabs"; +@import "@material/tab/mdc-tab"; +@import "@material/tab-bar/mdc-tab-bar"; +@import "@material/tab-indicator/mdc-tab-indicator"; +@import "@material/tab-scroller/mdc-tab-scroller"; @import "@material/textfield/mdc-text-field"; @import "@material/theme/mdc-theme"; @import "@material/toolbar/mdc-toolbar"; diff --git a/packages/material-components-web/package.json b/packages/material-components-web/package.json index 6834031aa92..fbe50ddb2d4 100644 --- a/packages/material-components-web/package.json +++ b/packages/material-components-web/package.json @@ -45,7 +45,10 @@ "@material/slider": "^0.36.0", "@material/snackbar": "^0.37.1", "@material/switch": "^0.36.1", - "@material/tabs": "^0.37.1", + "@material/tab": "^0.37.0", + "@material/tab-bar": "^0.0.0", + "@material/tab-indicator": "^0.0.0", + "@material/tab-scroller": "^0.0.0", "@material/textfield": "^0.37.1", "@material/theme": "^0.35.0", "@material/toolbar": "^0.37.1", diff --git a/packages/mdc-tab-bar/README.md b/packages/mdc-tab-bar/README.md new file mode 100644 index 00000000000..21e002b9bc2 --- /dev/null +++ b/packages/mdc-tab-bar/README.md @@ -0,0 +1,133 @@ + + +# Tab Bar + +Tabs organize and allow navigation between groups of content that are related and at the same level of hierarchy. +The Tab Bar contains the Tab Scroller and Tab components. + +## Design & API Documentation + + + +## Installation + +``` +npm install @material/tab-bar +``` + +## Basic Usage + +### HTML Structure + +```html +
+
+
+
+
+
+ + + +
+ +
+
+ + +``` + +### Styles + +```scss +@import "@material/tab-bar/mdc-tab-bar"; +@import "@material/tab-scroller/mdc-tab-scroller"; +@import "@material/tab-indicator/mdc-tab-indicator"; +@import "@material/tab/mdc-tab"; +``` + +### JavaScript Instantiation + +```js +import {MDCTabBar} from '@material/tab-bar'; + +const tabBar = new MDCTabBar(document.querySelector('.mdc-tab-bar')); +``` + +> See [Importing the JS component](../../docs/importing-js.md) for more information on how to import JavaScript. + +## Style Customization + +### CSS Classes + +CSS Class | Description +--- | --- +`mdc-tab-bar` | Mandatory. + +### Sass Mixins + +To customize the width of the tab bar, use the following mixin. + +Mixin | Description +--- | --- +`mdc-tab-bar-width($width)` | Customizes the width of the tab bar. + +## `MDCTabBar` Properties and Methods + +Method Signature | Description +--- | --- +`activateTab(index: number) => void` | Activates the tab at the given index. +`scrollIntoView(index: number) => void` | Scrolls the tab at the given index into view. + +Event Name | Event Data Structure | Description +--- | --- | --- +`MDCTabBar:activated` | `{"detail": {"index": number}}` | Emitted when a Tab is activated with the index of the activated Tab. Listen for this to update content when a Tab becomes active. + +## Usage within Web Frameworks + +If you are using a JavaScript framework, such as React or Angular, you can create a Tab Bar for your framework. Depending on your needs, you can use the _Simple Approach: Wrapping MDC Web Vanilla Components_, or the _Advanced Approach: Using Foundations and Adapters_. Please follow the instructions [here](../../docs/integrating-into-frameworks.md). + +### `MDCTabBarAdapter` + +Method Signature | Description +--- | --- +`scrollTo(scrollX: number) => void` | Scrolls the Tab Scroller to the given position. +`incrementScroll(scrollXIncrement: number) => void` | Increments the Tab Scroller by the given value. +`getScrollPosition() => number` | Returns the scroll position of the Tab Scroller. +`getScrollContentWidth() => number` | Returns the width of the Tab Scroller's scroll content element. +`getOffsetWidth() => number` | Returns the offsetWidth of the root element. +`isRTL() => boolean` | Returns if the text direction is RTL. +`activateTabAtIndex(index: number, clientRect: ClientRect) => void` | Activates the Tab at the given index with the given clientRect. +`deactivateTabAtIndex(index) => void` | Deactivates the Tab at the given index. +`getTabIndicatorClientRectAtIndex(index: number) => ClientRect` | Returns the client rect of the Tab at the given index. +`getTabDimensionsAtIndex(index) => MDCTabDimensions` | Returns the dimensions of the Tab at the given index. +`getTabListLength() => number` | Returns the number of child Tab components. +`getActiveTabIndex() => number` | Returns the index of the active Tab. +`getIndexOfTab(tab: MDCTab) => number` | Returns the index of the given Tab instance. +`notifyTabActivated(index: number) => void` | Emits the `MDCTabBar:activated` event. + +### `MDCTabBarFoundation` + +Method Signature | Description +--- | --- +`activateTab(index: number) => void` | Activates the Tab at the given index. +`handleKeyDown(evt: Event) => void` | Handles the logic for the `"keydown"` event. +`handleTabInteraction(evt: Event) => void` | Handles the logic for the `"MDCTab:interacted"` event. +`scrollIntoView(index: number) => void` | Scrolls the Tab at the given index into view. diff --git a/packages/mdc-tab-bar/_mixins.scss b/packages/mdc-tab-bar/_mixins.scss new file mode 100644 index 00000000000..0ea61ac3562 --- /dev/null +++ b/packages/mdc-tab-bar/_mixins.scss @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +@mixin mdc-tab-bar-width($width) { + width: $width; +} diff --git a/packages/mdc-tab-bar/adapter.js b/packages/mdc-tab-bar/adapter.js new file mode 100644 index 00000000000..ed9afcd0594 --- /dev/null +++ b/packages/mdc-tab-bar/adapter.js @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +/* eslint no-unused-vars: [2, {"args": "none"}] */ + +/* eslint-disable no-unused-vars */ +import {MDCTabDimensions} from '@material/tab/adapter'; +import {MDCTab} from '@material/tab/index'; +/* eslint-enable no-unused-vars */ + +/** + * Adapter for MDC Tab Bar. + * + * Defines the shape of the adapter expected by the foundation. Implement this + * adapter to integrate the Tab Bar into your framework. See + * https://github.com/material-components/material-components-web/blob/master/docs/authoring-components.md + * for more information. + * + * @record + */ +class MDCTabBarAdapter { + /** + * Scrolls to the given position + * @param {number} scrollX The position to scroll to + */ + scrollTo(scrollX) {} + + /** + * Increments the current scroll position by the given amount + * @param {number} scrollXIncrement The amount to increment scroll + */ + incrementScroll(scrollXIncrement) {} + + /** + * Returns the current scroll position + * @return {number} + */ + getScrollPosition() {} + + /** + * Returns the width of the scroll content + * @return {number} + */ + getScrollContentWidth() {} + + /** + * Returns the root element's offsetWidth + * @return {number} + */ + getOffsetWidth() {} + + /** + * Returns if the Tab Bar language direction is RTL + * @return {boolean} + */ + isRTL() {} + + /** + * Activates the tab at the given index with the given client rect + * @param {number} index The index of the tab to activate + * @param {!ClientRect} clientRect The client rect of the previously active Tab Indicator + */ + activateTabAtIndex(index, clientRect) {} + + /** + * Deactivates the tab at the given index + * @param {number} index The index of the tab to activate + */ + deactivateTabAtIndex(index) {} + + /** + * Returns the client rect of the tab's indicator + * @param {number} index The index of the tab + * @return {!ClientRect} + */ + getTabIndicatorClientRectAtIndex(index) {} + + /** + * Returns the tab dimensions of the tab at the given index + * @param {number} index The index of the tab + * @return {!MDCTabDimensions} + */ + getTabDimensionsAtIndex(index) {} + + /** + * Returns the length of the tab list + * @return {number} + */ + getTabListLength() {} + + /** + * Returns the index of the active tab + * @return {number} + */ + getActiveTabIndex() {} + + /** + * Returns the index of the given tab + * @param {!MDCTab} tab The tab whose index to determin + * @return {number} + */ + getIndexOfTab(tab) {} + + /** + * Emits the MDCTabBar:activated event + * @param {number} index The index of the activated tab + */ + notifyTabActivated(index) {} +} + +export default MDCTabBarAdapter; diff --git a/packages/mdc-tab-bar/constants.js b/packages/mdc-tab-bar/constants.js new file mode 100644 index 00000000000..26f7d4899a6 --- /dev/null +++ b/packages/mdc-tab-bar/constants.js @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** @enum {string} */ +const strings = { + TAB_ACTIVATED_EVENT: 'MDCTabBar:activated', + TAB_SCROLLER_SELECTOR: '.mdc-tab-scroller', + TAB_SELECTOR: '.mdc-tab', + END_KEY: 'End', + HOME_KEY: 'Home', + ARROW_LEFT_KEY: 'ArrowLeft', + ARROW_RIGHT_KEY: 'ArrowRight', +}; + +/** @enum {number} */ +const numbers = { + EXTRA_SCROLL_AMOUNT: 20, + END_KEYCODE: 35, + HOME_KEYCODE: 36, + ARROW_LEFT_KEYCODE: 37, + ARROW_RIGHT_KEYCODE: 39, +}; + +export { + numbers, + strings, +}; diff --git a/packages/mdc-tab-bar/foundation.js b/packages/mdc-tab-bar/foundation.js new file mode 100644 index 00000000000..ccc30691db5 --- /dev/null +++ b/packages/mdc-tab-bar/foundation.js @@ -0,0 +1,402 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCFoundation from '@material/base/foundation'; + +import {strings, numbers} from './constants'; +import MDCTabBarAdapter from './adapter'; + +/* eslint-disable no-unused-vars */ +import MDCTabFoundation from '@material/tab/foundation'; +import {MDCTabDimensions} from '@material/tab/adapter'; +/* eslint-enable no-unused-vars */ + +/** + * @type {Set} + */ +const ACCEPTABLE_KEYS = new Set(); +// IE11 has no support for new Set with iterable so we need to initialize this by hand +ACCEPTABLE_KEYS.add(strings.ARROW_LEFT_KEY); +ACCEPTABLE_KEYS.add(strings.ARROW_RIGHT_KEY); +ACCEPTABLE_KEYS.add(strings.END_KEY); +ACCEPTABLE_KEYS.add(strings.HOME_KEY); + +/** + * @type {Map} + */ +const KEYCODE_MAP = new Map(); +// IE11 has no support for new Map with iterable so we need to initialize this by hand +KEYCODE_MAP.set(numbers.HOME_KEYCODE, strings.HOME_KEY); +KEYCODE_MAP.set(numbers.END_KEYCODE, strings.END_KEY); +KEYCODE_MAP.set(numbers.ARROW_LEFT_KEYCODE, strings.ARROW_LEFT_KEY); +KEYCODE_MAP.set(numbers.ARROW_RIGHT_KEYCODE, strings.ARROW_RIGHT_KEY); + +/** + * @extends {MDCFoundation} + * @final + */ +class MDCTabBarFoundation extends MDCFoundation { + /** @return enum {string} */ + static get strings() { + return strings; + } + + /** @return enum {number} */ + static get numbers() { + return numbers; + } + + /** + * @see MDCTabBarAdapter for typing information + * @return {!MDCTabBarAdapter} + */ + static get defaultAdapter() { + return /** @type {!MDCTabBarAdapter} */ ({ + scrollTo: () => {}, + incrementScroll: () => {}, + getScrollPosition: () => {}, + getScrollContentWidth: () => {}, + getOffsetWidth: () => {}, + isRTL: () => {}, + activateTabAtIndex: () => {}, + deactivateTabAtIndex: () => {}, + getTabIndicatorClientRectAtIndex: () => {}, + getTabDimensionsAtIndex: () => {}, + getActiveTabIndex: () => {}, + getIndexOfTab: () => {}, + getTabListLength: () => {}, + notifyTabActivated: () => {}, + }); + } + + /** + * @param {!MDCTabBarAdapter} adapter + * */ + constructor(adapter) { + super(Object.assign(MDCTabBarFoundation.defaultAdapter, adapter)); + } + + init() { + const activeIndex = this.adapter_.getActiveTabIndex(); + this.scrollIntoView(activeIndex); + } + + /** + * Activates the tab at the given index + * @param {number} index + */ + activateTab(index) { + const previousActiveIndex = this.adapter_.getActiveTabIndex(); + if (!this.indexIsInRange_(index)) { + return; + } + + this.adapter_.deactivateTabAtIndex(previousActiveIndex); + this.adapter_.activateTabAtIndex(index, this.adapter_.getTabIndicatorClientRectAtIndex(previousActiveIndex)); + this.scrollIntoView(index); + + // Only notify the tab activation if the index is different than the previously active index + if (index !== previousActiveIndex) { + this.adapter_.notifyTabActivated(index); + } + } + + /** + * Handles the keydown event + * @param {!Event} evt + */ + handleKeyDown(evt) { + // Get the key from the event + const key = this.getKeyFromEvent_(evt); + + // Early exit if the event key isn't one of the keyboard navigation keys + if (key === undefined) { + return; + } + + evt.preventDefault(); + this.activateTabFromKey_(key); + } + + /** + * Handles the MDCTab:interacted event + * @param {!Event} evt + */ + handleTabInteraction(evt) { + this.activateTab(this.adapter_.getIndexOfTab(evt.detail.tab)); + } + + /** + * Scrolls the tab at the given index into view + * @param {number} index The tab index to make visible + */ + scrollIntoView(index) { + // Early exit if the index is out of range + if (!this.indexIsInRange_(index)) { + return; + } + + // Always scroll to 0 if scrolling to the 0th index + if (index === 0) { + return this.adapter_.scrollTo(0); + } + + // Always scroll to the max value if scrolling to the Nth index + // MDCTabScroller.scrollTo() will never scroll past the max possible value + if (index === this.adapter_.getTabListLength() - 1) { + return this.adapter_.scrollTo(this.adapter_.getScrollContentWidth()); + } + + if (this.isRTL_()) { + return this.scrollIntoViewRTL_(index); + } + + this.scrollIntoView_(index); + } + + /** + * Private method for activating a tab from a key + * @param {string} key The name of the key + * @private + */ + activateTabFromKey_(key) { + const isRTL = this.isRTL_(); + const maxTabIndex = this.adapter_.getTabListLength() - 1; + const shouldGoToEnd = key === strings.END_KEY; + const shouldDecrement = key === strings.ARROW_LEFT_KEY && !isRTL || key === strings.ARROW_RIGHT_KEY && isRTL; + const shouldIncrement = key === strings.ARROW_RIGHT_KEY && !isRTL || key === strings.ARROW_LEFT_KEY && isRTL; + let tabIndex = this.adapter_.getActiveTabIndex(); + + if (shouldGoToEnd) { + tabIndex = maxTabIndex; + } else if (shouldDecrement) { + tabIndex -= 1; + } else if (shouldIncrement) { + tabIndex += 1; + } else { + tabIndex = 0; + } + + if (tabIndex < 0) { + tabIndex = maxTabIndex; + } else if (tabIndex > maxTabIndex) { + tabIndex = 0; + } + + this.activateTab(tabIndex); + } + + /** + * Calculates the scroll increment that will make the tab at the given index visible + * @param {number} index The index of the tab + * @param {number} nextIndex The index of the next tab + * @param {number} scrollPosition The current scroll position + * @param {number} barWidth The width of the Tab Bar + * @return {number} + * @private + */ + calculateScrollIncrement_(index, nextIndex, scrollPosition, barWidth) { + const nextTabDimensions = this.adapter_.getTabDimensionsAtIndex(nextIndex); + const relativeContentLeft = nextTabDimensions.contentLeft - scrollPosition - barWidth; + const relativeContentRight = nextTabDimensions.contentRight - scrollPosition; + const leftIncrement = relativeContentRight - numbers.EXTRA_SCROLL_AMOUNT; + const rightIncrement = relativeContentLeft + numbers.EXTRA_SCROLL_AMOUNT; + + if (nextIndex < index) { + return Math.min(leftIncrement, 0); + } + + return Math.max(rightIncrement, 0); + } + + /** + * Calculates the scroll increment that will make the tab at the given index visible in RTL + * @param {number} index The index of the tab + * @param {number} nextIndex The index of the next tab + * @param {number} scrollPosition The current scroll position + * @param {number} barWidth The width of the Tab Bar + * @param {number} scrollContentWidth The width of the scroll content + * @return {number} + * @private + */ + calculateScrollIncrementRTL_(index, nextIndex, scrollPosition, barWidth, scrollContentWidth, ) { + const nextTabDimensions = this.adapter_.getTabDimensionsAtIndex(nextIndex); + const relativeContentLeft = scrollContentWidth - nextTabDimensions.contentLeft - scrollPosition; + const relativeContentRight = scrollContentWidth - nextTabDimensions.contentRight - scrollPosition - barWidth; + const leftIncrement = relativeContentRight + numbers.EXTRA_SCROLL_AMOUNT; + const rightIncrement = relativeContentLeft - numbers.EXTRA_SCROLL_AMOUNT; + + if (nextIndex > index) { + return Math.max(leftIncrement, 0); + } + + return Math.min(rightIncrement, 0); + } + + /** + * Determines the index of the adjacent tab closest to either edge of the Tab Bar + * @param {number} index The index of the tab + * @param {!MDCTabDimensions} tabDimensions The dimensions of the tab + * @param {number} scrollPosition The current scroll position + * @param {number} barWidth The width of the tab bar + * @return {number} + * @private + */ + findAdjacentTabIndexClosestToEdge_(index, tabDimensions, scrollPosition, barWidth) { + /** + * Tabs are laid out in the Tab Scroller like this: + * + * Scroll Position + * +---+ + * | | Bar Width + * | +-----------------------------------+ + * | | | + * | V V + * | +-----------------------------------+ + * V | Tab Scroller | + * +------------+--------------+-------------------+ + * | Tab | Tab | Tab | + * +------------+--------------+-------------------+ + * | | + * +-----------------------------------+ + * + * To determine the next adjacent index, we look at the Tab root left and + * Tab root right, both relative to the scroll position. If the Tab root + * left is less than 0, then we know it's out of view to the left. If the + * Tab root right minus the bar width is greater than 0, we know the Tab is + * out of view to the right. From there, we either increment or decrement + * the index. + */ + const relativeRootLeft = tabDimensions.rootLeft - scrollPosition; + const relativeRootRight = tabDimensions.rootRight - scrollPosition - barWidth; + const relativeRootDelta = relativeRootLeft + relativeRootRight; + const leftEdgeIsCloser = relativeRootLeft < 0 || relativeRootDelta < 0; + const rightEdgeIsCloser = relativeRootRight > 0 || relativeRootDelta > 0; + + if (leftEdgeIsCloser) { + return index - 1; + } + + if (rightEdgeIsCloser) { + return index + 1; + } + + return -1; + } + + /** + * Determines the index of the adjacent tab closest to either edge of the Tab Bar in RTL + * @param {number} index The index of the tab + * @param {!MDCTabDimensions} tabDimensions The dimensions of the tab + * @param {number} scrollPosition The current scroll position + * @param {number} barWidth The width of the tab bar + * @param {number} scrollContentWidth The width of the scroller content + * @return {number} + * @private + */ + findAdjacentTabIndexClosestToEdgeRTL_(index, tabDimensions, scrollPosition, barWidth, scrollContentWidth) { + const rootLeft = scrollContentWidth - tabDimensions.rootLeft - barWidth - scrollPosition; + const rootRight = scrollContentWidth - tabDimensions.rootRight - scrollPosition; + const rootDelta = rootLeft + rootRight; + const leftEdgeIsCloser = rootLeft > 0 || rootDelta > 0; + const rightEdgeIsCloser = rootRight < 0 || rootDelta < 0; + + if (leftEdgeIsCloser) { + return index + 1; + } + + if (rightEdgeIsCloser) { + return index - 1; + } + + return -1; + } + + /** + * Returns the key associated with a keydown event + * @param {!Event} evt The keydown event + * @return {string} + * @private + */ + getKeyFromEvent_(evt) { + if (ACCEPTABLE_KEYS.has(evt.key)) { + return evt.key; + } + + return KEYCODE_MAP.get(evt.keyCode); + } + + /** + * Returns whether a given index is inclusively between the ends + * @param {number} index The index to test + * @private + */ + indexIsInRange_(index) { + return index >= 0 && index < this.adapter_.getTabListLength(); + } + + /** + * Returns the view's RTL property + * @return {boolean} + * @private + */ + isRTL_() { + return this.adapter_.isRTL(); + } + + /** + * Scrolls the tab at the given index into view for left-to-right useragents + * @param {number} index The index of the tab to scroll into view + * @private + */ + scrollIntoView_(index) { + const scrollPosition = this.adapter_.getScrollPosition(); + const barWidth = this.adapter_.getOffsetWidth(); + const tabDimensions = this.adapter_.getTabDimensionsAtIndex(index); + const nextIndex = this.findAdjacentTabIndexClosestToEdge_(index, tabDimensions, scrollPosition, barWidth); + + if (!this.indexIsInRange_(nextIndex)) { + return; + } + + const scrollIncrement = this.calculateScrollIncrement_(index, nextIndex, scrollPosition, barWidth); + this.adapter_.incrementScroll(scrollIncrement); + } + + /** + * Scrolls the tab at the given index into view in RTL + * @param {number} index The tab index to make visible + * @private + */ + scrollIntoViewRTL_(index) { + const scrollPosition = this.adapter_.getScrollPosition(); + const barWidth = this.adapter_.getOffsetWidth(); + const tabDimensions = this.adapter_.getTabDimensionsAtIndex(index); + const scrollWidth = this.adapter_.getScrollContentWidth(); + const nextIndex = this.findAdjacentTabIndexClosestToEdgeRTL_( + index, tabDimensions, scrollPosition, barWidth, scrollWidth); + + if (!this.indexIsInRange_(nextIndex)) { + return; + } + + const scrollIncrement = this.calculateScrollIncrementRTL_(index, nextIndex, scrollPosition, barWidth, scrollWidth); + this.adapter_.incrementScroll(scrollIncrement); + } +} + +export default MDCTabBarFoundation; diff --git a/packages/mdc-tab-bar/index.js b/packages/mdc-tab-bar/index.js new file mode 100644 index 00000000000..1ec6ea937d6 --- /dev/null +++ b/packages/mdc-tab-bar/index.js @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MDCComponent from '@material/base/component'; + +import {MDCTab, MDCTabFoundation} from '@material/tab/index'; +import {MDCTabScroller} from '@material/tab-scroller/index'; + +import MDCTabBarAdapter from './adapter'; +import MDCTabBarFoundation from './foundation'; + +/** + * @extends {MDCComponent} + * @final + */ +class MDCTabBar extends MDCComponent { + /** + * @param {...?} args + */ + constructor(...args) { + super(...args); + + /** @private {!Array} */ + this.tabList_; + + /** @type {(function(!Element): !MDCTab)} */ + this.tabFactory_; + + /** @private {?MDCTabScroller} */ + this.tabScroller_; + + /** @type {(function(!Element): !MDCTabScroller)} */ + this.tabScrollerFactory_; + + /** @private {?function(?Event): undefined} */ + this.handleTabInteraction_; + + /** @private {?function(?Event): undefined} */ + this.handleKeyDown_; + } + + /** + * @param {!Element} root + * @return {!MDCTabBar} + */ + static attachTo(root) { + return new MDCTabBar(root); + } + + /** + * @param {(function(!Element): !MDCTab)=} tabFactory A function which creates a new MDCTab + * @param {(function(!Element): !MDCTabScroller)=} tabScrollerFactory A function which creates a new MDCTabScroller + */ + initialize( + tabFactory = (el) => new MDCTab(el), + tabScrollerFactory = (el) => new MDCTabScroller(el), + ) { + this.tabFactory_ = tabFactory; + this.tabScrollerFactory_ = tabScrollerFactory; + + const tabElements = [].slice.call(this.root_.querySelectorAll(MDCTabBarFoundation.strings.TAB_SELECTOR)); + this.tabList_ = tabElements.map((el) => this.tabFactory_(el)); + + const tabScrollerElement = this.root_.querySelector(MDCTabBarFoundation.strings.TAB_SCROLLER_SELECTOR); + if (tabScrollerElement) { + this.tabScroller_ = this.tabScrollerFactory_(tabScrollerElement); + } + } + + initialSyncWithDOM() { + this.handleTabInteraction_ = (evt) => this.foundation_.handleTabInteraction(evt); + this.handleKeyDown_ = (evt) => this.foundation_.handleKeyDown(evt); + + this.root_.addEventListener(MDCTabFoundation.strings.INTERACTED_EVENT, this.handleTabInteraction_); + this.root_.addEventListener('keydown', this.handleKeyDown_); + } + + destroy() { + super.destroy(); + this.root_.removeEventListener(MDCTabFoundation.strings.INTERACTED_EVENT, this.handleTabInteraction_); + this.root_.removeEventListener('keydown', this.handleKeyDown_); + this.tabList_.forEach((tab) => tab.destroy()); + this.tabScroller_.destroy(); + } + + /** + * @return {!MDCTabBarFoundation} + */ + getDefaultFoundation() { + return new MDCTabBarFoundation( + /** @type {!MDCTabBarAdapter} */ ({ + scrollTo: (scrollX) => this.tabScroller_.scrollTo(scrollX), + incrementScroll: (scrollXIncrement) => this.tabScroller_.incrementScroll(scrollXIncrement), + getScrollPosition: () => this.tabScroller_.getScrollPosition(), + getScrollContentWidth: () => this.tabScroller_.getScrollContentWidth(), + getOffsetWidth: () => this.root_.offsetWidth, + isRTL: () => window.getComputedStyle(this.root_).getPropertyValue('direction') === 'rtl', + activateTabAtIndex: (index, clientRect) => this.tabList_[index].activate(clientRect), + deactivateTabAtIndex: (index) => this.tabList_[index].deactivate(), + getTabIndicatorClientRectAtIndex: (index) => this.tabList_[index].computeIndicatorClientRect(), + getTabDimensionsAtIndex: (index) => this.tabList_[index].computeDimensions(), + getActiveTabIndex: () => { + for (let i = 0; i < this.tabList_.length; i++) { + if (this.tabList_[i].active) { + return i; + } + } + return -1; + }, + getIndexOfTab: (tabToFind) => this.tabList_.indexOf(tabToFind), + getTabListLength: () => this.tabList_.length, + notifyTabActivated: (index) => this.emit(MDCTabBarFoundation.strings.TAB_ACTIVATED_EVENT, {index}, true), + }) + ); + } + + /** + * Activates the tab at the given index + * @param {number} index The index of the tab + */ + activateTab(index) { + this.foundation_.activateTab(index); + } + + /** + * Scrolls the tab at the given index into view + * @param {number} index THe index of the tab + */ + scrollIntoView(index) { + this.foundation_.scrollIntoView(index); + } +} + +export {MDCTabBar, MDCTabBarFoundation}; diff --git a/packages/mdc-tab-bar/mdc-tab-bar.scss b/packages/mdc-tab-bar/mdc-tab-bar.scss new file mode 100644 index 00000000000..df351f43383 --- /dev/null +++ b/packages/mdc-tab-bar/mdc-tab-bar.scss @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@import "./mixins"; + +.mdc-tab-bar { + @include mdc-tab-bar-width(100%); +} diff --git a/packages/mdc-tab-bar/package.json b/packages/mdc-tab-bar/package.json new file mode 100644 index 00000000000..0ac5e8a37ec --- /dev/null +++ b/packages/mdc-tab-bar/package.json @@ -0,0 +1,26 @@ +{ + "name": "@material/tab-bar", + "description": "The Material Components for the web tab bar component", + "version": "0.0.0", + "license": "Apache-2.0", + "keywords": [ + "material components", + "material design", + "tab", + "bar" + ], + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/material-components/material-components-web.git" + }, + "dependencies": { + "@material/base": "^0.35.0", + "@material/tab": "^0.37.1", + "@material/tab-scroller": "^0.0.0", + "@material/elevation": "^0.36.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/mdc-tab-indicator/README.md b/packages/mdc-tab-indicator/README.md new file mode 100644 index 00000000000..2e8860a3720 --- /dev/null +++ b/packages/mdc-tab-indicator/README.md @@ -0,0 +1,161 @@ + + +# Tab Indicator + +A Tab Indicator is a visual guide that shows which Tab is active. + +## Design & API Documentation + + + +## Installation + +``` +npm install @material/tab-indicator +``` + +## Basic Usage + +### HTML Structure + +```html + + + +``` + +### Styles + +```scss +@import "@material/tab/mdc-tab-indicator"; +``` + +### JavaScript Instantiation + +```js +import {MDCTabIndicator} from '@material/tab-indicator'; + +const tabIndicator = new MDCTabIndicator(document.querySelector('.mdc-tab-indicator')); +``` + +> See [Importing the JS component](../../docs/importing-js.md) for more information on how to import JavaScript. + +## Variants + +### Active Indicator + +Add the `mdc-tab-indicator--active` class to the `mdc-tab-indicator` element to make the Tab Indicator active. + +### Indicator Types and Transitions + +The Tab Indicator may be represented in one of two ways: + +* Underline, indicated by the `mdc-tab-indicator__content--underline` class +* Icon, indicated by the `mdc-tab-indicator__content--icon` class + +> *NOTE*: One of these classes _must_ be applied to the Tab Indicator's content element. + +The Tab Indicator may transition in one of two ways: + +* Slide, the default behavior +* Fade, indicated by the `mdc-tab-indicator--fade` class + +#### Sliding Underline Indicator + +```html + + + +``` + +#### Fading Icon Indicator + +You can use [Material Icons](https://material.io/icons/) from Google Fonts within your Fading Icon Indicator, or you can use your own icons. + +```html + + star + +``` + +#### Sliding Icon Indicator + +```html + + star + +``` + +## Style Customization + +### CSS Classes + +CSS Class | Description +--- | --- +`mdc-tab-indicator` | Mandatory. Contains the tab indicator content. +`mdc-tab-indicator__content` | Mandatory. Denotes the tab indicator content. +`mdc-tab-indicator--active` | Optional. Visually activates the indicator. +`mdc-tab-indicator--fade` | Optional. Sets up the tab indicator to fade in on activation and fade out on deactivation. +`mdc-tab-indicator__content--underline` | Optional. Denotes an underline tab indicator. +`mdc-tab-indicator__content--icon` | Optional. Denotes an icon tab indicator. + +> *NOTE*: Exactly one of the `--underline` or `--icon` content modifier classes should be present. + +### Sass Mixins + +To customize the tab indicator, use the following mixins. + +Mixin | Description +--- | --- +`mdc-tab-indicator-surface` | Mandatory. Must be applied to the parent element of the `mdc-tab-indicator`. +`mdc-tab-indicator-underline-color($color)` | Customizes the color of the underline. +`mdc-tab-indicator-icon-color($color)` | Customizes the color of the icon subelement. +`mdc-tab-indicator-underline-height($height)` | Customizes the height of the underline. +`mdc-tab-indicator-icon-height($height)` | Customizes the height of the icon subelement. +`mdc-tab-indicator-underline-top-corner-radius($radius)` | Customizes the top left and top right border radius of the underline child element. + +## `MDCTabIndicator` Methods + +Method Signature | Description +--- | --- +`activate(previousIndicatorClientRect: ClientRect) => void` | Activates the tab indicator. +`deactivate() => void` | Deactivates the tab indicator. +`computeContentClientRect() => ClientRect` | Returns the content element bounding client rect. + +## Usage within Web Frameworks + +If you are using a JavaScript framework, such as React or Angular, you can create a Tab Indicator for your framework. Depending on your needs, you can use the _Simple Approach: Wrapping MDC Web Vanilla Components_, or the _Advanced Approach: Using Foundations and Adapters_. Please follow the instructions [here](../../docs/integrating-into-frameworks.md). + +### `MDCTabIndicatorAdapter` + +Method Signature | Description +--- | --- +`addClass(className: string) => void` | Adds a class to the root element. +`removeClass(className: string) => void` | Removes a class from the root element. +`registerEventHandler(evtType: string, handler: EventListener) => void` | Registers an event listener on the root element. +`deregisterEventHandler(evtType: string, handler: EventListener) => void` | Deregisters an event listener on the root element. +`setContentStyleProp(property: string, value: string) => void` | Sets the style property of the content element. +`computeContentClientRect() => ClientRect` | Returns the content element's bounding client rect. + +### `MDCTabIndicatorFoundation` + +Method Signature | Description +--- | --- +`handleTransitionEnd(evt: Event) => void` | Handles the logic for the `"transitionend"` event on the root element. +`activate(previousIndicatorClientRect: ClientRect) => void` | Activates the tab indicator. +`deactivate() => void` | Deactivates the tab indicator. +`computeContentClientRect() => ClientRect` | Returns the content element's bounding client rect. diff --git a/packages/mdc-tab-indicator/_mixins.scss b/packages/mdc-tab-indicator/_mixins.scss new file mode 100644 index 00000000000..47919ff0837 --- /dev/null +++ b/packages/mdc-tab-indicator/_mixins.scss @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +@import "@material/theme/mixins"; + +@mixin mdc-tab-indicator-surface { + position: relative; +} + +@mixin mdc-tab-indicator-underline-color($color) { + > .mdc-tab-indicator__content--underline { + @include mdc-theme-prop(background-color, $color); + } +} + +@mixin mdc-tab-indicator-underline-height($height) { + > .mdc-tab-indicator__content--underline { + height: $height; + } +} + +@mixin mdc-tab-indicator-underline-top-corner-radius($radius) { + > .mdc-tab-indicator__content--underline { + border-top-left-radius: $radius; + border-top-right-radius: $radius; + } +} + +@mixin mdc-tab-indicator-icon-color($color) { + > .mdc-tab-indicator__content--icon { + @include mdc-theme-prop(color, $color); + } +} + +@mixin mdc-tab-indicator-icon-height($height) { + > .mdc-tab-indicator__content--icon { + height: $height; + font-size: $height; + } +} diff --git a/packages/mdc-tab-indicator/adapter.js b/packages/mdc-tab-indicator/adapter.js new file mode 100644 index 00000000000..b3f3edf474b --- /dev/null +++ b/packages/mdc-tab-indicator/adapter.js @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +/* eslint no-unused-vars: [2, {"args": "none"}] */ + +/** + * Adapter for MDC Tab Indicator. + * + * Defines the shape of the adapter expected by the foundation. Implement this + * adapter to integrate the Tab Indicator into your framework. See + * https://github.com/material-components/material-components-web/blob/master/docs/authoring-components.md + * for more information. + * + * @record + */ +class MDCTabIndicatorAdapter { + /** + * Registers an event listener on the root element for a given event. + * @param {string} evtType + * @param {function(!Event): undefined} handler + */ + registerEventHandler(evtType, handler) {} + + /** + * Deregisters an event listener on the root element for a given event. + * @param {string} evtType + * @param {function(!Event): undefined} handler + */ + deregisterEventHandler(evtType, handler) {} + + /** + * Adds the given className to the root element. + * @param {string} className The className to add + */ + addClass(className) {} + + /** + * Removes the given className from the root element. + * @param {string} className The className to remove + */ + removeClass(className) {} + + /** + * Returns the client rect of the content element. + * @return {!ClientRect} + */ + computeContentClientRect() {} + + /** + * Sets a style property of the content element to the passed value + * @param {string} propName The style property name to set + * @param {string} value The style property value + */ + setContentStyleProperty(propName, value) {} +} + +export default MDCTabIndicatorAdapter; diff --git a/packages/mdc-tab-indicator/constants.js b/packages/mdc-tab-indicator/constants.js new file mode 100644 index 00000000000..0fcc30e4543 --- /dev/null +++ b/packages/mdc-tab-indicator/constants.js @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +/** @enum {string} */ +const cssClasses = { + ACTIVE: 'mdc-tab-indicator--active', + FADE: 'mdc-tab-indicator--fade', + FADING_ACTIVATE: 'mdc-tab-indicator--fading-activate', + FADING_DEACTIVATE: 'mdc-tab-indicator--fading-deactivate', + SLIDING_ACTIVATE: 'mdc-tab-indicator--sliding-activate', +}; + +/** @enum {string} */ +const strings = { + CONTENT_SELECTOR: '.mdc-tab-indicator__content', +}; + +export { + cssClasses, + strings, +}; diff --git a/packages/mdc-tab-indicator/fading-foundation.js b/packages/mdc-tab-indicator/fading-foundation.js new file mode 100644 index 00000000000..b86a96306ad --- /dev/null +++ b/packages/mdc-tab-indicator/fading-foundation.js @@ -0,0 +1,53 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCTabIndicatorFoundation from './foundation'; + +/** + * @extends {MDCTabIndicatorFoundation} + * @final + */ +class MDCFadingTabIndicatorFoundation extends MDCTabIndicatorFoundation { + /** @param {...?} args */ + constructor(...args) { + super(...args); + + /** @private {function(?Event): undefined} */ + this.handleTransitionEnd_ = () => this.handleTransitionEnd(); + } + + /** Handles the transitionend event */ + handleTransitionEnd() { + this.adapter_.deregisterEventHandler('transitionend', this.handleTransitionEnd_); + this.adapter_.removeClass(MDCTabIndicatorFoundation.cssClasses.FADING_ACTIVATE); + this.adapter_.removeClass(MDCTabIndicatorFoundation.cssClasses.FADING_DEACTIVATE); + } + + activate() { + this.adapter_.registerEventHandler('transitionend', this.handleTransitionEnd_); + this.adapter_.addClass(MDCTabIndicatorFoundation.cssClasses.FADING_ACTIVATE); + this.adapter_.addClass(MDCTabIndicatorFoundation.cssClasses.ACTIVE); + } + + deactivate() { + this.adapter_.registerEventHandler('transitionend', this.handleTransitionEnd_); + this.adapter_.addClass(MDCTabIndicatorFoundation.cssClasses.FADING_DEACTIVATE); + this.adapter_.removeClass(MDCTabIndicatorFoundation.cssClasses.ACTIVE); + } +} + +export default MDCFadingTabIndicatorFoundation; diff --git a/packages/mdc-tab-indicator/foundation.js b/packages/mdc-tab-indicator/foundation.js new file mode 100644 index 00000000000..fd160f38386 --- /dev/null +++ b/packages/mdc-tab-indicator/foundation.js @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCFoundation from '@material/base/foundation'; +import MDCTabIndicatorAdapter from './adapter'; +import { + cssClasses, + strings, +} from './constants'; + +/** + * @extends {MDCFoundation} + * @abstract + */ +class MDCTabIndicatorFoundation extends MDCFoundation { + /** @return enum {string} */ + static get cssClasses() { + return cssClasses; + } + + /** @return enum {string} */ + static get strings() { + return strings; + } + + /** + * @see MDCTabIndicatorAdapter for typing information + * @return {!MDCTabIndicatorAdapter} + */ + static get defaultAdapter() { + return /** @type {!MDCTabIndicatorAdapter} */ ({ + registerEventHandler: () => {}, + deregisterEventHandler: () => {}, + addClass: () => {}, + removeClass: () => {}, + computeContentClientRect: () => {}, + setContentStyleProperty: () => {}, + }); + } + + /** @param {!MDCTabIndicatorAdapter} adapter */ + constructor(adapter) { + super(Object.assign(MDCTabIndicatorFoundation.defaultAdapter, adapter)); + } + + /** @return {!ClientRect} */ + computeContentClientRect() { + return this.adapter_.computeContentClientRect(); + } + + /** + * Activates the indicator + * @param {!ClientRect=} previousIndicatorClientRect + * @abstract + */ + activate(previousIndicatorClientRect) {} // eslint-disable-line no-unused-vars + + /** @abstract */ + deactivate() {} +} + +export default MDCTabIndicatorFoundation; diff --git a/packages/mdc-tab-indicator/index.js b/packages/mdc-tab-indicator/index.js new file mode 100644 index 00000000000..47f120abeaa --- /dev/null +++ b/packages/mdc-tab-indicator/index.js @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCComponent from '@material/base/component'; + +import MDCTabIndicatorAdapter from './adapter'; +import MDCTabIndicatorFoundation from './foundation'; + +import MDCSlidingTabIndicatorFoundation from './sliding-foundation'; +import MDCFadingTabIndicatorFoundation from './fading-foundation'; + +/** + * @extends {MDCComponent} + * @final + */ +class MDCTabIndicator extends MDCComponent { + /** + * @param {!Element} root + * @return {!MDCTabIndicator} + */ + static attachTo(root) { + return new MDCTabIndicator(root); + } + + /** + * @param {...?} args + */ + constructor(...args) { + super(...args); + /** @type {?Element} */ + this.content_; + } + + initialize() { + this.content_ = this.root_.querySelector(MDCTabIndicatorFoundation.strings.CONTENT_SELECTOR); + } + + /** + * @return {!ClientRect} + */ + computeContentClientRect() { + return this.foundation_.computeContentClientRect(); + } + + /** + * @return {!MDCTabIndicatorFoundation} + */ + getDefaultFoundation() { + const adapter = /** @type {!MDCTabIndicatorAdapter} */ (Object.assign({ + registerEventHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler), + deregisterEventHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler), + addClass: (className) => this.root_.classList.add(className), + removeClass: (className) => this.root_.classList.remove(className), + computeContentClientRect: () => this.content_.getBoundingClientRect(), + setContentStyleProperty: (prop, value) => this.content_.style.setProperty(prop, value), + })); + + if (this.root_.classList.contains(MDCTabIndicatorFoundation.cssClasses.FADE)) { + return new MDCFadingTabIndicatorFoundation(adapter); + } + + // Default to the sliding indicator + return new MDCSlidingTabIndicatorFoundation(adapter); + } + + /** + * @param {!ClientRect=} previousIndicatorClientRect + */ + activate(previousIndicatorClientRect) { + this.foundation_.activate(previousIndicatorClientRect); + } + + deactivate() { + this.foundation_.deactivate(); + } +} + +export {MDCTabIndicator, MDCTabIndicatorFoundation, MDCSlidingTabIndicatorFoundation, MDCFadingTabIndicatorFoundation}; diff --git a/packages/mdc-tab-indicator/mdc-tab-indicator.scss b/packages/mdc-tab-indicator/mdc-tab-indicator.scss new file mode 100644 index 00000000000..445403901d5 --- /dev/null +++ b/packages/mdc-tab-indicator/mdc-tab-indicator.scss @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +@import "@material/animation/variables"; +@import "./mixins"; + +.mdc-tab-indicator { + @include mdc-tab-indicator-underline-color(primary); + @include mdc-tab-indicator-underline-height(2px); + @include mdc-tab-indicator-icon-color(secondary); + @include mdc-tab-indicator-icon-height(34px); + + display: flex; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; +} + +.mdc-tab-indicator__content { + transform-origin: left; + opacity: 0; +} + +.mdc-tab-indicator__content--underline { + align-self: flex-end; + width: 100%; +} + +.mdc-tab-indicator__content--icon { + align-self: center; + margin: 0 auto; +} + +.mdc-tab-indicator--active > .mdc-tab-indicator__content { + opacity: 1; +} + +.mdc-tab-indicator--sliding-activate > .mdc-tab-indicator__content { + transition: 250ms transform $mdc-animation-standard-curve-timing-function; +} + +.mdc-tab-indicator--fading-activate > .mdc-tab-indicator__content, +.mdc-tab-indicator--fading-deactivate > .mdc-tab-indicator__content { + transition: 150ms opacity linear; +} + +.mdc-tab-indicator--fading-activate > .mdc-tab-indicator__content { + transition-delay: 100ms; +} + diff --git a/packages/mdc-tab-indicator/package.json b/packages/mdc-tab-indicator/package.json new file mode 100644 index 00000000000..e954f317593 --- /dev/null +++ b/packages/mdc-tab-indicator/package.json @@ -0,0 +1,25 @@ +{ + "name": "@material/tab-indicator", + "description": "The Material Components for the web tab indicator component", + "version": "0.0.0", + "license": "Apache-2.0", + "keywords": [ + "material components", + "material design", + "tab", + "indicator" + ], + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/material-components/material-components-web.git" + }, + "dependencies": { + "@material/animation": "^0.34.0", + "@material/base": "^0.35.0", + "@material/theme": "^0.35.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/mdc-tab-indicator/sliding-foundation.js b/packages/mdc-tab-indicator/sliding-foundation.js new file mode 100644 index 00000000000..b601f7e8879 --- /dev/null +++ b/packages/mdc-tab-indicator/sliding-foundation.js @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCTabIndicatorFoundation from './foundation'; + +/** + * @extends {MDCTabIndicatorFoundation} + * @final + */ +class MDCSlidingTabIndicatorFoundation extends MDCTabIndicatorFoundation { + /** @param {...?} args */ + constructor(...args) { + super(...args); + + /** @private {function(?Event): undefined} */ + this.handleTransitionEnd_ = () => this.handleTransitionEnd(); + } + + /** Handles the transitionend event */ + handleTransitionEnd() { + this.adapter_.deregisterEventHandler('transitionend', this.handleTransitionEnd_); + this.adapter_.removeClass(MDCTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE); + } + + /** @param {!ClientRect=} previousIndicatorClientRect */ + activate(previousIndicatorClientRect) { + this.adapter_.addClass(MDCTabIndicatorFoundation.cssClasses.ACTIVE); + + // Early exit if no indicator is present to handle cases where an indicator + // may be activated without a prior indicator state + if (!previousIndicatorClientRect) { + return; + } + + // This animation uses the FLIP approach. You can read more about it at the link below: + // https://aerotwist.com/blog/flip-your-animations/ + + // Calculate the dimensions based on the dimensions of the previous indicator + const currentClientRect = this.computeContentClientRect(); + const widthDelta = previousIndicatorClientRect.width / currentClientRect.width; + const xPosition = previousIndicatorClientRect.left - currentClientRect.left; + this.adapter_.setContentStyleProperty('transform', `translateX(${xPosition}px) scaleX(${widthDelta})`); + + // Force repaint + this.computeContentClientRect(); + + // Add animating class and remove transformation in a new frame + requestAnimationFrame(() => { + this.adapter_.addClass(MDCTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE); + this.adapter_.setContentStyleProperty('transform', ''); + }); + + this.adapter_.registerEventHandler('transitionend', this.handleTransitionEnd_); + } + + deactivate() { + this.adapter_.removeClass(MDCTabIndicatorFoundation.cssClasses.ACTIVE); + // We remove the animating class in deactivate in case the Tab is deactivated before the animation completes and + // the "transitionend" handler isn't called. + this.adapter_.removeClass(MDCTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE); + this.adapter_.deregisterEventHandler('transitionend', this.handleTransitionEnd_); + } +} + +export default MDCSlidingTabIndicatorFoundation; diff --git a/packages/mdc-tab-scroller/README.md b/packages/mdc-tab-scroller/README.md new file mode 100644 index 00000000000..01cc2267f85 --- /dev/null +++ b/packages/mdc-tab-scroller/README.md @@ -0,0 +1,117 @@ + + +# Tab Scroller + +A Tab Scroller allows for smooth native and animated scrolling of tabs. + +## Design & API Documentation + + + +## Installation + +``` +npm install @material/tab-scroller +``` + +## Basic Usage + +### HTML Structure + +```html +
+
+
+
+
+``` + +### Styles + +```scss +@import "@material/tab/mdc-tab-scroller"; +``` + +### JavaScript Instantiation + +```js +import {MDCTabScroller} from '@material/tab-scroller'; + +const tabScroller = new MDCTabScroller(document.querySelector('.mdc-tab-scroller')); +``` + +> See [Importing the JS component](../../docs/importing-js.md) for more information on how to import JavaScript. + +## Style Customization + +### CSS Classes + +CSS Class | Description +--- | --- +`mdc-tab-scroller` | Mandatory. Contains the tab scroller content. +`mdc-tab-scroller__scroll-area` | Mandatory. Denotes the scrolling area. +`mdc-tab-scroller__scroll-content` | Mandatory. Denotes the scrolling content. +`mdc-tab-scroller--align-start` | Optional. Sets the elements inside the scroll content element to be aligned to the start of the scroll content element. +`mdc-tab-scroller--align-end` | Optional. Sets the elements inside the scroll content element to be aligned to the end of the scroll content element. +`mdc-tab-scroller--align-center` | Optional. Sets the elements inside the scroll content element to be aligned to the center of the scroll content element. + +## `MDCTabScroller` Methods + +Method Signature | Description +--- | --- +`scrollTo(scrollX: number) => void` | Scrolls to the scrollX value. +`incrementScroll(scrollX: number) => void` | Increments the current scroll value by the scrollX value. +`getScrollPosition() => number` | Returns the current visual scroll position. +`getScrollContentWidth() => number` | Returns the width of the scroll content element. + +## Usage within Web Frameworks + +If you are using a JavaScript framework, such as React or Angular, you can create a Tab Scroller for your framework. Depending on your needs, you can use the _Simple Approach: Wrapping MDC Web Vanilla Components_, or the _Advanced Approach: Using Foundations and Adapters_. Please follow the instructions [here](../../docs/integrating-into-frameworks.md). + +### `MDCTabScrollerAdapter` + +Method Signature | Description +--- | --- +`eventTargetMatchesSelector(eventTarget: EventTarget, selector: string) => boolean` | Returns `true` if the given event target satisfies the given CSS selector. +`addClass(className: string) => void` | Adds a class to the root element. +`addScrollAreaClass(className: string) => void` | Adds a class to the scroll area element. +`removeClass(className: string) => void` | Removes a class from the root element. +`setScrollAreaStyleProperty(property: string, value: string) => void` | Sets the given style property on the scroll area element. +`setScrollContentStyleProperty(property: string, value: string) => void` | Sets the given style property on the scroll content element. +`getScrollContentStyleValue(property: string) => string` | Returns the given style property value on the scroll content element. +`setScrollAreaScrollLeft(scrollLeft: number) => void` | Sets the scroll area element's `scrollLeft`. +`getScrollAreaScrollLeft() => number` | Returns the scroll area element's `scrollLeft`. +`getScrollContentOffsetWidth() => number` | Returns the scroll content element's `offsetWidth`. +`getScrollAreaOffsetWidth() => number` | Returns the scroll area element's `offsetWidth`. +`computeHorizontalScrollbarHeight() => number` | Returns the height of the browser's horizontal scrollbars (in px). + +#### `util` Functions + +MDC Tab Scroller provides a `util` module with functions to help implement adapter methods. + +Function Signature | Description +--- | --- +`computeHorizontalScrollbarHeight(document: Document) => number` | Returns the height of the browser's horizontal scrollbars (in px). +`getMatchesProperty(HTMLElementPrototype: Object) => string` | Returns the appropriate property name for the `matches` API in the current browser environment. + +### `MDCTabScrollerFoundation` + +Method Signature | Description +--- | --- +`scrollTo(scrollX: number) => void` | Scrolls to the `scrollX` value. +`incrementScroll(scrollX: number) => void` | Increments the current scroll value by the `scrollX` value. +`getScrollPosition() => number` | Returns the current visual scroll position. diff --git a/packages/mdc-tab-scroller/adapter.js b/packages/mdc-tab-scroller/adapter.js new file mode 100644 index 00000000000..78bfd46fe8a --- /dev/null +++ b/packages/mdc-tab-scroller/adapter.js @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* eslint no-unused-vars: [2, {"args": "none"}] */ + +/** + * MDCTabScrollerAnimation contains the values required for animating from the + * current scroll position to the new scroll position. The "finalScrollPosition" + * value represents the new scroll position while the "scrollDelta" value is the + * corresponding transformation that is applied to the scroll content. Together, + * they create the animation by first updating the scroll value then applying + * the transformation and animating the transition. Both pieces are necessary + * for the scroll animation to work. The values are used as-is by the tab + * scroller animation method, ensuring that all logic for determining scroll + * position or transformation is abstracted away from the animation method. + * @typedef {{finalScrollPosition: number, scrollDelta: number}} + */ +let MDCTabScrollerAnimation; + +/** + * MDCTabScrollerHorizontalEdges represents the left and right edges of the + * scroll content. These values vary depending on how scrolling in RTL is + * implemented by the browser. One value is always 0 and one value is always + * the max scrollable value as either a positive or negative integer. + * @typedef {{left: number, right: number}} + */ +let MDCTabScrollerHorizontalEdges; + +/** + * Adapter for MDC Tab Scroller. + * + * Defines the shape of the adapter expected by the foundation. Implement this + * adapter to integrate the Tab into your framework. See + * https://github.com/material-components/material-components-web/blob/master/docs/authoring-components.md + * for more information. + * + * @record + */ +class MDCTabScrollerAdapter { + /** + * Adds the given className to the root element. + * @param {string} className The className to add + */ + addClass(className) {} + + /** + * Removes the given className from the root element. + * @param {string} className The className to remove + */ + removeClass(className) {} + + /** + * Adds the given className to the scroll area element. + * @param {string} className The className to add + */ + addScrollAreaClass(className) {} + + /** + * Returns whether the event target matches given className. + * @param {EventTarget} evtTarget The event target + * @param {string} selector The selector to check + * @return {boolean} + */ + eventTargetMatchesSelector(evtTarget, selector) {} + + /** + * Sets a style property of the area element to the passed value. + * @param {string} propName The style property name to set + * @param {string} value The style property value + */ + setScrollAreaStyleProperty(propName, value) {} + + /** + * Sets a style property of the content element to the passed value. + * @param {string} propName The style property name to set + * @param {string} value The style property value + */ + setScrollContentStyleProperty(propName, value) {} + + /** + * Returns the scroll content element's computed style value of the given css property `propertyName`. + * We achieve this via `getComputedStyle(...).getPropertyValue(propertyName)`. + * @param {string} propertyName + * @return {string} + */ + getScrollContentStyleValue(propertyName) {} + + /** + * Sets the scrollLeft value of the scroll area element to the passed value. + * @param {number} scrollLeft The new scrollLeft value + */ + setScrollAreaScrollLeft(scrollLeft) {} + + /** + * Returns the scrollLeft value of the scroll area element. + * @return {number} + */ + getScrollAreaScrollLeft() {} + + /** + * Returns the offsetWidth of the scroll content element. + * @return {number} + */ + getScrollContentOffsetWidth() {} + + /** + * Returns the offsetWitdth of the scroll area element. + * @return {number} + */ + getScrollAreaOffsetWidth() {} + + /** + * Returns the bounding client rect of the scroll area element. + * @return {!ClientRect} + */ + computeScrollAreaClientRect() {} + + /** + * Returns the bounding client rect of the scroll content element. + * @return {!ClientRect} + */ + computeScrollContentClientRect() {} + + /** + * Returns the height of the browser's horizontal scrollbars (in px). + * @return {number} + */ + computeHorizontalScrollbarHeight() {} +} + +export {MDCTabScrollerAnimation, MDCTabScrollerHorizontalEdges, MDCTabScrollerAdapter}; diff --git a/packages/mdc-tab-scroller/constants.js b/packages/mdc-tab-scroller/constants.js new file mode 100644 index 00000000000..3ac76abb13f --- /dev/null +++ b/packages/mdc-tab-scroller/constants.js @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +/** @enum {string} */ +const cssClasses = { + ANIMATING: 'mdc-tab-scroller--animating', + SCROLL_TEST: 'mdc-tab-scroller__test', + SCROLL_AREA_SCROLL: 'mdc-tab-scroller__scroll-area--scroll', +}; + +/** @enum {string} */ +const strings = { + AREA_SELECTOR: '.mdc-tab-scroller__scroll-area', + CONTENT_SELECTOR: '.mdc-tab-scroller__scroll-content', +}; + +export { + cssClasses, + strings, +}; diff --git a/packages/mdc-tab-scroller/foundation.js b/packages/mdc-tab-scroller/foundation.js new file mode 100644 index 00000000000..8bf9a5a24e4 --- /dev/null +++ b/packages/mdc-tab-scroller/foundation.js @@ -0,0 +1,396 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCFoundation from '@material/base/foundation'; +import {cssClasses, strings} from './constants'; +/* eslint-disable no-unused-vars */ +import {MDCTabScrollerAnimation, MDCTabScrollerHorizontalEdges, MDCTabScrollerAdapter} from './adapter'; +import MDCTabScrollerRTL from './rtl-scroller'; +/* eslint-enable no-unused-vars */ +import MDCTabScrollerRTLDefault from './rtl-default-scroller'; +import MDCTabScrollerRTLNegative from './rtl-negative-scroller'; +import MDCTabScrollerRTLReverse from './rtl-reverse-scroller'; + +/** + * @extends {MDCFoundation} + * @final + */ +class MDCTabScrollerFoundation extends MDCFoundation { + /** @return enum {string} */ + static get cssClasses() { + return cssClasses; + } + + /** @return enum {string} */ + static get strings() { + return strings; + } + + /** + * @see MDCTabScrollerAdapter for typing information + * @return {!MDCTabScrollerAdapter} + */ + static get defaultAdapter() { + return /** @type {!MDCTabScrollerAdapter} */ ({ + eventTargetMatchesSelector: () => {}, + addClass: () => {}, + removeClass: () => {}, + addScrollAreaClass: () => {}, + setScrollAreaStyleProperty: () => {}, + setScrollContentStyleProperty: () => {}, + getScrollContentStyleValue: () => {}, + setScrollAreaScrollLeft: () => {}, + getScrollAreaScrollLeft: () => {}, + getScrollContentOffsetWidth: () => {}, + getScrollAreaOffsetWidth: () => {}, + computeScrollAreaClientRect: () => {}, + computeScrollContentClientRect: () => {}, + computeHorizontalScrollbarHeight: () => {}, + }); + } + + /** @param {!MDCTabScrollerAdapter} adapter */ + constructor(adapter) { + super(Object.assign(MDCTabScrollerFoundation.defaultAdapter, adapter)); + + /** + * This boolean controls whether we should handle the transitionend and interaction events during the animation. + * @private {boolean} + */ + this.isAnimating_ = false; + + /** + * The MDCTabScrollerRTL instance varies per browser and allows us to encapsulate the peculiar browser behavior + * of RTL scrolling in it's own class. + * @private {?MDCTabScrollerRTL} + */ + this.rtlScrollerInstance_; + } + + init() { + // Compute horizontal scrollbar height on scroller with overflow initially hidden, then update overflow to scroll + // and immediately adjust bottom margin to avoid the scrollbar initially appearing before JS runs. + const horizontalScrollbarHeight = this.adapter_.computeHorizontalScrollbarHeight(); + this.adapter_.setScrollAreaStyleProperty('margin-bottom', -horizontalScrollbarHeight + 'px'); + this.adapter_.addScrollAreaClass(MDCTabScrollerFoundation.cssClasses.SCROLL_AREA_SCROLL); + } + + /** + * Computes the current visual scroll position + * @return {number} + */ + getScrollPosition() { + if (this.isRTL_()) { + return this.computeCurrentScrollPositionRTL_(); + } + + const currentTranslateX = this.calculateCurrentTranslateX_(); + const scrollLeft = this.adapter_.getScrollAreaScrollLeft(); + return scrollLeft - currentTranslateX; + } + + /** + * Handles interaction events that occur during transition + */ + handleInteraction() { + // Early exit if we aren't animating + if (!this.isAnimating_) { + return; + } + + // Prevent other event listeners from handling this event + this.stopScrollAnimation_(); + } + + /** + * Handles the transitionend event + * @param {!Event} evt + */ + handleTransitionEnd(evt) { + // Early exit if we aren't animating or the event was triggered by a different element. + if (!this.isAnimating_ + || !this.adapter_.eventTargetMatchesSelector(evt.target, MDCTabScrollerFoundation.strings.CONTENT_SELECTOR)) { + return; + } + + this.isAnimating_ = false; + this.adapter_.removeClass(MDCTabScrollerFoundation.cssClasses.ANIMATING); + } + + /** + * Increment the scroll value by the scrollXIncrement + * @param {number} scrollXIncrement The value by which to increment the scroll position + */ + incrementScroll(scrollXIncrement) { + // Early exit for non-operational increment values + if (scrollXIncrement === 0) { + return; + } + + if (this.isRTL_()) { + return this.incrementScrollRTL_(scrollXIncrement); + } + + this.incrementScroll_(scrollXIncrement); + } + + /** + * Scrolls to the given scrollX value + * @param {number} scrollX + */ + scrollTo(scrollX) { + if (this.isRTL_()) { + return this.scrollToRTL_(scrollX); + } + + this.scrollTo_(scrollX); + } + + /** + * Returns the appropriate version of the MDCTabScrollerRTL + * @return {!MDCTabScrollerRTL} + */ + getRTLScroller() { + if (!this.rtlScrollerInstance_) { + this.rtlScrollerInstance_ = this.rtlScrollerFactory_(); + } + + return this.rtlScrollerInstance_; + } + + /** + * Returns the translateX value from a CSS matrix transform function string + * @return {number} + * @private + */ + calculateCurrentTranslateX_() { + const transformValue = this.adapter_.getScrollContentStyleValue('transform'); + // Early exit if no transform is present + if (transformValue === 'none') { + return 0; + } + + // The transform value comes back as a matrix transformation in the form + // of `matrix(a, b, c, d, tx, ty)`. We only care about tx (translateX) so + // we're going to grab all the parenthesized values, strip out tx, and + // parse it. + const results = /\((.+)\)/.exec(transformValue)[1]; + const parts = results.split(','); + return parseFloat(parts[4]); + } + + /** + * Calculates a safe scroll value that is > 0 and < the max scroll value + * @param {number} scrollX The distance to scroll + * @return {number} + * @private + */ + clampScrollValue_(scrollX) { + const edges = this.calculateScrollEdges_(); + return Math.min(Math.max(edges.left, scrollX), edges.right); + } + + /** + * @return {number} + * @private + */ + computeCurrentScrollPositionRTL_() { + const translateX = this.calculateCurrentTranslateX_(); + return this.getRTLScroller().getScrollPositionRTL(translateX); + } + + /** + * @return {!MDCTabScrollerHorizontalEdges} + * @private + */ + calculateScrollEdges_() { + const contentWidth = this.adapter_.getScrollContentOffsetWidth(); + const rootWidth = this.adapter_.getScrollAreaOffsetWidth(); + return /** @type {!MDCTabScrollerHorizontalEdges} */ ({ + left: 0, + right: contentWidth - rootWidth, + }); + } + + /** + * Internal scroll method + * @param {number} scrollX The new scroll position + * @private + */ + scrollTo_(scrollX) { + const currentScrollX = this.getScrollPosition(); + const safeScrollX = this.clampScrollValue_(scrollX); + const scrollDelta = safeScrollX - currentScrollX; + this.animate_(/** @type {!MDCTabScrollerAnimation} */ ({ + finalScrollPosition: safeScrollX, + scrollDelta: scrollDelta, + })); + } + + /** + * Internal RTL scroll method + * @param {number} scrollX The new scroll position + * @private + */ + scrollToRTL_(scrollX) { + const animation = this.getRTLScroller().scrollToRTL(scrollX); + this.animate_(animation); + } + + /** + * Internal increment scroll method + * @param {number} scrollX The new scroll position increment + * @private + */ + incrementScroll_(scrollX) { + const currentScrollX = this.getScrollPosition(); + const targetScrollX = scrollX + currentScrollX; + const safeScrollX = this.clampScrollValue_(targetScrollX); + const scrollDelta = safeScrollX - currentScrollX; + this.animate_(/** @type {!MDCTabScrollerAnimation} */ ({ + finalScrollPosition: safeScrollX, + scrollDelta: scrollDelta, + })); + } + + /** + * Internal incremenet scroll RTL method + * @param {number} scrollX The new scroll position RTL increment + * @private + */ + incrementScrollRTL_(scrollX) { + const animation = this.getRTLScroller().incrementScrollRTL(scrollX); + this.animate_(animation); + } + + /** + * Animates the tab scrolling + * @param {!MDCTabScrollerAnimation} animation The animation to apply + * @private + */ + animate_(animation) { + // Early exit if translateX is 0, which means there's no animation to perform + if (animation.scrollDelta === 0) { + return; + } + + this.stopScrollAnimation_(); + // This animation uses the FLIP approach. + // Read more here: https://aerotwist.com/blog/flip-your-animations/ + this.adapter_.setScrollAreaScrollLeft(animation.finalScrollPosition); + this.adapter_.setScrollContentStyleProperty('transform', `translateX(${animation.scrollDelta}px)`); + // Force repaint + this.adapter_.computeScrollAreaClientRect(); + + requestAnimationFrame(() => { + this.adapter_.addClass(MDCTabScrollerFoundation.cssClasses.ANIMATING); + this.adapter_.setScrollContentStyleProperty('transform', 'none'); + }); + + this.isAnimating_ = true; + } + + /** + * Stops scroll animation + * @private + */ + stopScrollAnimation_() { + this.isAnimating_ = false; + const currentScrollPosition = this.getAnimatingScrollPosition_(); + this.adapter_.removeClass(MDCTabScrollerFoundation.cssClasses.ANIMATING); + this.adapter_.setScrollContentStyleProperty('transform', 'translateX(0px)'); + this.adapter_.setScrollAreaScrollLeft(currentScrollPosition); + } + + /** + * Gets the current scroll position during animation + * @return {number} + * @private + */ + getAnimatingScrollPosition_() { + const currentTranslateX = this.calculateCurrentTranslateX_(); + const scrollLeft = this.adapter_.getScrollAreaScrollLeft(); + if (this.isRTL_()) { + return this.getRTLScroller().getAnimatingScrollPosition(scrollLeft, currentTranslateX); + } + + return scrollLeft - currentTranslateX; + } + + /** + * Determines the RTL Scroller to use + * @return {!MDCTabScrollerRTL} + * @private + */ + rtlScrollerFactory_() { + // Browsers have three different implementations of scrollLeft in RTL mode, + // dependent on the browser. The behavior is based off the max LTR + // scrollleft value and 0. + // + // * Default scrolling in RTL * + // - Left-most value: 0 + // - Right-most value: Max LTR scrollLeft value + // + // * Negative scrolling in RTL * + // - Left-most value: Negated max LTR scrollLeft value + // - Right-most value: 0 + // + // * Reverse scrolling in RTL * + // - Left-most value: Max LTR scrollLeft value + // - Right-most value: 0 + // + // We use those principles below to determine which RTL scrollLeft + // behavior is implemented in the current browser. + const initialScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + this.adapter_.setScrollAreaScrollLeft(initialScrollLeft - 1); + const newScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + + // If the newScrollLeft value is negative,then we know that the browser has + // implemented negative RTL scrolling, since all other implementations have + // only positive values. + if (newScrollLeft < 0) { + // Undo the scrollLeft test check + this.adapter_.setScrollAreaScrollLeft(initialScrollLeft); + return new MDCTabScrollerRTLNegative(this.adapter_); + } + + const rootClientRect = this.adapter_.computeScrollAreaClientRect(); + const contentClientRect = this.adapter_.computeScrollContentClientRect(); + const rightEdgeDelta = Math.round(contentClientRect.right - rootClientRect.right); + // Undo the scrollLeft test check + this.adapter_.setScrollAreaScrollLeft(initialScrollLeft); + + // By calculating the clientRect of the root element and the clientRect of + // the content element, we can determine how much the scroll value changed + // when we performed the scrollLeft subtraction above. + if (rightEdgeDelta === newScrollLeft) { + return new MDCTabScrollerRTLReverse(this.adapter_); + } + + return new MDCTabScrollerRTLDefault(this.adapter_); + } + + /** + * @return {boolean} + * @private + */ + isRTL_() { + return this.adapter_.getScrollContentStyleValue('direction') === 'rtl'; + } +} + +export default MDCTabScrollerFoundation; diff --git a/packages/mdc-tab-scroller/index.js b/packages/mdc-tab-scroller/index.js new file mode 100644 index 00000000000..cfe94a2cd82 --- /dev/null +++ b/packages/mdc-tab-scroller/index.js @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCComponent from '@material/base/component'; + +import {MDCTabScrollerAdapter} from './adapter'; +import MDCTabScrollerFoundation from './foundation'; +import * as util from './util'; + +/** + * @extends {MDCComponent} + * @final + */ +class MDCTabScroller extends MDCComponent { + /** + * @param {!Element} root + * @return {!MDCTabScroller} + */ + static attachTo(root) { + return new MDCTabScroller(root); + } + + constructor(...args) { + super(...args); + + /** @private {?Element} */ + this.content_; + + /** @private {?Element} */ + this.area_; + + /** @private {?function(?Event): undefined} */ + this.handleInteraction_; + + /** @private {?function(!Event): undefined} */ + this.handleTransitionEnd_; + } + + initialize() { + this.area_ = this.root_.querySelector(MDCTabScrollerFoundation.strings.AREA_SELECTOR); + this.content_ = this.root_.querySelector(MDCTabScrollerFoundation.strings.CONTENT_SELECTOR); + } + + initialSyncWithDOM() { + this.handleInteraction_ = () => this.foundation_.handleInteraction(); + this.handleTransitionEnd_ = (evt) => this.foundation_.handleTransitionEnd(evt); + + this.area_.addEventListener('wheel', this.handleInteraction_); + this.area_.addEventListener('touchstart', this.handleInteraction_); + this.area_.addEventListener('pointerdown', this.handleInteraction_); + this.area_.addEventListener('mousedown', this.handleInteraction_); + this.area_.addEventListener('keydown', this.handleInteraction_); + this.content_.addEventListener('transitionend', this.handleTransitionEnd_); + } + + destroy() { + super.destroy(); + + this.area_.removeEventListener('wheel', this.handleInteraction_); + this.area_.removeEventListener('touchstart', this.handleInteraction_); + this.area_.removeEventListener('pointerdown', this.handleInteraction_); + this.area_.removeEventListener('mousedown', this.handleInteraction_); + this.area_.removeEventListener('keydown', this.handleInteraction_); + this.content_.removeEventListener('transitionend', this.handleTransitionEnd_); + } + + /** + * @return {!MDCTabScrollerFoundation} + */ + getDefaultFoundation() { + const adapter = /** @type {!MDCTabScrollerAdapter} */ ({ + eventTargetMatchesSelector: (evtTarget, selector) => { + const MATCHES = util.getMatchesProperty(HTMLElement.prototype); + return evtTarget[MATCHES](selector); + }, + addClass: (className) => this.root_.classList.add(className), + removeClass: (className) => this.root_.classList.remove(className), + addScrollAreaClass: (className) => this.area_.classList.add(className), + setScrollAreaStyleProperty: (prop, value) => this.area_.style.setProperty(prop, value), + setScrollContentStyleProperty: (prop, value) => this.content_.style.setProperty(prop, value), + getScrollContentStyleValue: (propName) => window.getComputedStyle(this.content_).getPropertyValue(propName), + setScrollAreaScrollLeft: (scrollX) => this.area_.scrollLeft = scrollX, + getScrollAreaScrollLeft: () => this.area_.scrollLeft, + getScrollContentOffsetWidth: () => this.content_.offsetWidth, + getScrollAreaOffsetWidth: () => this.area_.offsetWidth, + computeScrollAreaClientRect: () => this.area_.getBoundingClientRect(), + computeScrollContentClientRect: () => this.content_.getBoundingClientRect(), + computeHorizontalScrollbarHeight: () => util.computeHorizontalScrollbarHeight(document), + }); + + return new MDCTabScrollerFoundation(adapter); + } + + /** + * Returns the current visual scroll position + * @return {number} + */ + getScrollPosition() { + return this.foundation_.getScrollPosition(); + } + + /** + * Returns the width of the scroll content + * @return {number} + */ + getScrollContentWidth() { + return this.content_.offsetWidth; + } + + /** + * Increments the scroll value by the given amount + * @param {number} scrollXIncrement The pixel value by which to increment the scroll value + */ + incrementScroll(scrollXIncrement) { + this.foundation_.incrementScroll(scrollXIncrement); + } + + /** + * Scrolls to the given pixel position + * @param {number} scrollX The pixel value to scroll to + */ + scrollTo(scrollX) { + this.foundation_.scrollTo(scrollX); + } +} + +export {MDCTabScroller, MDCTabScrollerFoundation, util}; diff --git a/packages/mdc-tab-scroller/mdc-tab-scroller.scss b/packages/mdc-tab-scroller/mdc-tab-scroller.scss new file mode 100644 index 00000000000..7565edd1fa4 --- /dev/null +++ b/packages/mdc-tab-scroller/mdc-tab-scroller.scss @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +@import "@material/animation/variables"; +@import "@material/tab/mixins"; + +// postcss-bem-linter: define tab-scroller +.mdc-tab-scroller { + overflow-y: hidden; +} + +// Selector for test element used to feature-detect horizontal scrollbar height +.mdc-tab-scroller__test { + position: absolute; + top: -9999px; + width: 100px; + height: 100px; + overflow-x: scroll; +} + +.mdc-tab-scroller__scroll-area { + -webkit-overflow-scrolling: touch; + display: flex; + overflow-x: hidden; +} + +.mdc-tab-scroller__scroll-area, +.mdc-tab-scroller__test { + &::-webkit-scrollbar { + display: none; + } +} + +// This modifier class will be added in JS after computing the OS scrollbar size in order to hide the scrollbar. +.mdc-tab-scroller__scroll-area--scroll { + overflow-x: scroll; +} + +.mdc-tab-scroller__scroll-content { + @include mdc-tab-parent-positioning; + + display: flex; + flex: 1 0 auto; + transform: none; + will-change: transform; +} + +.mdc-tab-scroller--align-start .mdc-tab-scroller__scroll-content { + justify-content: flex-start; +} + +.mdc-tab-scroller--align-end .mdc-tab-scroller__scroll-content { + justify-content: flex-end; +} + +.mdc-tab-scroller--align-center .mdc-tab-scroller__scroll-content { + justify-content: center; +} + +.mdc-tab-scroller--animating .mdc-tab-scroller__scroll-area { + -webkit-overflow-scrolling: auto; +} + +.mdc-tab-scroller--animating .mdc-tab-scroller__scroll-content { + transition: 250ms transform $mdc-animation-standard-curve-timing-function; +} + +// postcss-bem-linter: end diff --git a/packages/mdc-tab-scroller/package.json b/packages/mdc-tab-scroller/package.json new file mode 100644 index 00000000000..ed6084bd89f --- /dev/null +++ b/packages/mdc-tab-scroller/package.json @@ -0,0 +1,25 @@ +{ + "name": "@material/tab-scroller", + "description": "The Material Components for the web tab scroller component", + "version": "0.0.0", + "license": "Apache-2.0", + "keywords": [ + "material components", + "material design", + "tab", + "scroller" + ], + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/material-components/material-components-web.git" + }, + "dependencies": { + "@material/animation": "^0.34.0", + "@material/base": "^0.35.0", + "@material/tab": "^0.37.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/mdc-tab-scroller/rtl-default-scroller.js b/packages/mdc-tab-scroller/rtl-default-scroller.js new file mode 100644 index 00000000000..c32ced03107 --- /dev/null +++ b/packages/mdc-tab-scroller/rtl-default-scroller.js @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCTabScrollerRTL from './rtl-scroller'; + +/* eslint-disable no-unused-vars */ +import {MDCTabScrollerAnimation, MDCTabScrollerHorizontalEdges} from './adapter'; +/* eslint-enable no-unused-vars */ + +/** + * @extends {MDCTabScrollerRTL} + * @final + */ +class MDCTabScrollerRTLDefault extends MDCTabScrollerRTL { + /** + * @return {number} + */ + getScrollPositionRTL() { + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + const {right} = this.calculateScrollEdges_(); + // Scroll values on most browsers are ints instead of floats so we round + return Math.round(right - currentScrollLeft); + } + + /** + * @param {number} scrollX + * @return {!MDCTabScrollerAnimation} + */ + scrollToRTL(scrollX) { + const edges = this.calculateScrollEdges_(); + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + const clampedScrollLeft = this.clampScrollValue_(edges.right - scrollX); + return /** @type {!MDCTabScrollerAnimation} */ ({ + finalScrollPosition: clampedScrollLeft, + scrollDelta: clampedScrollLeft - currentScrollLeft, + }); + } + + /** + * @param {number} scrollX + * @return {!MDCTabScrollerAnimation} + */ + incrementScrollRTL(scrollX) { + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + const clampedScrollLeft = this.clampScrollValue_(currentScrollLeft - scrollX); + return /** @type {!MDCTabScrollerAnimation} */ ({ + finalScrollPosition: clampedScrollLeft, + scrollDelta: clampedScrollLeft - currentScrollLeft, + }); + } + + /** + * @param {number} scrollX + * @return {number} + */ + getAnimatingScrollPosition(scrollX) { + return scrollX; + } + + /** + * @return {!MDCTabScrollerHorizontalEdges} + * @private + */ + calculateScrollEdges_() { + const contentWidth = this.adapter_.getScrollContentOffsetWidth(); + const rootWidth = this.adapter_.getScrollAreaOffsetWidth(); + return /** @type {!MDCTabScrollerHorizontalEdges} */ ({ + left: 0, + right: contentWidth - rootWidth, + }); + } + + /** + * @param {number} scrollX + * @return {number} + * @private + */ + clampScrollValue_(scrollX) { + const edges = this.calculateScrollEdges_(); + return Math.min(Math.max(edges.left, scrollX), edges.right); + } +} + +export default MDCTabScrollerRTLDefault; diff --git a/packages/mdc-tab-scroller/rtl-negative-scroller.js b/packages/mdc-tab-scroller/rtl-negative-scroller.js new file mode 100644 index 00000000000..77695c16b71 --- /dev/null +++ b/packages/mdc-tab-scroller/rtl-negative-scroller.js @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCTabScrollerRTL from './rtl-scroller'; + +/* eslint-disable no-unused-vars */ +import {MDCTabScrollerAnimation, MDCTabScrollerHorizontalEdges} from './adapter'; +/* eslint-enable no-unused-vars */ + +/** + * @extends {MDCTabScrollerRTL} + * @final + */ +class MDCTabScrollerRTLNegative extends MDCTabScrollerRTL { + /** + * @param {number} translateX The current translateX position + * @return {number} + */ + getScrollPositionRTL(translateX) { + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + return Math.round(translateX - currentScrollLeft); + } + + /** + * @param {number} scrollX + * @return {!MDCTabScrollerAnimation} + */ + scrollToRTL(scrollX) { + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + const clampedScrollLeft = this.clampScrollValue_(-scrollX); + return /** @type {!MDCTabScrollerAnimation} */ ({ + finalScrollPosition: clampedScrollLeft, + scrollDelta: clampedScrollLeft - currentScrollLeft, + }); + } + + /** + * @param {number} scrollX + * @return {!MDCTabScrollerAnimation} + */ + incrementScrollRTL(scrollX) { + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + const clampedScrollLeft = this.clampScrollValue_(currentScrollLeft - scrollX); + return /** @type {!MDCTabScrollerAnimation} */ ({ + finalScrollPosition: clampedScrollLeft, + scrollDelta: clampedScrollLeft - currentScrollLeft, + }); + } + + /** + * @param {number} scrollX + * @param {number} translateX + * @return {number} + */ + getAnimatingScrollPosition(scrollX, translateX) { + return scrollX - translateX; + } + + /** + * @return {!MDCTabScrollerHorizontalEdges} + * @private + */ + calculateScrollEdges_() { + const contentWidth = this.adapter_.getScrollContentOffsetWidth(); + const rootWidth = this.adapter_.getScrollAreaOffsetWidth(); + return /** @type {!MDCTabScrollerHorizontalEdges} */ ({ + left: rootWidth - contentWidth, + right: 0, + }); + } + + /** + * @param {number} scrollX + * @return {number} + * @private + */ + clampScrollValue_(scrollX) { + const edges = this.calculateScrollEdges_(); + return Math.max(Math.min(edges.right, scrollX), edges.left); + } +} + +export default MDCTabScrollerRTLNegative; diff --git a/packages/mdc-tab-scroller/rtl-reverse-scroller.js b/packages/mdc-tab-scroller/rtl-reverse-scroller.js new file mode 100644 index 00000000000..a6b45e26a36 --- /dev/null +++ b/packages/mdc-tab-scroller/rtl-reverse-scroller.js @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import MDCTabScrollerRTL from './rtl-scroller'; + +/* eslint-disable no-unused-vars */ +import {MDCTabScrollerAnimation, MDCTabScrollerHorizontalEdges} from './adapter'; +/* eslint-enable no-unused-vars */ + +/** + * @extends {MDCTabScrollerRTL} + * @final + */ +class MDCTabScrollerRTLReverse extends MDCTabScrollerRTL { + /** + * @param {number} translateX + * @return {number} + */ + getScrollPositionRTL(translateX) { + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + // Scroll values on most browsers are ints instead of floats so we round + return Math.round(currentScrollLeft - translateX); + } + + /** + * @param {number} scrollX + * @return {!MDCTabScrollerAnimation} + */ + scrollToRTL(scrollX) { + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + const clampedScrollLeft = this.clampScrollValue_(scrollX); + return /** @type {!MDCTabScrollerAnimation} */ ({ + finalScrollPosition: clampedScrollLeft, + scrollDelta: currentScrollLeft - clampedScrollLeft, + }); + } + + /** + * @param {number} scrollX + * @return {!MDCTabScrollerAnimation} + */ + incrementScrollRTL(scrollX) { + const currentScrollLeft = this.adapter_.getScrollAreaScrollLeft(); + const clampedScrollLeft = this.clampScrollValue_(currentScrollLeft + scrollX); + return /** @type {!MDCTabScrollerAnimation} */ ({ + finalScrollPosition: clampedScrollLeft, + scrollDelta: currentScrollLeft - clampedScrollLeft, + }); + } + + /** + * @param {number} scrollX + * @return {number} + */ + getAnimatingScrollPosition(scrollX, translateX) { + return scrollX + translateX; + } + + /** + * @return {!MDCTabScrollerHorizontalEdges} + * @private + */ + calculateScrollEdges_() { + const contentWidth = this.adapter_.getScrollContentOffsetWidth(); + const rootWidth = this.adapter_.getScrollAreaOffsetWidth(); + return /** @type {!MDCTabScrollerHorizontalEdges} */ ({ + left: contentWidth - rootWidth, + right: 0, + }); + } + + /** + * @param {number} scrollX + * @return {number} + * @private + */ + clampScrollValue_(scrollX) { + const edges = this.calculateScrollEdges_(); + return Math.min(Math.max(edges.right, scrollX), edges.left); + } +} + +export default MDCTabScrollerRTLReverse; diff --git a/packages/mdc-tab-scroller/rtl-scroller.js b/packages/mdc-tab-scroller/rtl-scroller.js new file mode 100644 index 00000000000..73454c4dbfa --- /dev/null +++ b/packages/mdc-tab-scroller/rtl-scroller.js @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +/* eslint no-unused-vars: [2, {"args": "none"}] */ + +/* eslint-disable no-unused-vars */ +import {MDCTabScrollerAdapter, MDCTabScrollerAnimation} from './adapter'; +/* eslint-enable no-unused-vars */ + +/** + * @abstract + */ +class MDCTabScrollerRTL { + /** @param {!MDCTabScrollerAdapter} adapter */ + constructor(adapter) { + /** @private */ + this.adapter_ = adapter; + } + + /** + * @param {number} translateX The current translateX position + * @return {number} + * @abstract + */ + getScrollPositionRTL(translateX) {} + + /** + * @param {number} scrollX + * @return {!MDCTabScrollerAnimation} + * @abstract + */ + scrollToRTL(scrollX) {} + + /** + * @param {number} scrollX + * @return {!MDCTabScrollerAnimation} + * @abstract + */ + incrementScrollRTL(scrollX) {} + + /** + * @param {number} scrollX The current scrollX position + * @param {number} translateX The current translateX position + * @return {number} + * @abstract + */ + getAnimatingScrollPosition(scrollX, translateX) {} +} + +export default MDCTabScrollerRTL; diff --git a/packages/mdc-tab-scroller/util.js b/packages/mdc-tab-scroller/util.js new file mode 100644 index 00000000000..81b507f222f --- /dev/null +++ b/packages/mdc-tab-scroller/util.js @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {cssClasses} from './constants'; + +/** + * Stores result from computeHorizontalScrollbarHeight to avoid redundant processing. + * @private {number|undefined} + */ +let horizontalScrollbarHeight_; + +/** + * Computes the height of browser-rendered horizontal scrollbars using a self-created test element. + * May return 0 (e.g. on OS X browsers under default configuration). + * @param {!Document} documentObj + * @param {boolean=} shouldCacheResult + * @return {number} + */ +function computeHorizontalScrollbarHeight(documentObj, shouldCacheResult = true) { + if (shouldCacheResult && typeof horizontalScrollbarHeight_ !== 'undefined') { + return horizontalScrollbarHeight_; + } + + const el = documentObj.createElement('div'); + el.classList.add(cssClasses.SCROLL_TEST); + documentObj.body.appendChild(el); + + const horizontalScrollbarHeight = el.offsetHeight - el.clientHeight; + documentObj.body.removeChild(el); + + if (shouldCacheResult) { + horizontalScrollbarHeight_ = horizontalScrollbarHeight; + } + return horizontalScrollbarHeight; +} + +/** + * @param {!Object} HTMLElementPrototype + * @return {!Array} + */ +function getMatchesProperty(HTMLElementPrototype) { + return [ + 'msMatchesSelector', 'matches', + ].filter((p) => p in HTMLElementPrototype).pop(); +} + +export {computeHorizontalScrollbarHeight, getMatchesProperty}; diff --git a/packages/mdc-tab/README.md b/packages/mdc-tab/README.md index bbca3d0d54f..b24e77ce29b 100644 --- a/packages/mdc-tab/README.md +++ b/packages/mdc-tab/README.md @@ -1,13 +1,16 @@ -# Tab + - +# Tab -Tab is a selectable element with an active state +Tabs organize and allow navigation between groups of content that are related and at the same level of hierarchy. +Each Tab governs the visibility of one group of content. ## Design & API Documentation @@ -21,32 +24,77 @@ Tab is a selectable element with an active state ## Installation + ``` -npm install --save @material/tab +npm install @material/tab ``` -## Usage +## Basic Usage ### HTML Structure ```html - +``` + +### Styles + +```scss +@import "@material/tab/mdc-tab"; +``` + +### JavaScript Instantiation + +```js +import {MDCTab} from '@material/tab'; + +const tab = new MDCTab(document.querySelector('.mdc-tab')); +``` + +> See [Importing the JS component](../../docs/importing-js.md) for more information on how to import JavaScript. + +## Variants + +### Active Tab + +> *NOTE*: Don't forget to add the `mdc-tab-indicator--active` class to the `mdc-tab-indicator` subcomponent. + +```html + ``` +## Style Customization + ### CSS Classes CSS Class | Description --- | --- `mdc-tab` | Mandatory. +`mdc-tab__content` | Mandatory. Indicates the text label of the tab. +`mdc-tab__ripple` | Mandatory. Denotes the ripple surface for the tab. `mdc-tab--active` | Optional. Indicates that the tab is active. -`mdc-tab__content` | Mandatory. Indicates the text label of the tab -`mdc-tab__text-label` | Optional. Indicates an icon in the tab -`mdc-tab__icon` | Optional. Indicates a leading icon in the tab +`mdc-tab--stacked` | Optional. Indicates that the tab icon and label should flow vertically instead of horizontally. +`mdc-tab--min-width` | Optional. Indicates that the tab should shrink in size to be as narrow as possible without causing text to wrap. +`mdc-tab__text-label` | Optional. Indicates an icon in the tab. +`mdc-tab__icon` | Optional. Indicates a leading icon in the tab. ### Sass Mixins @@ -54,32 +102,60 @@ To customize the colors of any part of the tab, use the following mixins. Mixin | Description --- | --- -`mdc-tab-text-label-color($color)` | Customizes the color of the tab text label -`mdc-tab-icon-color($color)` | Customizes the color of the tab icon +`mdc-tab-text-label-color($color)` | Customizes the color of the tab text label. +`mdc-tab-icon-color($color)` | Customizes the color of the tab icon. +`mdc-tab-parent-positioning` | Sets the positioning of the MDCTab's parent element so that `MDCTab.computeDimensions()` reports the same values in all browsers. +`mdc-tab-fixed-width($width)` | Sets the fixed width of the tab. The tab will never be smaller than the given width. -### `MDCTab` +## `MDCTab` Properties and Methods Property | Value Type | Description --- | --- | --- -`active` | `boolean` | Allows getting/setting the active state of the tab -`ripple` | `MDCRipple` | The `MDCRipple` instance for the root element that `MDCChip` initializes +`active` | `boolean` | Allows getting the active state of the tab. + +Method Signature | Description +--- | --- +`activate(previousIndicatorClientRect: ClientRect=) => void` | Activates the indicator. `previousIndicatorClientRect` is an optional argument. +`deactivate() => void` | Deactivates the indicator. +`computeIndicatorClientRect() => ClientRect` | Returns the bounding client rect of the indicator. +`computeDimensions() => MDCTabDimensions` | Returns the dimensions of the Tab. + +Event Name | Event Data Structure | Description +--- | --- | --- +`MDCTab:interacted` | `{"detail": {"tab": MDCTab}}` | Emitted when the Tab is interacted with, regardless of its active state. Used by parent components to know which Tab to activate. + +## Usage within Web Frameworks + +If you are using a JavaScript framework, such as React or Angular, you can create a Tab for your framework. Depending on your needs, you can use the _Simple Approach: Wrapping MDC Web Vanilla Components_, or the _Advanced Approach: Using Foundations and Adapters_. Please follow the instructions [here](../../docs/integrating-into-frameworks.md). ### `MDCTabAdapter` Method Signature | Description --- | --- -`addClass(className: string) => void` | Adds a class to the root element -`removeClass(className: string) => void` | Removes a class from the root element -`hasClass(className: string) => boolean` | Returns true if the root element contains the given class -`registerEventHandler(evtType: string, handler: EventListener) => void` | Registers an event listener on the root element -`deregisterEventHandler(evtType: string, handler: EventListener) => void` | Deregisters an event listener on the root element -`setAttr(attr: string, value: string) => void` | Sets the given attribute on the root element to the given value +`addClass(className: string) => void` | Adds a class to the root element. +`removeClass(className: string) => void` | Removes a class from the root element. +`hasClass(className: string) => boolean` | Returns true if the root element contains the given class. +`registerEventHandler(evtType: string, handler: EventListener) => void` | Registers an event listener on the root element. +`deregisterEventHandler(evtType: string, handler: EventListener) => void` | Deregisters an event listener on the root element. +`setAttr(attr: string, value: string) => void` | Sets the given attribute on the root element to the given value. +`activateIndicator(previousIndicatorClientRect: ClientRect=) => void` | Activates the tab indicator subcomponent. `previousIndicatorClientRect` is an optional argument. +`deactivateIndicator() => void` | Deactivates the tab indicator subcomponent. +`computeIndicatorClientRect() => ClientRect` | Returns the tab indicator subcomponent's content bounding client rect. +`getOffsetLeft() => number` | Returns the `offsetLeft` value of the root element. +`getOffsetWidth() => number` | Returns the `offsetWidth` value of the root element. +`getContentOffsetLeft() => number` | Returns the `offsetLeft` value of the content element. +`getContentOffsetWidth() => number` | Returns the `offsetWidth` value of the content element. +`notifyInteracted() => void` | Emits the `MDCTab:interacted` event. +`focus() => void` | Applies focus to the root element. ### `MDCTabFoundation` Method Signature | Description --- | --- -`handleTransitionEnd(evt: Event) => void` | Handles the logic for the `"transitionend"` event -`isActive() => boolean` | Returns whether the tab is active -`activate() => void` | Activates the tab -`deactivate() => void` | Deactivates the tab +`handleTransitionEnd(evt: Event) => void` | Handles the logic for the `"transitionend"` event. +`handleClick() => void` | Handles the logic for the `"click"` event. +`isActive() => boolean` | Returns whether the tab is active. +`activate(previousIndicatorClientRect: ClientRect=) => void` | Activates the tab. `previousIndicatorClientRect` is an optional argument. +`deactivate() => void` | Deactivates the tab. +`computeIndicatorClientRect() => ClientRect` | Returns the tab indicator subcomponent's content bounding client rect. +`computeDimensions() => MDCTabDimensions` | Returns the dimensions of the tab. diff --git a/packages/mdc-tab/_mixins.scss b/packages/mdc-tab/_mixins.scss index b68278ca66a..40691116e8b 100644 --- a/packages/mdc-tab/_mixins.scss +++ b/packages/mdc-tab/_mixins.scss @@ -29,3 +29,11 @@ @include mdc-theme-prop(color, $color); } } + +@mixin mdc-tab-parent-positioning { + position: relative; +} + +@mixin mdc-tab-fixed-width($width) { + flex: 0 1 $width; +} diff --git a/packages/mdc-tab/_variables.scss b/packages/mdc-tab/_variables.scss index 8a377c8cc45..9463b129dbe 100644 --- a/packages/mdc-tab/_variables.scss +++ b/packages/mdc-tab/_variables.scss @@ -14,4 +14,8 @@ // limitations under the License. // -$mdc-tab-baseline-color: rgba(25, 25, 25, .6); +$mdc-tab-icon-size: 24px; +$mdc-tab-height: 48px; +$mdc-tab-stacked-height: 72px; +$mdc-tab-text-label-opacity: .6; +$mdc-tab-icon-opacity: .54; diff --git a/packages/mdc-tab/adapter.js b/packages/mdc-tab/adapter.js index 8b88ffd1826..3ee5b6caa6f 100644 --- a/packages/mdc-tab/adapter.js +++ b/packages/mdc-tab/adapter.js @@ -17,6 +17,14 @@ /* eslint no-unused-vars: [2, {"args": "none"}] */ +/** + * MDCTabDimensions provides details about the left and right edges of the Tab + * root element and the Tab content element. These values are used to determine + * the visual position of the Tab with respect it's parent container. + * @typedef {{rootLeft: number, rootRight: number, contentLeft: number, contentRight: number}} + */ +let MDCTabDimensions; + /** * Adapter for MDC Tab. * @@ -67,6 +75,55 @@ class MDCTabAdapter { * @param {string} value The value so give the attribute */ setAttr(attr, value) {} + + /** + * Activates the indicator element. + * @param {!ClientRect=} previousIndicatorClientRect The client rect of the previously activated indicator + */ + activateIndicator(previousIndicatorClientRect) {} + + /** Deactivates the indicator. */ + deactivateIndicator() {} + + /** + * Returns the client rect of the indicator. + * @return {!ClientRect} + */ + computeIndicatorClientRect() {} + + /** + * Emits the MDCTab:interacted event for use by parent components + */ + notifyInteracted() {} + + /** + * Returns the offsetLeft value of the root element. + * @return {number} + */ + getOffsetLeft() {} + + /** + * Returns the offsetWidth value of the root element. + * @return {number} + */ + getOffsetWidth() {} + + /** + * Returns the offsetLeft of the content element. + * @return {number} + */ + getContentOffsetLeft() {} + + /** + * Returns the offsetWidth of the content element. + * @return {number} + */ + getContentOffsetWidth() {} + + /** + * Applies focus to the root element + */ + focus() {} } -export default MDCTabAdapter; +export {MDCTabDimensions, MDCTabAdapter}; diff --git a/packages/mdc-tab/constants.js b/packages/mdc-tab/constants.js index 9eefed94d17..ebfaf14a204 100644 --- a/packages/mdc-tab/constants.js +++ b/packages/mdc-tab/constants.js @@ -25,6 +25,11 @@ const cssClasses = { /** @enum {string} */ const strings = { ARIA_SELECTED: 'aria-selected', + RIPPLE_SELECTOR: '.mdc-tab__ripple', + CONTENT_SELECTOR: '.mdc-tab__content', + TAB_INDICATOR_SELECTOR: '.mdc-tab-indicator', + TABINDEX: 'tabIndex', + INTERACTED_EVENT: 'MDCTab:interacted', }; export { diff --git a/packages/mdc-tab/foundation.js b/packages/mdc-tab/foundation.js index 9dea0ce7a81..8fd7829535a 100644 --- a/packages/mdc-tab/foundation.js +++ b/packages/mdc-tab/foundation.js @@ -16,7 +16,11 @@ */ import MDCFoundation from '@material/base/foundation'; -import MDCTabAdapter from './adapter'; + +/* eslint-disable no-unused-vars */ +import {MDCTabAdapter, MDCTabDimensions} from './adapter'; +/* eslint-enable no-unused-vars */ + import { cssClasses, strings, @@ -49,6 +53,15 @@ class MDCTabFoundation extends MDCFoundation { removeClass: () => {}, hasClass: () => {}, setAttr: () => {}, + activateIndicator: () => {}, + deactivateIndicator: () => {}, + computeIndicatorClientRect: () => {}, + notifyInteracted: () => {}, + getOffsetLeft: () => {}, + getOffsetWidth: () => {}, + getContentOffsetLeft: () => {}, + getContentOffsetWidth: () => {}, + focus: () => {}, }); } @@ -58,6 +71,13 @@ class MDCTabFoundation extends MDCFoundation { /** @private {function(!Event): undefined} */ this.handleTransitionEnd_ = (evt) => this.handleTransitionEnd(evt); + + /** @private {function(?Event): undefined} */ + this.handleClick_ = () => this.handleClick(); + } + + init() { + this.adapter_.registerEventHandler('click', this.handleClick_); } /** @@ -74,6 +94,15 @@ class MDCTabFoundation extends MDCFoundation { this.adapter_.removeClass(cssClasses.ANIMATING_DEACTIVATE); } + /** + * Handles the "click" event + */ + handleClick() { + // It's up to the parent component to keep track of the active Tab and + // ensure we don't activate a Tab that's already active. + this.adapter_.notifyInteracted(); + } + /** * Returns the Tab's active state * @return {boolean} @@ -84,16 +113,21 @@ class MDCTabFoundation extends MDCFoundation { /** * Activates the Tab + * @param {!ClientRect=} previousIndicatorClientRect */ - activate() { + activate(previousIndicatorClientRect) { // Early exit if (this.isActive()) { return; } + this.adapter_.registerEventHandler('transitionend', this.handleTransitionEnd_); this.adapter_.addClass(cssClasses.ANIMATING_ACTIVATE); this.adapter_.addClass(cssClasses.ACTIVE); this.adapter_.setAttr(strings.ARIA_SELECTED, 'true'); + this.adapter_.setAttr(strings.TABINDEX, '0'); + this.adapter_.activateIndicator(previousIndicatorClientRect); + this.adapter_.focus(); } /** @@ -104,10 +138,39 @@ class MDCTabFoundation extends MDCFoundation { if (!this.isActive()) { return; } + this.adapter_.registerEventHandler('transitionend', this.handleTransitionEnd_); this.adapter_.addClass(cssClasses.ANIMATING_DEACTIVATE); this.adapter_.removeClass(cssClasses.ACTIVE); this.adapter_.setAttr(strings.ARIA_SELECTED, 'false'); + this.adapter_.setAttr(strings.TABINDEX, '-1'); + this.adapter_.deactivateIndicator(); + } + + /** + * Returns the indicator's client rect + * @return {!ClientRect} + */ + computeIndicatorClientRect() { + return this.adapter_.computeIndicatorClientRect(); + } + + /** + * Returns the dimensions of the Tab + * @return {!MDCTabDimensions} + */ + computeDimensions() { + const rootWidth = this.adapter_.getOffsetWidth(); + const rootLeft = this.adapter_.getOffsetLeft(); + const contentWidth = this.adapter_.getContentOffsetWidth(); + const contentLeft = this.adapter_.getContentOffsetLeft(); + + return { + rootLeft, + rootRight: rootLeft + rootWidth, + contentLeft: rootLeft + contentLeft, + contentRight: rootLeft + contentLeft + contentWidth, + }; } } diff --git a/packages/mdc-tab/index.js b/packages/mdc-tab/index.js index 342de633a0e..cf4dacb4dff 100644 --- a/packages/mdc-tab/index.js +++ b/packages/mdc-tab/index.js @@ -16,9 +16,13 @@ */ import MDCComponent from '@material/base/component'; -import {MDCRipple} from '@material/ripple/index'; -import MDCTabAdapter from './adapter'; +/* eslint-disable no-unused-vars */ +import {MDCRipple, MDCRippleFoundation, RippleCapableSurface} from '@material/ripple/index'; +import {MDCTabIndicator, MDCTabIndicatorFoundation} from '@material/tab-indicator/index'; +import {MDCTabAdapter, MDCTabDimensions} from './adapter'; +/* eslint-enable no-unused-vars */ + import MDCTabFoundation from './foundation'; /** @@ -31,9 +35,12 @@ class MDCTab extends MDCComponent { */ constructor(...args) { super(...args); - - /** @private {!MDCRipple} */ - this.ripple_ = new MDCRipple(this.root_); + /** @private {?MDCRipple} */ + this.ripple_; + /** @private {?MDCTabIndicator} */ + this.tabIndicator_; + /** @private {?Element} */ + this.content_; } /** @@ -44,6 +51,24 @@ class MDCTab extends MDCComponent { return new MDCTab(root); } + initialize( + rippleFactory = (el, foundation) => new MDCRipple(el, foundation), + tabIndicatorFactory = (el) => new MDCTabIndicator(el)) { + const rippleSurface = this.root_.querySelector(MDCTabFoundation.strings.RIPPLE_SELECTOR); + const rippleAdapter = Object.assign(MDCRipple.createAdapter(/** @type {!RippleCapableSurface} */ (this)), { + addClass: (className) => rippleSurface.classList.add(className), + removeClass: (className) => rippleSurface.classList.remove(className), + updateCssVariable: (varName, value) => rippleSurface.style.setProperty(varName, value), + }); + const rippleFoundation = new MDCRippleFoundation(rippleAdapter); + this.ripple_ = rippleFactory(this.root_, rippleFoundation); + + const tabIndicatorElement = this.root_.querySelector(MDCTabFoundation.strings.TAB_INDICATOR_SELECTOR); + this.tabIndicator_ = tabIndicatorFactory(tabIndicatorElement); + + this.content_ = this.root_.querySelector(MDCTabFoundation.strings.CONTENT_SELECTOR); + } + destroy() { this.ripple_.destroy(); super.destroy(); @@ -53,17 +78,28 @@ class MDCTab extends MDCComponent { * @return {!MDCTabFoundation} */ getDefaultFoundation() { - return new MDCTabFoundation(/** @type {!MDCTabAdapter} */ (Object.assign({ - setAttr: (attr, value) => this.root_.setAttribute(attr, value), - registerEventHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler), - deregisterEventHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler), - addClass: (className) => this.root_.classList.add(className), - removeClass: (className) => this.root_.classList.remove(className), - hasClass: (className) => this.root_.classList.contains(className), - }))); + return new MDCTabFoundation( + /** @type {!MDCTabAdapter} */ ({ + setAttr: (attr, value) => this.root_.setAttribute(attr, value), + registerEventHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler), + deregisterEventHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler), + addClass: (className) => this.root_.classList.add(className), + removeClass: (className) => this.root_.classList.remove(className), + hasClass: (className) => this.root_.classList.contains(className), + activateIndicator: (previousIndicatorClientRect) => this.tabIndicator_.activate(previousIndicatorClientRect), + deactivateIndicator: () => this.tabIndicator_.deactivate(), + computeIndicatorClientRect: () => this.tabIndicator_.computeContentClientRect(), + notifyInteracted: () => this.emit(MDCTabFoundation.strings.INTERACTED_EVENT, {tab: this}, true /* bubble */), + getOffsetLeft: () => this.root_.offsetLeft, + getOffsetWidth: () => this.root_.offsetWidth, + getContentOffsetLeft: () => this.content_.offsetLeft, + getContentOffsetWidth: () => this.content_.offsetWidth, + focus: () => this.root_.focus(), + })); } /** + * Getter for the active state of the tab * @return {boolean} */ get active() { @@ -71,14 +107,33 @@ class MDCTab extends MDCComponent { } /** - * @param {boolean} isActive + * Activates the tab + * @param {!ClientRect=} computeIndicatorClientRect + */ + activate(computeIndicatorClientRect) { + this.foundation_.activate(computeIndicatorClientRect); + } + + /** + * Deactivates the tab + */ + deactivate() { + this.foundation_.deactivate(); + } + + /** + * Returns the indicator's client rect + * @return {!ClientRect} + */ + computeIndicatorClientRect() { + return this.foundation_.computeIndicatorClientRect(); + } + + /** + * @return {!MDCTabDimensions} */ - set active(isActive) { - if (isActive) { - this.foundation_.activate(); - } else { - this.foundation_.deactivate(); - } + computeDimensions() { + return this.foundation_.computeDimensions(); } } diff --git a/packages/mdc-tab/mdc-tab.scss b/packages/mdc-tab/mdc-tab.scss index 80533f43ba1..73272e39547 100644 --- a/packages/mdc-tab/mdc-tab.scss +++ b/packages/mdc-tab/mdc-tab.scss @@ -18,22 +18,21 @@ @import "@material/ripple/mixins"; @import "@material/rtl/mixins"; @import "@material/typography/mixins"; +@import "@material/tab-indicator/mixins"; @import "./mixins"; @import "./variables"; .mdc-tab { - @include mdc-ripple-surface; - @include mdc-ripple-radius-bounded; - @include mdc-states(primary); - @include mdc-tab-text-label-color($mdc-tab-baseline-color); - @include mdc-tab-icon-color($mdc-tab-baseline-color); + @include mdc-tab-text-label-color(on-surface); + @include mdc-tab-icon-color(on-surface); + @include mdc-tab-indicator-surface; + @include mdc-typography(button); display: flex; - position: relative; flex: 1 0 auto; justify-content: center; box-sizing: border-box; - height: 48px; + height: $mdc-tab-height; padding: 0 24px; border: none; outline: none; @@ -44,35 +43,73 @@ white-space: nowrap; cursor: pointer; -webkit-appearance: none; - overflow: hidden; z-index: 1; } +.mdc-tab--min-width { + flex: 0 1 auto; +} + +.mdc-tab__ripple { + @include mdc-ripple-surface; + @include mdc-ripple-radius-bounded; + @include mdc-states(primary); + + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow: hidden; +} + .mdc-tab__content { + @include mdc-tab-indicator-surface; + display: flex; - position: relative; + align-items: center; justify-content: center; height: inherit; - z-index: 1; + pointer-events: none; } -.mdc-tab__text-label { - @include mdc-typography-base; +.mdc-tab__text-label, +.mdc-tab__icon { + z-index: 2; +} +.mdc-tab__text-label { display: inline-block; - padding: 16px 0 20px; - font-size: 14px; - font-weight: 500; + opacity: $mdc-tab-text-label-opacity; + // Setting line-height here overrides the line-height from the typography + // mixin above. The line-height needs to be overridden so that the spacing + // between the text label and the icon as well as the text label and the + // bottom of the tab remain the same. + line-height: 1; } .mdc-tab__icon { - width: 24px; - height: 24px; - padding: 12px 0; + width: $mdc-tab-icon-size; + height: $mdc-tab-icon-size; + opacity: $mdc-tab-icon-opacity; } -.mdc-tab__icon + .mdc-tab__text-label { - @include mdc-rtl-reflexive-box(padding, left, 8px); +.mdc-tab--stacked { + height: $mdc-tab-stacked-height; +} + +.mdc-tab--stacked .mdc-tab__content { + flex-direction: column; + align-items: center; + justify-content: space-between; +} + +.mdc-tab--stacked .mdc-tab__icon { + padding-top: 12px; +} + +.mdc-tab--stacked .mdc-tab__text-label { + padding-bottom: 16px; } // The [de]activation animation affects color for text label and icon @@ -80,7 +117,7 @@ .mdc-tab--animating-activate .mdc-tab__icon, .mdc-tab--animating-deactivate .mdc-tab__text-label, .mdc-tab--animating-deactivate .mdc-tab__icon { - transition: 150ms color linear; + transition: 150ms color linear, 150ms opacity linear; } // The activation animation has a delay of 100ms @@ -92,4 +129,13 @@ .mdc-tab--active { @include mdc-tab-text-label-color(primary); @include mdc-tab-icon-color(primary); + + .mdc-tab__text-label, + .mdc-tab__icon { + opacity: 1; + } +} + +.mdc-tab:not(.mdc-tab--stacked) .mdc-tab__icon + .mdc-tab__text-label { + @include mdc-rtl-reflexive-box(padding, left, 8px); } diff --git a/packages/mdc-tab/package-lock.json b/packages/mdc-tab/package-lock.json deleted file mode 100644 index c99f45b885e..00000000000 --- a/packages/mdc-tab/package-lock.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "@material/tab", - "version": "0.0.0", - "lockfileVersion": 1, - "requires": true, - "dependencies": { - "@material/rtl": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@material/rtl/-/rtl-0.30.0.tgz", - "integrity": "sha512-zxEiXFESaQvNNm+tHO0QVKxkGERkL6rX4ghusl7HhosueOcjaVLNYCxH5F0x6Et04TL0wh1ycBmFUIEqVhEkWw==" - }, - "@material/theme": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/@material/theme/-/theme-0.30.0.tgz", - "integrity": "sha512-jd1iC5qyq5YX4w8sZj9+lXExeK4adXbAjw/lOQuDeT6WSGJf7HovxQlHjlyWJ4p/x0xTGrlaDHnrCxeJQCq4Og==" - } - } -} diff --git a/packages/mdc-tab/package.json b/packages/mdc-tab/package.json index 1caf3f5594d..99e8c219bc9 100644 --- a/packages/mdc-tab/package.json +++ b/packages/mdc-tab/package.json @@ -3,7 +3,6 @@ "description": "The Material Components for the web tab component", "version": "0.37.1", "license": "Apache-2.0", - "private": true, "keywords": [ "material components", "material design", @@ -17,9 +16,10 @@ "dependencies": { "@material/base": "^0.35.0", "@material/ripple": "^0.37.1", - "@material/rtl": "^0.30.0", - "@material/theme": "^0.30.0", - "@material/typography": "^0.37.1" + "@material/rtl": "^0.36.0", + "@material/theme": "^0.35.0", + "@material/typography": "^0.37.1", + "@material/tab-indicator": "^0.0.0" }, "publishConfig": { "access": "public" diff --git a/packages/mdc-tabs/README.md b/packages/mdc-tabs/README.md index b1247e545c9..fc238de10dd 100644 --- a/packages/mdc-tabs/README.md +++ b/packages/mdc-tabs/README.md @@ -1,12 +1,20 @@ +## Important - Deprecation Notice + +The `mdc-tabs` package is deprecated and no longer maintained, and is no longer included in the all-in-one +`material-components-web` package. Improved functionality is available across the `mdc-tab-bar`, `mdc-tab-scroller`, +`mdc-tab-indicator`, and `mdc-tab` packages, which are now included in the `material-components-web` package. +Bugs and feature requests will no longer be accepted for this package. It is recommended that you migrate to the new +packages to continue to receive new features and updates. + # MDC Tabs The MDC Tabs component contains components which are used to create spec-aligned tabbed navigation components adhering to the diff --git a/scripts/check-pkg-for-release.js b/scripts/check-pkg-for-release.js index 36ff709a13b..be7241d712b 100644 --- a/scripts/check-pkg-for-release.js +++ b/scripts/check-pkg-for-release.js @@ -45,6 +45,21 @@ const MASTER_PKG = require(path.join(process.env.PWD, MASTER_PKG_PATH)); // are necessary since other MDC packages depend on them. const CSS_WHITELIST = ['base', 'animation', 'auto-init', 'rtl', 'selection-control']; +// List of packages that are intentionally not included in the MCW package's dependencies +const NOT_MCW_DEP = [ + 'tabs', // Deprecated; CSS classes conflict with tab and tab-bar +]; + +const NOT_AUTOINIT = [ + 'auto-init', + 'base', + 'selection-control', + 'tab', // Only makes sense in context of tab-bar + 'tab-indicator', // Only makes sense in context of tab-bar + 'tab-scroller', // Only makes sense in context of tab-bar + 'tabs', // Deprecated +]; + main(); function main() { @@ -127,15 +142,17 @@ function checkDependencyAddedInMDCPackage() { } function checkPkgDependencyAddedInMDCPackage() { - assert.notEqual(typeof MASTER_PKG.dependencies[pkg.name], 'undefined', - 'FAILURE: Component ' + pkg.name + ' is not a denpendency for MDC Web. ' + - 'Please add ' + pkg.name +' to ' + MASTER_PKG_PATH + '\' dependencies before commit.'); + if (NOT_MCW_DEP.indexOf(getPkgName()) === -1) { + assert.notEqual(typeof MASTER_PKG.dependencies[pkg.name], 'undefined', + 'FAILURE: Component ' + pkg.name + ' is not a denpendency for MDC Web. ' + + 'Please add ' + pkg.name +' to ' + MASTER_PKG_PATH + '\' dependencies before commit.'); + } } function checkCSSDependencyAddedInMDCPackage() { const name = getPkgName(); const nameMDC = `mdc-${name}`; - if (CSS_WHITELIST.indexOf(name) === -1) { + if (CSS_WHITELIST.indexOf(name) === -1 && NOT_MCW_DEP.indexOf(name) === -1) { const src = fs.readFileSync(path.join(process.env.PWD, MASTER_CSS_PATH), 'utf8'); const cssRules = cssom.parse(src).cssRules; const cssRule = path.join(pkg.name, nameMDC); @@ -150,9 +167,8 @@ function checkCSSDependencyAddedInMDCPackage() { function checkJSDependencyAddedInMDCPackage() { const NOT_IMPORTED = ['animation']; - const NOT_AUTOINIT = ['auto-init', 'base', 'selection-control']; const name = getPkgName(); - if (typeof(pkg.main) !== 'undefined' && NOT_IMPORTED.indexOf(name) === -1) { + if (typeof(pkg.main) !== 'undefined' && NOT_IMPORTED.indexOf(name) === -1 && NOT_MCW_DEP.indexOf(name) === -1) { const nameCamel = camelCase(pkg.name.replace('@material/', '')); const src = fs.readFileSync(path.join(process.env.PWD, MASTER_JS_PATH), 'utf8'); const ast = recast.parse(src, { diff --git a/scripts/webpack/css-bundle-factory.js b/scripts/webpack/css-bundle-factory.js index 5ecef62e111..a84bac23e4c 100644 --- a/scripts/webpack/css-bundle-factory.js +++ b/scripts/webpack/css-bundle-factory.js @@ -162,6 +162,10 @@ class CssBundleFactory { 'mdc.slider': getAbsolutePath('/packages/mdc-slider/mdc-slider.scss'), 'mdc.snackbar': getAbsolutePath('/packages/mdc-snackbar/mdc-snackbar.scss'), 'mdc.switch': getAbsolutePath('/packages/mdc-switch/mdc-switch.scss'), + 'mdc.tab': getAbsolutePath('/packages/mdc-tab/mdc-tab.scss'), + 'mdc.tab-bar': getAbsolutePath('/packages/mdc-tab-bar/mdc-tab-bar.scss'), + 'mdc.tab-indicator': getAbsolutePath('/packages/mdc-tab-indicator/mdc-tab-indicator.scss'), + 'mdc.tab-scroller': getAbsolutePath('/packages/mdc-tab-scroller/mdc-tab-scroller.scss'), 'mdc.tabs': getAbsolutePath('/packages/mdc-tabs/mdc-tabs.scss'), 'mdc.textfield': getAbsolutePath('/packages/mdc-textfield/mdc-text-field.scss'), 'mdc.theme': getAbsolutePath('/packages/mdc-theme/mdc-theme.scss'), diff --git a/scripts/webpack/js-bundle-factory.js b/scripts/webpack/js-bundle-factory.js index 455cba118b4..38a0da5c373 100644 --- a/scripts/webpack/js-bundle-factory.js +++ b/scripts/webpack/js-bundle-factory.js @@ -148,6 +148,10 @@ class JsBundleFactory { slider: getAbsolutePath('/packages/mdc-slider/index.js'), snackbar: getAbsolutePath('/packages/mdc-snackbar/index.js'), switch: getAbsolutePath('/packages/mdc-switch/index.js'), + tab: getAbsolutePath('/packages/mdc-tab/index.js'), + tabBar: getAbsolutePath('/packages/mdc-tab-bar/index.js'), + tabIndicator: getAbsolutePath('/packages/mdc-tab-indicator/index.js'), + tabScroller: getAbsolutePath('/packages/mdc-tab-scroller/index.js'), tabs: getAbsolutePath('/packages/mdc-tabs/index.js'), textfield: getAbsolutePath('/packages/mdc-textfield/index.js'), toolbar: getAbsolutePath('/packages/mdc-toolbar/index.js'), diff --git a/test/unit/mdc-tab-bar/foundation.test.js b/test/unit/mdc-tab-bar/foundation.test.js new file mode 100644 index 00000000000..ae9194a32e4 --- /dev/null +++ b/test/unit/mdc-tab-bar/foundation.test.js @@ -0,0 +1,582 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import {verifyDefaultAdapter} from '../helpers/foundation'; +import {setupFoundationTest} from '../helpers/setup'; +import MDCTabBarFoundation from '../../../packages/mdc-tab-bar/foundation'; + +suite('MDCTabBarFoundation'); + +test('exports cssClasses', () => { + assert.isOk('cssClasses' in MDCTabBarFoundation); +}); + +test('exports strings', () => { + assert.isOk('strings' in MDCTabBarFoundation); +}); + +test('defaultAdapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCTabBarFoundation, [ + 'scrollTo', 'incrementScroll', 'getScrollPosition', 'getScrollContentWidth', + 'getOffsetWidth', 'isRTL', + 'activateTabAtIndex', 'deactivateTabAtIndex', + 'getTabIndicatorClientRectAtIndex', 'getTabDimensionsAtIndex', + 'getActiveTabIndex', 'getIndexOfTab', 'getTabListLength', + 'notifyTabActivated', + ]); +}); + +const setupTest = () => setupFoundationTest(MDCTabBarFoundation); + +test('#init() scrolls the active tab into view', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.scrollIntoView = td.function(); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(99); + foundation.init(); + td.verify(foundation.scrollIntoView(99), {times: 1}); +}); + +const stubActivateTab = () => { + const {foundation, mockAdapter} = setupTest(); + const activateTab = td.function(); + foundation.activateTab = activateTab; + return {activateTab, foundation, mockAdapter}; +}; + +const mockKeyDownEvent = ({key, keyCode}) => { + const preventDefault = td.function(); + const fakeEvent = { + key, + keyCode, + preventDefault, + }; + + return {preventDefault, fakeEvent}; +}; + +test('#handleTabInteraction() activates the tab', () => { + const {foundation, activateTab} = stubActivateTab(); + foundation.handleTabInteraction({detail: {}}); + td.verify(activateTab(td.matchers.anything()), {times: 1}); +}); + +test('#handleKeyDown() activates the tab at the 0th index when the home key is pressed', () => { + const {foundation, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'Home'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 36}); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(0), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the N - 1 index when the end key is pressed', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'End'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 35}); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(12), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the previous index when the left arrow key is pressed', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'ArrowLeft'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 37}); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(2); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(1), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the next index when the right arrow key is pressed' + + ' and the text direction is RTL', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'ArrowLeft'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 37}); + td.when(mockAdapter.isRTL()).thenReturn(true); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(2); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(3), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the N - 1 index when the left arrow key is pressed' + + ' and the current active index is 0', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'ArrowLeft'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 37}); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(0); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(12), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the N - 1 index when the right arrow key is pressed' + + ' and the current active index is the 0th index and the text direction is RTL', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'ArrowRight'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 39}); + td.when(mockAdapter.isRTL()).thenReturn(true); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(0); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(12), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the next index when the right arrow key is pressed', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'ArrowRight'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 39}); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(2); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(3), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the previous index when the right arrow key is pressed' + + ' and the text direction is RTL', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'ArrowRight'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 39}); + td.when(mockAdapter.isRTL()).thenReturn(true); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(2); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(1), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the 0th index when the right arrow key is pressed' + + ' and the current active index is the max index', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'ArrowRight'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 39}); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(12); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(0), {times: 2}); +}); + +test('#handleKeyDown() activates the tab at the 0th index when the left arrow key is pressed' + + ' and the current active index is the max index and the text direction is RTL', () => { + const {foundation, mockAdapter, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'ArrowLeft'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 37}); + td.when(mockAdapter.isRTL()).thenReturn(true); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(12); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(0), {times: 2}); +}); + +test('#handleKeyDown() prevents the default behavior when the pressed key is ArrowLeft, ArrowRight, End, or Home', + () => { + ['ArrowLeft', 'ArrowRight', 'Home', 'End'].forEach((evtName) => { + const {foundation} = stubActivateTab(); + const {fakeEvent, preventDefault} = mockKeyDownEvent({key: evtName}); + foundation.handleKeyDown(fakeEvent); + td.verify(preventDefault()); + }); + }); + +test('#handleKeyDown() prevents the default behavior when the pressed keyCode is 35, 36, 37, or 39', () => { + [35, 36, 37, 39].forEach((keyCode) => { + const {foundation} = stubActivateTab(); + const {fakeEvent, preventDefault} = mockKeyDownEvent({keyCode}); + foundation.handleKeyDown(fakeEvent); + td.verify(preventDefault()); + }); +}); + +test('#handleKeyDown() does not prevent the default behavior when a non-directional key is pressed', () => { + const {foundation} = stubActivateTab(); + const {fakeEvent, preventDefault} = mockKeyDownEvent({key: 'Shift'}); + foundation.handleKeyDown(fakeEvent); + td.verify(preventDefault(), {times: 0}); +}); + +test('#handleKeyDown() does not prevent the default behavior when a non-directional keyCode is pressed', () => { + const {foundation} = stubActivateTab(); + const {fakeEvent, preventDefault} = mockKeyDownEvent({keyCode: 16}); + foundation.handleKeyDown(fakeEvent); + td.verify(preventDefault(), {times: 0}); +}); + +test('#handleKeyDown() does not activate a tab when a non-directional key is pressed', () => { + const {foundation, activateTab} = stubActivateTab(); + const {fakeEvent: fakeKeyEvent} = mockKeyDownEvent({key: 'Shift'}); + const {fakeEvent: fakeKeyCodeEvent} = mockKeyDownEvent({keyCode: 16}); + foundation.handleKeyDown(fakeKeyEvent); + foundation.handleKeyDown(fakeKeyCodeEvent); + td.verify(activateTab(), {times: 0}); +}); + +const setupActivateTabTest = () => { + const {foundation, mockAdapter} = setupTest(); + const scrollIntoView = td.function(); + foundation.scrollIntoView = scrollIntoView; + return {foundation, mockAdapter, scrollIntoView}; +}; + +test('#activateTab() does nothing if the index overflows the tab list', () => { + const {foundation, mockAdapter} = setupActivateTabTest(); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.activateTab(13); + td.verify(mockAdapter.deactivateTabAtIndex(td.matchers.isA(Number)), {times: 0}); + td.verify(mockAdapter.activateTabAtIndex(td.matchers.isA(Number)), {times: 0}); +}); + +test('#activateTab() does nothing if the index underflows the tab list', () => { + const {foundation, mockAdapter} = setupActivateTabTest(); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + foundation.activateTab(-1); + td.verify(mockAdapter.deactivateTabAtIndex(td.matchers.isA(Number)), {times: 0}); + td.verify(mockAdapter.activateTabAtIndex(td.matchers.isA(Number)), {times: 0}); +}); + +test(`#activateTab() does not emit the ${MDCTabBarFoundation.strings.TAB_ACTIVATED_EVENT} event if the index` + + ' is the currently active index', () => { + const {foundation, mockAdapter} = setupActivateTabTest(); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(6); + foundation.activateTab(6); + td.verify(mockAdapter.notifyTabActivated(td.matchers.anything()), {times: 0}); +}); + +test('#activateTab() deactivates the previously active tab', () => { + const {foundation, mockAdapter} = setupActivateTabTest(); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(6); + foundation.activateTab(1); + td.verify(mockAdapter.deactivateTabAtIndex(6), {times: 1}); +}); + +test('#activateTab() activates the newly active tab with the previously active tab\'s indicatorClientRect', () => { + const {foundation, mockAdapter} = setupActivateTabTest(); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(6); + td.when(mockAdapter.getTabIndicatorClientRectAtIndex(6)).thenReturn({ + left: 22, right: 33, + }); + foundation.activateTab(1); + td.verify(mockAdapter.activateTabAtIndex(1, {left: 22, right: 33}), {times: 1}); +}); + +test('#activateTab() scrolls the new tab index into view', () => { + const {foundation, mockAdapter, scrollIntoView} = setupActivateTabTest(); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(6); + td.when(mockAdapter.getTabIndicatorClientRectAtIndex(6)).thenReturn({ + left: 22, right: 33, + }); + foundation.activateTab(1); + td.verify(scrollIntoView(1)); +}); + +test(`#activateTab() emits the ${MDCTabBarFoundation.strings.TAB_ACTIVATED_EVENT} with the index of the tab`, () => { + const {foundation, mockAdapter} = setupActivateTabTest(); + td.when(mockAdapter.getTabListLength()).thenReturn(13); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(6); + td.when(mockAdapter.getTabIndicatorClientRectAtIndex(6)).thenReturn({ + left: 22, right: 33, + }); + foundation.activateTab(1); + td.verify(mockAdapter.notifyTabActivated(1)); +}); + +function setupScrollIntoViewTest({ + activeIndex = 0, + tabListLength = 10, + indicatorClientRect = {}, + scrollContentWidth = 1000, + scrollPosition = 0, + offsetWidth = 400, + tabDimensionsMap = {}} = {}) { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getActiveTabIndex()).thenReturn(activeIndex); + td.when(mockAdapter.getTabListLength()).thenReturn(tabListLength); + td.when(mockAdapter.getTabIndicatorClientRectAtIndex(td.matchers.isA(Number))).thenReturn(indicatorClientRect); + td.when(mockAdapter.getScrollPosition()).thenReturn(scrollPosition); + td.when(mockAdapter.getScrollContentWidth()).thenReturn(scrollContentWidth); + td.when(mockAdapter.getOffsetWidth()).thenReturn(offsetWidth); + td.when(mockAdapter.getTabDimensionsAtIndex(td.matchers.isA(Number))).thenDo((index) => { + return tabDimensionsMap[index]; + }); + + return {foundation, mockAdapter}; +} + +test('#scrollIntoView() does nothing if the index overflows the tab list', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + tabListLength: 13, + }); + foundation.scrollIntoView(13); + td.verify(mockAdapter.scrollTo(td.matchers.isA(Number)), {times: 0}); + td.verify(mockAdapter.incrementScroll(td.matchers.isA(Number)), {times: 0}); +}); + +test('#scrollIntoView() does nothing if the index underflows the tab list', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + tabListLength: 9, + }); + foundation.scrollIntoView(-1); + td.verify(mockAdapter.scrollTo(td.matchers.isA(Number)), {times: 0}); + td.verify(mockAdapter.incrementScroll(td.matchers.isA(Number)), {times: 0}); +}); + +test('#scrollIntoView() scrolls to 0 if the index is 0', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + tabListLength: 9, + }); + foundation.scrollIntoView(0); + td.verify(mockAdapter.scrollTo(0), {times: 1}); +}); + +test('#scrollIntoView() scrolls to the scroll content width if the index is the max possible', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + tabListLength: 9, + scrollContentWidth: 987, + }); + foundation.scrollIntoView(8); + td.verify(mockAdapter.scrollTo(987), {times: 1}); +}); + +test('#scrollIntoView() increments the scroll by 150 when the selected tab is 100px to the right' + + ' and the closest tab\'s left content edge is 30px from its left root edge', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + activeIndex: 0, + tabListLength: 9, + scrollContentWidth: 1000, + offsetWidth: 200, + tabDimensionsMap: { + 1: { + rootLeft: 0, + rootRight: 300, + }, + 2: { + rootLeft: 300, + contentLeft: 330, + contentRight: 370, + rootRight: 400, + }, + }, + }); + foundation.scrollIntoView(1); + td.verify(mockAdapter.incrementScroll(130 + MDCTabBarFoundation.numbers.EXTRA_SCROLL_AMOUNT), {times: 1}); +}); + +test('#scrollIntoView() increments the scroll by 250 when the selected tab is 100px to the left, is 100px wide,' + + ' and the closest tab\'s left content edge is 30px from its left root edge and the text direction is RTL', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + activeIndex: 0, + tabListLength: 9, + scrollContentWidth: 1000, + offsetWidth: 200, + scrollPosition: 100, + tabDimensionsMap: { + 5: { + rootLeft: 400, + contentLeft: 430, + contentRight: 470, + rootRight: 500, + }, + 4: { + rootLeft: 500, + rootRight: 600, + }, + }, + }); + td.when(mockAdapter.isRTL()).thenReturn(true); + foundation.scrollIntoView(4); + td.verify(mockAdapter.incrementScroll(230 + MDCTabBarFoundation.numbers.EXTRA_SCROLL_AMOUNT), {times: 1}); +}); + +test('#scrollIntoView() increments the scroll by -250 when the selected tab is 100px to the left, is 100px wide,' + + ' and the closest tab\'s right content edge is 30px from its right root edge', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + activeIndex: 3, + tabListLength: 9, + scrollContentWidth: 1000, + scrollPosition: 500, + offsetWidth: 200, + tabDimensionsMap: { + 1: { + rootLeft: 190, + contentLeft: 220, + contentRight: 270, + rootRight: 300, + }, + 2: { + rootLeft: 300, + contentLeft: 330, + contentRight: 370, + rootRight: 400, + }, + }, + }); + foundation.scrollIntoView(2); + td.verify(mockAdapter.incrementScroll(-230 - MDCTabBarFoundation.numbers.EXTRA_SCROLL_AMOUNT), {times: 1}); +}); + +test('#scrollIntoView() increments the scroll by -150 when the selected tab is 100px wide,' + + ' and the closest tab\'s right content edge is 30px from its right root edge and the text direction is RTL', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + activeIndex: 3, + tabListLength: 9, + scrollContentWidth: 1000, + scrollPosition: 300, + offsetWidth: 200, + tabDimensionsMap: { + 2: { + rootLeft: 700, + contentLeft: 730, + contentRight: 770, + rootRight: 800, + }, + 1: { + rootLeft: 800, + contentLeft: 830, + contentRight: 870, + rootRight: 900, + }, + }, + }); + td.when(mockAdapter.isRTL()).thenReturn(true); + foundation.scrollIntoView(2); + td.verify(mockAdapter.incrementScroll(-130 - MDCTabBarFoundation.numbers.EXTRA_SCROLL_AMOUNT), {times: 1}); +}); + +test('#scrollIntoView() does nothing when the tab is perfectly in the center', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + activeIndex: 3, + tabListLength: 9, + scrollContentWidth: 1000, + scrollPosition: 200, + offsetWidth: 300, + tabDimensionsMap: { + 1: { + rootLeft: 200, + contentLeft: 230, + contentRight: 270, + rootRight: 300, + }, + 2: { + rootLeft: 300, + contentLeft: 330, + contentRight: 370, + rootRight: 400, + }, + }, + }); + + foundation.scrollIntoView(2); + td.verify(mockAdapter.scrollTo(td.matchers.isA(Number)), {times: 0}); + td.verify(mockAdapter.incrementScroll(td.matchers.isA(Number)), {times: 0}); +}); + +test('#scrollIntoView() does nothing when the tab is perfectly in the center and the text direction is RTL', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + activeIndex: 3, + tabListLength: 10, + scrollContentWidth: 1000, + scrollPosition: 500, + offsetWidth: 300, + tabDimensionsMap: { + 8: { + rootLeft: 200, + contentLeft: 230, + contentRight: 270, + rootRight: 300, + }, + 7: { + rootLeft: 300, + contentLeft: 330, + contentRight: 370, + rootRight: 400, + }, + }, + }); + td.when(mockAdapter.isRTL()).thenReturn(true); + foundation.scrollIntoView(7); + td.verify(mockAdapter.scrollTo(td.matchers.isA(Number)), {times: 0}); + td.verify(mockAdapter.incrementScroll(td.matchers.isA(Number)), {times: 0}); +}); + +test('#scrollIntoView() increments the scroll by 0 when the tab and its left neighbor\'s content are visible', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + activeIndex: 3, + tabListLength: 9, + scrollContentWidth: 1000, + scrollPosition: 200, + offsetWidth: 500, + tabDimensionsMap: { + 1: { + rootLeft: 200, + contentLeft: 230, + contentRight: 270, + rootRight: 300, + }, + 2: { + rootLeft: 300, + contentLeft: 330, + contentRight: 370, + rootRight: 400, + }, + }, + }); + + foundation.scrollIntoView(2); + td.verify(mockAdapter.incrementScroll(0), {times: 1}); +}); + +test('#scrollIntoView() increments the scroll by 0 when the tab and its right neighbor\'s content are visible', () => { + const {foundation, mockAdapter} = setupScrollIntoViewTest({ + activeIndex: 3, + tabListLength: 9, + scrollContentWidth: 1000, + scrollPosition: 22, + offsetWidth: 400, + tabDimensionsMap: { + 1: { + rootLeft: 200, + contentLeft: 230, + contentRight: 270, + rootRight: 300, + }, + 2: { + rootLeft: 300, + contentLeft: 330, + contentRight: 370, + rootRight: 400, + }, + }, + }); + + foundation.scrollIntoView(1); + td.verify(mockAdapter.incrementScroll(0), {times: 1}); +}); diff --git a/test/unit/mdc-tab-bar/mdc-tab-bar.test.js b/test/unit/mdc-tab-bar/mdc-tab-bar.test.js new file mode 100644 index 00000000000..ac323c8a7ad --- /dev/null +++ b/test/unit/mdc-tab-bar/mdc-tab-bar.test.js @@ -0,0 +1,225 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import bel from 'bel'; +import {assert} from 'chai'; +import td from 'testdouble'; +import domEvents from 'dom-events'; + +import {MDCTabBar, MDCTabBarFoundation} from '../../../packages/mdc-tab-bar'; +import {MDCTabFoundation} from '../../../packages/mdc-tab'; + +const getFixture = () => bel` +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+`; + +suite('MDCTabBar'); + +test('attachTo returns an MDCTabBar instance', () => { + assert.isOk(MDCTabBar.attachTo(getFixture()) instanceof MDCTabBar); +}); + +class FakeTab { + constructor() { + this.destroy = td.function(); + this.activate = td.function(); + this.deactivate = td.function(); + this.computeIndicatorClientRect = td.function(); + this.computeDimensions = td.function(); + this.active = false; + } +} + +class FakeTabScroller { + constructor() { + this.destroy = td.function(); + this.scrollTo = td.function(); + this.incrementScroll = td.function(); + this.getScrollPosition = td.function(); + this.getScrollContentWidth = td.function(); + } +} + +test('#constructor instantiates child tab components', () => { + const root = getFixture(); + const component = new MDCTabBar(root, undefined, (el) => new FakeTab(el), (el) => new FakeTabScroller(el)); + assert.equal(component.tabList_.length, 3); + assert.instanceOf(component.tabList_[0], FakeTab); + assert.instanceOf(component.tabList_[1], FakeTab); + assert.instanceOf(component.tabList_[2], FakeTab); +}); + +test('#constructor instantiates child tab scroller component', () => { + const root = getFixture(); + const component = new MDCTabBar(root, undefined, (el) => new FakeTab(el), (el) => new FakeTabScroller(el)); + assert.instanceOf(component.tabScroller_, FakeTabScroller); +}); + +test('#destroy cleans up child tab components', () => { + const root = getFixture(); + const component = new MDCTabBar(root, undefined, (el) => new FakeTab(el), (el) => new FakeTabScroller(el)); + component.destroy(); + td.verify(component.tabList_[0].destroy()); + td.verify(component.tabList_[1].destroy()); + td.verify(component.tabList_[2].destroy()); +}); + +function setupTest() { + const root = getFixture(); + const component = new MDCTabBar(root, undefined, (el) => new FakeTab(el), (el) => new FakeTabScroller(el)); + return {root, component}; +} + +test('#adapter.scrollTo calls scrollTo of the child tab scroller', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.scrollTo(123); + td.verify(component.tabScroller_.scrollTo(123)); +}); + +test('#adapter.incrementScroll calls incrementScroll of the child tab scroller', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.incrementScroll(123); + td.verify(component.tabScroller_.incrementScroll(123)); +}); + +test('#adapter.getScrollPosition calls getScrollPosition of the child tab scroller', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.getScrollPosition(); + td.verify(component.tabScroller_.getScrollPosition(), {times: 1}); +}); + +test('#adapter.getScrollContentWidth calls getScrollContentWidth of the child tab scroller', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.getScrollContentWidth(); + td.verify(component.tabScroller_.getScrollContentWidth(), {times: 1}); +}); + +test('#adapter.getOffsetWidth returns getOffsetWidth of the root element', () => { + const {component, root} = setupTest(); + assert.strictEqual(component.getDefaultFoundation().adapter_.getOffsetWidth(), root.offsetWidth); +}); + +test('#adapter.isRTL returns the RTL state of the root element', () => { + const {component, root} = setupTest(); + document.body.appendChild(root); + document.body.setAttribute('dir', 'rtl'); + assert.strictEqual(component.getDefaultFoundation().adapter_.isRTL(), true); + document.body.removeChild(root); + document.body.removeAttribute('dir'); +}); + +test('#adapter.activateTabAtIndex calls activate on the tab at the index', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.activateTabAtIndex(2, {}); + td.verify(component.tabList_[2].activate({}), {times: 1}); +}); + +test('#adapter.deactivateTabAtIndex calls deactivate on the tab at the index', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.deactivateTabAtIndex(1); + td.verify(component.tabList_[1].deactivate(), {times: 1}); +}); + +test('#adapter.getTabIndicatorClientRectAtIndex calls computeIndicatorClientRect on the tab at the index', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.getTabIndicatorClientRectAtIndex(0); + td.verify(component.tabList_[0].computeIndicatorClientRect(), {times: 1}); +}); + +test('#adapter.getTabDimensionsAtIndex calls computeDimensions on the tab at the index', () => { + const {component} = setupTest(); + component.getDefaultFoundation().adapter_.getTabDimensionsAtIndex(0); + td.verify(component.tabList_[0].computeDimensions(), {times: 1}); +}); + +test('#adapter.getActiveTabIndex returns the index of the active tab', () => { + const {component} = setupTest(); + component.tabList_[1].active = true; + assert.strictEqual(component.getDefaultFoundation().adapter_.getActiveTabIndex(), 1); +}); + +test('#adapter.getIndexOfTab returns the index of the given tab', () => { + const {component} = setupTest(); + const tab = component.tabList_[2]; + assert.strictEqual(component.getDefaultFoundation().adapter_.getIndexOfTab(tab), 2); +}); + +test('#adapter.getTabListLength returns the length of the tab list', () => { + const {component} = setupTest(); + assert.strictEqual(component.getDefaultFoundation().adapter_.getTabListLength(), 3); +}); + +test(`#adapter.notifyTabActivated emits the ${MDCTabBarFoundation.strings.TAB_ACTIVATED_EVENT} event`, () => { + const {component, root} = setupTest(); + const handler = td.function(); + domEvents.on(root, MDCTabBarFoundation.strings.TAB_ACTIVATED_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyTabActivated(66); + td.verify(handler(td.matchers.contains({detail: {index: 66}}))); +}); + +function setupMockFoundationTest(root = getFixture()) { + const MockFoundationConstructor = td.constructor(MDCTabBarFoundation); + const mockFoundation = new MockFoundationConstructor(); + const component = new MDCTabBar(root, mockFoundation); + return {root, component, mockFoundation}; +} + +test('#activateTab calls activateTab', () => { + const {component, mockFoundation} = setupMockFoundationTest(); + component.activateTab(1); + td.verify(mockFoundation.activateTab(1), {times: 1}); +}); + +test('#scrollIntoView calls scrollIntoView', () => { + const {component, mockFoundation} = setupMockFoundationTest(); + component.scrollIntoView(1); + td.verify(mockFoundation.scrollIntoView(1), {times: 1}); +}); + +test(`on ${MDCTabFoundation.strings.INTERACTED_EVENT}, call handleTabInteraction`, () => { + const {root, mockFoundation} = setupMockFoundationTest(); + const tab = root.querySelector(MDCTabBarFoundation.strings.TAB_SELECTOR); + domEvents.emit(tab, MDCTabFoundation.strings.INTERACTED_EVENT, { + bubbles: true, + }); + td.verify(mockFoundation.handleTabInteraction(td.matchers.anything()), {times: 1}); +}); + +test('on keydown, call handleKeyDown', () => { + const {root, mockFoundation} = setupMockFoundationTest(); + domEvents.emit(root, 'keydown'); + td.verify(mockFoundation.handleKeyDown(td.matchers.anything()), {times: 1}); +}); diff --git a/test/unit/mdc-tab-indicator/fading-foundation.test.js b/test/unit/mdc-tab-indicator/fading-foundation.test.js new file mode 100644 index 00000000000..6195d18c978 --- /dev/null +++ b/test/unit/mdc-tab-indicator/fading-foundation.test.js @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import td from 'testdouble'; + +import {captureHandlers} from '../helpers/foundation'; +import {setupFoundationTest} from '../helpers/setup'; +import MDCFadingTabIndicatorFoundation from '../../../packages/mdc-tab-indicator/fading-foundation'; + +suite('MDCFadingTabIndicatorFoundation'); + +const setupTest = () => setupFoundationTest(MDCFadingTabIndicatorFoundation); + +test('#activate registers a transitionend handler', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.activate(); + td.verify(mockAdapter.registerEventHandler('transitionend', td.matchers.isA(Function))); +}); + +test(`#activate adds the ${MDCFadingTabIndicatorFoundation.cssClasses.ACTIVE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + foundation.activate(); + td.verify(mockAdapter.addClass(MDCFadingTabIndicatorFoundation.cssClasses.ACTIVE)); +}); + +test(`#activate adds the ${MDCFadingTabIndicatorFoundation.cssClasses.FADING_ACTIVATE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + foundation.activate(); + td.verify(mockAdapter.addClass(MDCFadingTabIndicatorFoundation.cssClasses.FADING_ACTIVATE)); +}); + +test('#deactivate registers a transitionend handler', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.deactivate(); + td.verify(mockAdapter.registerEventHandler('transitionend', td.matchers.isA(Function))); +}); + +test(`#deactivate removes the ${MDCFadingTabIndicatorFoundation.cssClasses.ACTIVE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + foundation.deactivate(); + td.verify(mockAdapter.removeClass(MDCFadingTabIndicatorFoundation.cssClasses.ACTIVE)); +}); + +test(`#deactivate adds the ${MDCFadingTabIndicatorFoundation.cssClasses.FADING_DEACTIVATE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + foundation.deactivate(); + td.verify(mockAdapter.addClass(MDCFadingTabIndicatorFoundation.cssClasses.FADING_DEACTIVATE)); +}); + +test('on transitionend, deregister the transitionend handler', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); + foundation.activate(); + handlers.transitionend(); + td.verify(mockAdapter.deregisterEventHandler('transitionend', td.matchers.isA(Function))); +}); + +test(`on transitionend, remove the ${MDCFadingTabIndicatorFoundation.cssClasses.FADING_ACTIVATE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); + foundation.activate(); + handlers.transitionend(); + td.verify(mockAdapter.removeClass(MDCFadingTabIndicatorFoundation.cssClasses.FADING_ACTIVATE)); +}); + +test(`on transitionend, remove the ${MDCFadingTabIndicatorFoundation.cssClasses.FADING_DEACTIVATE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); + foundation.activate(); + handlers.transitionend(); + td.verify(mockAdapter.removeClass(MDCFadingTabIndicatorFoundation.cssClasses.FADING_DEACTIVATE)); +}); diff --git a/test/unit/mdc-tab-indicator/foundation.test.js b/test/unit/mdc-tab-indicator/foundation.test.js new file mode 100644 index 00000000000..0cf5d62ae99 --- /dev/null +++ b/test/unit/mdc-tab-indicator/foundation.test.js @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import {verifyDefaultAdapter} from '../helpers/foundation'; +import {setupFoundationTest} from '../helpers/setup'; +import MDCTabIndicatorFoundation from '../../../packages/mdc-tab-indicator/foundation'; + +suite('MDCTabIndicatorFoundation'); + +test('exports cssClasses', () => { + assert.isOk('cssClasses' in MDCTabIndicatorFoundation); +}); + +test('exports strings', () => { + assert.isOk('strings' in MDCTabIndicatorFoundation); +}); + +test('defaultAdapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCTabIndicatorFoundation, [ + 'registerEventHandler', 'deregisterEventHandler', + 'addClass', 'removeClass', + 'setContentStyleProperty', + 'computeContentClientRect', + ]); +}); + +const setupTest = () => setupFoundationTest(MDCTabIndicatorFoundation); + +test('#computeContentClientRect returns the client rect', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.computeContentClientRect(); + td.verify(mockAdapter.computeContentClientRect(), {times: 1}); +}); + +test('#activate is abstract and does nothing', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.activate(); + td.verify(mockAdapter.addClass, {times: 0}); + td.verify(mockAdapter.removeClass, {times: 0}); +}); + +test('#deactivate is abstract and does nothing', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.deactivate(); + td.verify(mockAdapter.addClass, {times: 0}); + td.verify(mockAdapter.removeClass, {times: 0}); +}); diff --git a/test/unit/mdc-tab-indicator/mdc-tab-indicator.test.js b/test/unit/mdc-tab-indicator/mdc-tab-indicator.test.js new file mode 100644 index 00000000000..94d437fb484 --- /dev/null +++ b/test/unit/mdc-tab-indicator/mdc-tab-indicator.test.js @@ -0,0 +1,147 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import bel from 'bel'; +import {assert} from 'chai'; +import td from 'testdouble'; +import domEvents from 'dom-events'; + +import { + MDCTabIndicator, + MDCSlidingTabIndicatorFoundation, + MDCFadingTabIndicatorFoundation, + MDCTabIndicatorFoundation, +} from '../../../packages/mdc-tab-indicator'; + +const getFixture = () => bel` + + + +`; + +const getFadingFixture = () => bel` + + + +`; + +suite('MDCTabIndicator'); + +test('attachTo returns an MDCTabIndicator instance', () => { + assert.isTrue(MDCTabIndicator.attachTo(getFixture()) instanceof MDCTabIndicator); +}); + +test('attachTo an icon returns an MDCTabIndicator instance', () => { + assert.isTrue(MDCTabIndicator.attachTo(getFadingFixture()) instanceof MDCTabIndicator); +}); + +function setupTest() { + const root = getFixture(); + const component = new MDCTabIndicator(root); + const content = root.querySelector(MDCTabIndicatorFoundation.strings.CONTENT_SELECTOR); + return {root, component, content}; +} + +test('#adapter.addClass adds a class to the root element', () => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.addClass('foo'); + assert.isTrue(root.classList.contains('foo')); +}); + +test('#adapter.removeClass removes a class to the root element', () => { + const {root, component} = setupTest(); + root.classList.add('foo'); + component.getDefaultFoundation().adapter_.removeClass('foo'); + assert.isFalse(root.classList.contains('foo')); +}); + +test('#adapter.registerEventHandler adds an event listener on the root element', () => { + const {root, component} = setupTest(); + const handler = td.func('transitionend handler'); + component.getDefaultFoundation().adapter_.registerEventHandler('transitionend', handler); + domEvents.emit(root, 'transitionend'); + td.verify(handler(td.matchers.anything())); +}); + +test('#adapter.deregisterEventHandler remoes an event listener from the root element', () => { + const {root, component} = setupTest(); + const handler = td.func('transitionend handler'); + root.addEventListener('transitionend', handler); + component.getDefaultFoundation().adapter_.deregisterEventHandler('transitionend', handler); + domEvents.emit(root, 'transitionend'); + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('#adapter.computeContentClientRect returns the root element client rect', () => { + const {component, root, content} = setupTest(); + document.body.appendChild(root); + assert.deepEqual( + component.getDefaultFoundation().adapter_.computeContentClientRect(), + content.getBoundingClientRect() + ); + document.body.removeChild(root); +}); + +test('#adapter.setContentStyleProperty sets a style property on the root element', () => { + const {component, content} = setupTest(); + component.getDefaultFoundation().adapter_.setContentStyleProperty('background-color', 'red'); + assert.strictEqual(content.style.backgroundColor, 'red'); +}); + +function setupMockSlidingFoundationTest(root = getFixture()) { + const MockFoundationConstructor = td.constructor(MDCSlidingTabIndicatorFoundation); + const mockFoundation = new MockFoundationConstructor(); + const component = new MDCTabIndicator(root, mockFoundation); + return {root, component, mockFoundation}; +} + +function setupMockFadingFoundationTest(root = getFadingFixture()) { + const MockFoundationConstructor = td.constructor(MDCFadingTabIndicatorFoundation); + const mockFoundation = new MockFoundationConstructor(); + const component = new MDCTabIndicator(root, mockFoundation); + return {root, component, mockFoundation}; +} + +test('#activate sliding indicator calls activate with passed args', () => { + const {component, mockFoundation} = setupMockSlidingFoundationTest(); + component.activate({width: 100, left: 0}); + td.verify(mockFoundation.activate({width: 100, left: 0}), {times: 1}); +}); + +test('#activate icon indicator calls activate with passed args', () => { + const {component, mockFoundation} = setupMockFadingFoundationTest(); + component.activate({width: 1, left: 2}); + td.verify(mockFoundation.activate({width: 1, left: 2}), {times: 1}); +}); + +test('#deactivate sliding indicator calls deactivate', () => { + const {component, mockFoundation} = setupMockSlidingFoundationTest(); + component.deactivate(); + td.verify(mockFoundation.deactivate(), {times: 1}); +}); + +test('#deactivate icon indicator calls deactivate', () => { + const {component, mockFoundation} = setupMockFadingFoundationTest(); + component.deactivate(); + td.verify(mockFoundation.deactivate(), {times: 1}); +}); + +test('#computeContentClientRect calls computeClientRect', () => { + const {component, mockFoundation} = setupMockSlidingFoundationTest(); + component.computeContentClientRect(); + td.verify(mockFoundation.computeContentClientRect(), {times: 1}); +}); diff --git a/test/unit/mdc-tab-indicator/sliding-foundation.test.js b/test/unit/mdc-tab-indicator/sliding-foundation.test.js new file mode 100644 index 00000000000..2696f751af8 --- /dev/null +++ b/test/unit/mdc-tab-indicator/sliding-foundation.test.js @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import td from 'testdouble'; + +import {captureHandlers} from '../helpers/foundation'; +import {setupFoundationTest} from '../helpers/setup'; +import {createMockRaf} from '../helpers/raf'; +import MDCSlidingTabIndicatorFoundation from '../../../packages/mdc-tab-indicator/sliding-foundation'; + +suite('MDCSlidingTabIndicatorFoundation'); + +const setupTest = () => setupFoundationTest(MDCSlidingTabIndicatorFoundation); + +test(`#activate adds the ${MDCSlidingTabIndicatorFoundation.cssClasses.ACTIVE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.computeContentClientRect()).thenReturn({width: 100, left: 10}); + foundation.activate({width: 90, left: 25}); + td.verify(mockAdapter.addClass(MDCSlidingTabIndicatorFoundation.cssClasses.ACTIVE)); +}); + +test('#activate sets the transform property', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.computeContentClientRect()).thenReturn({width: 100, left: 10}); + foundation.activate({width: 90, left: 25}); + td.verify(mockAdapter.setContentStyleProperty('transform', 'translateX(15px) scaleX(0.9)')); +}); + +test('#activate registers a transitionend handler', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.computeContentClientRect()).thenReturn({width: 100, left: 10}); + foundation.activate({width: 90, left: 25}); + td.verify(mockAdapter.registerEventHandler('transitionend', td.matchers.isA(Function))); +}); + +test(`#activate adds the ${MDCSlidingTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + td.when(mockAdapter.computeContentClientRect()).thenReturn({width: 100, left: 10}); + foundation.activate({width: 90, left: 25}); + raf.flush(); + raf.restore(); + td.verify(mockAdapter.addClass(MDCSlidingTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE)); +}); + +test('#activate resets the transform property', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + td.when(mockAdapter.computeContentClientRect()).thenReturn({width: 100, left: 10}); + foundation.activate({width: 90, left: 25}); + raf.flush(); + raf.restore(); + td.verify(mockAdapter.setContentStyleProperty('transform', '')); +}); + +test('#activate does not modify transform if no client rect is passed', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.activate(); + td.verify(mockAdapter.setContentStyleProperty('transform', td.matchers.isA(String)), {times: 0}); +}); + +test(`#deactivate removes the ${MDCSlidingTabIndicatorFoundation.cssClasses.ACTIVE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + foundation.deactivate(); + td.verify(mockAdapter.removeClass(MDCSlidingTabIndicatorFoundation.cssClasses.ACTIVE)); +}); + +test(`#deactivate removes the ${MDCSlidingTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + foundation.deactivate(); + td.verify(mockAdapter.removeClass(MDCSlidingTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE)); +}); + +test('#deactivate deregisters the transitionend handler', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.deactivate(); + td.verify(mockAdapter.deregisterEventHandler('transitionend', td.matchers.isA(Function))); +}); + +test('on transitionend, deregister the transitionend handler', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); + td.when(mockAdapter.computeContentClientRect()).thenReturn({width: 100, left: 10}); + foundation.activate({width: 90, left: 25}); + handlers.transitionend(); + td.verify(mockAdapter.deregisterEventHandler('transitionend', td.matchers.isA(Function))); +}); + +test(`on transitionend, remove the ${MDCSlidingTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE} class`, () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); + td.when(mockAdapter.computeContentClientRect()).thenReturn({width: 100, left: 10}); + foundation.activate({width: 90, left: 25}); + handlers.transitionend(); + td.verify(mockAdapter.removeClass(MDCSlidingTabIndicatorFoundation.cssClasses.SLIDING_ACTIVATE)); +}); diff --git a/test/unit/mdc-tab-scroller/foundation.test.js b/test/unit/mdc-tab-scroller/foundation.test.js new file mode 100644 index 00000000000..ef748c8ecf3 --- /dev/null +++ b/test/unit/mdc-tab-scroller/foundation.test.js @@ -0,0 +1,396 @@ +/** + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import {verifyDefaultAdapter} from '../helpers/foundation'; +import {createMockRaf} from '../helpers/raf'; +import {setupFoundationTest} from '../helpers/setup'; +import MDCTabScrollerFoundation from '../../../packages/mdc-tab-scroller/foundation'; +import MDCTabScrollerRTLDefault from '../../../packages/mdc-tab-scroller/rtl-default-scroller'; +import MDCTabScrollerRTLNegative from '../../../packages/mdc-tab-scroller/rtl-negative-scroller'; +import MDCTabScrollerRTLReverse from '../../../packages/mdc-tab-scroller/rtl-reverse-scroller'; + +suite('MDCTabScrollerFoundation'); + +test('exports cssClasses', () => { + assert.isOk('cssClasses' in MDCTabScrollerFoundation); +}); + +test('exports strings', () => { + assert.isOk('strings' in MDCTabScrollerFoundation); +}); + +test('defaultAdapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCTabScrollerFoundation, [ + 'eventTargetMatchesSelector', + 'addClass', 'removeClass', 'addScrollAreaClass', + 'setScrollAreaStyleProperty', 'setScrollContentStyleProperty', 'getScrollContentStyleValue', + 'setScrollAreaScrollLeft', 'getScrollAreaScrollLeft', + 'getScrollContentOffsetWidth', 'getScrollAreaOffsetWidth', + 'computeScrollAreaClientRect', 'computeScrollContentClientRect', 'computeHorizontalScrollbarHeight', + ]); +}); + +const setupTest = () => setupFoundationTest(MDCTabScrollerFoundation); + +test('#getScrollPosition() returns scroll value when transform is none', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getScrollContentStyleValue('transform')).thenReturn('none'); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenReturn(0); + assert.strictEqual(foundation.getScrollPosition(), 0); +}); + +test('#getScrollPosition() returns difference between scrollLeft and translateX', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getScrollContentStyleValue('transform')).thenReturn('matrix(1, 0, 0, 0, 101, 0)'); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenReturn(212); + assert.strictEqual(foundation.getScrollPosition(), 111); +}); + +test('#handleInteraction() does nothing if should not handle interaction', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.handleInteraction(); + td.verify(mockAdapter.removeClass(td.matchers.isA(String)), {times: 0}); + td.verify(mockAdapter.setScrollContentStyleProperty(td.matchers.isA(String), td.matchers.isA(String)), {times: 0}); +}); + +function setupHandleInteractionTest({scrollLeft=0, translateX=99}={}) { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getScrollContentStyleValue('transform')).thenReturn(`matrix(1, 0, 0, 1, ${translateX}, 0)`); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenReturn(scrollLeft); + foundation.isAnimating_ = true; + return {foundation, mockAdapter}; +} + +test(`#handleInteraction() removes the ${MDCTabScrollerFoundation.cssClasses.ANIMATING} class`, () => { + const {foundation, mockAdapter} = setupHandleInteractionTest(); + foundation.handleInteraction(); + td.verify(mockAdapter.removeClass(MDCTabScrollerFoundation.cssClasses.ANIMATING), {times: 1}); +}); + +test('#handleInteraction() sets the transform property to translateX(0px)', () => { + const {foundation, mockAdapter} = setupHandleInteractionTest(); + foundation.handleInteraction(); + td.verify(mockAdapter.setScrollContentStyleProperty('transform', 'translateX(0px)'), {times: 1}); +}); + +test('#handleInteraction() sets scrollLeft to the difference between scrollLeft and translateX', () => { + const {foundation, mockAdapter} = setupHandleInteractionTest({scrollLeft: 123, translateX: 101}); + foundation.handleInteraction(); + td.verify(mockAdapter.setScrollAreaScrollLeft(22), {times: 1}); +}); + +test('#handleTransitionEnd() does nothing if should not handle transition', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.handleTransitionEnd({ + target: {}, + }); + td.verify(mockAdapter.removeClass(td.matchers.isA(String)), {times: 0}); +}); + +test(`#handleTransitionEnd() removes the ${MDCTabScrollerFoundation.cssClasses.ANIMATING} class`, () => { + const {foundation, mockAdapter} = setupHandleInteractionTest({scrollLeft: 123, translateX: 101}); + td.when(mockAdapter.eventTargetMatchesSelector(td.matchers.isA(Object), td.matchers.isA(String))).thenReturn(true); + foundation.handleTransitionEnd({ + target: {}, + }); + td.verify(mockAdapter.removeClass(MDCTabScrollerFoundation.cssClasses.ANIMATING)); +}); + +function setupScrollToTest({ + rootWidth=300, + contentWidth=1000, + scrollLeft=0, + translateX=0, + isAnimating=false}={}) { + const opts = { + rootWidth, + contentWidth, + scrollLeft, + translateX, + isAnimating, + }; + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getScrollAreaOffsetWidth()).thenReturn(rootWidth); + td.when(mockAdapter.getScrollContentOffsetWidth()).thenReturn(contentWidth); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenReturn(scrollLeft); + foundation.isAnimating_ = isAnimating; + td.when(mockAdapter.getScrollContentStyleValue('transform')).thenReturn(`matrix(1, 0, 0, 1, ${translateX}, 0)`); + return {foundation, mockAdapter, opts}; +} + +test('#scrollTo() exits early if difference between scrollX and scrollLeft is 0', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 66}); + foundation.scrollTo(66); + td.verify(mockAdapter.setScrollContentStyleProperty(td.matchers.isA(String), td.matchers.isA(String)), {times: 0}); + td.verify(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number)), {times: 0}); +}); + +test('#scrollTo() scrolls to 0 if scrollX is less than 0', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 1}); + foundation.scrollTo(-999); + td.verify(mockAdapter.setScrollAreaScrollLeft(0), {times: 1}); +}); + +test('#scrollTo() scrolls to the max scrollable size if scrollX is greater than the max scrollable value', () => { + const {foundation, mockAdapter} = setupScrollToTest({rootWidth: 212, contentWidth: 1000}); + foundation.scrollTo(900); + td.verify(mockAdapter.setScrollAreaScrollLeft(788), {times: 1}); +}); + +test('#scrollTo() sets the content transform style property to the difference between scrollX and scrollLeft', () => { + const {foundation, mockAdapter} = setupScrollToTest({rootWidth: 300, contentWidth: 1000, scrollLeft: 123}); + foundation.scrollTo(456); + td.verify(mockAdapter.setScrollContentStyleProperty('transform', 'translateX(333px)'), {times: 1}); +}); + +test('#scrollTo() sets the scroll property to the computed scrollX', () => { + const {foundation, mockAdapter} = setupScrollToTest({rootWidth: 300, contentWidth: 1000, scrollLeft: 5}); + foundation.scrollTo(111); + td.verify(mockAdapter.setScrollAreaScrollLeft(111), {times: 1}); +}); + +test(`#scrollTo() adds the ${MDCTabScrollerFoundation.cssClasses.ANIMATING} class in a rAF`, () => { + const {foundation, mockAdapter} = setupScrollToTest(); + const raf = createMockRaf(); + foundation.scrollTo(100); + raf.flush(); + raf.restore(); + td.verify(mockAdapter.addClass(MDCTabScrollerFoundation.cssClasses.ANIMATING), {times: 1}); +}); + +test('#scrollTo() sets scrollLeft to the visual scroll position if called during an animation', () => { + const {foundation, mockAdapter} = setupScrollToTest({ + scrollLeft: 50, + rootWidth: 100, + contentWidth: 200, + translateX: 19, + isAnimating: true, + }); + foundation.scrollTo(33); + td.verify(mockAdapter.setScrollAreaScrollLeft(31), {times: 1}); +}); + +test(`#scrollTo() removes the ${MDCTabScrollerFoundation.cssClasses.ANIMATING} if called during an animation`, + () => { + const {foundation, mockAdapter} = setupScrollToTest({ + scrollLeft: 50, + rootWidth: 100, + contentWidth: 200, + translateX: 19, + isAnimating: true, + }); + foundation.scrollTo(60); + td.verify(mockAdapter.removeClass(MDCTabScrollerFoundation.cssClasses.ANIMATING)); + } +); + +test('#scrollTo() unsets the transform property in a rAF', () => { + const {foundation, mockAdapter} = setupScrollToTest(); + const raf = createMockRaf(); + foundation.scrollTo(212); + raf.flush(); + raf.restore(); + td.verify(mockAdapter.setScrollContentStyleProperty('transform', 'none'), {times: 1}); +}); + +test('#incrementScroll() exits early if increment is 0', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 700}); + foundation.incrementScroll(0); + td.verify(mockAdapter.setScrollContentStyleProperty(td.matchers.isA(String), td.matchers.isA(String)), {times: 0}); + td.verify(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number)), {times: 0}); +}); + +test('#incrementScroll() exits early if increment puts the scrollLeft over the max value', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 700}); + foundation.incrementScroll(10); + td.verify(mockAdapter.setScrollContentStyleProperty(td.matchers.isA(String), td.matchers.isA(String)), {times: 0}); + td.verify(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number)), {times: 0}); +}); + +test('#incrementScroll() exits early if increment puts the scrollLeft below the min value', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 0}); + foundation.incrementScroll(-10); + td.verify(mockAdapter.setScrollContentStyleProperty(td.matchers.isA(String), td.matchers.isA(String)), {times: 0}); + td.verify(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number)), {times: 0}); +}); + +test('#incrementScroll() increases the scrollLeft value by the given value', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 123}); + foundation.incrementScroll(11); + td.verify(mockAdapter.setScrollAreaScrollLeft(134), {times: 1}); +}); + +test('#incrementScroll() increases the scrollLeft value by the given value up to the max scroll value', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 99, rootWidth: 100, contentWidth: 200}); + foundation.incrementScroll(2); + td.verify(mockAdapter.setScrollAreaScrollLeft(100), {times: 1}); +}); + +test('#incrementScroll() decreases the scrollLeft value by the given value', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 123}); + foundation.incrementScroll(-11); + td.verify(mockAdapter.setScrollAreaScrollLeft(112), {times: 1}); +}); + +test('#incrementScroll() decreases the scrollLeft value by the given value down to the min scroll value', () => { + const {foundation, mockAdapter} = setupScrollToTest({scrollLeft: 1, rootWidth: 100, contentWidth: 200}); + foundation.incrementScroll(-2); + td.verify(mockAdapter.setScrollAreaScrollLeft(0), {times: 1}); +}); + +test('#incrementScroll() sets scrollLeft to the visual scroll position if called during an animation', () => { + const {foundation, mockAdapter} = setupScrollToTest({ + scrollLeft: 50, + rootWidth: 100, + contentWidth: 200, + translateX: 22, + isAnimating: true, + }); + foundation.incrementScroll(10); + td.verify(mockAdapter.setScrollAreaScrollLeft(28), {times: 1}); +}); + +test(`#incrementScroll() removes the ${MDCTabScrollerFoundation.cssClasses.ANIMATING} if called during an animation`, + () => { + const {foundation, mockAdapter} = setupScrollToTest({ + scrollLeft: 50, + rootWidth: 100, + contentWidth: 200, + translateX: 19, + isAnimating: true, + }); + foundation.incrementScroll(5); + td.verify(mockAdapter.removeClass(MDCTabScrollerFoundation.cssClasses.ANIMATING)); + } +); + +// RTL Mode + +function setupScrollToRTLTest() { + const {foundation, mockAdapter, opts} = setupScrollToTest(); + td.when(mockAdapter.getScrollContentStyleValue('direction')).thenReturn('rtl'); + td.when(mockAdapter.computeScrollAreaClientRect()).thenReturn({right: opts.rootWidth}); + td.when(mockAdapter.computeScrollContentClientRect()).thenReturn({right: opts.contentWidth}); + return {foundation, mockAdapter}; +} + +test('#scrollTo() sets the scrollLeft property in RTL', () => { + const {foundation, mockAdapter} = setupScrollToRTLTest(); + foundation.scrollTo(10); + td.verify(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number))); +}); + +test('#scrollTo() sets the transform style property in RTL', () => { + const {foundation, mockAdapter} = setupScrollToRTLTest(); + foundation.scrollTo(10); + td.verify(mockAdapter.setScrollContentStyleProperty('transform', 'translateX(690px)'), {times: 1}); +}); + +test('#incrementScroll() sets the scrollLeft property in RTL', () => { + const {foundation, mockAdapter} = setupScrollToRTLTest(); + foundation.incrementScroll(-10); + td.verify(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number))); +}); + +test('#incrementScroll() sets the transform style property in RTL', () => { + const {foundation, mockAdapter} = setupScrollToRTLTest(); + foundation.incrementScroll(-10); + td.verify(mockAdapter.setScrollContentStyleProperty('transform', 'translateX(10px)'), {times: 1}); +}); + +test('#getScrollPosition() returns a numeric scroll position in RTL', () => { + const {foundation} = setupScrollToRTLTest(); + assert.typeOf(foundation.getScrollPosition(), 'number'); +}); + +// RTLScroller + +function setupNegativeScroller() { + const {foundation, mockAdapter} = setupTest(); + const rootWidth = 200; + const contentWidth = 1000; + let scrollLeft = 0; + td.when(mockAdapter.getScrollAreaOffsetWidth()).thenDo(() => rootWidth); + td.when(mockAdapter.getScrollContentOffsetWidth()).thenDo(() => contentWidth); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenDo(() => scrollLeft); + td.when(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number))).thenDo( + (newScrollLeft) => scrollLeft = newScrollLeft); + td.when(mockAdapter.computeScrollAreaClientRect()).thenDo(() => { + return {right: rootWidth}; + }); + td.when(mockAdapter.computeScrollContentClientRect()).thenDo(() => { + return {right: rootWidth - scrollLeft}; + }); + return {foundation, mockAdapter}; +} + +test('#getRTLScroller() returns an instance of MDCTabScrollerRTLNegative', () => { + const {foundation} = setupNegativeScroller(); + assert.instanceOf(foundation.getRTLScroller(), MDCTabScrollerRTLNegative); +}); + +function setupReverseScroller() { + const {foundation, mockAdapter} = setupTest(); + const rootWidth = 200; + const contentWidth = 1000; + let scrollLeft = 0; + td.when(mockAdapter.getScrollAreaOffsetWidth()).thenDo(() => rootWidth); + td.when(mockAdapter.getScrollContentOffsetWidth()).thenDo(() => contentWidth); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenDo(() => scrollLeft); + td.when(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number))).thenDo((newScrollLeft) => { + scrollLeft = Math.max(newScrollLeft, scrollLeft); + }); + td.when(mockAdapter.computeScrollAreaClientRect()).thenDo(() => { + return {right: rootWidth}; + }); + td.when(mockAdapter.computeScrollContentClientRect()).thenDo(() => { + return {right: rootWidth - scrollLeft}; + }); + return {foundation, mockAdapter}; +} + +test('#getRTLScroller() returns an instance of MDCTabScrollerRTLReverse', () => { + const {foundation} = setupReverseScroller(); + assert.instanceOf(foundation.getRTLScroller(), MDCTabScrollerRTLReverse); +}); + +function setupDefaultScroller() { + const {foundation, mockAdapter} = setupTest(); + const rootWidth = 200; + const contentWidth = 1000; + let scrollLeft = 800; + td.when(mockAdapter.getScrollAreaOffsetWidth()).thenDo(() => rootWidth); + td.when(mockAdapter.getScrollContentOffsetWidth()).thenDo(() => contentWidth); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenDo(() => scrollLeft); + td.when(mockAdapter.setScrollAreaScrollLeft(td.matchers.isA(Number))).thenDo((newScrollLeft) => { + scrollLeft = newScrollLeft; + }); + td.when(mockAdapter.computeScrollAreaClientRect()).thenDo(() => { + return {right: rootWidth}; + }); + td.when(mockAdapter.computeScrollContentClientRect()).thenDo(() => { + return {right: contentWidth - scrollLeft}; + }); + return {foundation, mockAdapter}; +} + +test('#getRTLScroller() returns an instance of MDCTabScrollerRTLDefault', () => { + const {foundation} = setupDefaultScroller(); + assert.instanceOf(foundation.getRTLScroller(), MDCTabScrollerRTLDefault); +}); diff --git a/test/unit/mdc-tab-scroller/mdc-tab-scroller.test.js b/test/unit/mdc-tab-scroller/mdc-tab-scroller.test.js new file mode 100644 index 00000000000..56c30635f76 --- /dev/null +++ b/test/unit/mdc-tab-scroller/mdc-tab-scroller.test.js @@ -0,0 +1,246 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import bel from 'bel'; +import {assert} from 'chai'; +import td from 'testdouble'; +import domEvents from 'dom-events'; + +import { + MDCTabScroller, + MDCTabScrollerFoundation, + util, +} from '../../../packages/mdc-tab-scroller'; + +import MDCTabScrollerRTL from '../../../packages/mdc-tab-scroller/rtl-scroller'; + +const getFixture = () => bel` +
+
+
+
+
+`; + +suite('MDCTabScroller'); + +test('attachTo returns an MDCTabScroller instance', () => { + assert.isTrue(MDCTabScroller.attachTo(getFixture()) instanceof MDCTabScroller); +}); + +function setupTest() { + const root = getFixture(); + const component = new MDCTabScroller(root); + const area = root.querySelector(MDCTabScrollerFoundation.strings.AREA_SELECTOR); + const content = root.querySelector(MDCTabScrollerFoundation.strings.CONTENT_SELECTOR); + return {root, component, content, area}; +} + +test('#destroy() calls super.destroy()', () => { + const {component} = setupTest(); + const foundation = td.object(component.foundation_); + component.foundation_ = foundation; + component.destroy(); + td.verify(foundation.destroy(), {times: 1}); +}); + +test('#adapter.eventTargetMatchesSelector returns true if the event target matches the selector', () => { + const {area, component} = setupTest(); + assert.isTrue(component.getDefaultFoundation().adapter_.eventTargetMatchesSelector( + area, MDCTabScrollerFoundation.strings.AREA_SELECTOR)); +}); + +test('#adapter.addClass adds a class to the root element', () => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.addClass('foo'); + assert.isTrue(root.classList.contains('foo')); +}); + +test('#adapter.removeClass removes a class from the root element', () => { + const {root, component} = setupTest(); + root.classList.add('foo'); + component.getDefaultFoundation().adapter_.removeClass('foo'); + assert.isFalse(root.classList.contains('foo')); +}); + +test('#adapter.addScrollAreaClass adds a class to the area element', () => { + const {component, area} = setupTest(); + component.getDefaultFoundation().adapter_.addScrollAreaClass('foo'); + assert.isTrue(area.classList.contains('foo')); +}); + +test('#adapter.setScrollAreaStyleProperty sets a style property on the area element', () => { + const {component, area} = setupTest(); + component.getDefaultFoundation().adapter_.setScrollAreaStyleProperty('background-color', 'red'); + assert.strictEqual(area.style.backgroundColor, 'red'); +}); + +test('#adapter.setScrollContentStyleProperty sets a style property on the content element', () => { + const {component, content} = setupTest(); + component.getDefaultFoundation().adapter_.setScrollContentStyleProperty('background-color', 'red'); + assert.strictEqual(content.style.backgroundColor, 'red'); +}); + +test('#adapter.getScrollContentStyleValue returns the style property value on the content element', () => { + const {component, content} = setupTest(); + content.style.setProperty('color', 'chartreuse'); + assert.strictEqual( + component.getDefaultFoundation().adapter_.getScrollContentStyleValue('color'), + window.getComputedStyle(content).getPropertyValue('color') + ); +}); + +function setupScrollLeftTests() { + const {component, area, content, root} = setupTest(); + area.style.setProperty('width', '100px'); + area.style.setProperty('height', '10px'); + area.style.setProperty('overflow-x', 'scroll'); + content.style.setProperty('width', '10000px'); + content.style.setProperty('height', '10px'); + return {component, area, root}; +} + +test('#adapter.setScrollAreaScrollLeft sets the scrollLeft value of the area element', () => { + const {component, root, area} = setupScrollLeftTests(); + document.body.appendChild(root); + component.getDefaultFoundation().adapter_.setScrollAreaScrollLeft(101); + assert.isAtLeast(area.scrollLeft, 0); + document.body.removeChild(root); +}); + +test('#adapter.getScrollAreaScrollLeft returns the scrollLeft value of the root element', () => { + const {component, root, area} = setupScrollLeftTests(); + document.body.appendChild(root); + area.scrollLeft = 416; + assert.isAtLeast(component.getDefaultFoundation().adapter_.getScrollAreaScrollLeft(), 0); + document.body.removeChild(root); +}); + +test('#adapter.getScrollContentOffsetWidth returns the content element offsetWidth', () => { + const {component, root, content} = setupTest(); + document.body.appendChild(root); + assert.deepEqual( + component.getDefaultFoundation().adapter_.getScrollContentOffsetWidth(), + content.offsetWidth + ); + document.body.removeChild(root); +}); + +test('#adapter.getScrollAreaOffsetWidth returns the root element offsetWidth', () => { + const {component, root} = setupTest(); + document.body.appendChild(root); + assert.deepEqual( + component.getDefaultFoundation().adapter_.getScrollAreaOffsetWidth(), + root.offsetWidth + ); + document.body.removeChild(root); +}); + +test('#adapter.computeScrollAreaClientRect returns the root element bounding client rect', () => { + const {component, root} = setupTest(); + document.body.appendChild(root); + assert.deepEqual( + component.getDefaultFoundation().adapter_.computeScrollAreaClientRect(), + root.getBoundingClientRect() + ); + document.body.removeChild(root); +}); + +test('#adapter.computeScrollContentClientRect returns the content element bounding client rect', () => { + const {component, root, content} = setupTest(); + document.body.appendChild(root); + assert.deepEqual( + component.getDefaultFoundation().adapter_.computeScrollContentClientRect(), + content.getBoundingClientRect() + ); + document.body.removeChild(root); +}); + +test('#adapter.computeHorizontalScrollbarHeight uses util function to return scrollbar height', () => { + const {component, root} = setupTest(); + document.body.appendChild(root); + + // Unfortunately we can't stub the util API due to it transpiling to a read-only property, so we need to settle for + // comparing the return values in each browser. + assert.strictEqual(component.getDefaultFoundation().adapter_.computeHorizontalScrollbarHeight(), + util.computeHorizontalScrollbarHeight()); + document.body.removeChild(root); +}); + +function setupMockFoundationTest(root = getFixture()) { + const MockFoundationConstructor = td.constructor(MDCTabScrollerFoundation); + const mockFoundation = new MockFoundationConstructor(); + const component = new MDCTabScroller(root, mockFoundation); + return {root, component, mockFoundation}; +} + +test('#scrollTo calls scrollTo', () => { + const {component, mockFoundation} = setupMockFoundationTest(); + component.scrollTo(703); + td.verify(mockFoundation.scrollTo(703), {times: 1}); +}); + +test('#incrementScroll calls incrementScroll', () => { + const {component, mockFoundation} = setupMockFoundationTest(); + component.incrementScroll(10); + td.verify(mockFoundation.incrementScroll(10), {times: 1}); +}); + +test('#getScrollPosition() calls getScrollPosition', () => { + const {component, mockFoundation} = setupMockFoundationTest(); + component.getScrollPosition(); + td.verify(mockFoundation.getScrollPosition(), {times: 1}); +}); + +test('#getScrollContentWidth() returns the offsetWidth of the content element', () => { + const {component, root} = setupMockFoundationTest(); + const contentElement = root.querySelector(MDCTabScrollerFoundation.strings.CONTENT_SELECTOR); + assert.strictEqual(component.getScrollContentWidth(), contentElement.offsetWidth); +}); + +function setupTestRTL() { + const {root, content, component} = setupTest(); + root.style.setProperty('width', '100px'); + root.style.setProperty('height', '10px'); + root.style.setProperty('overflow-x', 'scroll'); + content.style.setProperty('width', '10000px'); + content.style.setProperty('height', '10px'); + content.style.setProperty('backgroundColor', 'red'); + root.setAttribute('dir', 'rtl'); + return {root, component, content}; +} + +test('#getRTLScroller() returns an instance of MDCTabScrollerRTL', () => { + const {root, component} = setupTestRTL(); + document.body.appendChild(root); + assert.instanceOf(component.getDefaultFoundation().getRTLScroller(), MDCTabScrollerRTL); + document.body.removeChild(root); +}); + +test('on interaction in the area element, call #handleInteraction()', () => { + const {root, mockFoundation} = setupMockFoundationTest(); + const area = root.querySelector(MDCTabScrollerFoundation.strings.AREA_SELECTOR); + domEvents.emit(area, 'touchstart', {bubbles: true}); + td.verify(mockFoundation.handleInteraction()); +}); + +test('on transitionend of the content element, call #handleTransitionEnd()', () => { + const {root, mockFoundation} = setupMockFoundationTest(); + const content = root.querySelector(MDCTabScrollerFoundation.strings.CONTENT_SELECTOR); + domEvents.emit(content, 'transitionend', {bubbles: true}); + td.verify(mockFoundation.handleTransitionEnd(td.matchers.anything())); +}); diff --git a/test/unit/mdc-tab-scroller/rtl-default-scroller.test.js b/test/unit/mdc-tab-scroller/rtl-default-scroller.test.js new file mode 100644 index 00000000000..2ff1c1d414f --- /dev/null +++ b/test/unit/mdc-tab-scroller/rtl-default-scroller.test.js @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import {setupFoundationTest} from '../helpers/setup'; +import MDCTabScrollerFoundation from '../../../packages/mdc-tab-scroller/foundation'; +import MDCTabScrollerRTLDefault from '../../../packages/mdc-tab-scroller/rtl-default-scroller'; + +suite('MDCTabScrollerRTLDefault'); + +const setupTest = ({rootWidth, contentWidth, scrollLeft}) => { + const {mockAdapter} = setupFoundationTest(MDCTabScrollerFoundation); + const scroller = new MDCTabScrollerRTLDefault(mockAdapter); + td.when(mockAdapter.getScrollAreaOffsetWidth()).thenReturn(rootWidth); + td.when(mockAdapter.getScrollContentOffsetWidth()).thenReturn(contentWidth); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenReturn(scrollLeft); + return {scroller, mockAdapter}; +}; + +test('#getScrollPositionRTL() returns the distance from the right edge', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.getScrollPositionRTL(), 123); +}); + +test('#scrollToRTL() returns a normalized scrollX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(111).finalScrollPosition, 689); +}); + +test('#scrollToRTL() returns a normalized translateX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(111).scrollDelta, 12); +}); + +test('#scrollToRTL() returns 0 for scrollX property when scrollLeft would be too far left', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(801).finalScrollPosition, 0); +}); + +test('#scrollToRTL() returns 0 for translateX property when scrollLeft would be the same', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(123).scrollDelta, 0); +}); + +test('#scrollToRTL() returns max scroll value for scrollX property when scrollLeft would be too far right', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(-10).finalScrollPosition, 800); +}); + +test('#incrementScrollRTL() returns a normalized scrollX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.incrementScrollRTL(50).finalScrollPosition, 627); +}); + +test('#incrementScrollRTL() returns a normalized translateX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.incrementScrollRTL(50).scrollDelta, -50); +}); + +test('#incrementScrollRTL() returns 0 for scrollX property when scrollLeft would be too far left', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.incrementScrollRTL(678).finalScrollPosition, 0); +}); + +test('#incrementScrollRTL() returns 0 for translateX property when scrollLeft would be the same', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.incrementScrollRTL(0).scrollDelta, 0); +}); + +test('#incrementScrollRTL() returns max scroll value for scrollX property when scrollLeft would be too far right', + () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.incrementScrollRTL(-124).finalScrollPosition, 800); + } +); + +test('#getAnimatingScrollPosition() returns just the scrollX value', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.getAnimatingScrollPosition(123, 11), 123); +}); diff --git a/test/unit/mdc-tab-scroller/rtl-negative-scroller.test.js b/test/unit/mdc-tab-scroller/rtl-negative-scroller.test.js new file mode 100644 index 00000000000..5c8ba4d5f1e --- /dev/null +++ b/test/unit/mdc-tab-scroller/rtl-negative-scroller.test.js @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import {setupFoundationTest} from '../helpers/setup'; +import MDCTabScrollerFoundation from '../../../packages/mdc-tab-scroller/foundation'; +import MDCTabScrollerRTLNegative from '../../../packages/mdc-tab-scroller/rtl-negative-scroller'; + +suite('MDCTabScrollerRTLNegative'); + +const setupTest = ({rootWidth, contentWidth, scrollLeft}) => { + const {mockAdapter} = setupFoundationTest(MDCTabScrollerFoundation); + const scroller = new MDCTabScrollerRTLNegative(mockAdapter); + td.when(mockAdapter.getScrollAreaOffsetWidth()).thenReturn(rootWidth); + td.when(mockAdapter.getScrollContentOffsetWidth()).thenReturn(contentWidth); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenReturn(scrollLeft); + return {scroller, mockAdapter}; +}; + +test('#getScrollPositionRTL() returns the current scroll distance when translateX is 0', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -123}); + assert.strictEqual(scroller.getScrollPositionRTL(0), 123); +}); + +test('#getScrollPositionRTL() returns the current scroll distance minus translateX', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -123}); + assert.strictEqual(scroller.getScrollPositionRTL(11), 134); +}); + +test('#scrollToRTL() returns a normalized scrollX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -111}); + assert.strictEqual(scroller.scrollToRTL(123).finalScrollPosition, -123); +}); + +test('#scrollToRTL() returns a normalized translateX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -111}); + assert.strictEqual(scroller.scrollToRTL(123).scrollDelta, -12); +}); + +test('#scrollToRTL() returns 0 for scrollX property when scrollLeft would be too far right', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -500}); + assert.strictEqual(scroller.scrollToRTL(-1).finalScrollPosition, 0); +}); + +test('#scrollToRTL() returns 0 for translateX property when scrollLeft would be the same', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -123}); + assert.strictEqual(scroller.scrollToRTL(123).scrollDelta, 0); +}); + +test('#scrollToRTL() returns min scroll value for scrollX property when scrollLeft would be too far left', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -677}); + assert.strictEqual(scroller.scrollToRTL(801).finalScrollPosition, -800); +}); + +test('#incrementScrollRTL() returns a normalized scrollX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -111}); + assert.strictEqual(scroller.incrementScrollRTL(17).finalScrollPosition, -128); +}); + +test('#incrementScrollRTL() returns a normalized translateX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -111}); + assert.strictEqual(scroller.incrementScrollRTL(50).scrollDelta, -50); +}); + +test('#incrementScrollRTL() returns 0 for scrollX property when scrollLeft would be too far right', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -45}); + assert.strictEqual(scroller.incrementScrollRTL(-46).finalScrollPosition, 0); +}); + +test('#incrementScrollRTL() returns 0 for translateX property when scrollLeft would be the same', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -123}); + assert.strictEqual(scroller.incrementScrollRTL(0).scrollDelta, 0); +}); + +test('#incrementScrollRTL() returns min scroll value for scrollX property when scrollLeft would be too far left', + () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: -677}); + assert.strictEqual(scroller.incrementScrollRTL(124).finalScrollPosition, -800); + } +); + +test('#getAnimatingScrollPosition() returns the difference between the scrollX value and the translateX value', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.getAnimatingScrollPosition(123, 11), 112); +}); diff --git a/test/unit/mdc-tab-scroller/rtl-reverse-scroller.test.js b/test/unit/mdc-tab-scroller/rtl-reverse-scroller.test.js new file mode 100644 index 00000000000..fff1e6bbd93 --- /dev/null +++ b/test/unit/mdc-tab-scroller/rtl-reverse-scroller.test.js @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import {assert} from 'chai'; +import td from 'testdouble'; + +import {setupFoundationTest} from '../helpers/setup'; +import MDCTabScrollerFoundation from '../../../packages/mdc-tab-scroller/foundation'; +import MDCTabScrollerRTLReverse from '../../../packages/mdc-tab-scroller/rtl-reverse-scroller'; + +suite('MDCTabScrollerRTLReverse'); + +const setupTest = ({rootWidth, contentWidth, scrollLeft}) => { + const {mockAdapter} = setupFoundationTest(MDCTabScrollerFoundation); + const scroller = new MDCTabScrollerRTLReverse(mockAdapter); + td.when(mockAdapter.getScrollAreaOffsetWidth()).thenReturn(rootWidth); + td.when(mockAdapter.getScrollContentOffsetWidth()).thenReturn(contentWidth); + td.when(mockAdapter.getScrollAreaScrollLeft()).thenReturn(scrollLeft); + return {scroller, mockAdapter}; +}; + +test('#getScrollPositionRTL() returns the negated scrollLeft value', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.getScrollPositionRTL(0), 677); +}); + +test('#getScrollPositionRTL() returns the negated current scroll distance minus translateX', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.getScrollPositionRTL(11), 666); +}); + +test('#scrollToRTL() returns a normalized scrollX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(111).finalScrollPosition, 111); +}); + +test('#scrollToRTL() returns a normalized translateX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 145}); + assert.strictEqual(scroller.scrollToRTL(111).scrollDelta, 34); +}); + +test('#scrollToRTL() returns 0 for scrollX property when scrollLeft would be too far right', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(-10).finalScrollPosition, 0); +}); + +test('#scrollToRTL() returns 0 for translateX property when scrollLeft would be the same', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(677).scrollDelta, 0); +}); + +test('#scrollToRTL() returns max scroll value for scrollX property when scrollLeft would be too far left', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.scrollToRTL(801).finalScrollPosition, 800); +}); + +test('#incrementScrollRTL() returns a normalized scrollX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 202}); + assert.strictEqual(scroller.incrementScrollRTL(50).finalScrollPosition, 252); +}); + +test('#incrementScrollRTL() returns a normalized translateX property', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 212}); + assert.strictEqual(scroller.incrementScrollRTL(50).scrollDelta, -50); +}); + +test('#incrementScrollRTL() returns 0 for scrollX property when scrollLeft would be too far right', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 45}); + assert.strictEqual(scroller.incrementScrollRTL(-50).finalScrollPosition, 0); +}); + +test('#incrementScrollRTL() returns 0 for translateX property when scrollLeft would be the same', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 0}); + assert.strictEqual(scroller.incrementScrollRTL(-50).scrollDelta, 0); +}); + +test('#incrementScrollRTL() returns max scroll value for scrollX property when scrollLeft would be too far left', + () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.incrementScrollRTL(124).finalScrollPosition, 800); + } +); + +test('#getAnimatingScrollPosition() returns the sum of the scrollX value and the translateX value', () => { + const {scroller} = setupTest({rootWidth: 200, contentWidth: 1000, scrollLeft: 677}); + assert.strictEqual(scroller.getAnimatingScrollPosition(123, 11), 134); +}); diff --git a/test/unit/mdc-tab-scroller/rtl-scroller.test.js b/test/unit/mdc-tab-scroller/rtl-scroller.test.js new file mode 100644 index 00000000000..a8fd10dc19d --- /dev/null +++ b/test/unit/mdc-tab-scroller/rtl-scroller.test.js @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License") + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. +*/ + +import {assert} from 'chai'; + +import {setupFoundationTest} from '../helpers/setup'; +import MDCTabScrollerFoundation from '../../../packages/mdc-tab-scroller/foundation'; +import MDCTabScrollerRTL from '../../../packages/mdc-tab-scroller/rtl-scroller'; + +suite('MDCTabScrollerRTL'); + +const setup = () => { + const {mockAdapter} = setupFoundationTest(MDCTabScrollerFoundation); + const scroller = new MDCTabScrollerRTL(mockAdapter); + return {scroller}; +}; + +test('#getScrollPositionRTL() is abstract and does nothing', () => { + const {scroller} = setup(); + assert.isUndefined(scroller.getScrollPositionRTL()); +}); + +test('#scrollToRTL() is abstract and does nothing', () => { + const {scroller} = setup(); + assert.isUndefined(scroller.scrollToRTL()); +}); + +test('#incrementScrollRTL() is abstract and does nothing', () => { + const {scroller} = setup(); + assert.isUndefined(scroller.incrementScrollRTL()); +}); + +test('#getAnimatingScrollPosition() is abstract and does nothing', () => { + const {scroller} = setup(); + assert.isUndefined(scroller.getAnimatingScrollPosition()); +}); diff --git a/test/unit/mdc-tab-scroller/util.test.js b/test/unit/mdc-tab-scroller/util.test.js new file mode 100644 index 00000000000..d006498331d --- /dev/null +++ b/test/unit/mdc-tab-scroller/util.test.js @@ -0,0 +1,62 @@ +/** + * Copyright 2018 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {assert} from 'chai'; +import td from 'testdouble'; +import {cssClasses} from '../../../packages/mdc-tab-scroller/constants'; +import * as util from '../../../packages/mdc-tab-scroller/util'; + +suite('MDCTabScroller - util'); + +function createMockDocumentForHorizontalScrollbarHeight(height) { + const classListAddFunc = td.func('classList.add'); + const mockDocument = { + appendedNodes: 0, + body: { + appendChild: () => mockDocument.appendedNodes++, + removeChild: () => mockDocument.appendedNodes--, + }, + createElement: () => { + return { + classList: {add: classListAddFunc}, + // Populate both offsetHeight and contentHeight such that the difference is the intended height, + // to test that the util function is computing based on both + clientHeight: height, + offsetHeight: height * 2, + }; + }, + }; + + return {mockDocument, classListAddFunc}; +} + +test('#computeHorizontalScrollbarHeight returns value based on difference between offset and client height', () => { + const expectedHeight = 17; + const {mockDocument, classListAddFunc} = createMockDocumentForHorizontalScrollbarHeight(expectedHeight); + + assert.strictEqual(util.computeHorizontalScrollbarHeight(mockDocument, false /* shouldCacheResult */), + expectedHeight); + td.verify(classListAddFunc(cssClasses.SCROLL_TEST)); +}); + +test('#getMatchesProperty returns the correct property for selector matching', () => { + assert.strictEqual(util.getMatchesProperty({matches: () => {}}), 'matches'); + assert.strictEqual(util.getMatchesProperty({msMatchesSelector: () => {}}), 'msMatchesSelector'); +}); + +test('#getMatchesProperty returns the standard function if more than one method is present', () => { + assert.strictEqual(util.getMatchesProperty({matches: () => {}, msMatchesSelector: () => {}}), 'matches'); +}); diff --git a/test/unit/mdc-tab/foundation.test.js b/test/unit/mdc-tab/foundation.test.js index dc88f550d32..c604cbeba05 100644 --- a/test/unit/mdc-tab/foundation.test.js +++ b/test/unit/mdc-tab/foundation.test.js @@ -36,6 +36,10 @@ test('defaultAdapter returns a complete adapter implementation', () => { 'registerEventHandler', 'deregisterEventHandler', 'addClass', 'removeClass', 'hasClass', 'setAttr', + 'activateIndicator', 'deactivateIndicator', 'computeIndicatorClientRect', + 'getOffsetLeft', 'getOffsetWidth', 'getContentOffsetLeft', 'getContentOffsetWidth', + 'notifyInteracted', + 'focus', ]); }); @@ -73,6 +77,30 @@ test('#activate sets the root element aria-selected attribute to true', () => { td.verify(mockAdapter.setAttr(MDCTabFoundation.strings.ARIA_SELECTED, 'true')); }); +test('#activate sets the root element tabindex to 0', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.activate(); + td.verify(mockAdapter.setAttr(MDCTabFoundation.strings.TABINDEX, '0')); +}); + +test('#activate activates the indicator', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.activate({width: 100, left: 200}); + td.verify(mockAdapter.activateIndicator({width: 100, left: 200})); +}); + +test('#activate focuses the root node', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.activate({width: 100, left: 200}); + td.verify(mockAdapter.focus()); +}); + +test('#computeIndicatorClientRect calls computeIndicatorClientRect on the adapter', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.computeIndicatorClientRect(); + td.verify(mockAdapter.computeIndicatorClientRect()); +}); + test('#deactivate does nothing if not active', () => { const {foundation, mockAdapter} = setupTest(); foundation.deactivate(); @@ -108,6 +136,20 @@ test('#deactivate sets the root element aria-selected attribute to false', () => td.verify(mockAdapter.setAttr(MDCTabFoundation.strings.ARIA_SELECTED, 'false')); }); +test('#deactivate deactivates the indicator', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.hasClass(MDCTabFoundation.cssClasses.ACTIVE)).thenReturn(true); + foundation.deactivate(); + td.verify(mockAdapter.deactivateIndicator()); +}); + +test('#deactivate sets the root element tabindex to -1', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.hasClass(MDCTabFoundation.cssClasses.ACTIVE)).thenReturn(true); + foundation.deactivate(); + td.verify(mockAdapter.setAttr(MDCTabFoundation.strings.TABINDEX, '-1')); +}); + test('#handleTransitionEnd removes mdc-tab--animating-activate class', () => { const {foundation, mockAdapter} = setupTest(); foundation.handleTransitionEnd({pseudoElement: ''}); @@ -134,12 +176,40 @@ test('#handleTransitionEnd does nothing when triggered by a pseudo element', () td.verify(mockAdapter.deregisterEventHandler('transitionend', td.matchers.isA(Function)), {times: 0}); }); -test('on transitionend, do nothing when triggered by a pseudeo element', () => { +test('on transitionend, call #handleTransitionEnd', () => { const {foundation, mockAdapter} = setupTest(); const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); + foundation.handleTransitionEnd = td.function('handles transitionend'); foundation.activate(); - handlers.transitionend({pseudoElement: '::after'}); - td.verify(mockAdapter.removeClass(MDCTabFoundation.cssClasses.ANIMATING_ACTIVATE), {times: 0}); - td.verify(mockAdapter.removeClass(MDCTabFoundation.cssClasses.ANIMATING_DEACTIVATE), {times: 0}); - td.verify(mockAdapter.deregisterEventHandler('transitionend', td.matchers.isA(Function)), {times: 0}); + handlers.transitionend(); + td.verify(foundation.handleTransitionEnd(td.matchers.anything()), {times: 1}); +}); + +test(`#handleClick emits the ${MDCTabFoundation.strings.INTERACTED_EVENT} event`, () => { + const {foundation, mockAdapter} = setupTest(); + foundation.handleClick(); + td.verify(mockAdapter.notifyInteracted(), {times: 1}); +}); + +test('on click, call #handleClick', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerEventHandler'); + foundation.handleClick = td.function('handles click'); + foundation.init(); + handlers.click(); + td.verify(foundation.handleClick(), {times: 1}); +}); + +test('#computeDimensions() returns the dimensions of the tab', () => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getOffsetLeft()).thenReturn(10); + td.when(mockAdapter.getOffsetWidth()).thenReturn(100); + td.when(mockAdapter.getContentOffsetLeft()).thenReturn(11); + td.when(mockAdapter.getContentOffsetWidth()).thenReturn(30); + assert.deepEqual(foundation.computeDimensions(), { + rootLeft: 10, + rootRight: 110, + contentLeft: 21, + contentRight: 51, + }); }); diff --git a/test/unit/mdc-tab/mdc-tab.test.js b/test/unit/mdc-tab/mdc-tab.test.js index 0c53e97bb52..2161cc0d081 100644 --- a/test/unit/mdc-tab/mdc-tab.test.js +++ b/test/unit/mdc-tab/mdc-tab.test.js @@ -28,6 +28,10 @@ const getFixture = () => bel` Foo + + + + `; @@ -39,8 +43,9 @@ test('attachTo returns an MDCTab instance', () => { function setupTest() { const root = getFixture(); + const content = root.querySelector(MDCTabFoundation.strings.CONTENT_SELECTOR); const component = new MDCTab(root); - return {root, component}; + return {root, content, component}; } test('#destroy removes the ripple', () => { @@ -96,6 +101,64 @@ test('#adapter.deregisterEventHandler removes an event listener from the root el td.verify(handler(td.matchers.anything()), {times: 0}); }); +test('#adapter.activateIndicator activates the indicator subcomponent', () => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.activateIndicator(); + assert.ok(root.querySelector('.mdc-tab-indicator').classList.contains('mdc-tab-indicator--active')); +}); + +test('#adapter.deactivateIndicator deactivates the indicator subcomponent', () => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.deactivateIndicator(); + assert.notOk(root.querySelector('.mdc-tab-indicator').classList.contains('mdc-tab-indicator--active')); +}); + +test('#adapter.computeIndicatorClientRect returns the indicator element\'s bounding client rect', () => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.deactivateIndicator(); + assert.deepEqual( + component.getDefaultFoundation().adapter_.computeIndicatorClientRect(), + root.querySelector('.mdc-tab-indicator').getBoundingClientRect() + ); +}); + +test('#adapter.getOffsetWidth() returns the offsetWidth of the root element', () => { + const {root, component} = setupTest(); + assert.strictEqual(component.getDefaultFoundation().adapter_.getOffsetWidth(), root.offsetWidth); +}); + +test('#adapter.getOffsetLeft() returns the offsetLeft of the root element', () => { + const {root, component} = setupTest(); + assert.strictEqual(component.getDefaultFoundation().adapter_.getOffsetLeft(), root.offsetLeft); +}); + +test('#adapter.getContentOffsetWidth() returns the offsetLeft of the content element', () => { + const {content, component} = setupTest(); + assert.strictEqual(component.getDefaultFoundation().adapter_.getContentOffsetWidth(), content.offsetWidth); +}); + +test('#adapter.getContentOffsetLeft() returns the offsetLeft of the content element', () => { + const {content, component} = setupTest(); + assert.strictEqual(component.getDefaultFoundation().adapter_.getContentOffsetLeft(), content.offsetLeft); +}); + +test('#adapter.focus() gives focus to the root element', () => { + const {root, component} = setupTest(); + document.body.appendChild(root); + component.getDefaultFoundation().adapter_.focus(); + assert.strictEqual(document.activeElement, root); + document.body.removeChild(root); +}); + +test(`#adapter.notifyInteracted() emits the ${MDCTabFoundation.strings.INTERACTED_EVENT} event`, () => { + const {component} = setupTest(); + const handler = td.func('interaction handler'); + + component.listen(MDCTabFoundation.strings.INTERACTED_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyInteracted(); + td.verify(handler(td.matchers.anything())); +}); + function setupMockFoundationTest(root = getFixture()) { const MockFoundationConstructor = td.constructor(MDCTabFoundation); const mockFoundation = new MockFoundationConstructor(); @@ -109,14 +172,32 @@ test('#active getter calls isActive', () => { td.verify(mockFoundation.isActive(), {times: 1}); }); -test('#active set to true calls activate', () => { +test('#activate() calls activate', () => { const {component, mockFoundation} = setupMockFoundationTest(); - component.active = true; - td.verify(mockFoundation.activate(), {times: 1}); + component.activate(); + td.verify(mockFoundation.activate(undefined), {times: 1}); }); -test('#active set to false calls deactivate', () => { +test('#activate({ClientRect}) calls activate', () => { const {component, mockFoundation} = setupMockFoundationTest(); - component.active = false; + component.activate({width: 100, left: 200}); + td.verify(mockFoundation.activate({width: 100, left: 200}), {times: 1}); +}); + +test('#deactivate() calls deactivate', () => { + const {component, mockFoundation} = setupMockFoundationTest(); + component.deactivate(); td.verify(mockFoundation.deactivate(), {times: 1}); }); + +test('#computeIndicatorClientRect() calls computeIndicatorClientRect', () => { + const {component, mockFoundation} = setupMockFoundationTest(); + component.computeIndicatorClientRect(); + td.verify(mockFoundation.computeIndicatorClientRect(), {times: 1}); +}); + +test('#computeDimensions() calls computeDimensions', () => { + const {component, mockFoundation} = setupMockFoundationTest(); + component.computeDimensions(); + td.verify(mockFoundation.computeDimensions(), {times: 1}); +});