From 0c00d3fdcf653c7e73217a030c28623ea306c4ed Mon Sep 17 00:00:00 2001 From: Alex Sheehan Date: Thu, 11 May 2017 18:35:30 -0400 Subject: [PATCH] feat(tabs): Implement a tab component (#581) --- demos/images/ic_tabs_24px.svg | 1 + demos/index.html | 11 +- demos/tabs.html | 608 ++++++++++++++++++ package.json | 1 + packages/material-components-web/index.js | 4 + .../material-components-web.scss | 1 + packages/material-components-web/package.json | 1 + packages/mdc-base/README.md | 2 +- packages/mdc-base/component.js | 9 +- packages/mdc-tabs/README.md | 428 ++++++++++++ packages/mdc-tabs/index.js | 18 + packages/mdc-tabs/mdc-tabs.scss | 18 + packages/mdc-tabs/package.json | 26 + packages/mdc-tabs/tab-bar/constants.js | 24 + packages/mdc-tabs/tab-bar/foundation.js | 170 +++++ packages/mdc-tabs/tab-bar/index.js | 107 +++ packages/mdc-tabs/tab-bar/mdc-tab-bar.scss | 111 ++++ packages/mdc-tabs/tab/constants.js | 19 + packages/mdc-tabs/tab/foundation.js | 102 +++ packages/mdc-tabs/tab/index.js | 84 +++ packages/mdc-tabs/tab/mdc-tab.scss | 174 +++++ packages/mdc-toolbar/mdc-toolbar.scss | 5 +- .../mdc-tabs/mdc-tab-bar-foundation.test.js | 164 +++++ test/unit/mdc-tabs/mdc-tab-bar.test.js | 334 ++++++++++ test/unit/mdc-tabs/mdc-tab-foundation.test.js | 240 +++++++ test/unit/mdc-tabs/mdc-tab.test.js | 168 +++++ webpack.config.js | 2 + 27 files changed, 2824 insertions(+), 8 deletions(-) create mode 100644 demos/images/ic_tabs_24px.svg create mode 100644 demos/tabs.html create mode 100644 packages/mdc-tabs/README.md create mode 100644 packages/mdc-tabs/index.js create mode 100644 packages/mdc-tabs/mdc-tabs.scss create mode 100644 packages/mdc-tabs/package.json create mode 100644 packages/mdc-tabs/tab-bar/constants.js create mode 100644 packages/mdc-tabs/tab-bar/foundation.js create mode 100644 packages/mdc-tabs/tab-bar/index.js create mode 100644 packages/mdc-tabs/tab-bar/mdc-tab-bar.scss create mode 100644 packages/mdc-tabs/tab/constants.js create mode 100644 packages/mdc-tabs/tab/foundation.js create mode 100644 packages/mdc-tabs/tab/index.js create mode 100644 packages/mdc-tabs/tab/mdc-tab.scss create mode 100644 test/unit/mdc-tabs/mdc-tab-bar-foundation.test.js create mode 100644 test/unit/mdc-tabs/mdc-tab-bar.test.js create mode 100644 test/unit/mdc-tabs/mdc-tab-foundation.test.js create mode 100644 test/unit/mdc-tabs/mdc-tab.test.js diff --git a/demos/images/ic_tabs_24px.svg b/demos/images/ic_tabs_24px.svg new file mode 100644 index 00000000000..b7798c8f403 --- /dev/null +++ b/demos/images/ic_tabs_24px.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/demos/index.html b/demos/index.html index cc1ee84f761..73c128b66ab 100644 --- a/demos/index.html +++ b/demos/index.html @@ -138,7 +138,7 @@ Floating action button - + The primary action in an application @@ -223,6 +223,14 @@ +
  • + + + Tabs + Tabs with support for icon and text labels + +
  • +
  • @@ -254,7 +262,6 @@ Type hierarchy
  • - diff --git a/demos/tabs.html b/demos/tabs.html new file mode 100644 index 00000000000..e033fe69c97 --- /dev/null +++ b/demos/tabs.html @@ -0,0 +1,608 @@ + + + + + + MDC-Web Tab Bar Demo + + + + + + + + + +
    +
    +
    + + + + Tabs +
    +
    +
    + +
    +
    + +
    + +
    + + +
    + +
    +
    + Basic Tab Bar + +
    +
    + +
    +
    + CSS-Only Tab Bar + +
    +
    + +
    +
    + Icon Tab Labels + +
    +
    + +
    +
    + CSS-Only Icon Tab Labels + +
    +
    + +
    +
    + Icon & Text Labels + +
    +
    + +
    +
    + CSS-Only Icon & Text Labels + +
    +
    + +
    +
    + Primary Color Indicator + +
    +
    + +
    +
    + Primary Color Indicator - CSS-Only + +
    +
    + +
    +
    + Accent Color Indicator + +
    +
    + +
    +
    + Accent Color Indicator - CSS-Only + +
    +
    + +
    +
    + Within mdc-toolbar +
    +
    +
    +

    Title

    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + Within MDCToolbar - fixed to bottom of toolbar +
    + + Note: We want to avoid too many modifier classes for layouts like this. Therefore, we recommend overriding the style of + mdc-toolbar__section for the MDCTabBar instance you'd like affixed to the bottom edge of mdc-toolbar. The style used to acheive this example is: + +
    +
    +
    +              
    +.my-modified-toolbar-section {
    +  position: absolute;
    +  right: 0;
    +  bottom: 0;
    +}
    +
    +[dir="rtl"] .my-modified-toolbar-section {
    +  right: auto;
    +  left: 0;
    +}
    +            
    +          
    +
    +
    +
    +
    +

    Title

    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    + Within mdc-toolbar, CSS-Only +
    +
    +
    +

    Title

    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + Within mdc-toolbar + primary indicator +
    + Note: Changing the toolbar's background color here so that the primary indicator can be visible +
    +
    +
    +
    +

    Title

    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    + Within mdc-toolbar + primary indicator, CSS-Only +
    +
    +
    +

    Title

    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + Within mdc-toolbar + accent indicator +
    +
    +
    +

    Title

    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    + Within mdc-toolbar + accent indicator, CSS-Only +
    +
    +
    +

    Title

    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    + Within Toolbar, Dynamic Content Control +
    +
    +
    + +
    +
    +
    +
    +
    +

    Item One

    + + +
    +
    + + + +
    +
    +
    +
    + +
    + + + + + + diff --git a/package.json b/package.json index 792e30f6500..2384a81074b 100644 --- a/package.json +++ b/package.json @@ -137,6 +137,7 @@ "select", "snackbar", "switch", + "tabs", "textfield", "theme", "toolbar", diff --git a/packages/material-components-web/index.js b/packages/material-components-web/index.js index 1c6bc2639da..003c4d565b1 100644 --- a/packages/material-components-web/index.js +++ b/packages/material-components-web/index.js @@ -27,6 +27,7 @@ import * as textfield from '@material/textfield'; import * as snackbar from '@material/snackbar'; import * as menu from '@material/menu'; import * as select from '@material/select'; +import * as tabs from '@material/tabs'; import * as toolbar from '@material/toolbar'; import autoInit from '@material/auto-init'; @@ -40,6 +41,8 @@ autoInit.register('MDCGridList', gridList.MDCGridList); autoInit.register('MDCIconToggle', iconToggle.MDCIconToggle); autoInit.register('MDCRadio', radio.MDCRadio); autoInit.register('MDCSnackbar', snackbar.MDCSnackbar); +autoInit.register('MDCTab', tabs.MDCTab); +autoInit.register('MDCTabBar', tabs.MDCTabBar); autoInit.register('MDCTextfield', textfield.MDCTextfield); autoInit.register('MDCSimpleMenu', menu.MDCSimpleMenu); autoInit.register('MDCSelect', select.MDCSelect); @@ -57,6 +60,7 @@ export { snackbar, dialog, drawer, + tabs, textfield, menu, select, diff --git a/packages/material-components-web/material-components-web.scss b/packages/material-components-web/material-components-web.scss index 21c243219e3..e8f91b111e3 100644 --- a/packages/material-components-web/material-components-web.scss +++ b/packages/material-components-web/material-components-web.scss @@ -33,6 +33,7 @@ @import "@material/select/mdc-select"; @import "@material/snackbar/mdc-snackbar"; @import "@material/switch/mdc-switch"; +@import "@material/tabs/mdc-tabs"; @import "@material/textfield/mdc-textfield"; @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 a311782c878..3270d07b969 100644 --- a/packages/material-components-web/package.json +++ b/packages/material-components-web/package.json @@ -34,6 +34,7 @@ "@material/select": "^0.3.4", "@material/snackbar": "^0.1.9", "@material/switch": "^0.1.5", + "@material/tabs": "^0.0.0", "@material/textfield": "^0.2.7", "@material/theme": "^0.1.4", "@material/toolbar": "^0.3.0", diff --git a/packages/mdc-base/README.md b/packages/mdc-base/README.md index a3ec54e6796..2a00d39850f 100644 --- a/packages/mdc-base/README.md +++ b/packages/mdc-base/README.md @@ -172,7 +172,7 @@ export class MyComponent extends MDCComponent { | `destroy()` | Subclasses may override this method if they wish to perform any additional cleanup work when a component is destroyed. For example, a component may want to deregister a window resize listener. | | `listen(type: string, handler: EventListener)` | Adds an event listener to the component's root node for the given `type`. Note that this is simply a proxy to `this.root_.addEventListener`. | | `unlisten(type: string, handler: EventListener)` | Removes an event listener from the component's root node. Note that this is simply a proxy to `this.root_.removeEventListener`. | -| `emit(type: string, data: Object)` | Dispatches a custom event of type `type` with detail `data` from the component's root node. This is the preferred way of dispatching events within our vanilla components. | +| `emit(type: string, data: Object, shouldBubble: boolean = false)` | Dispatches a custom event of type `type` with detail `data` from the component's root node. It also takes an optional shouldBubble argument to specify if the event should bubble. This is the preferred way of dispatching events within our vanilla components. | #### Static Methods diff --git a/packages/mdc-base/component.js b/packages/mdc-base/component.js index 54718d7d66e..0dcb85bb0a2 100644 --- a/packages/mdc-base/component.js +++ b/packages/mdc-base/component.js @@ -75,13 +75,16 @@ export default class MDCComponent { // Fires a cross-browser-compatible custom event from the component root of the given type, // with the given data. - emit(evtType, evtData) { + emit(evtType, evtData, shouldBubble = false) { let evt; if (typeof CustomEvent === 'function') { - evt = new CustomEvent(evtType, {detail: evtData}); + evt = new CustomEvent(evtType, { + detail: evtData, + bubbles: shouldBubble, + }); } else { evt = document.createEvent('CustomEvent'); - evt.initCustomEvent(evtType, false, false, evtData); + evt.initCustomEvent(evtType, shouldBubble, false, evtData); } this.root_.dispatchEvent(evt); diff --git a/packages/mdc-tabs/README.md b/packages/mdc-tabs/README.md new file mode 100644 index 00000000000..68f718305b7 --- /dev/null +++ b/packages/mdc-tabs/README.md @@ -0,0 +1,428 @@ +# MDC Tabs + +The MDC Tabs component contains components which are used to create spec-aligned tabbed navigation components adhering to the +[Material Design tabs guidelines](https://material.io/guidelines/components/tabs.html). These components are: + +- **mdc-tab**: The individual tab elements +- **mdc-tab-bar**: The main component which is composed of `mdc-tab` elements + +## Installation + +``` +npm install --save @material/tab-bar +``` + +## Tabs usage + +`mdc-tab-bar` can be used as a CSS only component, or a more dynamic JavaScript +component. + +There are also three different permutations of tab labels. These include text, +icon-only, and text with icon. An example of each is available on the demo site. + +#### Tab Bar with text labels +```html + +``` + +#### Tab Bar with icon labels +```html + +``` + +#### Tab Bar with icon and text labels +```html + +``` + +#### RTL Support + +Tab Bars will reverse the order of their tabs if they are placed within an +ancestor element with attribute `dir="rtl"`. + +```html + + + + +``` + +#### Dark Mode Support + +Like other MDC-Web components, tabs support dark mode either when an +`mdc-tab--theme-dark` class is attached to the root element, or the element has +an ancestor with class `mdc-theme--dark`. + +```html + + + + +``` + + +### Dynamic view switching + +While facilitating the view switching is left up to the developer, the demo site +provides a minimal example of how to do so using JavaScript, also shown below. + +#### Markup: +```html +
    + +
    +
    +
    +

    Item One

    + + +
    +
    + + + +
    +
    +``` + +#### Script: +```js +var dynamicTabBar = window.dynamicTabBar = new mdc.tabBar.MDCTabBar(document.querySelector('#dynamic-tab-bar')); +var dots = document.querySelector('.dots'); +var panels = document.querySelector('.panels'); + +dynamicTabBar.preventDefaultOnClick = true; + +function updateDot(index) { + var activeDot = dots.querySelector('.dot.active'); + if (activeDot) { + activeDot.classList.remove('active'); + } + var newActiveDot = dots.querySelector('.dot:nth-child(' + (index + 1) + ')'); + if (newActiveDot) { + newActiveDot.classList.add('active'); + } +} + +function updatePanel(index) { + var activePanel = panels.querySelector('.panel.active'); + if (activePanel) { + activePanel.classList.remove('active'); + } + var newActivePanel = panels.querySelector('.panel:nth-child(' + (index + 1) + ')'); + if (newActivePanel) { + newActivePanel.classList.add('active'); + } +} + +dynamicTabBar.listen('MDCTabBar:change', function ({detail: tabs}) { + var nthChildIndex = tabs.activeTabIndex; + + updatePanel(nthChildIndex); + updateDot(nthChildIndex); +}); + +dots.addEventListener('click', function (evt) { + if (!evt.target.classList.contains('dot')) { + return; + } + + evt.preventDefault(); + + var dotIndex = [].slice.call(dots.querySelectorAll('.dot')).indexOf(evt.target); + + if (dotIndex >= 0) { + dynamicTabBar.activeTabIndex = dotIndex; + } + + updatePanel(dotIndex); + updateDot(dotIndex); +}) +``` + +### Using the CSS-Only Component + +`mdc-tab-bar` ships with css for styling a tab layout according to the Material +Design spec. To use CSS only tab bars, simply use the available class names. +Note the available `mdc-tab--active` modifier class. This is used to denote the +currently active tab. + +```html + +``` + +### Using the JavaScript Component + +`mdc-tab-bar` ships with a Component/Foundation combo for ingesting instances of `mdc-tab` (a tab). +`mdc-tab-bar` uses its `initialize()` method call a factory function which gathers and instantiates +any tab elements that are children of the `mdc-tab-bar` root element. + + +#### Including in code + +##### ES2015 + +```javascript +import {MDCTab, MDCTabFoundation} from '@material/tabs'; +import {MDCTabBar, MDCTabBarFoundation} from '@material/tabs'; +``` + +##### CommonJS + +```javascript +const mdcTabs = require('@material/tabs'); +const MDCTab = mdcTabs.MDCTab; +const MDCTabFoundation = mdcTabs.MDCTabFoundation; + +const MDCTabBar = mdcTabs.MDCTabBar; +const MDCTabBarFoundation = mdcTabs.MDCTabBarFoundation; +``` + +##### AMD + +```javascript +require(['path/to/@material/tabs'], mdcTabs => { + const MDCTab = mdcTabs.MDCTab; + const MDCTabFoundation = mdcTabs.MDCTabFoundation; + + const MDCTabBar = mdcTabs.MDCTabBar; + const MDCTabBarFoundation = mdcTabs.MDCTabBarFoundation; +}); +``` + +##### Global + +```javascript +const MDCTab = mdc.tabs.MDCTab; +const MDCTabFoundation = mdc.tabs.MDCTabFoundation; + +const MDCTabBar = mdc.tabs.MDCTabBar; +const MDCTabBarFoundation = mdc.tabs.MDCTabBarFoundation; +``` + +#### Automatic Instantiation + +If you do not care about retaining the component instance for the tabs, simply +call `attachTo()` and pass it a DOM element. + +```javascript +mdc.tabs.MDCTabBar.attachTo(document.querySelector('#my-mdc-tab-bar')); +``` + +#### Manual Instantiation + +Tabs can easily be initialized using their default constructors as well, similar +to `attachTo`. This process involves a factory to create an instance of MDCTab +from each tab Element inside of the `mdc-tab-bar` node during the intialization phase +of `MDCTabBar`, e.g.: + +```html + +``` + +```javascript +import {MDCTabBar, MDCTabBarFoundation} from '@material/tabs'; + +const tabBar = new MDCTabBar(document.querySelector('#my-mdc-tab-bar')); +``` + +## Tab + +### Tab component API + +#### Properties + +| Property Name | Type | Description | +| --- | --- | --- | +| `computedWidth` | `number` | _(read-only)_ The width of the tab. | +| `computedLeft` | `number` | _(read-only)_ The left offset of the tab. | +| `isActive` | `boolean` | Whether or not the tab is active. Setting this makes the tab active. | +| `preventDefaultOnClick` | `boolean` | Whether or not the tab will call `preventDefault()` on an event. Setting this makes the tab call `preventDefault()` on events. | + +### Tab Events + +#### MDCTab:selected + +Broadcast when a user actions on the tab. + + +### Using the Foundation Class + +MDC Tab ships with an `MDCTabFoundation` class that external frameworks and libraries can +use to integrate the component. As with all foundation classes, an adapter object must be provided. + + +### Adapter API + +| 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. | +| `registerInteractionHandler(evt: string, handler: EventListener) => void` | Adds an event listener to the root element, for the specified event name. | +| `deregisterInteractionHandler(evt: string, handler: EventListener) => void` | Removes an event listener from the root element, for the specified event name. | +| `getOffsetWidth() => number` | Return the width of the tab | +| `getOffsetLeft() => number` | Return distance between left edge of tab and left edge of its parent element | +| `notifySelected() => {}` | Broadcasts an event denoting that the user has actioned on the tab | + + +### The full foundation API + +#### MDCTabFoundation.getComputedWidth() => number + +Return the computed width for tab. + +#### MDCTabFoundation.getComputedLeft() => number + +Return the computed left offset for tab. + +#### MDCTabFoundation.isActive() => boolean + +Return true if tab is active. + +#### MDCTabFoundation.setActive(isActive = false) => void + +Set tab to active. If `isActive` is true, adds the active modifier class, otherwise removes it. + +#### MDCTabFoundation.preventsDefaultOnClick() => boolean + +Return true if the tab prevents the default click action + +#### MDCTabFoundation.setPreventDefaultOnClick(preventDefaultOnClick = false) => void + +Sets tabs `preventDefaultOnClick` property to the value of the `preventDefaultOnClick` argument passed. + +#### MDCTabFoundation.measureSelf() => void + +Sets `computedWidth_` and `computedLeft_` for a tab. + + +## Tab Bar + +### Tab Bar component API + +#### Properties + +| Property Name | Type | Description | +| --- | --- | --- | +| `tabs` | `MDCTab[]` | _(read-only)_ An array of the tab bar's instances of MDC Tab. | +| `activeTab` | `MDCTab` | The currently active tab. Setting this makes the tab active. | +| `activeTabIndex` | `number` | The index of the currently active tab. Setting this makes the tab at the given index active. | + +### Tab Bar Events + +#### MDCTabBar:change + +Broadcast when a user actions on a tab, resulting in a change in the selected tab. + + +### Using the Foundation Class + +`mdc-tab-bar` ships with an `MDCTabBarFoundation` class that external frameworks +and libraries can use to integrate the component. As with all foundation +classes, an adapter object must be provided. + + +### Adapter API + +| 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. | +| `bindOnMDCTabSelectedEvent() => void` | Adds `MDCTab:selected` event listener to root | +| `unbindOnMDCTabSelectedEvent() => void` | Removes `MDCTab:selected` event listener from root | +| `registerResizeHandler(handler: EventListener) => void` | Adds an event listener to the root element, for a resize event. | +| `deregisterResizeHandler(handler: EventListener) => void` | Removes an event listener from the root element, for a resize event. | +| `getOffsetWidth() => number` | Returns width of root element. | +| `setStyleForIndicator(propertyName: string, value: string) => void` | Sets style property for indicator. | +| `getOffsetWidthForIndicator() => number` | Returns width of indicator. | +| `notifyChange(evtData: Object) => void` | Emits `MDCTabBar:change` event, passes evtData. | +| `getNumberOfTabs() => number` | Returns number of tabs in MDC Tabs instance. | +| `getActiveTab() => MDCTab` | Returns the instance of MDCTab that is currently active. | +| `isTabActiveAtIndex(index: number) => boolean` | Returns true if tab at index is active. | +| `setTabActiveAtIndex(index: number) => void` | Sets tab active at given index. | +| `isDefaultPreventedOnClickForTabAtIndex(index: number) => boolean` | Returns true if tab does not prevent default click action. | +| `setPreventDefaultOnClickForTabAtIndex(index: number, preventDefaultOnClick: boolean)` | Sets preventDefaultOnClick for tab at given index | +| `measureTabAtIndex(index: number) => void` | sets measurements (width, left offset) for tab at given index. | +| `getComputedWidthForTabAtIndex(index: number) => number` | Returns width of tab at given index. | +| `getComputedLeftForTabAtIndex(index: number) => number` | Returns left offset of tab at given index. | + + +### The full foundation API + +#### MDCTabBarFoundation.layout() => void + +Sets layout for the Tab Bar component. + +#### MDCTabBarFoundation.getActiveTabIndex() => number + +Returns index of currently active tab + +#### MDCTabBarFoundation.getComputedWidth() => number + +Returns the width of the element containing the tabs. + +#### MDCTabBarFoundation.switchToTabAtIndex(index, shouldNotify) => void + +Updates the active tab to be the tab at the given index, emits `MDCTabBar:change` if `shouldNotify` is true. + +#### MDCTabBarFoundation.getActiveTabIndex() => number + +Returns the index of the currently active tab. diff --git a/packages/mdc-tabs/index.js b/packages/mdc-tabs/index.js new file mode 100644 index 00000000000..53846062f16 --- /dev/null +++ b/packages/mdc-tabs/index.js @@ -0,0 +1,18 @@ +/** + * Copyright 2017 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. + */ + + export {MDCTabFoundation, MDCTab} from './tab'; + export {MDCTabBarFoundation, MDCTabBar} from './tab-bar'; diff --git a/packages/mdc-tabs/mdc-tabs.scss b/packages/mdc-tabs/mdc-tabs.scss new file mode 100644 index 00000000000..5935ea61240 --- /dev/null +++ b/packages/mdc-tabs/mdc-tabs.scss @@ -0,0 +1,18 @@ +// +// Copyright 2017 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 "./tab/mdc-tab"; +@import "./tab-bar/mdc-tab-bar"; diff --git a/packages/mdc-tabs/package.json b/packages/mdc-tabs/package.json new file mode 100644 index 00000000000..1294342512d --- /dev/null +++ b/packages/mdc-tabs/package.json @@ -0,0 +1,26 @@ +{ + "name": "@material/tabs", + "version": "0.0.0", + "description": "The Material Components for the web tabs component", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/material-components/material-components-web.git" + }, + "keywords": [ + "material components", + "material design", + "tabs" + ], + "publicConfig": { + "access": "public" + }, + "dependencies": { + "@material/base": "^0.1.2", + "@material/ripple": "^0.6.0", + "@material/animation": "^0.2.0", + "@material/typography": "^0.2.1", + "@material/rtl": "^0.1.3", + "@material/theme": "^0.1.4" + } +} diff --git a/packages/mdc-tabs/tab-bar/constants.js b/packages/mdc-tabs/tab-bar/constants.js new file mode 100644 index 00000000000..8107b93b8e7 --- /dev/null +++ b/packages/mdc-tabs/tab-bar/constants.js @@ -0,0 +1,24 @@ +/** + * Copyright 2017 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. + */ + +export const cssClasses = { + UPGRADED: 'mdc-tab-bar-upgraded', +}; + +export const strings = { + TAB_SELECTOR: '.mdc-tab', + INDICATOR_SELECTOR: '.mdc-tab-bar__indicator', +}; diff --git a/packages/mdc-tabs/tab-bar/foundation.js b/packages/mdc-tabs/tab-bar/foundation.js new file mode 100644 index 00000000000..fd2d421be9e --- /dev/null +++ b/packages/mdc-tabs/tab-bar/foundation.js @@ -0,0 +1,170 @@ +/** + * Copyright 2017 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 {getCorrectPropertyName} from '../../../packages/mdc-animation'; + +import {cssClasses, strings} from './constants'; + +export default class MDCTabBarFoundation extends MDCFoundation { + static get cssClasses() { + return cssClasses; + } + + static get strings() { + return strings; + } + + static get defaultAdapter() { + return { + addClass: (/* className: string */) => {}, + removeClass: (/* className: string */) => {}, + bindOnMDCTabSelectedEvent: () => {}, + unbindOnMDCTabSelectedEvent: () => {}, + registerResizeHandler: (/* handler: EventListener */) => {}, + deregisterResizeHandler: (/* handler: EventListener */) => {}, + getOffsetWidth: () => /* number */ 0, + setStyleForIndicator: (/* propertyName: string, value: string */) => {}, + getOffsetWidthForIndicator: () => /* number */ 0, + notifyChange: (/* evtData: {activeTabIndex: number} */) => {}, + getNumberOfTabs: () => /* number */ 0, + isTabActiveAtIndex: (/* index: number */) => /* boolean */ false, + setTabActiveAtIndex: (/* index: number, isActive: true */) => {}, + isDefaultPreventedOnClickForTabAtIndex: (/* index: number */) => /* boolean */ false, + setPreventDefaultOnClickForTabAtIndex: (/* index: number, preventDefaultOnClick: boolean */) => {}, + measureTabAtIndex: (/* index: number */) => {}, + getComputedWidthForTabAtIndex: (/* index: number */) => /* number */ 0, + getComputedLeftForTabAtIndex: (/* index: number */) => /* number */ 0, + }; + } + + constructor(adapter) { + super(Object.assign(MDCTabBarFoundation.defaultAdapter, adapter)); + + this.isIndicatorShown_ = false; + this.computedWidth_ = 0; + this.computedLeft_ = 0; + this.activeTabIndex_ = 0; + this.layoutFrame_ = 0; + this.resizeHandler_ = () => this.layout(); + } + + init() { + this.adapter_.addClass(cssClasses.UPGRADED); + this.adapter_.bindOnMDCTabSelectedEvent(); + this.adapter_.registerResizeHandler(this.resizeHandler_); + const activeTabIndex = this.findActiveTabIndex_(); + if (activeTabIndex >= 0) { + this.activeTabIndex_ = activeTabIndex; + } + this.layout(); + } + + destroy() { + this.adapter_.removeClass(cssClasses.UPGRADED); + this.adapter_.unbindOnMDCTabSelectedEvent(); + this.adapter_.deregisterResizeHandler(this.resizeHandler_); + } + + layoutInternal_() { + this.forEachTabIndex_((index) => this.adapter_.measureTabAtIndex(index)); + this.computedWidth_ = this.adapter_.getOffsetWidth(); + this.layoutIndicator_(); + } + + layoutIndicator_() { + const isIndicatorFirstRender = !this.isIndicatorShown_; + + // Ensure that indicator appears in the right position immediately for correct first render. + if (isIndicatorFirstRender) { + this.adapter_.setStyleForIndicator('transition', 'none'); + } + + const translateAmtForActiveTabLeft = this.adapter_.getComputedLeftForTabAtIndex(this.activeTabIndex_); + const scaleAmtForActiveTabWidth = + this.adapter_.getComputedWidthForTabAtIndex(this.activeTabIndex_) / this.adapter_.getOffsetWidth(); + + const transformValue = `translateX(${translateAmtForActiveTabLeft}px) scale(${scaleAmtForActiveTabWidth}, 1)`; + this.adapter_.setStyleForIndicator(getCorrectPropertyName(window, 'transform'), transformValue); + + if (isIndicatorFirstRender) { + // Force layout so that transform styles to take effect. + this.adapter_.getOffsetWidthForIndicator(); + this.adapter_.setStyleForIndicator('transition', ''); + this.adapter_.setStyleForIndicator('visibility', 'visible'); + this.isIndicatorShown_ = true; + } + } + + findActiveTabIndex_() { + let activeTabIndex = -1; + this.forEachTabIndex_((index) => { + if (this.adapter_.isTabActiveAtIndex(index)) { + activeTabIndex = index; + return true; + } + }); + return activeTabIndex; + } + + forEachTabIndex_(iterator) { + const numTabs = this.adapter_.getNumberOfTabs(); + for (let index = 0; index < numTabs; index++) { + const shouldBreak = iterator(index); + if (shouldBreak) { + break; + } + } + } + + layout() { + if (this.layoutFrame_) { + cancelAnimationFrame(this.layoutFrame_); + } + + this.layoutFrame_ = requestAnimationFrame(() => { + this.layoutInternal_(); + this.layoutFrame_ = 0; + }); + } + + switchToTabAtIndex(index, shouldNotify) { + if (index === this.activeTabIndex_) { + return; + } + + if (index < 0 || index >= this.adapter_.getNumberOfTabs()) { + throw new Error(`Out of bounds index specified for tab: ${index}`); + } + + const prevActiveTabIndex = this.activeTabIndex_; + this.activeTabIndex_ = index; + requestAnimationFrame(() => { + if (prevActiveTabIndex >= 0) { + this.adapter_.setTabActiveAtIndex(prevActiveTabIndex, false); + } + this.adapter_.setTabActiveAtIndex(this.activeTabIndex_, true); + this.layoutIndicator_(); + if (shouldNotify) { + this.adapter_.notifyChange({activeTabIndex: this.activeTabIndex_}); + } + }); + } + + getActiveTabIndex() { + return this.findActiveTabIndex_(); + } +} diff --git a/packages/mdc-tabs/tab-bar/index.js b/packages/mdc-tabs/tab-bar/index.js new file mode 100644 index 00000000000..0e774b2f310 --- /dev/null +++ b/packages/mdc-tabs/tab-bar/index.js @@ -0,0 +1,107 @@ +/** + * Copyright 2017 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} from '../tab'; +import {strings} from './constants'; +import MDCTabBarFoundation from './foundation'; + +export {MDCTabBarFoundation}; + +export class MDCTabBar extends MDCComponent { + static attachTo(root) { + return new MDCTabBar(root); + } + + get tabs() { + return this.tabs_; + } + + get activeTab() { + const activeIndex = this.foundation_.getActiveTabIndex(); + return this.tabs[activeIndex]; + } + + set activeTab(tab) { + this.setActiveTab_(tab, false); + } + + get activeTabIndex() { + return this.foundation_.getActiveTabIndex(); + } + + set activeTabIndex(index) { + this.setActiveTabIndex_(index, false); + } + + initialize(tabFactory = (el) => new MDCTab(el)) { + this.indicator_ = this.root_.querySelector(strings.INDICATOR_SELECTOR); + this.tabs_ = this.gatherTabs_(tabFactory); + this.tabSelectedHandler_ = ({detail}) => { + const {tab} = detail; + this.setActiveTab_(tab, true); + }; + } + + getDefaultFoundation() { + return new MDCTabBarFoundation({ + addClass: (className) => this.root_.classList.add(className), + removeClass: (className) => this.root_.classList.remove(className), + bindOnMDCTabSelectedEvent: () => this.listen('MDCTab:selected', this.tabSelectedHandler_), + unbindOnMDCTabSelectedEvent: () => this.unlisten('MDCTab:selected', this.tabSelectedHandler_), + registerResizeHandler: (handler) => window.addEventListener('resize', handler), + deregisterResizeHandler: (handler) => window.removeEventListener('resize', handler), + getOffsetWidth: () => this.root_.offsetWidth, + setStyleForIndicator: (propertyName, value) => this.indicator_.style.setProperty(propertyName, value), + getOffsetWidthForIndicator: () => this.indicator_.offsetWidth, + notifyChange: (evtData) => this.emit('MDCTabBar:change', evtData), + getNumberOfTabs: () => this.tabs.length, + isTabActiveAtIndex: (index) => this.tabs[index].isActive, + setTabActiveAtIndex: (index, isActive) => { + this.tabs[index].isActive = isActive; + }, + isDefaultPreventedOnClickForTabAtIndex: (index) => this.tabs[index].preventDefaultOnClick, + setPreventDefaultOnClickForTabAtIndex: (index, preventDefaultOnClick) => { + this.tabs[index].preventDefaultOnClick = preventDefaultOnClick; + }, + measureTabAtIndex: (index) => this.tabs[index].measureSelf(), + getComputedWidthForTabAtIndex: (index) => this.tabs[index].computedWidth, + getComputedLeftForTabAtIndex: (index) => this.tabs[index].computedLeft, + }); + } + + gatherTabs_(tabFactory) { + const tabElements = [].slice.call(this.root_.querySelectorAll(strings.TAB_SELECTOR)); + return tabElements.map((el) => tabFactory(el)); + } + + setActiveTabIndex_(activeTabIndex, notifyChange) { + this.foundation_.switchToTabAtIndex(activeTabIndex, notifyChange); + } + + layout() { + this.foundation_.layout(); + } + + setActiveTab_(activeTab, notifyChange) { + const indexOfTab = this.tabs.indexOf(activeTab); + if (indexOfTab < 0) { + throw new Error('Invalid tab component given as activeTab: Tab not found within this component\'s tab list'); + } + this.setActiveTabIndex_(indexOfTab, notifyChange); + } +} diff --git a/packages/mdc-tabs/tab-bar/mdc-tab-bar.scss b/packages/mdc-tabs/tab-bar/mdc-tab-bar.scss new file mode 100644 index 00000000000..604436418b4 --- /dev/null +++ b/packages/mdc-tabs/tab-bar/mdc-tab-bar.scss @@ -0,0 +1,111 @@ +// +// Copyright 2017 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/functions"; +@import "@material/theme/mixins"; +@import "@material/rtl/mixins"; + +// postcss-bem-linter: define tab-bar +.mdc-tab-bar { + display: table; + height: 48px; + margin: 0 auto; + position: relative; + text-transform: uppercase; + + &__indicator { + height: 2px; + width: 100%; + + @include mdc-theme-prop(background-color, text-primary-on-light); + + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-theme-prop(background-color, text-primary-on-dark); + } + + position: absolute; + bottom: 0; + left: 0; + transform-origin: left top; + transition: mdc-animation-enter(transform, 240ms); + will-change: transform; + visibility: hidden; + } + + // postcss-bem-linter: ignore + .mdc-toolbar & { + .mdc-tab { + @include mdc-theme-prop(color, text-secondary-on-primary); + + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-theme-prop(color, text-secondary-on-dark); + } + } + + .mdc-tab--active, + .mdc-tab:hover { + @include mdc-theme-prop(color, text-primary-on-primary); + + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-theme-prop(color, text-primary-on-dark); + } + } + + // postcss-bem-linter: ignore + .mdc-tab-bar__indicator { + @include mdc-theme-prop(background-color, text-primary-on-primary); + + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-theme-prop(background-color, text-primary-on-dark); + } + } + } +} + +.mdc-tab-bar--icons-with-text { + height: 72px; +} + +.mdc-tab-bar--indicator-primary, +.mdc-toolbar .mdc-tab-bar--indicator-primary { + .mdc-tab-bar__indicator { + @include mdc-theme-prop(background-color, primary); + + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-theme-prop(background-color, primary); + } + } + + &.mdc-tab-bar:not(.mdc-tab-bar-upgraded) .mdc-tab::after { + @include mdc-theme-prop(background-color, primary); + } +} + +.mdc-tab-bar--indicator-accent, +.mdc-toolbar .mdc-tab-bar--indicator-accent { + .mdc-tab-bar__indicator { + @include mdc-theme-prop(background-color, accent); + + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-theme-prop(background-color, accent); + }; + } + + &.mdc-tab-bar:not(.mdc-tab-bar-upgraded) .mdc-tab::after { + @include mdc-theme-prop(background-color, accent); + } +} +// postcss-bem-linter: end diff --git a/packages/mdc-tabs/tab/constants.js b/packages/mdc-tabs/tab/constants.js new file mode 100644 index 00000000000..08f8d0913e8 --- /dev/null +++ b/packages/mdc-tabs/tab/constants.js @@ -0,0 +1,19 @@ +/** + * Copyright 2017 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. + */ + +export const cssClasses = { + ACTIVE: 'mdc-tab--active', +}; diff --git a/packages/mdc-tabs/tab/foundation.js b/packages/mdc-tabs/tab/foundation.js new file mode 100644 index 00000000000..d0d1e7f66bf --- /dev/null +++ b/packages/mdc-tabs/tab/foundation.js @@ -0,0 +1,102 @@ +/** + * Copyright 2017 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'; +import MDCFoundation from '@material/base/foundation'; + +export default class MDCTabFoundation extends MDCFoundation { + static get cssClasses() { + return cssClasses; + } + + static get defaultAdapter() { + return { + addClass: (/* className: string */) => {}, + removeClass: (/* className: string */) => {}, + registerInteractionHandler: (/* type: string, handler: EventListener */) => {}, + deregisterInteractionHandler: (/* type: string, handler: EventListener */) => {}, + getOffsetWidth: () => /* number */ 0, + getOffsetLeft: () => /* number */ 0, + notifySelected: () => {}, + }; + } + + constructor(adapter = {}) { + super(Object.assign(MDCTabFoundation.defaultAdapter, adapter)); + + this.computedWidth_ = 0; + this.computedLeft_ = 0; + this.isActive_ = false; + this.preventDefaultOnClick_ = false; + + this.clickHandler_ = (evt) => { + if (this.preventDefaultOnClick_) { + evt.preventDefault(); + } + this.adapter_.notifySelected(); + }; + + this.keydownHandler_ = (evt) => { + if (evt.key && evt.key === 'Enter' || evt.keyCode === 13) { + this.adapter_.notifySelected(); + } + }; + } + + init() { + this.adapter_.registerInteractionHandler('click', this.clickHandler_); + this.adapter_.registerInteractionHandler('keydown', this.keydownHandler_); + } + + destroy() { + this.adapter_.deregisterInteractionHandler('click', this.clickHandler_); + this.adapter_.deregisterInteractionHandler('keydown', this.keydownHandler_); + } + + getComputedWidth() { + return this.computedWidth_; + } + + getComputedLeft() { + return this.computedLeft_; + } + + isActive() { + return this.isActive_; + } + + setActive(isActive) { + this.isActive_ = isActive; + if (this.isActive_) { + this.adapter_.addClass(cssClasses.ACTIVE); + } else { + this.adapter_.removeClass(cssClasses.ACTIVE); + } + } + + preventsDefaultOnClick() { + return this.preventDefaultOnClick_; + } + + setPreventDefaultOnClick(preventDefaultOnClick) { + this.preventDefaultOnClick_ = preventDefaultOnClick; + } + + measureSelf() { + this.computedWidth_ = this.adapter_.getOffsetWidth(); + this.computedLeft_ = this.adapter_.getOffsetLeft(); + } +} diff --git a/packages/mdc-tabs/tab/index.js b/packages/mdc-tabs/tab/index.js new file mode 100644 index 00000000000..b6fbd056559 --- /dev/null +++ b/packages/mdc-tabs/tab/index.js @@ -0,0 +1,84 @@ +/** + * Copyright 2017 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 {MDCRipple} from '@material/ripple'; + +import {cssClasses} from './constants'; +import MDCTabFoundation from './foundation'; + +export {MDCTabFoundation}; + +export class MDCTab extends MDCComponent { + static attachTo(root) { + return new MDCTab(root); + } + + get computedWidth() { + return this.foundation_.getComputedWidth(); + } + + get computedLeft() { + return this.foundation_.getComputedLeft(); + } + + get isActive() { + return this.foundation_.isActive(); + } + + set isActive(isActive) { + this.foundation_.setActive(isActive); + } + + get preventDefaultOnClick() { + return this.foundation_.preventsDefaultOnClick(); + } + + set preventDefaultOnClick(preventDefaultOnClick) { + this.foundation_.setPreventDefaultOnClick(preventDefaultOnClick); + } + + constructor(...args) { + super(...args); + + this.ripple_ = MDCRipple.attachTo(this.root_); + } + + destroy() { + this.ripple_.destroy(); + super.destroy(); + } + + getDefaultFoundation() { + return new MDCTabFoundation({ + addClass: (className) => this.root_.classList.add(className), + removeClass: (className) => this.root_.classList.remove(className), + registerInteractionHandler: (type, handler) => this.root_.addEventListener(type, handler), + deregisterInteractionHandler: (type, handler) => this.root_.removeEventListener(type, handler), + getOffsetWidth: () => this.root_.offsetWidth, + getOffsetLeft: () => this.root_.offsetLeft, + notifySelected: () => this.emit('MDCTab:selected', {tab: this}, true), + }); + } + + initialSyncWithDOM() { + this.isActive = this.root_.classList.contains(cssClasses.ACTIVE); + } + + measureSelf() { + this.foundation_.measureSelf(); + } +} diff --git a/packages/mdc-tabs/tab/mdc-tab.scss b/packages/mdc-tabs/tab/mdc-tab.scss new file mode 100644 index 00000000000..886fb313039 --- /dev/null +++ b/packages/mdc-tabs/tab/mdc-tab.scss @@ -0,0 +1,174 @@ +// +// Copyright 2017 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/functions"; +@import "@material/typography/mixins"; +@import "@material/theme/mixins"; +@import "@material/ripple/mixins"; +@import "@material/rtl/mixins"; + +$mdc-tab-with-text-height: 48px; +$mdc-tab-with-icon-and-text-height: 72px; + +@mixin mdc-tabs-icon-tab-content { + display: block; + margin: 0 auto; +} + +// postcss-bem-linter: define tab +.mdc-tab { + @include mdc-typography(body2); + + position: relative; + box-sizing: border-box; + display: table-cell; + text-align: center; + vertical-align: middle; + min-height: $mdc-tab-with-text-height; + min-width: 160px; + overflow: hidden; + padding: 0 24px; + text-decoration: none; + cursor: pointer; + white-space: nowrap; + + @include mdc-theme-prop(color, text-secondary-on-light); + + &:hover { + @include mdc-theme-prop(color, text-primary-on-light); + } + + &:focus { + @include mdc-theme-prop(outline-color, text-secondary-on-light); + } + + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-theme-prop(color, text-secondary-on-dark); + + &:hover { + @include mdc-theme-prop(color, text-primary-on-dark); + } + + &:focus { + @include mdc-theme-prop(outline-color, text-secondary-on-dark); + } + } + + // TODO: Replace with breakpoint variable + @media screen and (max-width: 600px) { + min-width: 72px; + padding: 0 12px; + } + + &__icon { + @include mdc-tabs-icon-tab-content; + + width: 24px; + height: 24px; + + // postcss-bem-linter: ignore + .mdc-tab-bar--icons-with-text & { + margin-top: 4px; + } + } + + &__icon-text { + @include mdc-tabs-icon-tab-content; + } + + // postcss-bem-linter: ignore + &__icon + &__icon-text { + padding-top: 6px; + } +} + +.mdc-tab--active { + @include mdc-theme-prop(color, text-primary-on-light); + + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-theme-prop(color, text-primary-on-dark); + } + + &::before { + bottom: 0; + } +} + +.mdc-tab-bar:not(.mdc-tab-bar-upgraded) .mdc-tab { + position: relative; + + &::after { + position: absolute; + left: 0; + top: $mdc-tab-with-text-height - 2px; + content: ""; + width: calc(100% - 4px); + height: 2px; + display: none; + pointer-events: none; + + @include mdc-theme-prop(background-color, text-primary-on-light); + @include mdc-theme-dark(".mdc-tab-bar", true) { + @include mdc-theme-prop(background-color, text-primary-on-dark); + } + + .mdc-toolbar & { + @include mdc-theme-prop(background-color, text-primary-on-primary); + } + } + + &--active, + &:active, + &:hover { + &::after { + display: block; + } + } + + &:not(.mdc-tab--active):hover::after { + opacity: .38; + } + + &--active, + &:not(.mdc-tab--active):active::after { + opacity: .87; + } +} + +.mdc-tab-bar--icons-with-text:not(.mdc-tab-bar-upgraded) .mdc-tab::after { + top: $mdc-tab-with-icon-and-text-height - 2px; +} + +.mdc-tab.mdc-ripple-upgraded { + @include mdc-ripple-base; + @include mdc-ripple-fg((pseudo: "::after")); + @include mdc-ripple-bg((pseudo: "::before")); + @include mdc-theme-dark(".mdc-tab-bar") { + @include mdc-ripple-bg((pseudo: "::before", base-color: map-get($mdc-theme-property-values, text-primary-on-dark), opacity: .16)); + @include mdc-ripple-fg((pseudo: "::after", base-color: map-get($mdc-theme-property-values, text-primary-on-dark), opacity: .16)); + } + + .mdc-toolbar & { + @include mdc-ripple-bg((pseudo: "::before", base-color: map-get($mdc-theme-property-values, text-primary-on-primary), opacity: .16)); + @include mdc-ripple-fg((pseudo: "::after", base-color: map-get($mdc-theme-property-values, text-primary-on-primary), opacity: .16)); + } + + &:focus { + outline: none; + } +} + +// postcss-bem-linter: end diff --git a/packages/mdc-toolbar/mdc-toolbar.scss b/packages/mdc-toolbar/mdc-toolbar.scss index d7a5cffb5b6..ea62caedced 100644 --- a/packages/mdc-toolbar/mdc-toolbar.scss +++ b/packages/mdc-toolbar/mdc-toolbar.scss @@ -44,13 +44,14 @@ $mdc-toolbar-mobile-breakpoint: 599px; display: flex; position: relative; width: 100%; - height: $mdc-toolbar-row-height; + height: auto; + min-height: $mdc-toolbar-row-height; padding: $mdc-toolbar-padding; box-sizing: border-box; // TODO: refactor this out when #23 is implemented @media (max-width: $mdc-toolbar-mobile-breakpoint) { - height: $mdc-toolbar-mobile-row-height; + min-height: $mdc-toolbar-mobile-row-height; padding: $mdc-toolbar-mobile-padding; } } diff --git a/test/unit/mdc-tabs/mdc-tab-bar-foundation.test.js b/test/unit/mdc-tabs/mdc-tab-bar-foundation.test.js new file mode 100644 index 00000000000..322b56423a6 --- /dev/null +++ b/test/unit/mdc-tabs/mdc-tab-bar-foundation.test.js @@ -0,0 +1,164 @@ +/** + * Copyright 2017 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 {verifyDefaultAdapter, captureHandlers} from '../helpers/foundation'; +import {createMockRaf} from '../helpers/raf'; + +import MDCTabBarFoundation from '../../../packages/mdc-tabs/tab-bar/foundation'; + +suite('MDCTabBarFoundation'); + +test('exports cssClasses', () => { + assert.isOk('cssClasses' in MDCTabBarFoundation); +}); + +test('default adapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCTabBarFoundation, [ + 'addClass', 'removeClass', 'bindOnMDCTabSelectedEvent', + 'unbindOnMDCTabSelectedEvent', 'registerResizeHandler', + 'deregisterResizeHandler', 'getOffsetWidth', 'setStyleForIndicator', + 'getOffsetWidthForIndicator', 'notifyChange', + 'getNumberOfTabs', 'isTabActiveAtIndex', 'setTabActiveAtIndex', + 'isDefaultPreventedOnClickForTabAtIndex', + 'setPreventDefaultOnClickForTabAtIndex', 'measureTabAtIndex', + 'getComputedWidthForTabAtIndex', 'getComputedLeftForTabAtIndex', + ]); +}); + +function setupTest() { + const {foundation, mockAdapter} = setupFoundationTest(MDCTabBarFoundation); + const {UPGRADED} = MDCTabBarFoundation.cssClasses; + const {TAB_SELECTOR, INDICATOR_SELECTOR} = MDCTabBarFoundation.strings; + const tabHandlers = captureHandlers(mockAdapter, 'bindOnMDCTabSelectedEvent'); + + return {foundation, mockAdapter, UPGRADED, TAB_SELECTOR, INDICATOR_SELECTOR, tabHandlers}; +} + +test('#init adds upgraded class to tabs', () => { + const {foundation, mockAdapter, UPGRADED} = setupTest(); + + foundation.init(); + td.verify(mockAdapter.addClass(UPGRADED)); +}); + +test('#init registers listeners', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.init(); + td.verify(mockAdapter.bindOnMDCTabSelectedEvent()); + td.verify(mockAdapter.registerResizeHandler(isA(Function))); +}); + +test('#init sets active tab if index of active tab on init() is > 0', () => { + const {foundation, mockAdapter} = setupTest(); + const activeTabIndex = 2; + const numberOfTabs = 5; + + td.when(mockAdapter.getNumberOfTabs()).thenReturn(numberOfTabs); + td.when(mockAdapter.isTabActiveAtIndex(activeTabIndex)).thenReturn(true); + + foundation.init(); + + assert.isTrue(mockAdapter.isTabActiveAtIndex(activeTabIndex)); +}); + +test('#init sets styles for indicator', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + + foundation.init(); + raf.flush(); + + td.verify(mockAdapter.setStyleForIndicator('transform', td.matchers.isA(String))); + td.verify(mockAdapter.setStyleForIndicator('transition', td.matchers.isA(String))); + td.verify(mockAdapter.setStyleForIndicator('visibility', td.matchers.isA(String))); + + raf.restore(); +}); + +test('#destroy removes class from tabs', () => { + const {foundation, mockAdapter, UPGRADED} = setupTest(); + + foundation.destroy(); + td.verify(mockAdapter.removeClass(UPGRADED)); +}); + +test('#destroy deregisters tab event handlers', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.destroy(); + td.verify(mockAdapter.unbindOnMDCTabSelectedEvent()); + td.verify(mockAdapter.deregisterResizeHandler(isA(Function))); +}); + +test('#switchToTabAtIndex does nothing if the currently active tab is clicked', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.switchToTabAtIndex(0, true); + + td.verify(mockAdapter.setTabActiveAtIndex( + td.matchers.anything(), td.matchers.isA(Boolean) + ), {times: 0}); + td.verify(mockAdapter.notifyChange(td.matchers.anything()), {times: 0}); +}); + +test('#switchToTabAtIndex throws if index negative', () => { + const {foundation} = setupTest(); + + assert.throws(() => foundation.switchToTabAtIndex(-1, true)); +}); + +test('#switchToTabAtIndex throws if index is out of bounds', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getNumberOfTabs()).thenReturn(2); + + assert.throws(() => foundation.switchToTabAtIndex(2, true)); +}); + +test('#switchToTabAtIndex makes tab active', () => { + const {foundation, mockAdapter} = setupTest(); + const raf = createMockRaf(); + const tabToSwitchTo = 1; + const shouldNotify = true; + + td.when(mockAdapter.getNumberOfTabs()).thenReturn(2); + + foundation.switchToTabAtIndex(tabToSwitchTo, shouldNotify); + raf.flush(); + + td.verify(mockAdapter.setTabActiveAtIndex(tabToSwitchTo, shouldNotify)); + td.verify(mockAdapter.notifyChange(td.matchers.anything())); + raf.restore(); +}); + +test('#getActiveTabIndex returns the active tab index', () => { + const {foundation, mockAdapter} = setupTest(); + const activeTabIndex = 2; + const numberOfTabs = 5; + + td.when(mockAdapter.getNumberOfTabs()).thenReturn(numberOfTabs); + td.when(mockAdapter.isTabActiveAtIndex(activeTabIndex)).thenReturn(true); + + foundation.init(); + + assert.equal(foundation.getActiveTabIndex(), activeTabIndex); +}); diff --git a/test/unit/mdc-tabs/mdc-tab-bar.test.js b/test/unit/mdc-tabs/mdc-tab-bar.test.js new file mode 100644 index 00000000000..0fe21ebe1e3 --- /dev/null +++ b/test/unit/mdc-tabs/mdc-tab-bar.test.js @@ -0,0 +1,334 @@ +/** + * Copyright 2017 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 bel from 'bel'; +import domEvents from 'dom-events'; +import td from 'testdouble'; +import {MDCTabBar} from '../../../packages/mdc-tabs/tab-bar'; +import {MDCTabBarFoundation} from '../../../packages/mdc-tabs/tab-bar'; +import {createMockRaf} from '../helpers/raf'; + +class MockTab { + constructor() { + this.root = bel`Item`; + this.measureSelf = td.func('measureSelf'); + this.isActive = false; + this.preventDefaultOnClick = false; + this.computedWidth = 100; + this.computedLeft = 200; + } +} + +function getFixture() { + return bel` +
    + +
    `; +} + +function setupTest() { + const MockTabBarFoundation = td.constructor(MDCTabBarFoundation); + const foundation = new MockTabBarFoundation(); + const mockTab = new MockTab(); + const fixture = getFixture(); + const root = fixture.querySelector('.mdc-tab-bar'); + const indicator = fixture.querySelector('.mdc-tab-bar__indicator'); + const component = new MDCTabBar(root, undefined, () => mockTab); + + return {fixture, root, indicator, component, foundation}; +} + +suite('MDCTabBar'); + +test('attachTo returns a component instance', () => { + assert.isOk(MDCTabBar.attachTo(setupTest().root) instanceof MDCTabBar); +}); + +test('#get tabs returns tabs', () => { + const {component} = setupTest(); + + assert.isArray(component.tabs); + + component.tabs.forEach((tab) => { + assert.instanceOf(tab, MockTab); + }); +}); + +test('#get activeTab returns active tab', () => { + const {component} = setupTest(); + const targetIndex = 1; + const tab = component.tabs[targetIndex]; + + tab.isActive = true; + assert.equal(tab, component.activeTab); +}); + +test('#set activeTab makes a tab the active tab', () => { + const raf = createMockRaf(); + const {component} = setupTest(); + const tab = new MockTab(); + component.tabs.push(tab); + + component.activeTab = tab; + raf.flush(); + + assert.isTrue(tab.isActive); + raf.restore(); +}); + +test('#get activeTabIndex returns active tab', () => { + const {component} = setupTest(); + const targetIndex = 0; + const tab = component.tabs[targetIndex]; + + tab.isActive = true; + assert.equal(component.tabs.indexOf(tab), component.activeTabIndex); +}); + +test('#set activeTabIndex makes a tab at a given index the active tab', () => { + const raf = createMockRaf(); + const {component} = setupTest(); + const tab = new MockTab(); + component.tabs.push(tab); + + component.activeTabIndex = component.tabs.indexOf(tab); + raf.flush(); + + assert.isTrue(tab.isActive); + raf.restore(); +}); + +test('adapter#addClass adds a class to the root element', () => { + const {root, component} = setupTest(); + + component.getDefaultFoundation().adapter_.addClass('foo'); + assert.isOk(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.isNotOk(root.classList.contains('foo')); +}); + +test('adapter#bindOnMDCTabSelectedEvent adds event listener for MDCTab:selected on ' + + 'the component', () => { + const {component, root} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const tab = new MockTab(); + component.tabs.push(tab); + const raf = createMockRaf(); + + adapter.bindOnMDCTabSelectedEvent(); + domEvents.emit(root, 'MDCTab:selected', {detail: {tab}}); + + raf.flush(); + + assert.isTrue(tab.isActive); + raf.restore(); +}); + +test('on MDCTab:selected if tab is not in tab bar, throw Error', () => { + const {component} = setupTest(); + const tab = new MockTab(); + + // This is purely to make sure that bindOnMDCTabSelectedEvent() is executed + // and does not throw. + component.getDefaultFoundation().adapter_.bindOnMDCTabSelectedEvent(); + + // NOTE: Because of this issue: https://bugs.chromium.org/p/chromium/issues/detail?id=239648# + // Chai cannot observe an error thrown as a result of an event being dispatched. + const evtObj = { + detail: {tab}, + }; + assert.throws(() => component.tabSelectedHandler_(evtObj)); +}); + +test('adapter#unbindOnMDCTabSelectedEvent removes listener from component', () => { + const {component} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const tab = new MockTab(); + component.tabs.push(tab); + const raf = createMockRaf(); + const handler = td.func('custom handler'); + + component.listen('MDCTab:selected', handler); + adapter.unbindOnMDCTabSelectedEvent(); + domEvents.emit(tab.root, 'MDCTab:selected', {detail: {tab}}); + + raf.flush(); + + td.verify(handler(td.matchers.anything()), {times: 0}); + assert.isFalse(tab.isActive); + raf.restore(); +}); + +test('adapter#registerResizeHandler adds resize listener to the component', () => { + const {component} = setupTest(); + const handler = td.func('resizeHandler'); + + component.getDefaultFoundation().adapter_.registerResizeHandler(handler); + domEvents.emit(window, 'resize'); + + td.verify(handler(td.matchers.anything())); + window.removeEventListener('resize', handler); +}); + +test('adapter#deregisterResizeHandler removes resize listener from component', () => { + const {component} = setupTest(); + const handler = td.func('resizeHandler'); + + window.addEventListener('resize', handler); + component.getDefaultFoundation().adapter_.deregisterResizeHandler(handler); + domEvents.emit(window, 'resize'); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('adapter#getOffsetWidth returns width of component', () => { + const {root, component} = setupTest(); + const tabBarWidth = + component.getDefaultFoundation().adapter_.getOffsetWidth(); + + assert.equal(tabBarWidth, root.offsetWidth); +}); + +test('adapter#setStyleForIndicator sets a given property to the given value', () => { + const {indicator, component} = setupTest(); + + component.getDefaultFoundation().adapter_.setStyleForIndicator('width', '200px'); + assert.equal(indicator.style.width, '200px'); +}); + +test('adapter#getOffsetWidthForIndicator returns the width of the active tab indicator', () => { + const {indicator, component} = setupTest(); + const indicatorWidth = + component.getDefaultFoundation().adapter_.getOffsetWidthForIndicator(); + + assert.equal(indicatorWidth, indicator.offsetWidth); +}); + +test('adapter#notifyChange emits MDCTabBar:change with event data', () => { + const {component} = setupTest(); + const handler = td.func('MDCTabBar:change handler'); + const data = td.object({ + tab: td.object({}), + }); + + component.listen('MDCTabBar:change', handler); + component.getDefaultFoundation().adapter_.notifyChange(data); + + td.verify(handler(td.matchers.isA(Object))); +}); + +test('adapter#getNumberOfTabs returns the number of tabs', () => { + const {component} = setupTest(); + + assert.equal(component.getDefaultFoundation().adapter_.getNumberOfTabs(), 3); +}); + +test('adapter#isTabActiveAtIndex returns true if index equals activeTab index', () => { + const {component} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const targetIndex = 1; + const tab = component.tabs[targetIndex]; + + assert.isFalse(adapter.isTabActiveAtIndex(targetIndex)); + tab.isActive = true; + assert.isTrue(adapter.isTabActiveAtIndex(targetIndex)); +}); + +test('adapter#setTabActiveAtIndex sets tab at target index to true or false', () => { + const {component} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const targetIndex = 1; + const tab = component.tabs[targetIndex]; + + assert.isFalse(tab.isActive); + adapter.setTabActiveAtIndex(targetIndex, true); + assert.isTrue(tab.isActive); +}); + +test('adapter#isDefaultPreventedOnClickForTabAtIndex returns the value ' + + ' of preventsDefaultOnClick', () => { + const {component} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const targetIndex = 1; + const tab = component.tabs[targetIndex]; + + assert.isFalse(adapter.isDefaultPreventedOnClickForTabAtIndex(targetIndex)); + tab.preventDefaultOnClick = true; + assert.isTrue(adapter.isDefaultPreventedOnClickForTabAtIndex(targetIndex)); +}); + +test('adapter#setPreventDefaultOnClickForTabAtIndex sets preventDefault ' + + ' for tab at index', () => { + const {component} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const targetIndex = 1; + const tab = component.tabs[targetIndex]; + + assert.isFalse(tab.preventDefaultOnClick); + adapter.setPreventDefaultOnClickForTabAtIndex(targetIndex, true); + assert.isTrue(tab.preventDefaultOnClick); +}); + +test('adapter#measureTabAtIndex calls measureSelf on the tab at a given index', () => { + const {component} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const targetIndex = 1; + const tab = component.tabs[targetIndex]; + + adapter.measureTabAtIndex(targetIndex); + + td.verify(tab.measureSelf()); +}); + +test('adapter#getComputedWidthForTabAtIndex returns width for a given tab', () => { + const {component} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const targetIndex = 1; + const tab = component.tabs[targetIndex]; + + assert.equal(adapter.getComputedWidthForTabAtIndex(targetIndex), tab.computedWidth); +}); + +test('adapter#getComputedLeftForTabAtIndex returns left offset for a given tab', () => { + const {component} = setupTest(); + const adapter = component.getDefaultFoundation().adapter_; + const targetIndex = 1; + const tab = component.tabs[targetIndex]; + + assert.equal(adapter.getComputedLeftForTabAtIndex(targetIndex), tab.computedLeft); +}); + +test('#layout proxies to foundation.layout()', () => { + const {root} = setupTest(); + const MockTabBarFoundation = td.constructor(MDCTabBarFoundation); + const foundation = new MockTabBarFoundation(); + const component = new MDCTabBar(root, foundation); + + component.layout(); + td.verify(foundation.layout()); +}); diff --git a/test/unit/mdc-tabs/mdc-tab-foundation.test.js b/test/unit/mdc-tabs/mdc-tab-foundation.test.js new file mode 100644 index 00000000000..48248dec0d0 --- /dev/null +++ b/test/unit/mdc-tabs/mdc-tab-foundation.test.js @@ -0,0 +1,240 @@ +/** + * Copyright 2017 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 {verifyDefaultAdapter, captureHandlers} from '../helpers/foundation'; + +import {cssClasses} from '../../../packages/mdc-tabs/tab/constants'; +import MDCTabFoundation from '../../../packages/mdc-tabs/tab/foundation'; + +suite('MDCTabFoundation'); + +test('exports cssClasses', () => { + assert.deepEqual(MDCTabFoundation.cssClasses, cssClasses); +}); + +test('default adapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCTabFoundation, [ + 'addClass', 'removeClass', 'registerInteractionHandler', + 'deregisterInteractionHandler', 'getOffsetWidth', 'getOffsetLeft', + 'notifySelected', + ]); +}); + +function setupTest() { + return setupFoundationTest(MDCTabFoundation); +} + +test('#init registers tab interaction handlers', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.init(); + + td.verify(mockAdapter.registerInteractionHandler('click', isA(Function))); + td.verify(mockAdapter.registerInteractionHandler('keydown', isA(Function))); +}); + +test('#destroy deregisters tab interaction handlers', () => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.destroy(); + + td.verify(mockAdapter.deregisterInteractionHandler('click', isA(Function))); + td.verify(mockAdapter.deregisterInteractionHandler('keydown', isA(Function))); +}); + +test('#getComputedWidth returns the width of the tab', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getOffsetWidth()).thenReturn(200); + + foundation.measureSelf(); + assert.equal(foundation.getComputedWidth(), 200); +}); + +test('#getComputedLeft returns the left offset of the tab', () => { + const {foundation, mockAdapter} = setupTest(); + + td.when(mockAdapter.getOffsetLeft()).thenReturn(100); + + foundation.measureSelf(); + assert.equal(foundation.getComputedLeft(), 100); +}); + +test('#isActive returns active state of the tab', () => { + const {foundation} = setupTest(); + + foundation.setActive(false); + assert.isFalse(foundation.isActive()); + + foundation.setActive(true); + assert.isTrue(foundation.isActive()); +}); + +test('#setActive adds active class when isActive is true', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.setActive(true); + td.verify(mockAdapter.addClass(cssClasses.ACTIVE)); +}); + +test('#setActive removes active class when isActive is false', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.setActive(false); + td.verify(mockAdapter.removeClass(cssClasses.ACTIVE)); +}); + +test('#preventsDefaultOnClick returns state of preventsDefaultOnClick', () => { + const {foundation} = setupTest(); + + foundation.setPreventDefaultOnClick(false); + assert.isFalse(foundation.preventsDefaultOnClick()); + + foundation.setPreventDefaultOnClick(true); + assert.isTrue(foundation.preventsDefaultOnClick()); +}); + +test('#setPreventDefaultOnClick does not preventDefault if set to false', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler'); + const evt = { + preventDefault: td.func('evt.stopPropagation'), + }; + + foundation.init(); + foundation.setPreventDefaultOnClick(false); + + handlers.click(evt); + td.verify(evt.preventDefault(), {times: 0}); +}); + +test('#setPreventDefaultOnClick calls preventDefault if set to true', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler'); + const evt = { + preventDefault: td.func('evt.stopPropagation'), + }; + + foundation.init(); + foundation.setPreventDefaultOnClick(true); + + handlers.click(evt); + td.verify(evt.preventDefault()); +}); + +test('#setPreventDefaultOnClick sets preventDefaultOnClick_ to true. Subsequent clicks ' + + 'call preventDefault()', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler'); + const evt = { + preventDefault: td.func('evt.stopPropagation'), + }; + + foundation.init(); + foundation.setPreventDefaultOnClick(true); + + handlers.click(evt); + td.verify(evt.preventDefault()); +}); + +test('#setPreventDefaultOnClick sets preventDefaultOnClick_ to false. Subsequent clicks ' + + 'do not call preventDefault()', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler'); + const evt = { + preventDefault: td.func('evt.stopPropagation'), + }; + + foundation.init(); + foundation.setPreventDefaultOnClick(false); + + handlers.click(evt); + td.verify(evt.preventDefault(), {times: 0}); +}); + +test('#measureSelf sets computedWidth_ and computedLeft_ for tab', () => { + const {foundation, mockAdapter} = setupTest(); + + foundation.measureSelf(); + + td.verify(mockAdapter.getOffsetWidth()); + td.verify(mockAdapter.getOffsetLeft()); +}); + +test('on document keydown notifies selected when enter key is pressed using keycode', () => { + const {foundation, mockAdapter} = setupTest(); + let keydown; + + td.when(mockAdapter.registerInteractionHandler('keydown', td.matchers.isA(Function))).thenDo((type, handler) => { + keydown = handler; + }); + + foundation.init(); + keydown({ + keyCode: 13, + }); + + td.verify(mockAdapter.notifySelected()); +}); + +test('on document keydown notifies selected when enter key is pressed using Enter', () => { + const {foundation, mockAdapter} = setupTest(); + let keydown; + + td.when(mockAdapter.registerInteractionHandler('keydown', td.matchers.isA(Function))).thenDo((type, handler) => { + keydown = handler; + }); + + foundation.init(); + keydown({ + key: 'Enter', + }); + + td.verify(mockAdapter.notifySelected()); +}); + +test('on document click calls evt.preventDefault() preventDefaultOnClick_ is true', () => { + const {foundation, mockAdapter} = setupTest(); + const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler'); + const evt = { + preventDefault: td.func('evt.stopPropagation'), + }; + + foundation.init(); + foundation.setPreventDefaultOnClick(true); + handlers.click(evt); + + td.verify(evt.preventDefault()); +}); + +test('on document click notifies selected when clicked', () => { + const {foundation, mockAdapter} = setupTest(); + let click; + + td.when(mockAdapter.registerInteractionHandler('click', td.matchers.isA(Function))).thenDo((type, handler) => { + click = handler; + }); + + foundation.init(); + click(); + + td.verify(mockAdapter.notifySelected()); +}); diff --git a/test/unit/mdc-tabs/mdc-tab.test.js b/test/unit/mdc-tabs/mdc-tab.test.js new file mode 100644 index 00000000000..287e7e71745 --- /dev/null +++ b/test/unit/mdc-tabs/mdc-tab.test.js @@ -0,0 +1,168 @@ +/** + * Copyright 2017 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 bel from 'bel'; +import domEvents from 'dom-events'; +import td from 'testdouble'; +import {createMockRaf} from '../helpers/raf'; +import {supportsCssVariables} from '../../../packages/mdc-ripple/util'; +import {MDCTab} from '../../../packages/mdc-tabs/tab'; +import {MDCTabFoundation} from '../../../packages/mdc-tabs/tab'; +import {cssClasses} from '../../../packages/mdc-tabs/tab/constants'; + +function getFixture() { + return bel` +
    + Item One +
    `; +} + +function setupTest() { + const fixture = getFixture(); + const root = fixture.querySelector('.mdc-tab'); + const component = new MDCTab(root); + return {fixture, root, component}; +} + +suite('MDCTab'); + +test('attachTo returns a component instance', () => { + assert.isOk(MDCTab.attachTo(getFixture()) instanceof MDCTab); +}); + +if (supportsCssVariables(window)) { + test('#constructor initializes the root element with a ripple', () => { + const raf = createMockRaf(); + const {root} = setupTest(); + raf.flush(); + assert.isOk(root.classList.contains('mdc-ripple-upgraded')); + raf.restore(); + }); + + test('#destroy cleans up ripple on tab', () => { + const raf = createMockRaf(); + const {root, component} = setupTest(); + raf.flush(); + component.destroy(); + raf.flush(); + assert.isNotOk(root.classList.contains('mdc-ripple-upgraded')); + }); +} + +test('#get computedWidth returns computed width of tab', () => { + const {root, component} = setupTest(); + + assert.equal(component.computedWidth, root.offsetWidth); +}); + +test('#get computedLeft returns computed left offset of tab', () => { + const {root, component} = setupTest(); + + assert.equal(component.computedLeft, root.offsetLeft); +}); + +test('#get/set isActive', () => { + const {component, root} = setupTest(); + + component.isActive = false; + assert.isFalse(root.classList.contains(cssClasses.ACTIVE)); + + component.isActive = true; + assert.isTrue(root.classList.contains(cssClasses.ACTIVE)); +}); + +test('#get/set preventDefaultOnClick', () => { + const {component} = setupTest(); + + component.preventDefaultOnClick = false; + assert.isFalse(component.preventDefaultOnClick); + + component.preventDefaultOnClick = true; + assert.isTrue(component.preventDefaultOnClick); +}); + +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#registerInteractionHandler adds an event listener to the root element', () => { + const {root, component} = setupTest(); + const type = 'click'; + const handler = td.func('eventHandler'); + + component.getDefaultFoundation().adapter_.registerInteractionHandler(type, handler); + domEvents.emit(root, 'click'); + + td.verify(handler(td.matchers.anything())); +}); + +test('adapter#deregisterInteractionHandler removes an event listener from the root element', () => { + const {root, component} = setupTest(); + const type = 'click'; + const handler = td.func('eventHandler'); + + root.addEventListener(type, handler); + + component.getDefaultFoundation().adapter_.deregisterInteractionHandler(type, handler); + domEvents.emit(root, type); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('adapter#getOffsetWidth returns the width of the tab', () => { + const {root, component} = setupTest(); + + assert.equal(component.getDefaultFoundation().adapter_.getOffsetWidth(), root.offsetWidth); +}); + +test('adapter#getOffsetLeft returns left offset for the tab', () => { + const {root, component} = setupTest(); + + assert.equal(component.getDefaultFoundation().adapter_.getOffsetLeft(), root.offsetLeft); +}); + +test('adapter#notifySelected emits MDCTab:selected', () => { + const {component} = setupTest(); + + const handler = td.func('acceptHandler'); + + component.listen('MDCTab:selected', handler); + component.getDefaultFoundation().adapter_.notifySelected(); + + td.verify(handler(td.matchers.anything())); +}); + +test('#measureSelf proxies to the foundation\'s measureSelf', () => { + const MockTabFoundation = td.constructor(MDCTabFoundation); + const root = document.createElement('a'); + const foundation = new MockTabFoundation(); + const component = new MDCTab(root, foundation); + + component.measureSelf(); + td.verify(foundation.measureSelf()); +}); diff --git a/webpack.config.js b/webpack.config.js index fe575093d5d..fb87bf3f43c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -86,6 +86,7 @@ module.exports = [{ ripple: [path.resolve('./packages/mdc-ripple/index.js')], select: [path.resolve('./packages/mdc-select/index.js')], snackbar: [path.resolve('./packages/mdc-snackbar/index.js')], + tabs: [path.resolve('./packages/mdc-tabs/index.js')], textfield: [path.resolve('./packages/mdc-textfield/index.js')], toolbar: [path.resolve('./packages/mdc-toolbar/index.js')], }, @@ -167,6 +168,7 @@ module.exports = [{ 'mdc.select': path.resolve('./packages/mdc-select/mdc-select.scss'), 'mdc.snackbar': path.resolve('./packages/mdc-snackbar/mdc-snackbar.scss'), 'mdc.switch': path.resolve('./packages/mdc-switch/mdc-switch.scss'), + 'mdc.tabs': path.resolve('./packages/mdc-tabs/mdc-tabs.scss'), 'mdc.textfield': path.resolve('./packages/mdc-textfield/mdc-textfield.scss'), 'mdc.theme': path.resolve('./packages/mdc-theme/mdc-theme.scss'), 'mdc.toolbar': path.resolve('./packages/mdc-toolbar/mdc-toolbar.scss'),