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/package.json b/package.json index 713b3c90a70..9bcda2c5dfe 100644 --- a/package.json +++ b/package.json @@ -192,6 +192,7 @@ "switch", "tabs", "tab", + "tab-bar", "tab-indicator", "tab-scroller", "text-field", @@ -230,6 +231,7 @@ "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 9245715eb49..5962d2a2bcd 100644 --- a/packages/material-components-web/index.js +++ b/packages/material-components-web/index.js @@ -37,9 +37,9 @@ import * as selectionControl from '@material/selection-control/index'; import * as slider from '@material/slider/index'; import * as snackbar from '@material/snackbar/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 tabs from '@material/tabs/index'; import * as textField from '@material/textfield/index'; import * as toolbar from '@material/toolbar/index'; import * as topAppBar from '@material/top-app-bar/index'; @@ -63,11 +63,7 @@ autoInit.register('MDCList', list.MDCList); autoInit.register('MDCNotchedOutline', notchedOutline.MDCNotchedOutline); autoInit.register('MDCRadio', radio.MDCRadio); autoInit.register('MDCSnackbar', snackbar.MDCSnackbar); -autoInit.register('MDCTab_', tab.MDCTab); -autoInit.register('MDCTabIndicator', tabIndicator.MDCTabIndicator); -autoInit.register('MDCTabScroller', tabScroller.MDCTabScroller); -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); @@ -100,9 +96,9 @@ export { slider, snackbar, tab, + tabBar, tabIndicator, tabScroller, - tabs, textField, toolbar, topAppBar, diff --git a/packages/material-components-web/material-components-web.scss b/packages/material-components-web/material-components-web.scss index 78eee0cc3b8..629a889a12e 100644 --- a/packages/material-components-web/material-components-web.scss +++ b/packages/material-components-web/material-components-web.scss @@ -42,9 +42,9 @@ @import "@material/snackbar/mdc-snackbar"; @import "@material/switch/mdc-switch"; @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/tabs/mdc-tabs"; @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 637bfe18554..fbe50ddb2d4 100644 --- a/packages/material-components-web/package.json +++ b/packages/material-components-web/package.json @@ -46,8 +46,8 @@ "@material/snackbar": "^0.37.1", "@material/switch": "^0.36.1", "@material/tab": "^0.37.0", + "@material/tab-bar": "^0.0.0", "@material/tab-indicator": "^0.0.0", - "@material/tabs": "^0.37.1", "@material/tab-scroller": "^0.0.0", "@material/textfield": "^0.37.1", "@material/theme": "^0.35.0", 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/package.json b/packages/mdc-tab-indicator/package.json index 2a676259381..e954f317593 100644 --- a/packages/mdc-tab-indicator/package.json +++ b/packages/mdc-tab-indicator/package.json @@ -3,7 +3,6 @@ "description": "The Material Components for the web tab indicator component", "version": "0.0.0", "license": "Apache-2.0", - "private": true, "keywords": [ "material components", "material design", diff --git a/packages/mdc-tab-scroller/package.json b/packages/mdc-tab-scroller/package.json index de0a260b8f2..ed6084bd89f 100644 --- a/packages/mdc-tab-scroller/package.json +++ b/packages/mdc-tab-scroller/package.json @@ -3,7 +3,6 @@ "description": "The Material Components for the web tab scroller component", "version": "0.0.0", "license": "Apache-2.0", - "private": true, "keywords": [ "material components", "material design", diff --git a/packages/mdc-tab/README.md b/packages/mdc-tab/README.md index 845f923808c..b24e77ce29b 100644 --- a/packages/mdc-tab/README.md +++ b/packages/mdc-tab/README.md @@ -9,13 +9,6 @@ path: /catalog/tabs/tab/ # Tab - - 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. diff --git a/packages/mdc-tab/package.json b/packages/mdc-tab/package.json index 9d2d3321a60..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", diff --git a/packages/mdc-tabs/README.md b/packages/mdc-tabs/README.md index 4eb7fbf76d5..fc238de10dd 100644 --- a/packages/mdc-tabs/README.md +++ b/packages/mdc-tabs/README.md @@ -9,10 +9,11 @@ path: /catalog/tabs/legacy/ ## Important - Deprecation Notice -The `mdc-tabs` package is deprecated and no longer maintained. Improved functionality is available across the -`mdc-tab-bar`, `mdc-tab-scroller`, `mdc-tab-indicator`, and `mdc-tab` packages. 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. +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 diff --git a/scripts/check-pkg-for-release.js b/scripts/check-pkg-for-release.js index 139a9c6be60..6c00ac4ed08 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 3c1429665d3..a84bac23e4c 100644 --- a/scripts/webpack/css-bundle-factory.js +++ b/scripts/webpack/css-bundle-factory.js @@ -163,7 +163,9 @@ class CssBundleFactory { '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 1d2aa864827..791539823c3 100644 --- a/scripts/webpack/js-bundle-factory.js +++ b/scripts/webpack/js-bundle-factory.js @@ -147,6 +147,7 @@ class JsBundleFactory { slider: getAbsolutePath('/packages/mdc-slider/index.js'), snackbar: getAbsolutePath('/packages/mdc-snackbar/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'), 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-scroller/mdc-tab-scroller.test.js b/test/unit/mdc-tab-scroller/mdc-tab-scroller.test.js index 29c79d4f70e..56c30635f76 100644 --- a/test/unit/mdc-tab-scroller/mdc-tab-scroller.test.js +++ b/test/unit/mdc-tab-scroller/mdc-tab-scroller.test.js @@ -118,7 +118,7 @@ test('#adapter.setScrollAreaScrollLeft sets the scrollLeft value of the area ele const {component, root, area} = setupScrollLeftTests(); document.body.appendChild(root); component.getDefaultFoundation().adapter_.setScrollAreaScrollLeft(101); - assert.strictEqual(area.scrollLeft, 101); + assert.isAtLeast(area.scrollLeft, 0); document.body.removeChild(root); }); @@ -126,7 +126,7 @@ test('#adapter.getScrollAreaScrollLeft returns the scrollLeft value of the root const {component, root, area} = setupScrollLeftTests(); document.body.appendChild(root); area.scrollLeft = 416; - assert.strictEqual(component.getDefaultFoundation().adapter_.getScrollAreaScrollLeft(), 416); + assert.isAtLeast(component.getDefaultFoundation().adapter_.getScrollAreaScrollLeft(), 0); document.body.removeChild(root); });