` 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 | Tab 1
29 | Tab 2
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 `` element. E.g className, style, title, data attributes, etc.
53 |
54 | ```js
55 |
60 | React web tabs
61 |
62 | ```
63 |
--------------------------------------------------------------------------------
/docs/examples/README.md:
--------------------------------------------------------------------------------
1 | # Examples
2 |
3 | * [Basic tabs](basic.md)
4 | * [Vertical tabs](vertical.md)
5 | * [Tabs with custom markup](custom_markup.md)
6 |
--------------------------------------------------------------------------------
/docs/examples/basic.md:
--------------------------------------------------------------------------------
1 | # Basic Tabs
2 |
3 | ```js
4 | import React, { Component } from 'react';
5 | import { render } from 'react-dom';
6 | import { Tabs, Tab, TabPanel, TabList } from 'react-web-tabs';
7 |
8 |
9 | class MyBasicTabs extends Component {
10 | render() {
11 | return (
12 |
13 |
14 | Tab 1
15 | Tab 2
16 | Tab 3
17 |
18 |
19 | Tab 1 content
20 |
21 |
22 | Tab 2 content
23 |
24 |
25 | Tab 3 content
26 |
27 |
28 | );
29 | }
30 | }
31 |
32 | render( , document.getElementById('app'));
33 | ```
34 |
--------------------------------------------------------------------------------
/docs/examples/custom_markup.md:
--------------------------------------------------------------------------------
1 | # Tabs with custom markup
2 |
3 | ```js
4 | import React, { Component } from 'react';
5 | import { render } from 'react-dom';
6 | import { TabProvider, Tab, TabPanel, TabList } from 'react-web-tabs';
7 |
8 |
9 | class CustomTabs extends Component {
10 | render() {
11 | return (
12 |
13 |
14 |
15 | Tab 1
16 | •
17 | Tab 2
18 | •
19 | Tab 3
20 |
21 |
22 |
23 | Tab 1 content
24 |
25 |
26 | Tab 2 content
27 |
28 |
29 | Tab 3 content
30 |
31 |
32 |
33 |
34 | );
35 | }
36 | }
37 |
38 | render( , document.getElementById('app'));
39 | ```
40 |
--------------------------------------------------------------------------------
/docs/examples/vertical.md:
--------------------------------------------------------------------------------
1 | # Vertical Tabs
2 |
3 | ```js
4 | import React, { Component } from 'react';
5 | import { render } from 'react-dom';
6 | import { Tabs, Tab, TabPanel, TabList } from 'react-web-tabs';
7 |
8 |
9 | class MyVerticalTabs extends Component {
10 | render() {
11 | return (
12 |
13 |
14 | Tab 1
15 | Tab 2
16 | Tab 3
17 |
18 |
19 | Tab 1 content
20 |
21 |
22 | Tab 2 content
23 |
24 |
25 | Tab 3 content
26 |
27 |
28 | );
29 | }
30 | }
31 |
32 | render( , document.getElementById('app'));
33 | ```
34 |
--------------------------------------------------------------------------------
/jestSetup.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-extraneous-dependencies */
2 | import Enzyme from 'enzyme';
3 | import Adapter from 'enzyme-adapter-react-16';
4 |
5 | Enzyme.configure({ adapter: new Adapter() });
6 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-web-tabs",
3 | "version": "1.0.1",
4 | "description": "Modular and accessible React tabs component",
5 | "main": "dist/react-web-tabs.js",
6 | "module": "es/index.js",
7 | "files": [
8 | "dist",
9 | "es",
10 | "lib"
11 | ],
12 | "author": "Marcus Lindfeldt",
13 | "homepage": "https://github.com/marcuslindfeldt/react-web-tabs",
14 | "bugs": {
15 | "url": "https://github.com/marcuslindfeldt/react-web-tabs/issues"
16 | },
17 | "repository": {
18 | "type": "git",
19 | "url": "https://github.com/marcuslindfeldt/react-web-tabs.git"
20 | },
21 | "keywords": [
22 | "react",
23 | "tabs",
24 | "a11y"
25 | ],
26 | "license": "MIT",
27 | "jest": {
28 | "setupTestFrameworkScriptFile": "./jestSetup.js",
29 | "coverageReporters": [
30 | "lcov",
31 | "text"
32 | ]
33 | },
34 | "scripts": {
35 | "prepublish": "yarn run clean && yarn run lint && yarn run test && yarn run build",
36 | "clean": "rimraf dist es lib coverage",
37 | "build": "yarn run build:es && yarn build:commonjs && yarn run build:umd && yarn run build:umd:min && yarn build:styles && yarn build:styles:min",
38 | "build:es": "babel src -d es --ignore __tests__",
39 | "build:commonjs": "cross-env BABEL_ENV=commonjs babel src -d lib --ignore __tests__",
40 | "build:umd": "cross-env NODE_ENV=production webpack",
41 | "build:umd:min": "cross-env NODE_ENV=production webpack -p --env.minify true",
42 | "build:styles": "postcss -o dist/react-web-tabs.css styles/*.css",
43 | "build:styles:min": "cross-env NODE_ENV=production postcss -o dist/react-web-tabs.min.css styles/*.css",
44 | "test": "jest",
45 | "test:watch": "jest --watch",
46 | "test:coverage": "jest --coverage",
47 | "test:travis": "yarn run test:coverage && cat coverage/lcov.info | coveralls",
48 | "lint": "eslint .",
49 | "docs:clean": "rimraf _book",
50 | "docs:prepare": "gitbook install",
51 | "docs:build": "yarn run docs:prepare && gitbook build -g marcuslindfeldt/react-web-tabs",
52 | "docs:watch": "yarn run docs:prepare && gitbook serve",
53 | "docs:publish": "yarn run docs:clean && yarn run docs:build && cd _book && git init && git commit --allow-empty -m 'update book' && git checkout -b gh-pages && touch .nojekyll && git add . && git commit -am 'update book' && git push git@github.com:marcuslindfeldt/react-web-tabs gh-pages --force"
54 | },
55 | "devDependencies": {
56 | "babel-cli": "^6.24.1",
57 | "babel-eslint": "^7.2.2",
58 | "babel-jest": "^19.0.0",
59 | "babel-loader": "^6.4.1",
60 | "babel-preset-env": "^1.3.3",
61 | "babel-preset-react": "^6.24.1",
62 | "babel-preset-stage-2": "^6.24.1",
63 | "coveralls": "^2.13.0",
64 | "cross-env": "^4.0.0",
65 | "cssnano": "^3.10.0",
66 | "enzyme": "^3.3.0",
67 | "enzyme-adapter-react-16": "npm:enzyme-react-adapter-future",
68 | "eslint": "^3.19.0",
69 | "eslint-config-airbnb": "^14.1.0",
70 | "eslint-plugin-import": "^2.2.0",
71 | "eslint-plugin-jsx-a11y": "^4.0.0",
72 | "eslint-plugin-react": "^6.10.3",
73 | "gitbook-cli": "^2.3.0",
74 | "jest": "23.1.0",
75 | "postcss-cli": "^3.1.1",
76 | "postcss-import": "^9.1.0",
77 | "prop-types": "^15.5.8",
78 | "react": "^16.3.0",
79 | "react-dom": "^16.3.0",
80 | "react-test-renderer": "^16.3.0",
81 | "rimraf": "^2.6.1",
82 | "webpack": "^2.3.3"
83 | },
84 | "peerDependencies": {
85 | "prop-types": "^15.5.8",
86 | "react": "^16.3.0",
87 | "react-dom": "^16.3.0"
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = ({ env } = {}) => ({
2 | plugins: {
3 | 'postcss-import': true,
4 | cssnano: env === 'production' ? {} : false,
5 | },
6 | });
7 |
--------------------------------------------------------------------------------
/src/Tab.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import TabComponent from './TabComponent';
4 | import withTabSelection from './withTabSelection';
5 |
6 | export const KeyCode = {
7 | END: 35,
8 | HOME: 36,
9 | LEFT_ARROW: 37,
10 | UP_ARROW: 38,
11 | RIGHT_ARROW: 39,
12 | DOWN_ARROW: 40,
13 | };
14 |
15 | class Tab extends Component {
16 | static defaultProps = {
17 | className: '',
18 | selected: false,
19 | onClick: undefined,
20 | }
21 |
22 | static propTypes = {
23 | selection: PropTypes.shape({
24 | subscribe: PropTypes.func.isRequired,
25 | unsubscribe: PropTypes.func.isRequired,
26 | register: PropTypes.func.isRequired,
27 | unregister: PropTypes.func.isRequired,
28 | isSelected: PropTypes.func.isRequired,
29 | select: PropTypes.func.isRequired,
30 | selectNext: PropTypes.func.isRequired,
31 | selectPrevious: PropTypes.func.isRequired,
32 | selectFirst: PropTypes.func.isRequired,
33 | selectLast: PropTypes.func.isRequired,
34 | isVertical: PropTypes.func.isRequired,
35 | }).isRequired,
36 | tabFor: PropTypes.string.isRequired,
37 | children: PropTypes.node.isRequired,
38 | className: PropTypes.string,
39 | selected: PropTypes.bool,
40 | onClick: PropTypes.func,
41 | }
42 |
43 | constructor(props) {
44 | super(props);
45 | this.update = this.update.bind(this);
46 | this.handleClick = this.handleClick.bind(this);
47 | this.handleKeyDown = this.handleKeyDown.bind(this);
48 |
49 | props.selection.register(props.tabFor);
50 | }
51 |
52 | componentDidMount() {
53 | this.props.selection.subscribe(this.update);
54 | }
55 |
56 | componentWillUnmount() {
57 | this.props.selection.unsubscribe(this.update);
58 | this.props.selection.unregister(this.props.tabFor);
59 | }
60 |
61 | update({ focus } = {}) {
62 | this.forceUpdate();
63 | if (focus && this.props.selection.isSelected(this.props.tabFor)) {
64 | this.tabComponent.focus();
65 | }
66 | }
67 |
68 | handleClick(event) {
69 | this.props.selection.select(this.props.tabFor);
70 |
71 | if (this.props.onClick) {
72 | this.props.onClick(event);
73 | }
74 | }
75 |
76 | handleKeyDown(e) {
77 | const verticalOrientation = this.props.selection.isVertical();
78 | if (e.keyCode === KeyCode.HOME) {
79 | this.props.selection.selectFirst({ focus: true });
80 | } else if (e.keyCode === KeyCode.END) {
81 | this.props.selection.selectLast({ focus: true });
82 | } else if (e.keyCode === KeyCode.LEFT_ARROW && !verticalOrientation) {
83 | this.props.selection.selectPrevious({ focus: true });
84 | } else if (e.keyCode === KeyCode.RIGHT_ARROW && !verticalOrientation) {
85 | this.props.selection.selectNext({ focus: true });
86 | } else if (e.keyCode === KeyCode.UP_ARROW && verticalOrientation) {
87 | e.preventDefault();
88 | this.props.selection.selectPrevious({ focus: true });
89 | } else if (e.keyCode === KeyCode.DOWN_ARROW && verticalOrientation) {
90 | e.preventDefault();
91 | this.props.selection.selectNext({ focus: true });
92 | }
93 | }
94 |
95 | render() {
96 | const { tabFor, children, className, ...props } = this.props;
97 | const isSelected = this.props.selection.isSelected(tabFor);
98 |
99 | return (
100 | { this.tabComponent = e; }}
108 | >
109 | {children}
110 |
111 | );
112 | }
113 | }
114 |
115 |
116 | export default withTabSelection(Tab);
117 |
--------------------------------------------------------------------------------
/src/TabComponent.jsx:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | class TabComponent extends Component {
5 | static defaultProps = {
6 | className: '',
7 | selected: false,
8 | focusable: false,
9 | onClick: undefined,
10 | onKeyDown: undefined,
11 | tabRef: undefined,
12 | }
13 |
14 | static propTypes = {
15 | tabFor: PropTypes.string.isRequired,
16 | children: PropTypes.node.isRequired,
17 | className: PropTypes.string,
18 | selected: PropTypes.bool,
19 | focusable: PropTypes.bool,
20 | onClick: PropTypes.func,
21 | onKeyDown: PropTypes.func,
22 | tabRef: PropTypes.func,
23 | }
24 |
25 | render() {
26 | const {
27 | tabFor,
28 | children,
29 | className,
30 | selected,
31 | focusable,
32 | tabRef,
33 | onClick,
34 | onKeyDown,
35 | ...props
36 | } = this.props;
37 |
38 | return (
39 |
51 | {children}
52 |
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 |
46 | {component ? (
47 | React.createElement(component, childProps)
48 | ) : render ? (
49 | render(childProps) : null
50 | ) : children ? (
51 | children : null
52 | ) : null}
53 |
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 |
--------------------------------------------------------------------------------