├── .editorconfig
├── .eslintrc
├── .gitignore
├── .travis.yml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── demo
└── src
│ └── index.js
├── nwb.config.js
├── package.json
├── src
├── components
│ ├── Panel.js
│ ├── Panel.spec.js
│ ├── Panels.js
│ ├── Panels.spec.js
│ ├── Tab.js
│ ├── Tab.spec.js
│ ├── TabList.js
│ ├── TabList.spec.js
│ ├── Tabs.js
│ └── Tabs.spec.js
├── helpers
│ └── idSafeName.js
└── index.js
└── tools
├── testData.js
└── testSetup.js
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 | root = true
3 |
4 | [*.{css,html,js,scss}]
5 | indent_style = space
6 | indent_size = 4
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
12 | [*.md]
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parserOptions": {
3 | "ecmaVersion": 6,
4 | "ecmaFeatures": {
5 | "jsx": true
6 | },
7 | "sourceType": "module"
8 | },
9 | "env": {
10 | "browser": true
11 | },
12 | "globals": {
13 | "module": true,
14 | },
15 | "plugins": [
16 | "react"
17 | ],
18 | "extends": ["plugin:react/recommended"],
19 | "rules": {
20 | "comma-dangle": [2, "never"],
21 | "no-cond-assign": [2, "except-parens"],
22 | "no-console": 1,
23 | "no-constant-condition": 2,
24 | "no-control-regex": 2,
25 | "no-debugger": 2,
26 | "no-dupe-args": 2,
27 | "no-dupe-keys": 2,
28 | "no-duplicate-case": 2,
29 | "no-empty": 1,
30 | "no-empty-character-class": 2,
31 | "no-ex-assign": 2,
32 | "no-extra-boolean-cast": 2,
33 | "no-extra-parens": [2, "functions"],
34 | "no-extra-semi": 2,
35 | "no-func-assign": 2,
36 | "no-invalid-regexp": 2,
37 | "no-irregular-whitespace": 2,
38 | "no-obj-calls": 2,
39 | "no-regex-spaces": 1,
40 | "no-sparse-arrays": 2,
41 | "no-unexpected-multiline": 2,
42 | "no-unreachable": 2,
43 | "no-unsafe-negation": 2,
44 | "use-isnan": 2,
45 | "valid-typeof": 2,
46 |
47 | "accessor-pairs": [2, { "getWithoutSet":true }],
48 | "block-scoped-var": 2,
49 | "curly": [2, "all"],
50 | "dot-location": [2, "property"],
51 | "eqeqeq": [2, "smart"],
52 | "no-alert": 2,
53 | "no-else-return": 2,
54 | "no-eval": 2,
55 | "no-extra-bind": 2,
56 | "no-fallthrough": 2,
57 | "no-floating-decimal": 2,
58 | "no-global-assign": 2,
59 | "no-implicit-globals": 2,
60 | "no-implied-eval": 2,
61 | "no-labels": 2,
62 | "no-multi-spaces": 2,
63 | "no-multi-str": 2,
64 | "no-new-func": 2,
65 | "no-octal-escape": 2,
66 | "no-octal": 2,
67 | "no-redeclare": 2,
68 | "no-return-assign": [2, "except-parens"],
69 | "no-script-url": 2,
70 | "no-self-assign": 2,
71 | "no-throw-literal": 2,
72 | "no-unused-labels": 2,
73 | "no-useless-call": 2,
74 | "no-useless-concat": 2,
75 | "no-void": 2,
76 | "no-with": 2,
77 | "radix": 2,
78 | "wrap-iife": [2, "inside"],
79 | "yoda": [2, "never", {}],
80 |
81 | "strict": [2,"function"],
82 |
83 | "no-delete-var": 2,
84 | "no-shadow": [2, { "builtinGlobals": false, "hoist": "functions", "allow": [] }],
85 | "no-shadow-restricted-names": 2,
86 | "no-undef": 2,
87 | "no-undef-init": 2,
88 | "no-unused-vars": 2,
89 | "no-use-before-define": [2, "nofunc"],
90 |
91 | "array-bracket-spacing": [2, "never"],
92 | "block-spacing": [2, "always"],
93 | "brace-style": [2, "stroustrup", { "allowSingleLine": false }],
94 | "camelcase": [2, { "properties": "always" }],
95 | "comma-spacing": [2, { "before": false, "after": true }],
96 | "comma-style": [2, "last"],
97 | "computed-property-spacing": [2, "never"],
98 | "eol-last": 2,
99 | "func-call-spacing": [2, "never"],
100 | "indent": [2, 4, { "SwitchCase": 1 }],
101 | "key-spacing": [2, { "beforeColon": false, "afterColon": true }],
102 | "keyword-spacing": 2,
103 | "linebreak-style": [2, "unix"],
104 | "new-cap": 2,
105 | "new-parens": 2,
106 | "newline-after-var": [2, "always"],
107 | "newline-before-return": 2,
108 | "no-lonely-if": 2,
109 | "no-mixed-spaces-and-tabs": [2, "smart-tabs"],
110 | "no-multiple-empty-lines": [2, { "max": 2 }],
111 | "no-nested-ternary": 2,
112 | "no-tabs": 1,
113 | "no-trailing-spaces": [2, { "skipBlankLines": false }],
114 | "no-whitespace-before-property": 2,
115 | "quote-props": [2, "consistent-as-needed"],
116 | "quotes": [1, "single"],
117 | "semi": [2, "always"],
118 | "semi-spacing": [2, { "before": false, "after": true }],
119 | "space-before-blocks": [2, "always"],
120 | "space-before-function-paren": [2, "always"],
121 | "space-infix-ops": 2,
122 | "wrap-regex": 2,
123 |
124 | "react/jsx-uses-vars": 2
125 | }
126 | }
127 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /coverage
2 | /demo/dist
3 | /es
4 | /lib
5 | /node_modules
6 | /umd
7 | npm-debug.log*
8 | .DS_Store
9 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "4"
4 | install:
5 | - "npm install"
6 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # React Accessible Tabs
2 |
3 | ## Prerequisites
4 |
5 | [Node.js](http://nodejs.org/) >= v4 must be installed.
6 |
7 | ## Installation
8 |
9 | - Running `npm install` in the components's root directory will install everything you need for development.
10 |
11 | ## Demo Development Server
12 |
13 | - `npm start` will run a development server with the component's demo app at [http://localhost:3000](http://localhost:3000) with hot module reloading.
14 |
15 | ## Running Tests
16 |
17 | - `npm test` will run the tests once.
18 |
19 | - `npm run test:watch` will run the tests on every change.
20 |
21 | ## Building
22 |
23 | - `npm run build` will build the component for publishing to npm and also bundle the demo app.
24 |
25 | - `npm run clean` will delete built resources.
26 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Matt Stow
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | 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, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Accessible Tabs
2 |
3 | An accessible React tabs component, ported from [my vanilla JS plugin](http://codepen.io/stowball/pen/xVWwWe).
4 |
5 | [](https://badge.fury.io/js/react-accessible-tabs)
6 | [](https://travis-ci.org/stowball/react-accessible-tabs)
7 |
8 | ## Demo
9 |
10 | [See it in action](https://stowball.github.io/react-accessible-tabs/).
11 |
12 | ## Usage
13 |
14 | ### Installation
15 |
16 | ```sh
17 | npm install react-accessible-tabs --save
18 | ```
19 |
20 | ### In React
21 |
22 | ```js
23 | import Tabs from 'react-accessible-tabs';
24 |
25 | class App extends React.Component {
26 | render () {
27 | const tabContent = [
28 | {
29 | label: 'Tab 1',
30 | content:
31 | },
32 | {
33 | label: 'Tab 2',
34 | content:
35 | },
36 | {
37 | label: 'Tab 3',
38 | content: '
Tab 3 content
'
39 | },
40 | {
41 | label: 'Tab 4',
42 | content: [
43 | ,
44 | 'Tab 4 content
'
45 |
46 | ]
47 | }
48 | ];
49 | const initialSelectedIndex = 1;
50 |
51 | return (
52 |
53 | );
54 | }
55 | }
56 | ```
57 |
58 | * `data[x].label` accepts a `string`
59 | * `data[x].content` accepts React `element`s, a `string` or an `array` of `element`s and `string`s
60 |
61 | ### Styling
62 |
63 | The styling is up to you and uses BEM selectors:
64 |
65 | ```scss
66 | .tabs {}
67 |
68 | .tabs__tab-list {}
69 | .tabs__tab-list-item {}
70 | .tabs__trigger {
71 | &.is-selected {}
72 | }
73 |
74 | .tabs__panels {}
75 | .tabs__panel {
76 | &.is-hidden {}
77 | }
78 | ```
79 |
80 | ---
81 |
82 | Copyright (c) 2017 [Matt Stow](http://mattstow.com)
83 | Licensed under the MIT license *(see [LICENSE](https://github.com/stowball/react-accessible-tabs/blob/master/LICENSE) for details)*
84 |
--------------------------------------------------------------------------------
/demo/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from 'react-dom';
3 |
4 | import Tabs from '../../src';
5 |
6 | var tabContent = [
7 | {
8 | label: 'Section 1',
9 | content: `
10 | Section 1
11 | Duis sagittis, est sit amet gravida tristique, purus lectus venenatis urna, id molestie magna risus ut nunc. Aliquam nisl enim, tristique tempus placerat at, posuere in lectus. Vestibulum sit amet ipsum lacus. Suspendisse potenti.
12 | Nulla lobortis tempus commodo. Fusce ac sodales magna. Cras molestie risus a enim convallis vitae
luctus libero lacinia. Donec tempus tempus tellus, ac lacinia turpis mattis ac. Maecenas sit amet tellus nec mi gravida posuere
non pretium magna. Aliquam nisl enim, tristique tempus placerat at, posuere in lectus. Aliquam tincidunt velit sit amet ante hendrerit tempus. Sed mauris arcu, aliquet ultrices malesuada sed, pretium id CTRL + V massa.
13 | `
14 | },
15 | {
16 | label: 'Section 2',
17 | content: `
18 | Section 2
19 | Fusce ac sodales CSS magna. Donec et nisi dictum felis sollicitudin congue. Aliquam nisl enim, tristique tempus placerat at, posuere in lectus. Sed dapibus, lectus sit amet adipiscing egestas, mauris est viverra nibh, iaculis pretium sem orci aliquet mauris. Suspendisse potenti. Duis sagittis, est sit amet gravida tristique, purus lectus venenatis urna, id molestie magna risus ut nunc. Fusce ac sodales magna. Potenti et eros sed justo commodo bibendum non at nunc.
20 | Suspendisse potenti cras molestie, risus a enim convallis vitae luctus libero lacinia. Nulla vel magna sit amet dui lobortis commodo vitae vel nulla. Suspendisse potenti. Sed dapibus, lectus sit amet adipiscing egestas, mauris est viverra nibh, iaculis pretium sem orci aliquet mauris. Donec a congue leo. Vestibulum sit amet ipsum lacus.
21 | `
22 | },
23 | {
24 | label: 'Section 3',
25 | content: `
26 | Section 3
27 | Potenti et eros sed justo commodo bibendum non at nunc. Maecenas sit amet tellus nec mi gravida posuere non pretium magna. Nulla auctor eleifend turpis consequat pharetra.
28 | Vestibulum sit amet ipsum lacus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed dapibus, lectus sit amet adipiscing egestas, mauris est viverra nibh, iaculis pretium sem orci aliquet mauris. Duis sagittis, est sit amet gravida tristique, purus lectus venenatis urna, id molestie magna risus ut nunc. Nulla vel magna sit amet dui lobortis commodo vitae vel nulla.
29 | `
30 | }
31 | ];
32 |
33 | var css = `
34 | .tabs__tab-list {
35 | display: flex;
36 | list-style: none;
37 | margin: 0;
38 | padding: 0;
39 | }
40 |
41 | .tabs__trigger {
42 | background: lightgrey;
43 | border: 1px solid;
44 | border-bottom: none;
45 | color: #000;
46 | display: block;
47 | font-weight: bold;
48 | margin: 0 5px;
49 | padding: 15px 20px;
50 | text-decoration: none;
51 | }
52 |
53 | .tabs__trigger.is-selected {
54 | background: lightblue;
55 | }
56 |
57 | .tabs__panel {
58 | border: 1px solid;
59 | display: inherit;
60 | padding: 20px;
61 | }
62 |
63 | .tabs__panel.is-hidden {
64 | display: none;
65 | }
66 |
67 | body {
68 | font-family: sans-serif;
69 | margin: auto;
70 | padding: 30px;
71 | max-width: 800px;
72 | overflow-y: scroll;
73 | }
74 |
75 | h3 {
76 | margin-top: 0;
77 | }
78 |
79 | p:last-child {
80 | margin-bottom: 0;
81 | }
82 |
83 | :focus {
84 | box-shadow: 0 0 4px dodgerblue;
85 | outline: none;
86 | }
87 | `;
88 |
89 | const initialSelectedIndex = 1;
90 |
91 | class Demo extends React.Component {
92 | render () {
93 | return (
94 |
95 |
96 |
97 |
98 | );
99 | }
100 | }
101 |
102 | render(, document.getElementById('demo'));
103 |
--------------------------------------------------------------------------------
/nwb.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | type: 'react-component',
3 | npm: {
4 | esModules: true,
5 | umd: false
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-accessible-tabs",
3 | "version": "0.3.3",
4 | "description": "Accessible React tabs component",
5 | "main": "lib/index.js",
6 | "jsnext:main": "es/index.js",
7 | "module": "es/index.js",
8 | "files": [
9 | "css",
10 | "es",
11 | "lib",
12 | "umd"
13 | ],
14 | "scripts": {
15 | "build": "nwb build-react-component",
16 | "clean": "nwb clean-module && npm clean-demo",
17 | "start": "nwb serve-react-demo",
18 | "test": "mocha tools/testSetup.js \"src/**/*.spec.js\" --reporter progress",
19 | "test:watch": "npm run test -- --watch",
20 | "prepublish": "npm run test && npm run build"
21 | },
22 | "dependencies": {
23 | "classnames": "^2.2.5",
24 | "react-addons-create-fragment": "^15.5.4"
25 | },
26 | "peerDependencies": {
27 | "prop-types": "15.x",
28 | "react": "15.x"
29 | },
30 | "devDependencies": {
31 | "babel-preset-es2015": "^6.16.0",
32 | "babel-preset-react": "^6.16.0",
33 | "babel-preset-stage-0": "^6.16.0",
34 | "babel-register": "^6.16.3",
35 | "enzyme": "^2.8.2",
36 | "expect": "^1.20.2",
37 | "mocha": "^3.4.2",
38 | "nwb": "0.12.x",
39 | "prop-types": "^15.5.10",
40 | "react": "^15.5.4",
41 | "react-addons-test-utils": "^15.5.1",
42 | "react-dom": "^15.5.4"
43 | },
44 | "author": "Matt Stow",
45 | "homepage": "http://mattstow.com",
46 | "license": "MIT",
47 | "repository": "https://github.com/stowball/react-accessible-tabs.git",
48 | "keywords": [
49 | "react",
50 | "react-component",
51 | "ui",
52 | "accessible",
53 | "a11y",
54 | "tabs",
55 | "es6"
56 | ],
57 | "babel": {
58 | "env": {
59 | "test": {
60 | "presets": [
61 | "es2015",
62 | "react",
63 | "stage-0"
64 | ]
65 | }
66 | }
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/components/Panel.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import classNames from 'classnames';
4 |
5 | class Panel extends Component {
6 | createMarkup (str) {
7 | return { __html: str };
8 | }
9 |
10 | renderContents (child) {
11 | return React.isValidElement(child) ? child : ;
12 | }
13 |
14 | render () {
15 | const { children, id, index, selectedIndex } = this.props;
16 | const isSelected = index === selectedIndex;
17 | const className = classNames(
18 | 'tabs__panel',
19 | { 'is-hidden': !isSelected }
20 | );
21 |
22 | return (
23 |
29 | {React.Children.map(children, this.renderContents, this)}
30 |
31 | );
32 | }
33 | }
34 |
35 | Panel.propTypes = {
36 | children: PropTypes.oneOfType([
37 | PropTypes.element,
38 | PropTypes.string,
39 | PropTypes.arrayOf(PropTypes.oneOfType([
40 | PropTypes.element,
41 | PropTypes.string
42 | ]))
43 | ]),
44 | id: PropTypes.string,
45 | index: PropTypes.number,
46 | selectedIndex: PropTypes.number
47 | };
48 |
49 | export default Panel;
50 |
--------------------------------------------------------------------------------
/src/components/Panel.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { describe, it } from 'mocha';
3 | import { shallow } from 'enzyme';
4 | import expect from 'expect';
5 |
6 | import data from '../../tools/testData';
7 | import Panel from './Panel';
8 |
9 | describe('', () => {
10 | it('renders section.tabs__panel[role="tabpanel"]', () => {
11 | const wrapper = shallow();
12 |
13 | expect(wrapper.is('section.tabs__panel[role="tabpanel"]')).toBe(true);
14 | });
15 |
16 | it('section has correct aria states when selected', () => {
17 | const wrapper = shallow();
18 | const section = wrapper.find('section');
19 |
20 | expect(!section.prop('aria-hidden') && section.prop('className').indexOf('is-hidden') === -1 && section.prop('tabIndex') === 0).toBe(true);
21 | });
22 |
23 | it('section has correct aria states when un-selected', () => {
24 | const wrapper = shallow();
25 | const section = wrapper.find('section');
26 |
27 | expect(section.prop('aria-hidden') && section.prop('className').indexOf('is-hidden') > -1 && section.prop('tabIndex') === -1).toBe(true);
28 | });
29 |
30 | it('renders single React elements', () => {
31 | const wrapper = shallow();
32 |
33 | expect(wrapper.childAt(0).html()).toBe('Tab 1 content
');
34 | });
35 |
36 | it('renders single string elements', () => {
37 | const wrapper = shallow();
38 |
39 | expect(wrapper.childAt(0).html()).toBe('');
40 | });
41 |
42 | it('renders arrays of React and string elements', () => {
43 | const wrapper = shallow();
44 |
45 | expect(wrapper.childAt(0).html() === 'Tab 3 content 1
' && wrapper.childAt(1).html() === '').toBe(true);
46 | });
47 | });
48 |
--------------------------------------------------------------------------------
/src/components/Panels.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import createFragment from 'react-addons-create-fragment';
4 | import Panel from './Panel';
5 | import idSafeName from '../helpers/idSafeName';
6 |
7 | class Panels extends Component {
8 | render () {
9 | const { data, selectedIndex } = this.props;
10 |
11 | if (!data.length) {
12 | return null;
13 | }
14 |
15 | return (
16 |
17 | {data.map((panel, index) => {
18 | const id = idSafeName(panel.label, index);
19 | let o = {};
20 |
21 | o[id] = panel.content;
22 | const children = createFragment(o);
23 |
24 | return (
25 |
30 | {children}
31 |
32 | );
33 | })}
34 |
35 | );
36 | }
37 | }
38 |
39 | Panels.propTypes = {
40 | data: PropTypes.array,
41 | selectedIndex: PropTypes.number
42 | };
43 |
44 | export default Panels;
45 |
--------------------------------------------------------------------------------
/src/components/Panels.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { describe, it } from 'mocha';
3 | import { shallow } from 'enzyme';
4 | import expect from 'expect';
5 |
6 | import data from '../../tools/testData';
7 | import Panels from './Panels';
8 |
9 | describe('', () => {
10 | it('renders nothing with no data', () => {
11 | const wrapper = shallow();
12 |
13 | expect(wrapper.type()).toBe(null);
14 | });
15 |
16 | it('renders div.tabs__panels', () => {
17 | const wrapper = shallow();
18 |
19 | expect(wrapper.is('div.tabs__panels')).toBe(true);
20 | });
21 |
22 | it('contains 3 s', () => {
23 | const wrapper = shallow();
24 |
25 | expect(wrapper.html().match(//g).length).toBe(3);
26 | });
27 |
28 | it('.tabs__panel 1\'s [id] is i0-Tab1', () => {
29 | const wrapper = shallow();
30 | const tabPanel = wrapper.html().match(//)[0];
31 |
32 | expect(tabPanel.indexOf('id="i0-Tab1"') > -1).toBe(true);
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/src/components/Tab.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import classNames from 'classnames';
4 |
5 | class Tab extends Component {
6 | componentDidUpdate () {
7 | const { id, index, userInvokedSelection, selectedIndex, resetUserInvokedSelection } = this.props;
8 | const isSelected = index === selectedIndex;
9 |
10 | if (userInvokedSelection && isSelected && this.refs[id]) {
11 | this.refs[id].focus();
12 | resetUserInvokedSelection();
13 | }
14 | }
15 |
16 | render () {
17 | const { id, index, label, selectedIndex, onClick, onKeyDown } = this.props;
18 | const isSelected = index === selectedIndex;
19 | const className = classNames(
20 | 'tabs__trigger',
21 | { 'is-selected': isSelected }
22 | );
23 |
24 | return (
25 |
26 | onClick(e, index)}
35 | onKeyDown={(e) => onKeyDown(e)}
36 | >
37 | {label}
38 |
39 |
40 | );
41 | }
42 | }
43 |
44 | Tab.propTypes = {
45 | id: PropTypes.string,
46 | index: PropTypes.number,
47 | label: PropTypes.string,
48 | selectedIndex: PropTypes.number,
49 | onClick: PropTypes.func,
50 | onKeyDown: PropTypes.func
51 | };
52 |
53 | export default Tab;
54 |
--------------------------------------------------------------------------------
/src/components/Tab.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { describe, it } from 'mocha';
3 | import { shallow } from 'enzyme';
4 | import expect from 'expect';
5 |
6 | import data from '../../tools/testData';
7 | import Tab from './Tab';
8 |
9 | describe('', () => {
10 | it('renders li.tabs__tab-list-item[role="presentation"]', () => {
11 | const wrapper = shallow();
12 |
13 | expect(wrapper.is('li.tabs__tab-list-item[role="presentation"]')).toBe(true);
14 | });
15 |
16 | it('contains 1 a.tabs__trigger[role="tab"]', () => {
17 | const wrapper = shallow();
18 |
19 | expect(wrapper.find('a.tabs__trigger[role="tab"]').length).toBe(1);
20 | });
21 |
22 | it('a has correct content', () => {
23 | const wrapper = shallow();
24 | const a = wrapper.find('a');
25 |
26 | expect(a.text()).toBe('Tab 1');
27 | });
28 |
29 | it('a has correct aria states when selected', () => {
30 | const wrapper = shallow();
31 | const a = wrapper.find('a');
32 |
33 | expect(a.prop('aria-selected') && a.prop('className').indexOf('is-selected') > -1 && a.prop('tabIndex') === 0).toBe(true);
34 | });
35 |
36 | it('a has correct aria states when un-selected', () => {
37 | const wrapper = shallow();
38 | const a = wrapper.find('a');
39 |
40 | expect(!a.prop('aria-selected') && a.prop('className').indexOf('is-selected') === -1 && a.prop('tabIndex') === -1).toBe(true);
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/components/TabList.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import Tab from './Tab';
4 | import idSafeName from '../helpers/idSafeName';
5 |
6 | class TabList extends Component {
7 | render () {
8 | const { data, userInvokedSelection, selectedIndex, onClick, onKeyDown, resetUserInvokedSelection } = this.props;
9 |
10 | if (!data.length) {
11 | return null;
12 | }
13 |
14 | return (
15 |
16 | {data.map((tab, index) => {
17 | const id = idSafeName(tab.label, index);
18 |
19 | return (
20 |
31 | );
32 | })}
33 |
34 | );
35 | }
36 | }
37 |
38 | TabList.propTypes = {
39 | data: PropTypes.array,
40 | selectedIndex: PropTypes.number,
41 | onClick: PropTypes.func,
42 | onKeyDown: PropTypes.func
43 | };
44 |
45 | export default TabList;
46 |
--------------------------------------------------------------------------------
/src/components/TabList.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { describe, it } from 'mocha';
3 | import { shallow } from 'enzyme';
4 | import expect from 'expect';
5 |
6 | import data from '../../tools/testData';
7 | import TabList from './TabList';
8 | import Tab from './Tab';
9 |
10 | describe('', () => {
11 | it('renders nothing with no data', () => {
12 | const wrapper = shallow();
13 |
14 | expect(wrapper.type()).toBe(null);
15 | });
16 |
17 | it('renders ul.tabs__tab-list[role="tablist"]', () => {
18 | const wrapper = shallow();
19 |
20 | expect(wrapper.is('ul.tabs__tab-list[role="tablist"]')).toBe(true);
21 | });
22 |
23 | it('contains component', () => {
24 | const wrapper = shallow();
25 |
26 | expect(wrapper.containsMatchingElement()).toBe(true);
27 | });
28 |
29 | it('contains 3 s', () => {
30 | const wrapper = shallow();
31 |
32 | expect(wrapper.html().match(/<\/li>/g).length).toBe(3);
33 | });
34 |
35 | it('.tabs__tab-list-item 1\'s [aria-controls] & [href] is i0-Tab1', () => {
36 | const wrapper = shallow();
37 | const tabListItem = wrapper.html().match(/<\/li>/)[0];
38 |
39 | expect(tabListItem.indexOf('aria-controls="i0-Tab1"') > -1 && tabListItem.indexOf('href="#i0-Tab1"') > -1).toBe(true);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/components/Tabs.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import TabList from './TabList';
4 | import Panels from './Panels';
5 |
6 | class Tabs extends Component {
7 | constructor (props) {
8 | super(props);
9 |
10 | this.tabTriggersLength = this.props.data.length;
11 |
12 | this.state = {
13 | userInvokedSelection: false,
14 | selectedIndex: this.props.initialSelectedIndex < 0 || this.props.initialSelectedIndex > this.tabTriggersLength - 1 ? 0 : this.props.initialSelectedIndex
15 | };
16 | }
17 |
18 | handleClick = (e, index) => {
19 | e.preventDefault();
20 |
21 | if (this.state.selectedIndex === index) {
22 | return;
23 | }
24 |
25 | this.setState({
26 | userInvokedSelection: true,
27 | selectedIndex: index
28 | });
29 | }
30 |
31 | handleKeyDown = (e) => {
32 | if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
33 | e.preventDefault();
34 | }
35 | else {
36 | return;
37 | }
38 |
39 | let targetIndex;
40 |
41 | if (e.key === 'ArrowLeft' && this.state.selectedIndex > 0) {
42 | targetIndex = this.state.selectedIndex - 1;
43 | }
44 | else if (e.key === 'ArrowRight' && this.state.selectedIndex < this.tabTriggersLength - 1) {
45 | targetIndex = this.state.selectedIndex + 1;
46 | }
47 | else {
48 | return;
49 | }
50 |
51 | this.setState({
52 | userInvokedSelection: true,
53 | selectedIndex: targetIndex
54 | });
55 | }
56 |
57 | resetUserInvokedSelection = () => {
58 | this.setState({
59 | userInvokedSelection: false
60 | });
61 | }
62 |
63 | render () {
64 | if (!this.tabTriggersLength) {
65 | return null;
66 | }
67 |
68 | return (
69 |
80 | );
81 | }
82 | }
83 |
84 | Tabs.defaultProps = {
85 | data: [],
86 | initialSelectedIndex: 0
87 | };
88 |
89 | Tabs.propTypes = {
90 | data: PropTypes.arrayOf(PropTypes.shape({
91 | label: PropTypes.string,
92 | content: PropTypes.oneOfType([
93 | PropTypes.element,
94 | PropTypes.string,
95 | PropTypes.arrayOf(PropTypes.oneOfType([
96 | PropTypes.element,
97 | PropTypes.string
98 | ]))
99 | ])
100 | })),
101 | selectedIndex: PropTypes.number,
102 | initialSelectedIndex: PropTypes.number
103 | };
104 |
105 | export default Tabs;
106 |
--------------------------------------------------------------------------------
/src/components/Tabs.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { describe, it } from 'mocha';
3 | import { shallow } from 'enzyme';
4 | import expect from 'expect';
5 |
6 | import data from '../../tools/testData';
7 | import Tabs from './Tabs';
8 | import TabList from './TabList';
9 | import Panels from './Panels';
10 |
11 | const noop = () => null;
12 |
13 | describe('', () => {
14 | it('renders nothing with no data', () => {
15 | const wrapper = shallow();
16 |
17 | expect(wrapper.type()).toBe(null);
18 | });
19 |
20 | it('renders div.tab with data', () => {
21 | const wrapper = shallow();
22 |
23 | expect(wrapper.is('div.tabs')).toBe(true);
24 | });
25 |
26 | it('contains and ', () => {
27 | const wrapper = shallow();
28 |
29 | expect(wrapper.containsAllMatchingElements([, ])).toBe(true);
30 | });
31 |
32 | it('selectedIndex should be set to what\'s supplied', () => {
33 | const wrapper = shallow();
34 |
35 | expect(wrapper.state('selectedIndex')).toBe(1);
36 | });
37 |
38 | it('when initialSelectedIndex < 0, selectedIndex should be 0', () => {
39 | const wrapper = shallow();
40 |
41 | expect(wrapper.state('selectedIndex')).toBe(0);
42 | });
43 |
44 | it('when initialSelectedIndex > data.length, selectedIndex should be 0', () => {
45 | const wrapper = shallow();
46 |
47 | expect(wrapper.state('selectedIndex')).toBe(0);
48 | });
49 |
50 | it('when prev key pressed & index === 0, selectedIndex should === 0', () => {
51 | const wrapper = shallow();
52 |
53 | wrapper.at(0).children().at(0).simulate('keyDown', { preventDefault: noop, key: 'ArrowLeft' });
54 | expect(wrapper.state('selectedIndex')).toBe(0);
55 | });
56 |
57 | it('when prev key pressed & index === data.length - 1, selectedIndex should === data.length - 2', () => {
58 | const wrapper = shallow();
59 |
60 | wrapper.at(0).children().at(0).simulate('keyDown', { preventDefault: noop, key: 'ArrowLeft' });
61 | expect(wrapper.state('selectedIndex')).toBe(data.length - 2);
62 | });
63 |
64 | it('when next key pressed & index === 0, selectedIndex should === 1', () => {
65 | const wrapper = shallow();
66 |
67 | wrapper.at(0).children().at(0).simulate('keyDown', { preventDefault: noop, key: 'ArrowRight' });
68 | expect(wrapper.state('selectedIndex')).toBe(1);
69 | });
70 |
71 | it('when next key pressed & index === data.length - 1, selectedIndex should === data.length - 1', () => {
72 | const wrapper = shallow();
73 |
74 | wrapper.at(0).children().at(0).simulate('keyDown', { preventDefault: noop, key: 'ArrowRight' });
75 | expect(wrapper.state('selectedIndex')).toBe(data.length - 1);
76 | });
77 | });
78 |
--------------------------------------------------------------------------------
/src/helpers/idSafeName.js:
--------------------------------------------------------------------------------
1 | export default function (str, index) {
2 | return `i${index === undefined ? '' : index}-${str.replace(/\W/g, '')}`;
3 | }
4 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import Tabs from './components/Tabs';
2 |
3 | export default Tabs;
4 |
--------------------------------------------------------------------------------
/tools/testData.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const data = [
4 | {
5 | label: 'Tab 1',
6 | content: Tab 1 content
7 | },
8 | {
9 | label: 'Tab 2',
10 | content: 'Tab 2 content
'
11 | },
12 | {
13 | label: 'Tab 3',
14 | content: [
15 | Tab 3 content 1
,
16 | 'Tab 3 content 2
'
17 | ]
18 | }
19 | ];
20 |
21 | export default data;
22 |
--------------------------------------------------------------------------------
/tools/testSetup.js:
--------------------------------------------------------------------------------
1 | // Tests are placed alongside files under test.
2 | // This file does the following:
3 | // 1. Sets the environment to 'test' so that
4 | // dev-specific babel config in .babelrc doesn't run.
5 | // 2. Disables Webpack-specific features that Mocha doesn't understand.
6 | // 3. Registers babel for transpiling our code for testing.
7 |
8 | // This assures the .babelrc dev config (which includes
9 | // hot module reloading code) doesn't apply for tests.
10 | // Setting NODE_ENV to test instead of production because setting it to production will suppress error messaging
11 | // and propType validation warnings.
12 | process.env.NODE_ENV = 'test';
13 |
14 | // Disable webpack-specific features for tests since
15 | // Mocha doesn't know what to do with them.
16 | ['.css', '.scss', '.png', '.jpg'].forEach(ext => {
17 | require.extensions[ext] = () => null;
18 | });
19 |
20 | // Register babel so that it will transpile ES6 to ES5
21 | // before our tests run.
22 | require('babel-register')();
23 |
--------------------------------------------------------------------------------