├── .babelrc ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── SUMMARY.md ├── book.json ├── docs ├── README.md ├── api │ ├── README.md │ ├── Tab.md │ ├── TabList.md │ ├── TabPanel.md │ ├── TabProvider.md │ └── Tabs.md └── examples │ ├── README.md │ ├── basic.md │ ├── custom_markup.md │ └── vertical.md ├── jestSetup.js ├── package.json ├── postcss.config.js ├── src ├── Tab.jsx ├── TabComponent.jsx ├── TabList.jsx ├── TabListComponent.jsx ├── TabPanel.jsx ├── TabPanelComponent.jsx ├── TabProvider.jsx ├── TabSelection.js ├── Tabs.jsx ├── __tests__ │ ├── Tab.test.js │ ├── TabComponent.test.js │ ├── TabList.test.js │ ├── TabListComponent.test.js │ ├── TabPanel.test.js │ ├── TabPanelComponent.test.js │ ├── TabProvider.js │ ├── TabSelection.test.js │ ├── Tabs.js │ └── withSelection.test.js ├── index.js └── withTabSelection.jsx ├── styles └── style.css ├── webpack.config.babel.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [["env"], "stage-2", "react"] 5 | }, 6 | "production": { 7 | "presets": [["env"], "stage-2", "react"] 8 | }, 9 | "commonjs": { 10 | "presets": [["env", { "modules": "commonjs" }], "stage-2", "react"] 11 | } 12 | }, 13 | "presets": [ 14 | ["env", { "modules": false }], 15 | "stage-2", 16 | "react" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | max_line_length = 0 15 | trim_trailing_whitespace = false 16 | 17 | [COMMIT_EDITMSG] 18 | max_line_length = 0 19 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | es 3 | lib 4 | coverage 5 | _book 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "jest": true 6 | }, 7 | "extends": "airbnb", 8 | "parser": "babel-eslint", 9 | "rules": { 10 | "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | 39 | dist 40 | es 41 | lib 42 | _book 43 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "node" 4 | script: 5 | - yarn run test:travis 6 | cache: 7 | yarn: true 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Marcus Lindfeldt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-web-tabs 2 | [![npm](https://img.shields.io/npm/v/react-web-tabs.svg?style=flat-square)](https://www.npmjs.com/package/react-web-tabs) 3 | [![Travis](https://img.shields.io/travis/marcuslindfeldt/react-web-tabs.svg?style=flat-square)](https://travis-ci.org/marcuslindfeldt/react-web-tabs) 4 | [![Coveralls](https://img.shields.io/coveralls/marcuslindfeldt/react-web-tabs.svg?style=flat-square)](https://coveralls.io/github/marcuslindfeldt/react-web-tabs?branch=master) 5 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://raw.githubusercontent.com/marcuslindfeldt/react-web-tabs/master/LICENSE) 6 | 7 | Modular and accessible React tabs according to the [WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel). 8 | 9 | ## Demo 10 | See the [demo website](https://react-web-tabs.firebaseapp.com/) for a live example. 11 | 12 | ## Documentation 13 | Read the [docs](https://marcuslindfeldt.github.io/react-web-tabs/) for more comprehensive [examples](https://marcuslindfeldt.github.io/react-web-tabs/examples) and [API Reference](https://marcuslindfeldt.github.io/react-web-tabs/docs/api/). 14 | 15 | ## Installation 16 | > Note! This package depends on [React](https://facebook.github.io/react/) ^16.3.0 17 | 18 | Using [npm](https://www.npmjs.com/): 19 | ```bash 20 | npm install --save react-web-tabs 21 | ``` 22 | Using [yarn](https://yarnpkg.com/en/): 23 | ```bash 24 | yarn add react-web-tabs 25 | ``` 26 | 27 | Then with a module bundler like [webpack](https://webpack.js.org/) you can import it like usual: 28 | 29 | ```js 30 | // using ES6 modules 31 | import { Tabs, Tab, TabPanel, TabList } from 'react-web-tabs'; 32 | 33 | // using ES6 Partial imports 34 | import Tabs from 'react-web-tabs/lib/Tabs'; 35 | import Tab from 'react-web-tabs/lib/Tab'; 36 | import TabPanel from 'react-web-tabs/lib/TabPanel'; 37 | import TabList from 'react-web-tabs/lib/TabList'; 38 | 39 | // using CommonJS modules 40 | var Tabs = require('react-web-tabs').Tabs; 41 | var Tab = require('react-web-tabs').Tab; 42 | var TabPanel = require('react-web-tabs').TabPanel; 43 | var TabList = require('react-web-tabs').TabList; 44 | ``` 45 | 46 | The UMD build is also available on unpkg: 47 | 48 | ```html 49 | 50 | ``` 51 | 52 | ## Usage 53 | 54 | ```js 55 | import React, { Component } from 'react'; 56 | import { render } from 'react-dom'; 57 | import { Tabs, Tab, TabPanel, TabList } from 'react-web-tabs'; 58 | 59 | 60 | class App extends Component { 61 | render() { 62 | return ( 63 | { console.log(tabId) }} 66 | > 67 | 68 | Tab 1 69 | Tab 2 70 | Tab 3 71 | 72 | 73 |

Tab 1 content

74 |
75 | 76 |

Tab 2 content

77 |
78 | 79 |

Tab 3 content

80 |
81 |
82 | ); 83 | } 84 | } 85 | 86 | render(, document.getElementById('app')); 87 | ``` 88 | 89 | If you need to make it more interesting and mix in other elements you can do that to: 90 | 91 | ```js 92 | import React, { Component } from 'react'; 93 | import { render } from 'react-dom'; 94 | import { TabProvider, Tab, TabPanel, TabList } from 'react-web-tabs'; 95 | 96 | 97 | class App extends Component { 98 | render() { 99 | return ( 100 | 101 |
102 | 103 | Tab 1 104 | 105 | Tab 2 106 | 107 | Tab 3 108 | 109 |
110 | 111 |

Tab 1 content

112 |
113 | 114 |

Tab 2 content

115 |
116 | 117 |

Tab 3 content

118 |
119 |
120 |
121 |
122 | ); 123 | } 124 | } 125 | 126 | render(, document.getElementById('app')); 127 | ``` 128 | 129 | And of course every component supports adding additional props like custom className's or data attributes. 130 | 131 | ## Styles 132 | 133 | Some basic styles are provided as well but they are optional as the tabs are fully functional without styling and I do encourage you to create your own. Both minified and unminified versions are available in the `/dist` folder. 134 | 135 | With webpack: 136 | ```js 137 | import 'react-web-tabs/dist/react-web-tabs.css'; 138 | ``` 139 | 140 | ## Keyboard support 141 | The following keys can be used to navigate between tabs when in focus, according to the [WAI-ARIA Authoring Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel). 142 | 143 | 144 | * Navigate to previous tab 145 | * Navigate to next tab 146 | * HOME Navigate to first tab 147 | * END Navigate to last tab 148 | 149 | When the tabs are vertical: 150 | 151 | * Navigate to previous tab 152 | * Navigate to next tab 153 | * HOME Navigate to first tab 154 | * END Navigate to last tab 155 | 156 | According to the [WAI-ARIA Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel) only the active tab should receive focus upon entering and leaving the tab list. Some people find this behavior confusing so to make all tabs focusable you can override this behavior by adding the `focusable` flag to each `` component. E.g. 157 | 158 | ```js 159 | 160 | React web tabs 161 | ``` 162 | 163 | 164 | ## Issues 165 | If you find a bug, please file an issue on the [issue tracker on GitHub](https://github.com/marcuslindfeldt/react-web-tabs/issues). 166 | 167 | ## Licence 168 | MIT 169 | -------------------------------------------------------------------------------- /SUMMARY.md: -------------------------------------------------------------------------------- 1 | docs/README.md -------------------------------------------------------------------------------- /book.json: -------------------------------------------------------------------------------- 1 | { 2 | "gitbook": "3.2.2", 3 | "title": "React Web Tabs", 4 | "plugins": ["edit-link", "prism", "-highlight", "github", "anchorjs", "algolia"], 5 | "pluginsConfig": { 6 | "edit-link": { 7 | "base": "https://github.com/marcuslindfeldt/react-web-tabs/tree/master", 8 | "label": "Edit This Page" 9 | }, 10 | "github": { 11 | "url": "https://github.com/marcuslindfeldt/react-web-tabs/" 12 | }, 13 | "algolia": { 14 | "index": "react-web-tabs", 15 | "applicationID": "I61SUGLEM0", 16 | "publicKey": "59ab1c30d433859b5350d3e1e36c8231", 17 | "freeAccount": "true" 18 | }, 19 | "theme-default": { 20 | "styles": { 21 | "website": "build/gitbook.css" 22 | } 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Table of Contents 2 | 3 | * [Read Me](/README.md) 4 | * [Examples](/docs/examples/README.md) 5 | * [Basic](/docs/examples/basic.md) 6 | * [Vertical](/docs/examples/vertical.md) 7 | * [Custom markup](/docs/examples/custom_markup.md) 8 | * [API Reference](/docs/api/README.md) 9 | * [Tab](/docs/api/Tab.md) 10 | * [TabList](/docs/api/TabList.md) 11 | * [TabPanel](/docs/api/TabPanel.md) 12 | * [TabProvider](/docs/api/TabProvider.md) 13 | * [Tabs](/docs/api/Tabs.md) 14 | -------------------------------------------------------------------------------- /docs/api/README.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | React Web Tabs only consist of five components: 4 | 5 | * [Tab](Tab.md) 6 | * [TabList](TabList.md) 7 | * [TabPanel](TabPanel.md) 8 | * [TabPanel](TabPanel.md) 9 | * [TabProvider](TabProvider.md) 10 | * [Tabs](Tabs.md) 11 | -------------------------------------------------------------------------------- /docs/api/Tab.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | A Tab element with the correct WAI-ARIA attributes. 4 | 5 | ```js 6 | import { Tab } from 'react-web-tabs' 7 | 8 | React web tabs 9 | ``` 10 | 11 | ## children: node 12 | 13 | Any child node 14 | 15 | ```js 16 | 17 | 18 | React web tabs 19 | 20 | ``` 21 | 22 | ## tabFor: string 23 | 24 | To connect a `` to a `` we need to make an id reference similar to how form inputs and labels work. 25 | 26 | ## focusable: bool (optional) 27 | 28 | According to the [WAI-ARIA Practices 1.1](https://www.w3.org/TR/wai-aria-practices-1.1/#tabpanel) only the active tab should receive focus upon entering and leaving the tab list. Some people find this behavior confusing so to make all tabs focusable you can override this by adding the `focusable` flag. 29 | 30 | ```js 31 | 32 | React web tabs 33 | ``` 34 | 35 | ## onClick: func (optional) 36 | 37 | On click callback, If you wrap your tab in a `` or `` component it will get called after it has been selected. 38 | 39 | ## props: mixed (optional) 40 | 41 | Any additional props that you can provide to a ` 10 | 11 | 12 | ``` 13 | 14 | ## children: node 15 | 16 | Any child node 17 | 18 | ```js 19 | 20 | 21 | 22 | 23 | ``` 24 | 25 | ## vertical: bool (optional) 26 | 27 | Adds the `aria-orientation="vertical"` attribute. 28 | Implicitly set when wrapped in a `` or `` component with the vertical prop. 29 | 30 | ```js 31 | 32 | 33 | 34 | 35 | ``` 36 | 37 | 38 | 39 | ## props: mixed (optional) 40 | 41 | Any additional props that you can provide to a `
` element. E.g className, style, title, data attributes, etc. 42 | 43 | ```js 44 | 49 | 50 | 51 | 52 | ``` 53 | -------------------------------------------------------------------------------- /docs/api/TabPanel.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | A Tab panel element with the correct WAI-ARIA attributes. 4 | 5 | ```js 6 | import { TabPanel } from 'react-web-tabs' 7 | 8 | 9 |

My tab panel

10 |
11 | ``` 12 | 13 | ## TabPanel render methods 14 | 15 | There are 3 ways to render something with a `` very similar to how react router works: 16 | 17 | - [``](#component-func) 18 | - [``](#render-func) 19 | - [``](#children-node) 20 | 21 | Each is useful in different circumstances. You should use only one of these props on a given ``. See their explanations below to understand why you have 3 options. 22 | 23 | ## TabPanel props 24 | 25 | All three [render methods](#tabpanel-render-methods) will be passed a boolean property `selected` that will indicate if the tab is selected or not. 26 | 27 | ## component 28 | 29 | A React component. It will be rendered with [tabpanel props](#tabpanel-props). 30 | 31 | ```js 32 | 33 | 34 | const Foo = () => { 35 | return

Foo!

36 | } 37 | ``` 38 | 39 | When you use `component` (instead of `render`, below) the tabpanel uses [`React.createElement`](https://facebook.github.io/react/docs/react-api.html#createelement) to create a new [React element](https://facebook.github.io/react/docs/rendering-elements.html) from the given component. That means if you provide an inline function, you are creating a new component every render. This results in the existing component unmounting and the new component mounting instead of just updating the existing component. For inline rendering, use the `render` prop (below). 40 | 41 | ## render: func 42 | 43 | This allows for convenient inline rendering and wrapping without the undesired remounting explained above. 44 | 45 | Instead of having a new [React element](https://facebook.github.io/react/docs/rendering-elements.html) created for you using the [`component`](#component-func) prop, you can pass in a function. The `render` prop receives all the same [props](#tabpanel-props) as the `component` render prop. 46 | 47 | ```js 48 | // convenient inline rendering 49 | selected ? ( 50 |
My TabPanel
51 | ) : ( 52 | null 53 | )}/> 54 | 55 | // code-splitting 56 | import('./MyTabPanel.jsx')}/> 57 | ``` 58 | 59 | **Warning:** `` takes precendence over `` so don't use both in the same ``. 60 | 61 | ## children: node 62 | 63 | Any child node 64 | 65 | ```js 66 | 67 |

My tab panel

68 |
69 | ``` 70 | 71 | ## tabId: string 72 | 73 | To connect a `` to a `` we need to make an id reference similar to how form inputs and labels work. 74 | 75 | ## props: mixed (optional) 76 | 77 | Any additional props that you can provide to a `
` element. E.g className, style, title, data attributes, etc. 78 | 79 | The following props are reserved: id, role, aria-expanded, aria-hidden, aria-labelledby, hidden. 80 | 81 | ```js 82 | 88 |

My tab panel

89 |
90 | ``` 91 | -------------------------------------------------------------------------------- /docs/api/TabProvider.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | A Higher Order Component (HOC) that provides the tab selection functionality. 4 | 5 | ```js 6 | import { TabProvider } from 'react-web-tabs' 7 | 8 | 9 |
10 | ... 11 |
12 |
13 | ``` 14 | 15 | ## children: node 16 | 17 | A single child node. 18 | 19 | ```js 20 | 21 |
22 | ... 23 |
24 |
25 | ``` 26 | 27 | ## defaultTab: string (optional) 28 | 29 | The id of the tab that should be selected by default. If none is provided it will be the first tab. 30 | 31 | ```js 32 | 33 |
34 | 35 | Tab 1 36 | Tab 2 37 | 38 | 39 |

Tab 1 content

40 |
41 | 42 |

Tab 2 content

43 |
44 |
45 |
46 | ``` 47 | 48 | ## vertical: bool (optional) 49 | 50 | Provides support for vertically aligned tabs. Correct aria-attributes will be set and keyboard shortcuts will change from right/left arrow to up/down arrow. 51 | 52 | ## collapsible: bool (optional) 53 | 54 | Provides support for deselection of current tab. If an active tab is selected it will be deselected. 55 | 56 | ## onChange: func (optional) 57 | 58 | A callback that is triggered when a new tab has been selected. 59 | 60 | ```js 61 | { console.log(tabId) }}> 62 |
63 | ... 64 |
65 |
66 | ``` 67 | -------------------------------------------------------------------------------- /docs/api/Tabs.md: -------------------------------------------------------------------------------- 1 | # `` 2 | 3 | A Tabs container element that uses the `` behind the scenes. 4 | 5 | ```js 6 | import { Tabs } from 'react-web-tabs' 7 | 8 | { console.log(tabId) }}> 9 | 10 | Tab 1 11 | Tab 2 12 | 13 | 14 |

Tab 1 content

15 |
16 | 17 |

Tab 2 content

18 |
19 |
20 | ``` 21 | 22 | ## children: node 23 | 24 | Any child node 25 | 26 | ```js 27 | 28 | 29 | 30 | 31 | ``` 32 | 33 | ## defaultTab: string (optional) 34 | 35 | See ``. 36 | 37 | ## vertical: bool (optional) 38 | 39 | Adds the `data-rwt-vertical="true"` attribute and provides functionality for vertical tabs. 40 | See ``. 41 | 42 | ## collapsible: bool (optional) 43 | 44 | See ``. 45 | 46 | ## onChange: func (optional) 47 | 48 | See ``. 49 | 50 | ## props: mixed (optional) 51 | 52 | Any additional props that you can provide to a ` 53 | ); 54 | } 55 | 56 | } 57 | 58 | export default TabComponent; 59 | -------------------------------------------------------------------------------- /src/TabList.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TabListComponent from './TabListComponent'; 4 | import withTabSelection from './withTabSelection'; 5 | 6 | class TabList extends Component { 7 | static defaultProps = { 8 | className: '', 9 | } 10 | 11 | static propTypes = { 12 | selection: PropTypes.shape({ 13 | isVertical: PropTypes.func.isRequired, 14 | }).isRequired, 15 | children: PropTypes.node.isRequired, 16 | className: PropTypes.string, 17 | } 18 | 19 | render() { 20 | const { selection, children, className, ...props } = this.props; 21 | const verticalOrientation = selection.isVertical(); 22 | 23 | return ( 24 | 29 | {children} 30 | 31 | ); 32 | } 33 | } 34 | 35 | 36 | export default withTabSelection(TabList); 37 | -------------------------------------------------------------------------------- /src/TabListComponent.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | /* eslint-disable jsx-a11y/role-supports-aria-props */ 5 | class TabListComponent extends Component { 6 | static defaultProps = { 7 | className: '', 8 | verticalOrientation: false, 9 | } 10 | 11 | static propTypes = { 12 | children: PropTypes.node.isRequired, 13 | className: PropTypes.string, 14 | verticalOrientation: PropTypes.bool, 15 | } 16 | 17 | render() { 18 | const { children, className, verticalOrientation, ...props } = this.props; 19 | 20 | return ( 21 |
27 | {children} 28 |
29 | ); 30 | } 31 | 32 | } 33 | 34 | export default TabListComponent; 35 | -------------------------------------------------------------------------------- /src/TabPanel.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TabPanelComponent from './TabPanelComponent'; 4 | import withTabSelection from './withTabSelection'; 5 | 6 | /* eslint-disable no-nested-ternary */ 7 | class TabPanel extends Component { 8 | static propTypes = { 9 | selection: PropTypes.shape({ 10 | subscribe: PropTypes.func.isRequired, 11 | unsubscribe: PropTypes.func.isRequired, 12 | isSelected: PropTypes.func.isRequired, 13 | }).isRequired, 14 | tabId: PropTypes.string.isRequired, 15 | } 16 | 17 | constructor(props) { 18 | super(props); 19 | this.update = this.update.bind(this); 20 | } 21 | 22 | componentDidMount() { 23 | this.props.selection.subscribe(this.update); 24 | } 25 | 26 | componentWillUnmount() { 27 | this.props.selection.unsubscribe(this.update); 28 | } 29 | 30 | update() { 31 | this.forceUpdate(); 32 | } 33 | 34 | render() { 35 | const { 36 | tabId, 37 | ...props 38 | } = this.props; 39 | 40 | const selected = this.props.selection.isSelected(tabId); 41 | 42 | return ( 43 | 48 | ); 49 | } 50 | } 51 | 52 | export default withTabSelection(TabPanel); 53 | -------------------------------------------------------------------------------- /src/TabPanelComponent.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | /* eslint-disable no-nested-ternary */ 5 | class TabPanelComponent extends Component { 6 | static defaultProps = { 7 | className: '', 8 | component: null, 9 | children: null, 10 | render: null, 11 | selected: false, 12 | } 13 | 14 | static propTypes = { 15 | tabId: PropTypes.string.isRequired, 16 | children: PropTypes.node, 17 | className: PropTypes.string, 18 | component: PropTypes.func, 19 | render: PropTypes.func, 20 | selected: PropTypes.bool, 21 | } 22 | 23 | render() { 24 | const { 25 | component, 26 | render, 27 | tabId, 28 | children, 29 | className, 30 | selected, 31 | ...props 32 | } = this.props; 33 | 34 | const childProps = { selected }; 35 | return ( 36 | 54 | ); 55 | } 56 | 57 | } 58 | 59 | export default TabPanelComponent; 60 | -------------------------------------------------------------------------------- /src/TabProvider.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TabSelection from './TabSelection'; 4 | 5 | 6 | export const TabSelectionContext = React.createContext({ 7 | selection: {}, 8 | }); 9 | 10 | class TabProvider extends Component { 11 | static defaultProps = { 12 | defaultTab: undefined, 13 | onChange: undefined, 14 | vertical: false, 15 | collapsible: false, 16 | } 17 | 18 | static propTypes = { 19 | children: PropTypes.node.isRequired, 20 | defaultTab: PropTypes.string, 21 | vertical: PropTypes.bool, 22 | collapsible: PropTypes.bool, 23 | onChange: PropTypes.func, 24 | } 25 | 26 | constructor(props) { 27 | super(props); 28 | 29 | this.selection = new TabSelection({ 30 | defaultTab: props.defaultTab, 31 | vertical: props.vertical, 32 | collapsible: props.collapsible, 33 | onChange: props.onChange, 34 | }); 35 | } 36 | 37 | componentWillReceiveProps(nextProps) { 38 | if (this.props.defaultTab !== nextProps.defaultTab && !this.selection.isSelected(nextProps.defaultTab)) { 39 | this.selection.select(nextProps.defaultTab); 40 | } 41 | } 42 | 43 | render() { 44 | const { children } = this.props; 45 | return ( 46 | 47 | {children} 48 | 49 | ); 50 | } 51 | } 52 | 53 | export default TabProvider; 54 | -------------------------------------------------------------------------------- /src/TabSelection.js: -------------------------------------------------------------------------------- 1 | class TabSelection { 2 | constructor({ defaultTab, vertical = false, collapsible = false, onChange } = {}) { 3 | this.selected = defaultTab; 4 | this.tabs = []; 5 | this.subscribtions = []; 6 | this.onChange = onChange; 7 | this.vertical = vertical; 8 | this.collapsible = collapsible; 9 | } 10 | 11 | select(tabId, { focus = false } = {}) { 12 | if (!this.tabs.includes(tabId) || (!this.collapsible && this.isSelected(tabId))) { 13 | return; 14 | } 15 | 16 | if (this.isSelected(tabId)) { 17 | this.selected = undefined; 18 | } else { 19 | this.selected = tabId; 20 | } 21 | 22 | this.subscribtions.forEach(callback => callback({ focus })); 23 | 24 | if (this.onChange) { 25 | this.onChange(tabId); 26 | } 27 | } 28 | 29 | selectPrevious(options) { 30 | const prevIndex = this.tabs.indexOf(this.selected) - 1; 31 | 32 | this.select(this.tabs[prevIndex >= 0 ? prevIndex : this.tabs.length - 1], options); 33 | } 34 | 35 | selectNext(options) { 36 | const nextIndex = (this.tabs.indexOf(this.selected) + 1) % this.tabs.length; 37 | 38 | this.select(this.tabs[nextIndex], options); 39 | } 40 | 41 | selectFirst(options) { 42 | this.select(this.tabs[0], options); 43 | } 44 | 45 | selectLast(options) { 46 | this.select(this.tabs[this.tabs.length - 1], options); 47 | } 48 | 49 | isSelected(tabId) { 50 | return tabId === this.selected; 51 | } 52 | 53 | isVertical() { 54 | return this.vertical; 55 | } 56 | 57 | register(tabId) { 58 | if (this.tabs.includes(tabId)) { 59 | return; 60 | } 61 | 62 | this.tabs.push(tabId); 63 | 64 | // set the first registered tab as select if no tab was assigned as default 65 | if (!this.selected) { 66 | this.select(tabId); 67 | } 68 | } 69 | 70 | unregister(tabId) { 71 | this.tabs = this.tabs.filter(id => id !== tabId); 72 | } 73 | 74 | subscribe(callback) { 75 | this.subscribtions.push(callback); 76 | } 77 | 78 | unsubscribe(callback) { 79 | this.subscribtions = this.subscribtions.filter(cb => cb !== callback); 80 | } 81 | } 82 | 83 | export default TabSelection; 84 | -------------------------------------------------------------------------------- /src/Tabs.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import TabProvider from './TabProvider'; 4 | 5 | class Tabs extends Component { 6 | static defaultProps = { 7 | defaultTab: undefined, 8 | className: '', 9 | vertical: false, 10 | collapsible: false, 11 | onChange: undefined, 12 | } 13 | 14 | static propTypes = { 15 | children: PropTypes.node.isRequired, 16 | defaultTab: PropTypes.string, 17 | className: PropTypes.string, 18 | vertical: PropTypes.bool, 19 | collapsible: PropTypes.bool, 20 | onChange: PropTypes.func, 21 | } 22 | 23 | render() { 24 | const { 25 | children, 26 | defaultTab, 27 | onChange, 28 | vertical, 29 | collapsible, 30 | className, 31 | ...props 32 | } = this.props; 33 | 34 | return ( 35 | 41 |
42 | {children} 43 |
44 |
45 | ); 46 | } 47 | } 48 | 49 | export default Tabs; 50 | -------------------------------------------------------------------------------- /src/__tests__/Tab.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | import Tab, { KeyCode } from '../Tab'; 4 | 5 | const mockSelection = () => ({ 6 | register: jest.fn(), 7 | unregister: jest.fn(), 8 | subscribe: jest.fn(), 9 | unsubscribe: jest.fn(), 10 | isSelected: jest.fn(), 11 | select: jest.fn(), 12 | selectPrevious: jest.fn(), 13 | selectNext: jest.fn(), 14 | selectFirst: jest.fn(), 15 | selectLast: jest.fn(), 16 | isVertical: jest.fn(), 17 | }); 18 | 19 | test(' should exist', () => { 20 | const tab = shallow(( 21 | Tab 1 22 | )); 23 | 24 | expect(tab).toBeDefined(); 25 | }); 26 | 27 | test(' should render children', () => { 28 | const content = Tab 1; 29 | const tab = mount(( 30 | {content} 31 | )); 32 | 33 | expect(tab.find('#content')).toBeTruthy(); 34 | }); 35 | 36 | test(' should call callback on click', () => { 37 | const onClick = jest.fn(); 38 | const tab = mount(( 39 | Tab 1 40 | )); 41 | 42 | tab.simulate('click'); 43 | 44 | expect(onClick).toHaveBeenCalled(); 45 | }); 46 | 47 | test(' should be selectable', () => { 48 | const selection = mockSelection(); 49 | selection.isSelected = () => false; 50 | const unselected = mount(( 51 | Tab 1 52 | )); 53 | 54 | expect(unselected.find('button').prop('aria-selected')).toBe(false); 55 | 56 | selection.isSelected = () => true; 57 | const selected = mount(( 58 | Tab 1 59 | )); 60 | 61 | expect(selected.find('button').prop('aria-selected')).toBe(true); 62 | }); 63 | 64 | test(' should be able to select previous tab with LEFT_ARROW key', () => { 65 | const selection = mockSelection(); 66 | const tab = mount( 67 | Tab 1, 68 | ); 69 | 70 | tab.simulate('keydown', { keyCode: KeyCode.LEFT_ARROW }); 71 | 72 | expect(selection.selectPrevious).toHaveBeenCalled(); 73 | }); 74 | 75 | test(' should be able to select next tab RIGHT_ARROW key', () => { 76 | const selection = mockSelection(); 77 | const tab = mount( 78 | Tab 1, 79 | ); 80 | 81 | tab.simulate('keydown', { keyCode: KeyCode.RIGHT_ARROW }); 82 | 83 | expect(selection.selectNext).toHaveBeenCalled(); 84 | }); 85 | 86 | test(' should not be able to select prev/next tab with UP_ARROW/DOWN_ARROW key when horizontal', () => { 87 | const selection = mockSelection(); 88 | 89 | const tab = mount( 90 | Tab 1, 91 | ); 92 | 93 | tab.simulate('keydown', { keyCode: KeyCode.UP_ARROW }); 94 | tab.simulate('keydown', { keyCode: KeyCode.DOWN_ARROW }); 95 | 96 | expect(selection.selectPrevious).not.toHaveBeenCalled(); 97 | expect(selection.selectNext).not.toHaveBeenCalled(); 98 | }); 99 | 100 | test(' should be able to select previous tab with UP_ARROW key when vertical', () => { 101 | const selection = mockSelection(); 102 | 103 | selection.isVertical = jest.fn(() => true); 104 | 105 | const tab = mount( 106 | Tab 1, 107 | ); 108 | 109 | tab.simulate('keydown', { keyCode: KeyCode.UP_ARROW }); 110 | 111 | expect(selection.selectPrevious).toHaveBeenCalled(); 112 | }); 113 | 114 | test(' should be able to select next tab DOWN_ARROW key when vertical', () => { 115 | const selection = mockSelection(); 116 | 117 | selection.isVertical = jest.fn(() => true); 118 | 119 | const tab = mount( 120 | Tab 1, 121 | ); 122 | 123 | tab.simulate('keydown', { keyCode: KeyCode.DOWN_ARROW }); 124 | 125 | expect(selection.selectNext).toHaveBeenCalled(); 126 | }); 127 | 128 | test(' should not be able to select prev/next tab with LEFT_ARROW/RIGHT_ARROW key when vertical', () => { 129 | const selection = mockSelection(); 130 | 131 | selection.isVertical = jest.fn(() => true); 132 | 133 | const tab = mount( 134 | Tab 1, 135 | ); 136 | 137 | tab.simulate('keydown', { keyCode: KeyCode.LEFT_ARROW }); 138 | tab.simulate('keydown', { keyCode: KeyCode.RIGHT_ARROW }); 139 | 140 | expect(selection.selectPrevious).not.toHaveBeenCalled(); 141 | expect(selection.selectNext).not.toHaveBeenCalled(); 142 | }); 143 | 144 | test(' should be able to select first tab with HOME key', () => { 145 | const selection = mockSelection(); 146 | const tab = mount( 147 | Tab 1, 148 | ); 149 | 150 | tab.simulate('keydown', { keyCode: KeyCode.HOME }); 151 | 152 | expect(selection.selectFirst).toHaveBeenCalled(); 153 | }); 154 | 155 | test(' should be able to select last tab with END key', () => { 156 | const selection = mockSelection(); 157 | const tab = mount( 158 | Tab 1, 159 | ); 160 | 161 | tab.simulate('keydown', { keyCode: KeyCode.END }); 162 | 163 | expect(selection.selectLast).toHaveBeenCalled(); 164 | }); 165 | 166 | test(' should not change selection on unrecognized key event', () => { 167 | const selection = mockSelection(); 168 | const tab = mount( 169 | Tab 1, 170 | ); 171 | 172 | tab.simulate('keydown'); 173 | 174 | expect(selection.selectFirst).not.toHaveBeenCalled(); 175 | expect(selection.selectLast).not.toHaveBeenCalled(); 176 | expect(selection.selectPrevious).not.toHaveBeenCalled(); 177 | expect(selection.selectNext).not.toHaveBeenCalled(); 178 | expect(selection.select).not.toHaveBeenCalled(); 179 | }); 180 | 181 | test(' should shift focus if selecting a different tab using keyboard navigation', () => { 182 | const selection = mockSelection(); 183 | const tab = mount( 184 | Tab 1, 185 | ); 186 | 187 | tab.simulate('keydown', { keyCode: KeyCode.LEFT_ARROW }); 188 | 189 | expect(selection.selectPrevious).toHaveBeenCalledWith({ focus: true }); 190 | }); 191 | 192 | test(' should subscribe and unsubscribe for context changes', () => { 193 | const selection = mockSelection(); 194 | const tab = mount( 195 | Tab 1, 196 | ); 197 | 198 | expect(selection.register).toHaveBeenCalledTimes(1); 199 | expect(selection.subscribe).toHaveBeenCalledTimes(1); 200 | tab.unmount(); 201 | expect(selection.register).not.toHaveBeenCalledTimes(2); 202 | expect(selection.subscribe).not.toHaveBeenCalledTimes(2); 203 | expect(selection.unsubscribe).toHaveBeenCalledTimes(1); 204 | expect(selection.unregister).toHaveBeenCalledTimes(1); 205 | }); 206 | 207 | test(' should unsubscribe with the same function as subscribed with', () => { 208 | const selection = mockSelection(); 209 | const tab = mount( 210 | Tab 1, 211 | ); 212 | 213 | tab.unmount(); 214 | const subscribeArgs = selection.subscribe.mock.calls[0]; 215 | const unsubscribeArgs = selection.unsubscribe.mock.calls[0]; 216 | const registerArgs = selection.register.mock.calls[0]; 217 | const unregisterArgs = selection.unregister.mock.calls[0]; 218 | 219 | expect(subscribeArgs).toEqual(unsubscribeArgs); 220 | expect(registerArgs).toEqual(unregisterArgs); 221 | }); 222 | -------------------------------------------------------------------------------- /src/__tests__/TabComponent.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | import TabComponent from '../TabComponent'; 4 | 5 | test(' should exist', () => { 6 | const tab = shallow(( 7 | Tab 1 8 | )); 9 | 10 | expect(tab).toBeDefined(); 11 | }); 12 | 13 | test(' should be a button', () => { 14 | const tab = mount(( 15 | Tab 1 16 | )); 17 | 18 | expect(tab.find('button')).toBeDefined(); 19 | }); 20 | 21 | test(' should render children', () => { 22 | const content = Tab 1; 23 | const tab = mount(( 24 | {content} 25 | )); 26 | 27 | expect(tab.find('#content')).toBeTruthy(); 28 | }); 29 | 30 | test(' should call callback on click', () => { 31 | const onClick = jest.fn(); 32 | const tab = mount(( 33 | Tab 1 34 | )); 35 | 36 | tab.simulate('click'); 37 | 38 | expect(onClick).toHaveBeenCalled(); 39 | }); 40 | 41 | test(' should be selectable', () => { 42 | const unselected = mount(( 43 | Tab 1 44 | )); 45 | 46 | expect(unselected.find('button').prop('aria-selected')).toBe(false); 47 | 48 | const selected = mount(( 49 | Tab 1 50 | )); 51 | 52 | expect(selected.find('button').prop('aria-selected')).toBe(true); 53 | }); 54 | 55 | test(' that is unselected is not focusable by default', () => { 56 | const unselected = mount(( 57 | Tab 1 58 | )); 59 | 60 | expect(unselected.find('button').prop('tabIndex')).toBe('-1'); 61 | 62 | const selected = mount(( 63 | Tab 1 64 | )); 65 | 66 | expect(selected.find('button').prop('tabIndex')).toBe('0'); 67 | }); 68 | 69 | 70 | test(' that is focusable should always have tabIndex 0', () => { 71 | const unselected = mount(( 72 | Tab 1 73 | )); 74 | 75 | expect(unselected.find('button').prop('tabIndex')).toBe('0'); 76 | 77 | const selected = mount(( 78 | Tab 1 79 | )); 80 | 81 | expect(selected.find('button').prop('tabIndex')).toBe('0'); 82 | }); 83 | 84 | test(' should have the correct aria attributes', () => { 85 | const tab = mount(( 86 | Tab 1 87 | )); 88 | 89 | expect(tab.find('button').prop('id')).toBe('foo-tab'); 90 | expect(tab.find('button').prop('aria-controls')).toBe('foo'); 91 | expect(tab.find('button').prop('role')).toBe('tab'); 92 | }); 93 | 94 | test(' should be able to set any className', () => { 95 | const tab = shallow(( 96 | Tab 1 97 | )); 98 | 99 | expect(tab.hasClass('foo')).toBe(true); 100 | }); 101 | -------------------------------------------------------------------------------- /src/__tests__/TabList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import TabList from '../TabList'; 5 | 6 | const mockSelection = () => ({ 7 | isVertical: jest.fn(), 8 | }); 9 | 10 | test(' should exist', () => { 11 | const tabList = shallow(( 12 | 13 | Foo 14 | 15 | )); 16 | 17 | expect(tabList).toBeDefined(); 18 | }); 19 | 20 | test(' should render children', () => { 21 | const tabList = mount(( 22 | 23 | Foo 24 | 25 | )); 26 | 27 | expect(tabList.find('#content')).toBeTruthy(); 28 | }); 29 | -------------------------------------------------------------------------------- /src/__tests__/TabListComponent.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | 4 | import TabListComponent from '../TabListComponent'; 5 | 6 | test(' should exist', () => { 7 | const tabList = shallow(( 8 | 9 | Foo 10 | 11 | )); 12 | 13 | expect(tabList).toBeDefined(); 14 | }); 15 | 16 | test(' should render children', () => { 17 | const tabList = mount(( 18 | 19 | Foo 20 | 21 | )); 22 | 23 | expect(tabList.find('#content')).toBeTruthy(); 24 | }); 25 | 26 | test(' should have the correct aria attributes', () => { 27 | const tabList = shallow(( 28 | 29 | Foo 30 | 31 | )); 32 | 33 | expect(tabList.prop('role')).toEqual('tablist'); 34 | }); 35 | 36 | test(' should be able to set any className', () => { 37 | const tabList = shallow(( 38 | 39 | Foo 40 | 41 | )); 42 | 43 | expect(tabList.hasClass('foo')).toBe(true); 44 | }); 45 | 46 | test(' should be set aria-orientation when vertical', () => { 47 | const tabList = shallow(( 48 | Foo 49 | )); 50 | 51 | expect(tabList.prop('aria-orientation')).toBe('vertical'); 52 | }); 53 | -------------------------------------------------------------------------------- /src/__tests__/TabPanel.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import TabPanel from '../TabPanel'; 4 | 5 | const mockSelection = () => ({ 6 | subscribe: jest.fn(), 7 | unsubscribe: jest.fn(), 8 | isSelected: jest.fn(), 9 | }); 10 | 11 | test(' should exist', () => { 12 | const tabPanel = mount(( 13 | Foo 14 | )); 15 | 16 | expect(tabPanel).toBeDefined(); 17 | }); 18 | 19 | test(' should render children', () => { 20 | const tabPanel = mount(( 21 | Foo 22 | )); 23 | 24 | expect(tabPanel.find('#content')).toBeTruthy(); 25 | }); 26 | 27 | test(' should subscribe and unsubscribe for context changes', () => { 28 | const selection = mockSelection(); 29 | 30 | const tabPanel = mount( 31 | Foo, 32 | ); 33 | 34 | expect(selection.subscribe).toHaveBeenCalledTimes(1); 35 | tabPanel.unmount(); 36 | expect(selection.subscribe).not.toHaveBeenCalledTimes(2); 37 | expect(selection.unsubscribe).toHaveBeenCalledTimes(1); 38 | }); 39 | 40 | test(' should unsubscribe with the same function as subscribed with', () => { 41 | const selection = mockSelection(); 42 | 43 | const tabPanel = mount( 44 | Foo, 45 | ); 46 | 47 | tabPanel.unmount(); 48 | const subscribeArgs = selection.subscribe.mock.calls[0]; 49 | const unsubscribeArgs = selection.unsubscribe.mock.calls[0]; 50 | 51 | expect(subscribeArgs).toEqual(unsubscribeArgs); 52 | }); 53 | -------------------------------------------------------------------------------- /src/__tests__/TabPanelComponent.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow, mount } from 'enzyme'; 3 | import TabPanelComponent from '../TabPanelComponent'; 4 | 5 | const mockSelection = () => ({ 6 | subscribe: jest.fn(), 7 | unsubscribe: jest.fn(), 8 | isSelected: jest.fn(), 9 | }); 10 | 11 | test(' should exist', () => { 12 | const tabPanel = mount(( 13 | Foo 14 | )); 15 | 16 | expect(tabPanel).toBeDefined(); 17 | }); 18 | 19 | test(' should render component', () => { 20 | const Foo = () => (Foo); 21 | 22 | const tabPanel = mount(( 23 | 24 | )); 25 | 26 | expect(tabPanel.find('#content')).toBeTruthy(); 27 | expect(tabPanel.find('Foo')).toBeTruthy(); 28 | }); 29 | 30 | test(' should be able to pass a render function', () => { 31 | const tabPanel = mount(( 32 | (Foo)} /> 33 | )); 34 | 35 | expect(tabPanel.find('#content')).toBeTruthy(); 36 | }); 37 | 38 | test(' should render children', () => { 39 | const tabPanel = mount(( 40 | Foo 41 | )); 42 | 43 | expect(tabPanel.find('#content')).toBeTruthy(); 44 | }); 45 | 46 | test(' should have the correct aria attributes', () => { 47 | const tabPanel = mount(( 48 | Foo 49 | )); 50 | 51 | expect(tabPanel.find('div').prop('id')).toBe('foo'); 52 | expect(tabPanel.find('div').prop('aria-labelledby')).toBe('foo-tab'); 53 | expect(tabPanel.find('div').prop('role')).toBe('tabpanel'); 54 | }); 55 | 56 | test(' should have the rwt__tabpanel className by default', () => { 57 | const tabPanel = mount(( 58 | Foo 59 | )); 60 | 61 | expect(tabPanel.find('div').prop('className').trim()).toEqual('rwt__tabpanel'); 62 | }); 63 | 64 | test(' should be able to set any className', () => { 65 | const tabPanel = shallow(( 66 | Foo 67 | )); 68 | 69 | expect(tabPanel.hasClass('foo')).toBe(true); 70 | }); 71 | -------------------------------------------------------------------------------- /src/__tests__/TabProvider.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import { Tab, TabProvider, TabPanel, TabList } from '../'; 5 | import { KeyCode } from '../Tab'; 6 | 7 | test(' should exist', () => { 8 | const tabs = mount(( 9 |

Foo

10 | )); 11 | 12 | expect(tabs).toBeDefined(); 13 | }); 14 | 15 | test(' should select correct tab by default', () => { 16 | const tabs = mount(( 17 | 18 |
19 | 20 | Tab 1 21 | Tab 2 22 | 23 |

TabPanel 1

24 |

TabPanel 2

25 |
26 |
27 | )); 28 | 29 | expect(tabs.find('#first-tab').prop('aria-selected')).toBe(false); 30 | expect(tabs.find('#first').prop('aria-expanded')).toBe(false); 31 | 32 | expect(tabs.find('#second-tab').prop('aria-selected')).toBe(true); 33 | expect(tabs.find('#second').prop('aria-expanded')).toBe(true); 34 | }); 35 | 36 | test(' should update to new tab on click', () => { 37 | const tabs = mount(( 38 | 39 |
40 | 41 | Tab 1 42 | Tab 2 43 | 44 |

TabPanel 1

45 |

TabPanel 2

46 |
47 |
48 | )); 49 | 50 | tabs.find('#first-tab').simulate('click'); 51 | 52 | expect(tabs.find('#first-tab').prop('aria-selected')).toBe(true); 53 | expect(tabs.find('#first').prop('aria-expanded')).toBe(true); 54 | 55 | expect(tabs.find('#second-tab').prop('aria-selected')).toBe(false); 56 | expect(tabs.find('#second').prop('aria-expanded')).toBe(false); 57 | }); 58 | 59 | test(' should not reset to default tab when parent updates', () => { 60 | class TestComponent extends React.Component { 61 | state = { 62 | state: 'one', 63 | } 64 | 65 | render() { 66 | return ( 67 | 68 |
69 | 70 | Tab 1 71 | Tab 2 72 | 73 |

TabPanel 1

74 |

TabPanel 2

75 |
76 |
77 | ); 78 | } 79 | } 80 | const tabs = mount(( 81 | 82 | )); 83 | 84 | expect(tabs.find('#second-tab').prop('aria-selected')).toBe(true); 85 | expect(tabs.find('#second').prop('aria-expanded')).toBe(true); 86 | 87 | tabs.find('#first-tab').simulate('click'); 88 | 89 | expect(tabs.find('#first-tab').prop('aria-selected')).toBe(true); 90 | expect(tabs.find('#first').prop('aria-expanded')).toBe(true); 91 | 92 | tabs.setState({ state: 'two' }); 93 | 94 | expect(tabs.find('#first-tab').prop('aria-selected')).toBe(true); 95 | expect(tabs.find('#first').prop('aria-expanded')).toBe(true); 96 | 97 | expect(tabs.find('#second-tab').prop('aria-selected')).toBe(false); 98 | expect(tabs.find('#second').prop('aria-expanded')).toBe(false); 99 | }); 100 | 101 | test(' should call onChange callback on selection', () => { 102 | const onChange = jest.fn(); 103 | 104 | const tabs = mount(( 105 | 106 |
107 | 108 | Tab 1 109 | Tab 2 110 | 111 |

TabPanel 1

112 |

TabPanel 2

113 |
114 |
115 | )); 116 | 117 | tabs.find('#first-tab').simulate('click'); 118 | 119 | expect(onChange).toHaveBeenCalledWith('first'); 120 | }); 121 | 122 | test(' should select correct tab when default tab prop changes', () => { 123 | const onChange = jest.fn(); 124 | 125 | const tabs = mount(( 126 | 127 |
128 | 129 | Tab 1 130 | Tab 2 131 | 132 |

TabPanel 1

133 |

TabPanel 2

134 |
135 |
136 | )); 137 | 138 | expect(tabs.find('#second-tab').prop('aria-selected')).toBe(true); 139 | expect(tabs.find('#second').prop('aria-expanded')).toBe(true); 140 | tabs.setProps({ defaultTab: 'first' }); 141 | expect(tabs.find('#first-tab').prop('aria-selected')).toBe(true); 142 | expect(tabs.find('#first').prop('aria-expanded')).toBe(true); 143 | expect(tabs.find('#second-tab').prop('aria-selected')).toBe(false); 144 | expect(tabs.find('#second').prop('aria-expanded')).toBe(false); 145 | }); 146 | 147 | test(' should not change tab when props are unchanged', () => { 148 | const onChange = jest.fn(); 149 | 150 | const tabs = mount(( 151 | 152 |
153 | 154 | Tab 1 155 | Tab 2 156 | 157 |

TabPanel 1

158 |

TabPanel 2

159 |
160 |
161 | )); 162 | 163 | tabs.setProps({ defaultTab: 'second' }); 164 | expect(tabs.find('#second-tab').prop('aria-selected')).toBe(true); 165 | expect(tabs.find('#second').prop('aria-expanded')).toBe(true); 166 | }); 167 | 168 | test(' should not change selection when prop updates to currently selected', () => { 169 | const onChange = jest.fn(); 170 | 171 | const tabs = mount(( 172 | 173 |
174 | 175 | Tab 1 176 | Tab 2 177 | 178 |

TabPanel 1

179 |

TabPanel 2

180 |
181 |
182 | )); 183 | 184 | tabs.find('#second-tab').simulate('click'); 185 | tabs.setProps({ defaultTab: 'second' }); 186 | expect(tabs.find('#second-tab').prop('aria-selected')).toBe(true); 187 | expect(tabs.find('#second').prop('aria-expanded')).toBe(true); 188 | }); 189 | 190 | test(' should shift tab using keyboard navigation', () => { 191 | const tabs = mount(( 192 | 193 |
194 | 195 | Tab 1 196 | Tab 2 197 | 198 |

TabPanel 1

199 |

TabPanel 2

200 |
201 |
202 | )); 203 | 204 | tabs.find('#second-tab').simulate('keydown', { keyCode: KeyCode.LEFT_ARROW }); 205 | expect(tabs.find('#first-tab').prop('aria-selected')).toBe(true); 206 | }); 207 | 208 | test(' should shift tab using keyboard navigation when vertical', () => { 209 | const tabs = mount(( 210 | 211 |
212 | 213 | Tab 1 214 | Tab 2 215 | 216 |

TabPanel 1

217 |

TabPanel 2

218 |
219 |
220 | )); 221 | 222 | tabs.find('#second-tab').simulate('keydown', { keyCode: KeyCode.UP_ARROW }); 223 | expect(tabs.find('#first-tab').prop('aria-selected')).toBe(true); 224 | }); 225 | 226 | test(' should set correct aria properties on when vertical', () => { 227 | const tabs = mount(( 228 | 229 |
230 | 231 | Tab 1 232 | Tab 2 233 | 234 |

TabPanel 1

235 |

TabPanel 2

236 |
237 |
238 | )); 239 | 240 | expect(tabs.find('.rwt__tablist').prop('aria-orientation')).toBe('vertical'); 241 | }); 242 | -------------------------------------------------------------------------------- /src/__tests__/TabSelection.test.js: -------------------------------------------------------------------------------- 1 | import TabSelection from '../TabSelection'; 2 | 3 | test('TabSelection should accept default selection', () => { 4 | const tabSelection = new TabSelection({ defaultTab: 'foo' }); 5 | expect(tabSelection).toBeDefined(); 6 | expect(tabSelection.selected).toBe('foo'); 7 | }); 8 | 9 | test('TabSelection should be able to register new tabs', () => { 10 | const tabSelection = new TabSelection(); 11 | tabSelection.register('foo'); 12 | expect(tabSelection.tabs).toEqual(['foo']); 13 | }); 14 | 15 | test('TabSelection should not be able to register the same tab several times', () => { 16 | const tabSelection = new TabSelection(); 17 | tabSelection.register('foo'); 18 | tabSelection.register('foo'); 19 | tabSelection.register('foo'); 20 | expect(tabSelection.tabs).toEqual(['foo']); 21 | }); 22 | 23 | test('TabSelection should set first registered tab as selected if no defaultTab', () => { 24 | const tabSelection = new TabSelection(); 25 | tabSelection.register('foo'); 26 | expect(tabSelection.selected).toBe('foo'); 27 | }); 28 | 29 | test('TabSelection should be able to unregister', () => { 30 | const tabSelection = new TabSelection(); 31 | tabSelection.register('foo'); 32 | tabSelection.register('bar'); 33 | tabSelection.unregister('bar'); 34 | expect(tabSelection.tabs).toEqual(['foo']); 35 | }); 36 | 37 | test('TabSelection should not be able to select unregistered tab', () => { 38 | const tabSelection = new TabSelection(); 39 | tabSelection.register('foo'); 40 | tabSelection.register('bar'); 41 | 42 | tabSelection.select('baz'); 43 | expect(tabSelection.selected).not.toBe('baz'); 44 | }); 45 | 46 | test('TabSelection should be able to select previous tab', () => { 47 | const tabSelection = new TabSelection({ defaultTab: 'baz' }); 48 | tabSelection.register('foo'); 49 | tabSelection.register('bar'); 50 | tabSelection.register('baz'); 51 | 52 | expect(tabSelection.selected).toBe('baz'); 53 | tabSelection.selectPrevious(); 54 | expect(tabSelection.selected).toBe('bar'); 55 | tabSelection.selectPrevious(); 56 | expect(tabSelection.selected).toBe('foo'); 57 | }); 58 | 59 | test('TabSelection should be able to select next tab', () => { 60 | const tabSelection = new TabSelection({ defaultTab: 'foo' }); 61 | tabSelection.register('foo'); 62 | tabSelection.register('bar'); 63 | tabSelection.register('baz'); 64 | 65 | expect(tabSelection.selected).toBe('foo'); 66 | tabSelection.selectNext(); 67 | expect(tabSelection.selected).toBe('bar'); 68 | tabSelection.selectNext(); 69 | expect(tabSelection.selected).toBe('baz'); 70 | }); 71 | 72 | test('TabSelection should have roving selection when selecting prev/next tab', () => { 73 | const tabSelection = new TabSelection({ defaultTab: 'foo' }); 74 | tabSelection.register('foo'); 75 | tabSelection.register('bar'); 76 | tabSelection.register('baz'); 77 | 78 | expect(tabSelection.selected).toBe('foo'); 79 | tabSelection.selectPrevious(); 80 | expect(tabSelection.selected).toBe('baz'); 81 | tabSelection.selectNext(); 82 | expect(tabSelection.selected).toBe('foo'); 83 | }); 84 | 85 | test('TabSelection should be able to select first tab', () => { 86 | const tabSelection = new TabSelection({ defaultTab: 'baz' }); 87 | tabSelection.register('foo'); 88 | tabSelection.register('bar'); 89 | tabSelection.register('baz'); 90 | 91 | tabSelection.selectFirst(); 92 | 93 | expect(tabSelection.selected).toBe('foo'); 94 | }); 95 | 96 | test('TabSelection should be able to select last tab', () => { 97 | const tabSelection = new TabSelection({ defaultTab: 'foo' }); 98 | tabSelection.register('foo'); 99 | tabSelection.register('bar'); 100 | tabSelection.register('baz'); 101 | 102 | tabSelection.selectLast(); 103 | 104 | expect(tabSelection.selected).toBe('baz'); 105 | }); 106 | 107 | test('TabSelection should be able to pass selection options', () => { 108 | const subscriber = jest.fn(); 109 | const tabSelection = new TabSelection(); 110 | tabSelection.register('foo'); 111 | tabSelection.register('bar'); 112 | tabSelection.register('baz'); 113 | tabSelection.subscribe(subscriber); 114 | 115 | tabSelection.select('bar', { focus: true }); 116 | expect(subscriber).toHaveBeenCalledWith({ focus: true }); 117 | tabSelection.selectFirst({ focus: false }); 118 | expect(subscriber).toHaveBeenCalledWith({ focus: false }); 119 | tabSelection.selectNext({ focus: true }); 120 | expect(subscriber).toHaveBeenCalledWith({ focus: true }); 121 | tabSelection.selectLast({ focus: false }); 122 | expect(subscriber).toHaveBeenCalledWith({ focus: false }); 123 | tabSelection.selectPrevious({ focus: true }); 124 | expect(subscriber).toHaveBeenCalledWith({ focus: true }); 125 | 126 | expect(subscriber).toHaveBeenCalledTimes(5); 127 | }); 128 | 129 | test('TabSelection should be able to select a tab', () => { 130 | const tabSelection = new TabSelection(); 131 | tabSelection.register('foo'); 132 | tabSelection.register('bar'); 133 | 134 | expect(tabSelection.selected).toBe('foo'); 135 | 136 | tabSelection.select('bar'); 137 | expect(tabSelection.selected).not.toBe('foo'); 138 | expect(tabSelection.selected).toBe('bar'); 139 | }); 140 | 141 | test('TabSelection should be able to subscribe for changes', () => { 142 | const subscriber = jest.fn(); 143 | 144 | const tabSelection = new TabSelection(); 145 | tabSelection.subscribe(subscriber); 146 | 147 | tabSelection.register('foo'); 148 | tabSelection.register('bar'); 149 | 150 | expect(subscriber).toHaveBeenCalledTimes(1); 151 | tabSelection.select('bar'); 152 | expect(subscriber).toHaveBeenCalledTimes(2); 153 | }); 154 | 155 | test('TabSelection should be able to unsubscribe', () => { 156 | const subscriber = jest.fn(); 157 | 158 | const tabSelection = new TabSelection(); 159 | tabSelection.subscribe(subscriber); 160 | 161 | tabSelection.register('foo'); 162 | tabSelection.register('bar'); 163 | 164 | expect(subscriber).toHaveBeenCalledTimes(1); 165 | tabSelection.unsubscribe(subscriber); 166 | tabSelection.select('bar'); 167 | expect(subscriber).toHaveBeenCalledTimes(1); 168 | }); 169 | 170 | test('TabSelection should call an optional onChange callback when something has changed', () => { 171 | const onChange = jest.fn(); 172 | 173 | const tabSelection = new TabSelection({ defaultTab: 'foo', onChange }); 174 | tabSelection.register('foo'); 175 | tabSelection.register('bar'); 176 | 177 | tabSelection.select('bar'); 178 | expect(onChange).toHaveBeenCalledTimes(1); 179 | expect(onChange).toHaveBeenCalledWith('bar'); 180 | }); 181 | 182 | test('If the collapsible option is passed to TabSelection it should deselect the current tab if selected again', () => { 183 | const collapsible = true; 184 | 185 | const tabSelection = new TabSelection({ defaultTab: 'foo', collapsible }); 186 | tabSelection.register('foo'); 187 | tabSelection.register('bar'); 188 | 189 | tabSelection.select('bar'); 190 | expect(tabSelection.selected).toBe('bar'); 191 | 192 | tabSelection.select('bar'); 193 | expect(tabSelection.selected).toBe(undefined); 194 | expect(tabSelection.selected).not.toBe('bar'); 195 | }); 196 | 197 | test('If the collapsible option is not passed to TabSelection it should not deselect the current tab if selected again', () => { 198 | const tabSelection = new TabSelection({ defaultTab: 'foo' }); 199 | tabSelection.register('foo'); 200 | tabSelection.register('bar'); 201 | 202 | tabSelection.select('bar'); 203 | expect(tabSelection.selected).toBe('bar'); 204 | 205 | tabSelection.select('bar'); 206 | expect(tabSelection.selected).toBe('bar'); 207 | expect(tabSelection.selected).not.toBe(undefined); 208 | }); 209 | -------------------------------------------------------------------------------- /src/__tests__/Tabs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import Tabs from '../Tabs'; 5 | import TabProvider from '../TabProvider'; 6 | 7 | test(' should exist', () => { 8 | const tabs = mount(( 9 |

Foo

10 | )); 11 | 12 | expect(tabs).toBeDefined(); 13 | }); 14 | 15 | test(' should have the className rwt__tabs by default', () => { 16 | const tabs = mount(( 17 |

Foo

18 | )); 19 | 20 | expect(tabs.find('.rwt__tabs')).toBeDefined(); 21 | }); 22 | 23 | test(' should be able to set any classname', () => { 24 | const tabs = mount(( 25 |

Foo

26 | )); 27 | 28 | expect(tabs.find('.rwt__tabs')).toBeDefined(); 29 | expect(tabs.find('.foo')).toBeDefined(); 30 | }); 31 | 32 | test(' should render children', () => { 33 | const tabs = mount(( 34 |

Foo

35 | )); 36 | 37 | expect(tabs.find('#child')).toBeDefined(); 38 | }); 39 | 40 | test(' should be able to pass vertical prop', () => { 41 | const tabs = mount(( 42 |

Foo

43 | )); 44 | 45 | expect(tabs.find('[data-rwt-vertical="true"]')).toBeDefined(); 46 | }); 47 | 48 | test(' should by wrapped by a tabProvider', () => { 49 | const tabs = mount(( 50 |

Foo

51 | )); 52 | 53 | expect(tabs.find(TabProvider)).toBeDefined(); 54 | }); 55 | -------------------------------------------------------------------------------- /src/__tests__/withSelection.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | import { TabProvider } from '../'; 5 | import withTabSelection from '../withTabSelection'; 6 | 7 | const Foo = () => ( 8 |

Foo

9 | ); 10 | 11 | test(' should exist', () => { 12 | const WrappedComponent = withTabSelection(Foo); 13 | const wrappedComponent = mount(( 14 | 15 |
16 | 17 |
18 |
19 | )); 20 | 21 | expect(wrappedComponent).toBeDefined(); 22 | }); 23 | 24 | test(' should return WrappedComponent', () => { 25 | const WrappedComponent = withTabSelection(Foo); 26 | 27 | expect(WrappedComponent.WrappedComponent).toEqual(Foo); 28 | }); 29 | 30 | test(' should set correct displayName', () => { 31 | const WrappedComponent = withTabSelection(Foo); 32 | 33 | expect(WrappedComponent.displayName).toEqual('withTabSelection(Foo)'); 34 | }); 35 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Tab } from './Tab'; 2 | export { default as TabComponent } from './TabComponent'; 3 | export { default as Tabs } from './Tabs'; 4 | export { default as TabList } from './TabList'; 5 | export { default as TabListComponent } from './TabListComponent'; 6 | export { default as TabPanel } from './TabPanel'; 7 | export { default as TabPanelComponent } from './TabPanelComponent'; 8 | export { default as TabProvider } from './TabProvider'; 9 | export { default as TabSelection } from './TabSelection'; 10 | -------------------------------------------------------------------------------- /src/withTabSelection.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TabSelectionContext } from './TabProvider'; 3 | 4 | function getDisplayName(WrappedComponent) { 5 | return WrappedComponent.displayName || WrappedComponent.name || 'Component'; 6 | } 7 | 8 | const withTabSelection = (Component) => { 9 | const TabSelectionComponent = props => ( 10 | 11 | {selection => } 12 | 13 | ); 14 | TabSelectionComponent.WrappedComponent = Component; 15 | TabSelectionComponent.displayName = `withTabSelection(${getDisplayName(Component)})`; 16 | return TabSelectionComponent; 17 | }; 18 | 19 | export default withTabSelection; 20 | -------------------------------------------------------------------------------- /styles/style.css: -------------------------------------------------------------------------------- 1 | .rwt__tabs[data-rwt-vertical="true"] { 2 | display: flex; 3 | } 4 | 5 | .rwt__tablist:not([aria-orientation="vertical"]) { 6 | border-bottom: 1px solid #ddd; 7 | } 8 | 9 | .rwt__tablist[aria-orientation="vertical"] { 10 | display: flex; 11 | flex-direction: column; 12 | flex-shrink: 0; 13 | flex-grow: 0; 14 | border-right: 1px solid #ddd; 15 | margin-right: 1rem; 16 | } 17 | 18 | .rwt__tab { 19 | background: transparent; 20 | border: 0; 21 | font-family: inherit; 22 | font-size: inherit; 23 | padding: 1rem 2rem; 24 | transition: background 0.3s cubic-bezier(0.22, 0.61, 0.36, 1); 25 | } 26 | 27 | .rwt__tab[aria-selected="false"]:hover, 28 | .rwt__tab:focus { 29 | outline: 0; 30 | background-color: #f4f4f4; 31 | background-color: rgba(0,0,0,0.05); 32 | } 33 | 34 | .rwt__tab[aria-selected="true"] { 35 | position: relative; 36 | } 37 | 38 | .rwt__tab[aria-selected="true"]:after { 39 | content: ''; 40 | position: absolute; 41 | } 42 | 43 | .rwt__tablist:not([aria-orientation="vertical"]) .rwt__tab[aria-selected="true"]:after { 44 | bottom: -1px; 45 | left: 0; 46 | width: 100%; 47 | border-bottom: 3px solid #00d8ff; 48 | } 49 | 50 | .rwt__tablist[aria-orientation="vertical"] .rwt__tab[aria-selected="true"]:after { 51 | right: -1px; 52 | top: 0; 53 | height: 100%; 54 | border-right: 3px solid #00d8ff; 55 | } 56 | -------------------------------------------------------------------------------- /webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export default ({ minify = false } = {}) => ({ 4 | entry: './src/index.js', 5 | 6 | output: { 7 | path: path.resolve(__dirname, 'dist'), 8 | filename: `react-web-tabs${minify ? '.min' : ''}.js`, 9 | libraryTarget: 'umd', 10 | library: 'react-web-tabs', 11 | }, 12 | 13 | externals: { 14 | react: { 15 | commonjs: 'react', 16 | commonjs2: 'react', 17 | amd: 'react', 18 | root: 'React', 19 | }, 20 | 'react-dom': { 21 | commonjs: 'react-dom', 22 | commonjs2: 'react-dom', 23 | amd: 'react-dom', 24 | root: 'ReactDOM', 25 | }, 26 | 'prop-types': { 27 | commonjs: 'prop-types', 28 | commonjs2: 'prop-types', 29 | amd: 'prop-types', 30 | root: 'PropTypes', 31 | }, 32 | }, 33 | 34 | resolve: { 35 | extensions: ['.js', '.jsx'], 36 | }, 37 | 38 | module: { 39 | rules: [ 40 | { 41 | test: /\.jsx?$/, 42 | exclude: /node_modules/, 43 | use: 'babel-loader', 44 | }, 45 | ], 46 | }, 47 | }); 48 | --------------------------------------------------------------------------------