├── .eslintignore ├── .npmignore ├── .gitignore ├── demo ├── js │ ├── demo.js │ ├── statelessDemo.jsx │ ├── fancyDemo.jsx │ ├── statefulDemo.jsx │ └── dynamicTabsDemo.jsx ├── baseStyle.css ├── svg │ ├── trophy.svg │ ├── megaphone.svg │ └── map.svg ├── index.html └── tabStyle.css ├── index.js ├── .editorconfig ├── lib ├── specialAssign.js ├── TabList.js ├── Wrapper.js ├── TabPanel.js ├── Tab.js └── createManager.js ├── .eslintrc ├── LICENSE ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CHANGELOG.md ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*-bundle.js 2 | **/*-bundle.min.js 3 | umd 4 | demo/**/*.js 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | demo/ 2 | test/ 3 | .* 4 | index.html 5 | karma.conf.js 6 | webpack* 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | **/*-bundle.js 3 | **/*-bundle.min.js 4 | *.log 5 | umd 6 | -------------------------------------------------------------------------------- /demo/js/demo.js: -------------------------------------------------------------------------------- 1 | require('./statefulDemo'); 2 | require('./statelessDemo'); 3 | require('./dynamicTabsDemo'); 4 | require('./fancyDemo'); 5 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Tab: require('./lib/Tab'), 3 | TabList: require('./lib/TabList'), 4 | TabPanel: require('./lib/TabPanel'), 5 | Wrapper: require('./lib/Wrapper'), 6 | }; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | charset = utf-8 11 | -------------------------------------------------------------------------------- /lib/specialAssign.js: -------------------------------------------------------------------------------- 1 | // Assign to `a` all properties in `b` that are not in `reserved` 2 | // or already in `a` 3 | module.exports = function(a, b, reserved) { 4 | for (var x in b) { 5 | if (!b.hasOwnProperty(x)) continue; 6 | if (a[x]) continue; 7 | if (reserved[x]) continue; 8 | a[x] = b[x]; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /demo/baseStyle.css: -------------------------------------------------------------------------------- 1 | body { 2 | color: #333; 3 | font-family: "Helvetica Neue", "Helvetica", Helvetica, Arial, sans-serif; 4 | font-size: 14px; 5 | line-height: 1.4; 6 | -webkit-box-sizing: border-box; 7 | -moz-box-sizing: border-box; 8 | box-sizing: border-box; 9 | -webkit-font-smoothing: antialiased; 10 | max-width: 600px; 11 | margin: 0 auto 200px; 12 | padding: 10px; 13 | } 14 | -------------------------------------------------------------------------------- /lib/TabList.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var PropTypes = require('prop-types'); 3 | var createReactClass = require('create-react-class'); 4 | var specialAssign = require('./specialAssign'); 5 | 6 | var checkedProps = { 7 | children: PropTypes.node.isRequired, 8 | tag: PropTypes.string, 9 | role: PropTypes.string, 10 | }; 11 | 12 | module.exports = createReactClass({ 13 | displayName: 'AriaTabPanel-TabList', 14 | 15 | propTypes: checkedProps, 16 | 17 | getDefaultProps: function() { 18 | return { tag: 'div', role: 'tablist' }; 19 | }, 20 | 21 | render: function() { 22 | var props = this.props; 23 | var elProps = { 24 | role: props.role, 25 | }; 26 | specialAssign(elProps, props, checkedProps); 27 | return React.createElement(props.tag, elProps, props.children); 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "node": true, 5 | "browser": true, 6 | }, 7 | "rules": { 8 | "comma-dangle": [2, "always-multiline"], 9 | "curly": 0, 10 | "radix": 2, 11 | "wrap-iife": 2, 12 | "brace-style": 0, 13 | "comma-style": 2, 14 | "consistent-this": 0, 15 | "indent": [2, 2, { 16 | "SwitchCase": 1 17 | }], 18 | "jsx-quotes": [2, "prefer-single"], 19 | "no-lonely-if": 2, 20 | "no-nested-ternary": 2, 21 | "no-use-before-define": [2, "nofunc"], 22 | "quotes": [2, "single"], 23 | "keyword-spacing": [2, { "before": true, "after": true }], 24 | "space-before-blocks": [2, "always"], 25 | "space-before-function-paren": [2, "never"], 26 | "space-in-parens": [2, "never"], 27 | "space-unary-ops": 2, 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2016 David Clark 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 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'stable' 4 | env: 5 | global: 6 | - secure: BCP7e3FNR7TnFTVefsa7N53t9lD7eRqfV2MCItPxXKPKMSwqHIdp+Xtbk3J7hr7+29abHMqrqNhUGhlB0wZaPxKNyYX7yN58K/3IjoUI6BZ+2GcJJtrQ1K4s+ygRaUrP9cDE+ZAqulfX3H1rrwFFnKdxAIrJXeC5re+zYWSvAd6wbpm61MirivWP4Ge+triIdu9yMMsiFA6uOsDtBfQMkG/oN0abQT3iNVRpSVn9QdOXS1o4UoLzoTViz9vDB63D7oXZo1rDF7zIGCF0roBJ0Vck0uyY+ZZLw5YZNyFPlfOaFq6qGh0EBLpKgsWrENQg/n6Un784rND42SUPwyJGpu32rCBMnNS5u2CbqxFZHYpgEXmhgfP2x5dkBUReQmpi/Uo1skM2DzuDDAANmBh0mG9y617FTJ8bD9RAcCYUHXra94gkfffJXedpc3ZOT21wfYlHe5k3T0Vb+WuHJySgwKweGpUs4bGzJtm1SR3VEzo3CEUQ746+K4AQ2ti/VdT9J4kGZaR/DQIlN1i7dZQ7YHZa+5lAVMKXNCOgKR4xQ96pMxw1NDmUqLrMlDJn21VXRTeemGo6WdDb6+UWe5VAyTYsRXUC6/r+BxBCt5/aT6eLMeUhD/MtGEXqhvRK+2LpC40QSF/tG5qeTGyjci9juYspBcU8txHwHahpW9jX2vc= 7 | - secure: tAA1HBXI4u+WSr89KLShr2pOXp0y+ZGXIzdrsNDrU4Q4onRnw35akqjxGW5h6TrkkUz/+vsD3Uix2aZF/uvS1XwmEfwh/CHDTkdwU5ks2jMh5m6rGEX7vTKjHtERlkuMwQsMchvnZ4y9tAYfAQ6jMhceAl0VXdmqpppnxGEanuD4Sxa5OJ4G5t6LE/ww7fyPwOgTPPXmJAxIQzDAxn8oXMmo4/oZc8SjhjmrRv/8HRG8rBRAG40TcBDRHTDvwsKQTYyMZgZgN3eM474ROypCafqIp1N1h2I68vYb0KpQrFRliRU/a7JL1Z6EkvUaJ8ZE8c4XpwpcjitMzsqrpfJrnE3Iij2RXqwt+Qy3cTepRtw6/ZFwcSUL5mbMyayp+hFCLrqr4TNKTNIrR5hv6el55fK2zcvYTf0NSVIQkDlHK7m8gtFClJGh+C4UlXqW9RwaW9va22uvuYQr2D4t/L1SiKYfURo4gBUyLkSZc7d+UScRz1X0F+XFIU8RB5oJL73BK2PdJB/uEWjg2DKsVDBDTrQRV0ZLMHzXJfr+DJ8TMWxQ7a+QdwFXl3qaZh/mk6iEM9KSiJEbWs0BzUGjS0XxThbjOQ1/77RUPErxhoJpLY9TNrW3nT9nlqVRCzSve4zz2eSjoHhGnGM4vJ5yImpM446spqpP+hUqd1BngfLK0ug= 8 | -------------------------------------------------------------------------------- /demo/svg/trophy.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /demo/svg/megaphone.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /lib/Wrapper.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var PropTypes = require('prop-types'); 3 | var createReactClass = require('create-react-class'); 4 | var createManager = require('./createManager'); 5 | var specialAssign = require('./specialAssign'); 6 | 7 | var checkedProps = { 8 | children: PropTypes.node.isRequired, 9 | activeTabId: PropTypes.string, 10 | letterNavigation: PropTypes.bool, 11 | onChange: PropTypes.func, 12 | tag: PropTypes.string, 13 | }; 14 | 15 | module.exports = createReactClass({ 16 | displayName: 'AriaTabPanel-Wrapper', 17 | 18 | propTypes: checkedProps, 19 | 20 | getDefaultProps: function() { 21 | return { tag: 'div' }; 22 | }, 23 | 24 | childContextTypes: { 25 | atpManager: PropTypes.object.isRequired, 26 | }, 27 | 28 | getChildContext: function() { 29 | return { atpManager: this.manager }; 30 | }, 31 | 32 | componentWillMount: function() { 33 | this.manager = createManager({ 34 | onChange: this.props.onChange, 35 | activeTabId: this.props.activeTabId, 36 | letterNavigation: this.props.letterNavigation, 37 | }); 38 | }, 39 | 40 | componentWillUnmount: function() { 41 | this.manager.destroy(); 42 | }, 43 | 44 | componentDidMount: function() { 45 | this.manager.activate(); 46 | }, 47 | 48 | componentDidUpdate: function(prevProps) { 49 | var updateActiveTab = (prevProps.activeTabId === this.manager.activeTabId) && (prevProps.activeTabId !== this.props.activeTabId); 50 | 51 | if (updateActiveTab) { 52 | this.manager.activateTab(this.props.activeTabId); 53 | } 54 | }, 55 | 56 | render: function() { 57 | var props = this.props; 58 | var elProps = {}; 59 | specialAssign(elProps, props, checkedProps); 60 | return React.createElement(props.tag, elProps, props.children); 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /demo/svg/map.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct. 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 4.4.0 4 | 5 | - Add `role` prop to `TabList`, `Tab`, and `TabPanel`. 6 | - Added support for controlling tab state externally. 7 | - Added support for dynamically adding and removing tabs. 8 | 9 | ## 4.3.0 10 | - Use `prop-types` and `create-react-class` packages for compatibility with newer versions of React. 11 | 12 | ## 4.2.2 13 | - Use `display: none` for inactive tab panels instead of not rendering them at all, preventing reloading of some resources. 14 | 15 | ## 4.2.1 16 | - Allow React 15 as peer dependency. 17 | 18 | ## 4.2.0 19 | - Allow arbitrary props to pass through to elements (not just `id`, `className`, `style`). 20 | 21 | ## 4.1.0 22 | - Allow universal/isomorphic rendering. 23 | 24 | ## 4.0.3 25 | - Fix bug caused when Tab or TabPanel tried to register themselves with their manager twice. 26 | 27 | ## 4.0.2 28 | - Fix more leftover ES2015 bugs (stupid :(), and fix ESLint config to catch them. 29 | - Change `react` and `react-dom` to `peerDependencies`. 30 | 31 | ## 4.0.1 32 | - Fix bug caused by leftover ES2015 code. 33 | 34 | ## 4.0.0 35 | - Add `letterNavigation` option, via prop in `Wrapper`. 36 | - Add `aria-describedby` on `TabPanel`s (pointing to id of their 37 | corresponding `Tab`). 38 | - Add `active` props to `Tab` and `TabPanel` for statelessness. 39 | - Remove `tabId` prop from `Tab` and `id` prop from `TabPanel`. 40 | Now the `TabPanel`'s `tabId` prop should correspond with some `Tab`'s 41 | `id` prop; and the `TabPanel`'s DOM node will automatically get an 42 | id attribute of `\`${props.tabId}-panel\``. 43 | 44 | ## 3.0.3 45 | - Add `aria-selected` property to active tab. 46 | 47 | ## 3.0.1 48 | - Fix bug in `index.js`. 49 | 50 | ## 3.0.0 51 | - Add `id` prop to `TabPanel`, and use it for the DOM node's id attribute (rather than `tabId`). 52 | Intended to push this with 2.0.0, as it is a slight but breaking change. 53 | 54 | ## 2.0.0 55 | - Upgrade to react 0.14 and its companion react-dom. 56 | - Use React's `context` to simplify the API, which involves adding the `Wrapper` component. 57 | - Add `style` prop to all components. 58 | 59 | ## 1.0.1 60 | - Fix PropTypes validation of TabPanel. 61 | 62 | ## 1.0.0 63 | - Initial release. 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-aria-tabpanel", 3 | "version": "4.4.0", 4 | "description": "A style- and markup-agnostic, React-powered Tab Panel component that fulfills the WAI-ARIA Design Pattern", 5 | "main": "index.js", 6 | "scripts": { 7 | "demo-bundle": "browserify demo/js/demo.js -t babelify --extension=.jsx -o demo/demo-bundle.js", 8 | "demo-watch": "watchify demo/js/demo.js -t babelify -d --extension=.jsx -o demo/demo-bundle.js -v", 9 | "demo-dev": "npm run demo-watch & http-server demo", 10 | "lint": "eslint .", 11 | "test": "npm run lint", 12 | "build": "mkdir -p umd && browserify index.js -p bundle-collapser/plugin -x react -x react-dom -t babelify --standalone ariaTabPanel | uglifyjs --compress --mangle > umd/ariaTabPanel.js", 13 | "prepare": "npm run build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/davidtheclark/react-aria-tabpanel.git" 18 | }, 19 | "author": { 20 | "name": "David Clark", 21 | "email": "david.dave.clark@gmail.com", 22 | "url": "http://davidtheclark.com" 23 | }, 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/davidtheclark/react-aria-tabpanel/issues" 27 | }, 28 | "homepage": "https://github.com/davidtheclark/react-aria-tabpanel", 29 | "keywords": [ 30 | "react", 31 | "reactjs", 32 | "react-component", 33 | "aria", 34 | "accessibility", 35 | "tabs", 36 | "tab-panel", 37 | "widget" 38 | ], 39 | "dependencies": { 40 | "create-react-class": "^15.6.2", 41 | "focus-group": "^0.2.2", 42 | "prop-types": "^15.6.0" 43 | }, 44 | "peerDependencies": { 45 | "react": "0.14.x || ^15.0.0 || ^16.0.0", 46 | "react-dom": "0.14.x || ^15.0.0 || ^16.0.0" 47 | }, 48 | "babel": { 49 | "presets": [ 50 | "es2015", 51 | "react" 52 | ] 53 | }, 54 | "devDependencies": { 55 | "babel-preset-es2015": "6.6.0", 56 | "babel-preset-react": "6.5.0", 57 | "babelify": "7.2.0", 58 | "browserify": "13.0.0", 59 | "bundle-collapser": "1.2.1", 60 | "eslint": "2.7.0", 61 | "http-server": "0.9.0", 62 | "react": "16.3.2", 63 | "react-dom": "16.3.2", 64 | "uglify-js": "2.6.2", 65 | "watchify": "3.7.0" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /lib/TabPanel.js: -------------------------------------------------------------------------------- 1 | var React = require('react'); 2 | var PropTypes = require('prop-types'); 3 | var createReactClass = require('create-react-class'); 4 | var specialAssign = require('./specialAssign'); 5 | 6 | var checkedProps = { 7 | children: PropTypes.oneOfType([ 8 | PropTypes.node, 9 | PropTypes.func, 10 | ]).isRequired, 11 | tabId: PropTypes.string.isRequired, 12 | tag: PropTypes.string, 13 | role: PropTypes.string, 14 | active: PropTypes.bool, 15 | }; 16 | 17 | module.exports = createReactClass({ 18 | displayName: 'AriaTabPanel-TabPanel', 19 | 20 | propTypes: checkedProps, 21 | 22 | getDefaultProps: function() { 23 | return { tag: 'div', role: 'tabpanel' }; 24 | }, 25 | 26 | contextTypes: { 27 | atpManager: PropTypes.object.isRequired, 28 | }, 29 | 30 | getInitialState: function() { 31 | return { 32 | isActive: this.context.atpManager.memberStartsActive(this.props.tabId) || false, 33 | }; 34 | }, 35 | 36 | handleKeyDown: function(event) { 37 | if (event.ctrlKey && event.key === 'ArrowUp') { 38 | event.preventDefault(); 39 | this.context.atpManager.focusTab(this.props.tabId); 40 | } 41 | }, 42 | 43 | updateActiveState: function(nextActiveState) { 44 | this.setState({ isActive: nextActiveState }); 45 | }, 46 | 47 | registerWithManager: function(el) { 48 | if (this.isRegistered) return; 49 | this.isRegistered = true; 50 | this.context.atpManager.registerTabPanel({ 51 | node: el, 52 | update: this.updateActiveState, 53 | tabId: this.props.tabId, 54 | }); 55 | }, 56 | 57 | render: function() { 58 | var props = this.props; 59 | var isActive = (props.active === undefined) ? this.state.isActive || false : props.active; 60 | 61 | var kids = (typeof props.children === 'function') 62 | ? props.children({ isActive: isActive }) 63 | : props.children; 64 | 65 | var style = props.style || {}; 66 | if (!isActive) { 67 | style.display = 'none'; 68 | } 69 | 70 | var elProps = { 71 | className: props.className, 72 | id: this.context.atpManager.getTabPanelId(props.tabId), 73 | onKeyDown: this.handleKeyDown, 74 | role: props.role, 75 | style: style, 76 | 'aria-hidden': !isActive, 77 | 'aria-describedby': props.tabId, 78 | ref: this.registerWithManager, 79 | }; 80 | specialAssign(elProps, props, checkedProps); 81 | 82 | return React.createElement(props.tag, elProps, kids); 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /demo/js/statelessDemo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import AriaTabPanel from '../..'; 4 | 5 | const tabDescriptions = [ 6 | { 7 | title: 'one', 8 | id: 't1', 9 | content: ( 10 |
34 | These interactions are intended to fulfill the WAI-ARIA Tab Panel Design Pattern. 35 |
36 | 37 |41 | The following tab component maintains its own internal state and allows letter-key navigation. 42 |
43 | 44 |
52 | The following tab component runs an onChange() function passed in from its parent, so the tab component itself is stateless. It does not allow letter-key navigation.
53 |
63 | The following is an example of adding/removing tabs dynamically and setting the active tab programmatically via a wrapper component's state. Similar to the Stateless Demo, it does not allow letter-key navigation. 64 |
65 | 66 |75 | The provided Tab and TabPanel components can accept strings, elements, arrays of elements, and also functions (which receive the tab state as an argument). So anything is possible. 76 |
77 | 78 |79 | Here's a fancier demo, that uses some icons and transitions in the tabs, and allow letter-key navigation. 80 |
81 | 82 | 83 | 84 |88 | Return to the repository. 89 |
90 | 91 | 92 | 93 |