├── .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 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demo/svg/megaphone.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 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 | 5 | 6 | 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 |
11 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 12 |
13 | ), 14 | }, 15 | { 16 | title: 'two', 17 | id: 't2', 18 | content: ( 19 |
20 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 21 |
22 | ), 23 | }, 24 | { 25 | title: 'three', 26 | id: 't3', 27 | content: ( 28 |
29 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 30 |
31 | ), 32 | }, 33 | ]; 34 | 35 | class StatelessDemo extends React.Component { 36 | constructor(props) { 37 | super(props); 38 | this.state = { activeTab: 't2' }; 39 | } 40 | 41 | setTab(newActiveTabId) { 42 | this.setState({ activeTab: newActiveTabId }); 43 | } 44 | 45 | render() { 46 | const { activeTab } = this.state; 47 | 48 | const tabs = tabDescriptions.map((tabDescription, i) => { 49 | let innerCl = 'Tabs-tabInner'; 50 | if (tabDescription.id === activeTab) innerCl += ' is-active'; 51 | return ( 52 |
  • 53 | 58 |
    59 | {tabDescription.title} 60 |
    61 |
    62 |
  • 63 | ); 64 | }); 65 | 66 | const panels = tabDescriptions.map((tabDescription, i) => { 67 | return ( 68 | 73 | {tabDescription.content} 74 | 75 | ); 76 | }); 77 | 78 | return ( 79 | 83 | 84 | 87 | 88 |
    89 | {panels} 90 |
    91 |
    92 | ); 93 | } 94 | } 95 | 96 | ReactDOM.render( 97 | , 98 | document.getElementById('stateless-demo') 99 | ); 100 | -------------------------------------------------------------------------------- /lib/Tab.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 | id: PropTypes.string.isRequired, 12 | tag: PropTypes.string, 13 | role: PropTypes.string, 14 | index: PropTypes.number, 15 | active: PropTypes.bool, 16 | letterNavigationText: PropTypes.string, 17 | }; 18 | 19 | module.exports = createReactClass({ 20 | displayName: 'AriaTabPanel-Tab', 21 | 22 | propTypes: checkedProps, 23 | 24 | getDefaultProps: function() { 25 | return { tag: 'div', role: 'tab' }; 26 | }, 27 | 28 | contextTypes: { 29 | atpManager: PropTypes.object.isRequired, 30 | }, 31 | 32 | getInitialState: function() { 33 | return { 34 | isActive: this.context.atpManager.memberStartsActive(this.props.id) || false, 35 | }; 36 | }, 37 | 38 | handleFocus: function() { 39 | this.context.atpManager.handleTabFocus(this.props.id); 40 | }, 41 | 42 | handleRef: function(el) { 43 | if (el) { 44 | this.elRef = el; 45 | this.registerWithManager(this.elRef); 46 | } 47 | }, 48 | 49 | updateActiveState: function(nextActiveState) { 50 | this.setState({ isActive: nextActiveState }); 51 | }, 52 | 53 | registerWithManager: function(el) { 54 | if (this.isRegistered) return; 55 | this.isRegistered = true; 56 | this.context.atpManager.registerTab({ 57 | id: this.props.id, 58 | node: el, 59 | update: this.updateActiveState, 60 | index: this.props.index, 61 | letterNavigationText: this.props.letterNavigationText, 62 | active: (this.props.active === undefined) ? this.state.isActive : this.props.active, 63 | }); 64 | }, 65 | 66 | unregisterWithManager: function() { 67 | var props = this.props; 68 | this.context.atpManager.unregisterTab(props.id); 69 | }, 70 | 71 | render: function() { 72 | var props = this.props; 73 | var isActive = (props.active === undefined) ? this.state.isActive : props.active; 74 | 75 | var kids = (function() { 76 | if (typeof props.children === 'function') { 77 | return props.children({ isActive: isActive }); 78 | } 79 | return props.children; 80 | }()); 81 | 82 | var elProps = { 83 | id: props.id, 84 | tabIndex: (isActive) ? 0 : -1, 85 | onClick: this.handleClick, 86 | onFocus: this.handleFocus, 87 | role: props.role, 88 | 'aria-selected': isActive, 89 | 'aria-controls': this.context.atpManager.getTabPanelId(props.id), 90 | ref: this.handleRef, 91 | }; 92 | specialAssign(elProps, props, checkedProps); 93 | 94 | return React.createElement(props.tag, elProps, kids); 95 | }, 96 | 97 | componentWillUnmount: function() { 98 | this.unregisterWithManager(); 99 | }, 100 | }); 101 | -------------------------------------------------------------------------------- /demo/js/fancyDemo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Wrapper, Tab, TabList, TabPanel} from '../..'; 4 | 5 | class FancyDemo extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 |
      11 |
    • 12 | 13 | {demoTab.bind(null, ( 14 |
      15 | 16 | 17 | Maps 18 | 19 |
      20 | ))} 21 |
      22 |
    • 23 |
    • 24 | 25 | {demoTab.bind(null, ( 26 |
      27 | 28 | 29 | Megaphones 30 | 31 |
      32 | ))} 33 |
      34 |
    • 35 |
    • 36 | 37 | {demoTab.bind(null, ( 38 |
      39 | 40 | 41 | Trophies 42 | 43 |
      44 | ))} 45 |
      46 |
    • 47 |
    48 |
    49 |
    50 | 51 |
    52 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 53 |
    54 |
    55 | 56 |
    57 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 58 |
    59 |
    60 | 61 |
    62 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 63 |
    64 |
    65 |
    66 |
    67 | ); 68 | } 69 | } 70 | 71 | ReactDOM.render( 72 | , 73 | document.getElementById('fancy-demo') 74 | ); 75 | 76 | function demoTab(content, tabState) { 77 | let cl = 'FancyTabs-tabInner'; 78 | if (tabState.isActive) cl += ' is-active'; 79 | return ( 80 |
    81 | {content} 82 |
    83 | ); 84 | } 85 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-aria-tabpanel demo 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |

    react-aria-tabpanel demo

    14 |

    15 | Interactions to try 16 |

    17 | 32 | 33 |

    34 | These interactions are intended to fulfill the WAI-ARIA Tab Panel Design Pattern. 35 |

    36 | 37 |

    38 | Stateful demo 39 |

    40 |

    41 | The following tab component maintains its own internal state and allows letter-key navigation. 42 |

    43 | 44 |
    45 |
    46 |
    47 | 48 |

    49 | Stateless demo 50 |

    51 |

    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 |

    54 | 55 |
    56 |
    57 |
    58 | 59 |

    60 | Dynamic Tabs demo 61 |

    62 |

    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 |
    67 |
    68 |
    69 | 70 |

    71 | Fancier Demo 72 |

    73 | 74 |

    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 |

    85 | Great! What do I do now? 86 |

    87 |

    88 | Return to the repository. 89 |

    90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /demo/js/statefulDemo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { Wrapper, Tab, TabList, TabPanel } from '../..'; 4 | 5 | class StatefulDemo extends React.Component { 6 | render() { 7 | return ( 8 | 9 | 10 |
      11 |
    • 12 | 17 | {demoTab.bind(null, '§ one')} 18 | 19 |
    • 20 |
    • 21 | 26 | {demoTab.bind(null, '§ two')} 27 | 28 |
    • 29 |
    • 30 | 35 | {demoTab.bind(null, '§ three')} 36 | 37 |
    • 38 |
    • 39 | 44 | {demoTab.bind(null, '§ four')} 45 | 46 |
    • 47 |
    • 48 | 53 | {demoTab.bind(null, '§ five')} 54 | 55 |
    • 56 |
    57 |
    58 |
    59 | 60 | ONE: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 61 | 62 | 63 | TWO: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 64 | 65 | 66 | THREE: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 67 | 68 | 69 | FOUR: Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 70 | 71 | 72 | FIVE: Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 73 | 74 |
    75 |
    76 | ); 77 | } 78 | } 79 | 80 | ReactDOM.render( 81 | , 82 | document.getElementById('stateful-demo') 83 | ); 84 | 85 | function demoTab(content, tabState) { 86 | let cl = 'Tabs-tabInner'; 87 | if (tabState.isActive) cl += ' is-active'; 88 | return ( 89 |
    90 | {content} 91 |
    92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /lib/createManager.js: -------------------------------------------------------------------------------- 1 | var createFocusGroup = require('focus-group'); 2 | 3 | function Manager(options) { 4 | this.options = options; 5 | 6 | var focusGroupOptions = { 7 | wrap: true, 8 | forwardArrows: ['down', 'right'], 9 | backArrows: ['up', 'left'], 10 | stringSearch: options.letterNavigation, 11 | }; 12 | 13 | this.focusGroup = createFocusGroup(focusGroupOptions); 14 | 15 | // These component references are added when the relevant components mount 16 | this.tabs = []; 17 | this.tabPanels = []; 18 | 19 | this.activeTabId = options.activeTabId; 20 | } 21 | 22 | Manager.prototype.activate = function() { 23 | this.focusGroup.activate(); 24 | }; 25 | 26 | Manager.prototype.memberStartsActive = function(tabId) { 27 | if (this.activeTabId === tabId) { 28 | return true; 29 | } 30 | 31 | if (this.activeTabId === undefined) { 32 | this.activeTabId = tabId; 33 | return true; 34 | } 35 | 36 | return false; 37 | }; 38 | 39 | Manager.prototype.registerTab = function(tabMember) { 40 | if (tabMember.index === undefined) { 41 | this.tabs.push(tabMember); 42 | } else { 43 | this.tabs.splice(tabMember.index, 0, tabMember); 44 | } 45 | 46 | var focusGroupMember = (tabMember.letterNavigationText) ? { 47 | node: tabMember.node, 48 | text: tabMember.letterNavigationText, 49 | } : tabMember.node; 50 | 51 | this.focusGroup.addMember(focusGroupMember, tabMember.index); 52 | var activeTabId = this.activeTabId; 53 | 54 | if (!this.activeTabId || (tabMember.active && (tabMember.id !== this.activeTabId))) { 55 | activeTabId = tabMember.id; 56 | } 57 | 58 | this.activateTab(activeTabId); 59 | }; 60 | 61 | Manager.prototype.unregisterTab = function(tabId) { 62 | var tabIdx; 63 | var tab; 64 | 65 | if (this.tabs && this.tabs.length > 0) { 66 | this.tabs.forEach(function(tabMember, idx) { 67 | if (tabMember.id === tabId) { 68 | tabIdx = idx; 69 | tab = tabMember; 70 | } 71 | }); 72 | 73 | if (tab && tab.node) { 74 | this.tabs.splice(tabIdx, 1); 75 | this.focusGroup.removeMember(tab.node) 76 | } 77 | } 78 | } 79 | 80 | Manager.prototype.registerTabPanel = function(tabPanelMember) { 81 | this.tabPanels.push(tabPanelMember); 82 | this.activateTab(this.activeTabId); 83 | 84 | this.activateTab(this.activeTabId || tabPanelMember.tabId); 85 | }; 86 | 87 | Manager.prototype.activateTab = function(nextActiveTabId) { 88 | if (nextActiveTabId === this.activeTabId) return; 89 | this.activeTabId = nextActiveTabId; 90 | 91 | if (this.options.onChange) { 92 | this.options.onChange(nextActiveTabId); 93 | return; 94 | } 95 | 96 | this.tabPanels.forEach(function(tabPanelMember) { 97 | tabPanelMember.update(nextActiveTabId === tabPanelMember.tabId); 98 | }); 99 | this.tabs.forEach(function(tabMember) { 100 | tabMember.update(nextActiveTabId === tabMember.id); 101 | }); 102 | } 103 | 104 | Manager.prototype.handleTabFocus = function(focusedTabId) { 105 | this.activateTab(focusedTabId); 106 | }; 107 | 108 | Manager.prototype.focusTab = function(tabId) { 109 | var tabMemberToFocus = this.tabs.find(function(tabMember) { 110 | return tabMember.id === tabId; 111 | }); 112 | if (!tabMemberToFocus) return; 113 | tabMemberToFocus.node.focus(); 114 | }; 115 | 116 | Manager.prototype.destroy = function() { 117 | this.focusGroup.deactivate(); 118 | }; 119 | 120 | Manager.prototype.getTabPanelId = function(tabId) { 121 | return tabId + '-panel'; 122 | }; 123 | 124 | module.exports = function(options) { 125 | return new Manager(options); 126 | }; 127 | -------------------------------------------------------------------------------- /demo/js/dynamicTabsDemo.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import AriaTabPanel from '../..'; 4 | 5 | const uniqueId = function() { 6 | return 'id-' + Math.random().toString(36).substr(2, 16); 7 | }; 8 | 9 | const tabTitles = [ 10 | 'I am a tab', 11 | 'Tabs are Cool!', 12 | 'New Tab Here', 13 | 'Just Another Tab', 14 | 'Doing Tab Things', 15 | ]; 16 | 17 | const tabsData = [ 18 | { 19 | title: 'one', 20 | id: uniqueId(), 21 | content: ( 22 |
    23 | Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. 24 |
    25 | ), 26 | }, 27 | { 28 | title: 'two', 29 | id: uniqueId(), 30 | content: ( 31 |
    32 | Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. 33 |
    34 | ), 35 | }, 36 | { 37 | title: 'three', 38 | id: uniqueId(), 39 | content: ( 40 |
    41 | Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. 42 |
    43 | ), 44 | }, 45 | ]; 46 | 47 | class DynamicTabsDemo extends React.Component { 48 | constructor(props) { 49 | super(props); 50 | this.state = { 51 | activeTab: tabsData[1].id, 52 | tabDescriptions: tabsData, 53 | }; 54 | } 55 | 56 | setTab(newActiveTabId) { 57 | this.setState({ activeTab: newActiveTabId }); 58 | } 59 | 60 | render() { 61 | const { activeTab, tabDescriptions } = this.state; 62 | const tabs = tabDescriptions.map((tabDescription) => this.renderTab(tabDescription)); 63 | const panels = tabDescriptions.map((tabDescription) => this.renderTabContent(tabDescription)); 64 | const hasMultipleTabs = tabDescriptions.length > 1; 65 | 66 | return ( 67 |
    68 | 72 | 73 |
      74 | {tabs} 75 |
    76 |
    77 |
    78 | {panels} 79 |
    80 |
    81 |
    82 | 88 | {' '} 89 | 96 | {' '} 97 | 104 |
    105 |
    106 | ); 107 | } 108 | 109 | renderTab(tabDescription) { 110 | const { activeTab } = this.state; 111 | let innerCl = 'Tabs-tabInner'; 112 | 113 | if (tabDescription.id === activeTab) innerCl += ' is-active'; 114 | 115 | return ( 116 |
  • 117 | 122 |
    123 | 124 | {tabDescription.title} 125 | 126 |
    127 |
    128 |
  • 129 | ); 130 | } 131 | 132 | renderTabContent(tabDescription) { 133 | const { activeTab } = this.state; 134 | 135 | return ( 136 | 141 | {tabDescription.content} 142 | 143 | ); 144 | } 145 | 146 | handleAddNewTabClick() { 147 | const { tabDescriptions } = this.state; 148 | const tabsData = tabDescriptions.slice(); 149 | const tabId = uniqueId(); 150 | const newTab = { 151 | title: this.generateTabTitle(), 152 | id: tabId, 153 | content: this.generateTabContent(), 154 | }; 155 | 156 | tabsData.push(newTab); 157 | 158 | this.setState({ 159 | activeTab: tabId, 160 | tabDescriptions: tabsData, 161 | }); 162 | } 163 | 164 | handleRemoveRandomTabClick() { 165 | const { tabDescriptions, activeTab } = this.state; 166 | const newState = {}; 167 | const tabsData = tabDescriptions.slice(); 168 | const tabToRemoveIdx = Math.floor(Math.random() * tabDescriptions.length); 169 | const tabToRemove = tabDescriptions[tabToRemoveIdx]; 170 | const isActiveTab = tabToRemove.id === activeTab; 171 | const nextActiveTabId = tabDescriptions[(tabToRemoveIdx === 0) ? 1 : (tabToRemoveIdx - 1)].id; 172 | 173 | tabsData.splice(tabToRemoveIdx, 1); 174 | newState.tabDescriptions = tabsData; 175 | 176 | if (isActiveTab) { 177 | newState.activeTab = nextActiveTabId; 178 | } 179 | 180 | this.setState(newState); 181 | } 182 | 183 | handleChangeActiveTabClick() { 184 | const { tabDescriptions, activeTab } = this.state; 185 | const activeTabIdx = tabDescriptions.findIndex((tabDescription) => tabDescription.id === activeTab); 186 | let newActiveTabIdx = Math.floor(Math.random() * tabDescriptions.length); 187 | 188 | do { 189 | if (newActiveTabIdx === activeTabIdx) { 190 | newActiveTabIdx = Math.floor(Math.random() * tabDescriptions.length); 191 | } 192 | } while (newActiveTabIdx === activeTabIdx); 193 | 194 | const newActiveTabId = tabDescriptions[newActiveTabIdx].id; 195 | 196 | this.setState({ 197 | activeTab: newActiveTabId, 198 | }); 199 | } 200 | 201 | generateTabContent() { 202 | return tabsData[Math.floor(Math.random() * tabsData.length)].content; 203 | } 204 | 205 | generateTabTitle() { 206 | return tabTitles[Math.floor(Math.random() * tabTitles.length)]; 207 | } 208 | } 209 | 210 | ReactDOM.render( 211 | , 212 | document.getElementById('dynamic-tabs-demo') 213 | ); 214 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-aria-tabpanel [![Build Status](https://travis-ci.org/davidtheclark/react-aria-tabpanel.svg?branch=master)](https://travis-ci.org/davidtheclark/react-aria-tabpanel) 2 | 3 | --- 4 | 5 | **SEEKING CO-MAINTAINERS!** Continued development of this project is going to require the work of one or more dedicated co-maintainers (or forkers). If you're interested, please comment in [this issue](https://github.com/davidtheclark/react-aria-tabpanel/issues/28). 6 | 7 | --- 8 | 9 | 10 | A React component that helps you build *accessible* tabs, by providing keyboard interactions and ARIA attributes described in [the WAI-ARIA Tab Panel Design Pattern](http://www.w3.org/TR/wai-aria-practices/#tabpanel). 11 | 12 | Please check out [the demo](http://davidtheclark.github.io/react-aria-tabpanel/demo/). 13 | 14 | ## Project Goal 15 | 16 | A React component that provides a style- and markup-agnostic foundation for fully accessible tab panels. *You provide the inner elements: this module gives you "smart" wrapper components that will handle keyboard interactions and ARIA attributes*. 17 | 18 | **If you think that this component could be even more accessible, please file an issue.** 19 | 20 | If you like this kind of module (accessible, flexible, unstyled, with framework-agnostic low-level modules) you should also check out these projects: 21 | - [react-aria-modal](https://github.com/davidtheclark/react-aria-modal) 22 | - [react-aria-menubutton](https://github.com/davidtheclark/react-aria-menubutton) 23 | 24 | ## Installation 25 | 26 | ``` 27 | npm install react-aria-tabpanel 28 | ``` 29 | 30 | Dependencies: 31 | - react 0.14.x 32 | - react-dom 0.14.x 33 | - [focus-group](//github.com/davidtheclark/focus-group) 34 | 35 | The modular approach of this library means you're much better off building it into your code with a module bundling system like browserify or webpack. 36 | 37 | But if you need a UMD version (which will include `focus-group`, but of course not React), you can get it via npmcdm at `https://unpkg.com/react-aria-tabpanel@[version-of-choice]/umd/ariaTabPanel.js`. 38 | If you don't know about unpkg, [read about it here](https://unpkg.com). 39 | 40 | ## Usage 41 | 42 | ```js 43 | var AriaTabPanel = require('react-aria-tabpanel'); 44 | 45 | // Now use AriaTabPanel.Wrapper, AriaTabPanel.TabList, 46 | // AriaTabPanel.Tab, and AriaTabPanel.TabPanel ... 47 | ``` 48 | 49 | ## Examples 50 | 51 | Have a look at the code in `demo/js/` for varied examples. 52 | 53 | ## API 54 | 55 | The AriaTabPanel object exposes four components: `Wrapper`, `TabList`, `Tab`, and `TabPanel`. Each of these is documented below. 56 | 57 | **`TabList`, `Tab`, and `TabPanel` must always be wrapped in a `Wrapper`.** 58 | 59 | ### `Wrapper` 60 | 61 | A simple component to group a `TabList`/`Tab`/`TabPanel` set, coordinating their interactions. 62 | *It should wrap your entire tab panel widget.* 63 | 64 | All `TabList`, `Tab`, and `TabPanel` components *must* be nested within a `Wrapper` component. 65 | 66 | Each wrapper should contain *only one* `TabList`, *multiple* `Tab`s, and *multiple* `TabPanel`s. 67 | 68 | #### props 69 | 70 | All props are optional. 71 | 72 | **onChange** { Function }: A callback to run when the user changes tabs (i.e. clicks a tab or navigates to another with the arrow keys). It will be passed the the newly activated tab's ID. 73 | 74 | By default, the tabs maintain state internally. *Use this prop to make the tabs "stateless," and take control yourself.* You can run any arbitrary code when the user performs an action that indicates a tab change (e.g. change your route and update a store, etc.). 75 | 76 | Stateless tabs may make sense if you want to manage the tab's state in a Redux store, for example. 77 | 78 | **letterNavigation** { Boolean }: If `true`, the tabs can be navigated not 79 | only by arrow keys, but also by letters. This library uses 80 | [focus-group](https://github.com/davidtheclark/focus-group), so you 81 | can read about how letter-key navigation in that module's ["String Searching" docs](https://github.com/davidtheclark/focus-group#string-searching). 82 | 83 | **activeTabId** { String }: Directly tell the tabs which one is active. By default, the first tab provided will be the initially active tab, and from then on the active tab state is managed internally. This prop, then, can be used two ways: 84 | 85 | - to give the tabs an initial active tab other than the first, or 86 | - if you have seized control of the state (via an `onChange` function), to continuously tell the tabs which one is active. 87 | 88 | **tag** { String }: The HTML tag for this element. Default: `'div'`. 89 | 90 | *Any additional props (e.g. id, className, data-whatever) are passed directly to the HTML element.* 91 | 92 | ### `TabList` 93 | 94 | Wrap the `Tab`s with a `TabList`. 95 | 96 | A `TabList`'s children should be React elements. 97 | 98 | #### props 99 | 100 | All props are optional. 101 | 102 | **tag** { String }: The HTML tag for this element. Default: `'div'`. 103 | 104 | **role** { String }: The `role` attribute for the element. Default: `'tablist'`. The parameter is useful when you have want the same interaction as tabs but want screen readers to describe the content differently. 105 | 106 | *Any additional props (e.g. id, className, data-whatever) are passed directly to the HTML element, unless TabList needs them itself.* 107 | 108 | ### `Tab` 109 | 110 | The active tabs is focusable. Inactive tabs are not. 111 | 112 | You can switch from one tab to another by clicking with the mouse or using the arrow keys. 113 | 114 | A `Tab`'s children may be any of the following: 115 | 116 | - A string 117 | - A React element 118 | - A function accepting the following tab-state object: 119 | ```js 120 | { 121 | isActive: Boolean // self-explanatory 122 | } 123 | ``` 124 | 125 | #### props 126 | 127 | All props are optional except `id`. 128 | 129 | **id** { String } *Required.* The id attribute for this element and the 130 | identifier that ties this `Tab` to its `TabPanel` 131 | (so there should be a `TabPanel` component with a matching `tabId`). 132 | 133 | **active** { Boolean }: If you are controlling the state yourself (with an `onChange` function on your `Wrapper`), 134 | use this prop to tell the `Tab` whether it is active or not. 135 | 136 | **letterNavigationText** { String }: If you are using letter-key navigation 137 | (having turned it on via the prop on `Wrapper`), you 138 | can use this prop to specify this `Tabs`'s searchable text. 139 | By default, the element's `textContent` is used — which is 140 | usually what you want. 141 | 142 | **tag** { String }: The HTML tag for this element. Default: `'div'`. 143 | 144 | **role** { String }: The `role` attribute for the element. Default: `'tab'`. The parameter is useful when you have want the same interaction as tabs but want screen readers to describe the content differently. 145 | 146 | *Any additional props (e.g. className, data-whatever) are passed directly to the HTML element, unless Tab needs them itself.* 147 | 148 | ### `TabPanel` 149 | 150 | The content area for your tabs. The active tab panel is visible; the inactive tab panels are not. 151 | 152 | A `TabPanels`'s children may be any of the following: 153 | 154 | - A string 155 | - A React element 156 | - A function accepting the following panel-state object: 157 | ```js 158 | { 159 | isActive: Boolean // self-explanatory 160 | } 161 | ``` 162 | 163 | #### props 164 | 165 | All props are optional except `tabId`. 166 | 167 | **tabId** { String }: *Required.* The id of the `Tab` that corresponds 168 | to this `TabPanel`. 169 | 170 | **active** { Boolean }: If you are controlling the state yourself (with an `onChange` function on your `Wrapper`), 171 | use this prop to tell the `TabPanel` whether it is active or not. 172 | 173 | **tag** { String }: The HTML tag for this element. Default: `'div'`. 174 | 175 | **role** { String }: The `role` attribute for the element. Default: `'tabpanel'`. The parameter is useful when you have want the same interaction as tabs but want screen readers to describe the content differently. 176 | 177 | *Any additional props (e.g. className, data-whatever) are passed directly to the HTML element, unless TabPanel needs them itself.* 178 | 179 | ## Contributing & Development 180 | 181 | Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms. 182 | 183 | Lint with `npm run lint`. 184 | 185 | Test with `npm run test-dev`, which will give you a URL to open in your browser. Look at the console log for TAP output. 186 | 187 | ### Tests 188 | 189 | [The demo](http://davidtheclark.github.io/react-aria-tabpanel/demo/) serves for integration testing. If you'd like to help write decent automated tests, please file a PR. 190 | -------------------------------------------------------------------------------- /demo/tabStyle.css: -------------------------------------------------------------------------------- 1 | .Tabs-tablist { 2 | list-style-type: none; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | .Tabs-tablistItem { 8 | display: inline-block; 9 | } 10 | 11 | .Tabs-tablistItem + .Tabs-tablistItem { 12 | margin-left: 0.3em; 13 | } 14 | 15 | .Tabs-panel { 16 | border: 1px solid #ccc; 17 | padding: 1em; 18 | } 19 | 20 | .Tabs-tab { 21 | cursor: pointer; 22 | } 23 | 24 | .Tabs-tab:focus { 25 | box-shadow: 0 0 3px 3px #09f; 26 | } 27 | 28 | .Tabs-tabInner { 29 | border: 1px solid transparent; 30 | border-bottom: 0; 31 | position: relative; 32 | padding: 5px 20px; 33 | background: #ccc; 34 | position: relative; 35 | } 36 | 37 | .Tabs-tabInner.is-active { 38 | background: #fff; 39 | border-color: #ccc; 40 | } 41 | 42 | .Tabs-tabInner.is-active::after { 43 | content: ""; 44 | background: #fff; 45 | height: 1px; 46 | position: absolute; 47 | bottom: -1px; 48 | left: 0; 49 | right: 0; 50 | } 51 | 52 | /* Dynamic */ 53 | .Tabs-tablist.Tabs-dynamic-tablist { 54 | width: 100%; 55 | display: flex; 56 | } 57 | 58 | .Tabs-tablist.Tabs-dynamic-tablist 59 | .Tabs-tabInner-text { 60 | white-space: nowrap; 61 | text-overflow: ellipsis; 62 | display: block; 63 | overflow: hidden; 64 | } 65 | 66 | .Tabs-tablist.Tabs-dynamic-tablist 67 | .Tabs-tablistItem { 68 | min-width: 10px; 69 | flex: 0 1 auto; 70 | } 71 | 72 | .dynamic-tabs__add-tab-btn, 73 | .dynamic-tabs__remove-tab-btn, 74 | .dynamic-tabs__change-active-tab-btn { 75 | font-size: 13px; 76 | font-weight: bold; 77 | border-radius: 3px; 78 | padding: 10px; 79 | } 80 | 81 | .dynamic-tabs__add-tab-btn:disabled, 82 | .dynamic-tabs__remove-tab-btn:disabled, 83 | .dynamic-tabs__change-active-tab-btn:disabled { 84 | opacity: 0.5; 85 | cursor: not-allowed; 86 | } 87 | 88 | .dynamic-tabs__add-tab-btn { 89 | color: #fff; 90 | background-color: #1976d2; 91 | } 92 | 93 | .dynamic-tabs__remove-tab-btn { 94 | color: #fff; 95 | background-color: #d32f2f; 96 | } 97 | 98 | .dynamic-tabs__change-active-tab-btn { 99 | color: #263238; 100 | } 101 | 102 | /* Fancy */ 103 | 104 | .FancyTabs-tablist { 105 | list-style-type: none; 106 | margin: 0; 107 | padding: 0; 108 | } 109 | 110 | .FancyTabs-tablistItem { 111 | display: inline-block; 112 | } 113 | 114 | .FancyTabs-tablistItem + .FancyTabs-tablistItem { 115 | margin-left: 0.3em; 116 | } 117 | 118 | .FancyTabs-panel { 119 | border-top: 5px solid #A9F47E; 120 | padding: 1em; 121 | position: relative; 122 | z-index: 2; 123 | background: #fff; 124 | overflow: hidden; 125 | } 126 | 127 | .FancyTabs-tab { 128 | cursor: pointer; 129 | } 130 | 131 | .FancyTabs-tab:focus { 132 | text-decoration: underline; 133 | outline: 0; 134 | } 135 | 136 | .FancyTabs-tabInner { 137 | border: 1px solid transparent; 138 | border-bottom: 0; 139 | position: relative; 140 | padding: 15px 20px; 141 | background: #ccc; 142 | position: relative; 143 | transition: transform 0.2s ease-out, background 0.2s linear; 144 | -webkit-transition: -webkit-transform 0.2s ease-out, background 0.2s linear; 145 | transform: translateY(10px); 146 | -webkit-transform: translateY(10px); 147 | } 148 | 149 | .FancyTabs-tabInner.is-active { 150 | background: #A9F47E; 151 | border-color: #41D0CB; 152 | border-width: 1px; 153 | transform: translateY(0); 154 | -webkit-transform: translateY(0); 155 | } 156 | 157 | .FancyTabs-tabInner.is-active::after { 158 | content: ""; 159 | background: #fff; 160 | height: 1px; 161 | position: absolute; 162 | bottom: -1px; 163 | left: 0; 164 | right: 0; 165 | } 166 | 167 | .FancyTabs-tabIcon { 168 | vertical-align: middle; 169 | display: inline-block; 170 | width: 30px; 171 | height: 30px; 172 | background-repeat: no-repeat; 173 | background-position: center center; 174 | background-size: contain; 175 | margin-right: 0.5em; 176 | } 177 | 178 | .FancyTabs-tabIcon--map { 179 | background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNDEiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCA0MSAzMiI+PHBhdGggZmlsbD0iIzAwMDAwMCIgZD0iTTkuMjM5IDMxLjkyN2MwLjAwOSAwLjAwNiAwLjAyMSAwLjAwMyAwLjAzMCAwLjAwOSAwLjA3MyAwLjAzNyAwLjE0OSAwLjA2NCAwLjIzMSAwLjA2NCAwLjA0NCAwIDAuMDg4LTAuMDA2IDAuMTMyLTAuMDE4bDEwLjg2OC0yLjk2NiAxMC44NjggMi45NjZjMC4wNDQgMC4wMTIgMC4wODggMC4wMTggMC4xMzIgMC4wMTggMC4wODIgMCAwLjE1OC0wLjAyNyAwLjIzLTAuMDY1IDAuMDEwLTAuMDA1IDAuMDIxLTAuMDAzIDAuMDMwLTAuMDA5bDktNS41YzAuMTkxLTAuMTE3IDAuMjgxLTAuMzQ4IDAuMjItMC41NjNsLTQuOTg0LTE3LjVjLTAuMDQxLTAuMTQ3LTAuMTQ4LTAuMjY3LTAuMjktMC4zMjYtMC4xNDItMC4wNTctMC4zMDEtMC4wNDgtMC40MzYgMC4wMjZsLTQuOTYyIDIuNzg0Yy0wLjI0IDAuMTM1LTAuMzI2IDAuNDQtMC4xOTEgMC42ODFzMC40MzkgMC4zMjcgMC42ODIgMC4xOTFsNC40MDktMi40NzUgNC43MDcgMTYuNTI2LTguMDE1IDQuODk5LTEuOTA0LTE1LjIzMWMtMC4wMzQtMC4yNzUtMC4yOTMtMC40NjYtMC41NTktMC40MzQtMC4yNzMgMC4wMzQtMC40NjggMC4yODQtMC40MzQgMC41NThsMS45MDcgMTUuMjU5LTkuOTEtMi43MDV2LTIuNzNjMC0wLjI3Ni0wLjIyNC0wLjUtMC41LTAuNXMtMC41IDAuMjI0LTAuNSAwLjV2Mi43M2wtOS45MTEgMi43MDUgMS45MDctMTUuMjU5YzAuMDM0LTAuMjc0LTAuMTYtMC41MjQtMC40MzQtMC41NTgtMC4yNzItMC4wMzItMC41MjQgMC4xNTktMC41NTkgMC40MzRsLTEuOTAzIDE1LjIzMS04LjAxNS00Ljg5OCA0LjcwNy0xNi41MjUgNC40MDkgMi40NzVjMC4yNDIgMC4xMzQgMC41NDYgMC4wNDkgMC42ODItMC4xOTEgMC4xMzUtMC4yNDEgMC4wNDktMC41NDUtMC4xOTEtMC42ODFsLTQuOTYzLTIuNzg1Yy0wLjEzMy0wLjA3NS0wLjI5Mi0wLjA4NS0wLjQzNS0wLjAyNnMtMC4yNDkgMC4xNzgtMC4yOSAwLjMyNmwtNC45ODQgMTcuNWMtMC4wNjIgMC4yMTYgMC4wMjggMC40NDYgMC4yMiAwLjU2M2w4Ljk5OSA1LjV6TTIwLjE2MSAyMy4zNjhjMC4wOTYgMC4wODggMC4yMTcgMC4xMzIgMC4zMzkgMC4xMzIgMC4xMiAwIDAuMjQtMC4wNDMgMC4zMzYtMC4xMjkgMC4zMzMtMC4zMDMgOC4xNjQtNy40ODkgOC4xNjQtMTQuODcxIDAtNC43NjctMy43MzMtOC41LTguNS04LjVzLTguNSAzLjczMy04LjUgOC41YzAgNy4yNTQgNy44MjggMTQuNTYgOC4xNjEgMTQuODY4ek0yMC41IDFjNC4yNzUgMCA3LjUgMy4yMjQgNy41IDcuNSAwIDYuMDk3LTUuOTkzIDEyLjMzNy03LjQ5NyAxMy44MDctMS41MDEtMS40ODctNy41MDMtNy44MDktNy41MDMtMTMuODA3IDAtNC4yNzYgMy4yMjUtNy41IDcuNS03LjV6TTI1IDguNWMwLTIuNDgxLTIuMDE5LTQuNS00LjUtNC41cy00LjUgMi4wMTktNC41IDQuNSAyLjAxOSA0LjUgNC41IDQuNSA0LjUtMi4wMTkgNC41LTQuNXpNMjAuNSAxMmMtMS45MyAwLTMuNS0xLjU3LTMuNS0zLjVzMS41Ny0zLjUgMy41LTMuNSAzLjUgMS41NyAzLjUgMy41LTEuNTcgMy41LTMuNSAzLjV6Ij48L3BhdGg+PC9zdmc+); 180 | } 181 | 182 | .FancyTabs-tabIcon--megaphone { 183 | background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iNDAiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCA0MCAzMiI+PHBhdGggZmlsbD0iIzAwMDAwMCIgZD0iTTM5LjQ5OCAwYy0wLjI3NyAwLTAuNDk4IDAuMjI0LTAuNDk4IDAuNXYzMWMwIDAuMjc2IDAuMjIxIDAuNSAwLjQ5OCAwLjVzMC41LTAuMjI0IDAuNS0wLjV2LTMxYzAtMC4yNzYtMC4yMjQtMC41LTAuNS0wLjV6TTM3LjcxNyAxLjIwNGMtMC4xNy0wLjA4My0wLjM3Ni0wLjA2Mi0wLjUyNiAwLjA1NWwtMC41NjUgMC40NDVjLTIuOTc4IDIuMzU1LTcuOTU5IDYuMjk2LTE4LjEyNiA2LjI5NmgtMTMuODY5Yy0xLjQ1MSAwLTIuNjMxIDEuMi0yLjYzMSAyLjY3NHYxMC43MTRjMCAxLjQ0IDEuMTggMi42MTIgMi42MzEgMi42MTJoMy4zOTRjMC4wODggMS4xMjUgMC41MDIgMy43OTQgMi40NTQgNS43NjEgMS40NzQgMS40ODYgMy41IDIuMjM5IDYuMDIxIDIuMjM5IDAuMjc2IDAgMC41LTAuMjI0IDAuNS0wLjVzLTAuMjI0LTAuNS0wLjUtMC41Yy0yLjI0MiAwLTQuMDI2LTAuNjUyLTUuMzA2LTEuOTM4LTEuNjY3LTEuNjc2LTIuMDY3LTQuMDI0LTIuMTYzLTUuMDYyaDIuOTk1YzAuMDg1IDAuNjgyIDAuMzYgMS44ODEgMS4yNzQgMi44MDIgMC43ODkgMC43OTUgMS44NjYgMS4xOTggMy4yIDEuMTk4IDAuMjc2IDAgMC41LTAuMjI0IDAuNS0wLjVzLTAuMjI0LTAuNS0wLjUtMC41Yy0xLjA1NSAwLTEuODkxLTAuMzAyLTIuNDg0LTAuODk2LTAuNjU3LTAuNjU5LTAuODk0LTEuNTQ2LTAuOTgxLTIuMTA0aDUuMzk2YzEwLjIxNiAwIDE1LjIzNyAzLjk2MyAxOC4yMzcgNi4zMzFsMC41MjIgMC40MTFjMC4wODkgMC4wNzAgMC4xOTggMC4xMDUgMC4zMDcgMC4xMDUgMC4wNzUgMCAwLjE1LTAuMDE3IDAuMjE5LTAuMDUxIDAuMTcyLTAuMDg0IDAuMjgxLTAuMjU4IDAuMjgxLTAuNDQ5di0yOC42OTRjMC4wMDEtMC4xOTEtMC4xMDgtMC4zNjUtMC4yOC0wLjQ0OXpNMyAyMS4zODh2LTEwLjcxNGMwLTAuOTIzIDAuNzMxLTEuNjc0IDEuNjMxLTEuNjc0aDMuMzY5djE0aC0zLjM2OWMtMC45MTUgMC0xLjYzMS0wLjcwOC0xLjYzMS0xLjYxMnpNMzcgMjkuMzE3Yy0zLjEzNC0yLjQ2Ni04LjMyOC02LjMxNy0xOC41NjgtNi4zMTdoLTUuNzk3Yy0wLjA0Ny0wLjAxNS0wLjA5NS0wLjAzMC0wLjE0OC0wLjAzMS0wLjAwMSAwLTAuMDAxIDAtMC4wMDIgMC0wLjA1NCAwLTAuMTA1IDAuMDE1LTAuMTU0IDAuMDMxaC0zLjMzMXYtMTRoOS41YzEwLjIzNyAwIDE1LjM5NC0zLjg2NCAxOC41LTYuMzE2djI2LjYzM3pNMC41IDIxLjg1N2MwLjI3NiAwIDAuNS0wLjIyNCAwLjUtMC41di0xMC43MTRjMC0wLjI3Ni0wLjIyNC0wLjUtMC41LTAuNXMtMC41IDAuMjIzLTAuNSAwLjV2MTAuNzE0YzAgMC4yNzYgMC4yMjQgMC41IDAuNSAwLjV6Ij48L3BhdGg+PC9zdmc+); 184 | } 185 | 186 | .FancyTabs-tabIcon--trophy { 187 | background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz48IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPjxzdmcgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB3aWR0aD0iMzQiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAzNCAzMiI+PHBhdGggZmlsbD0iIzAwMDAwMCIgZD0iTTI1LjUgMWMwLjI3NiAwIDAuNS0wLjIyNCAwLjUtMC41cy0wLjIyNC0wLjUtMC41LTAuNWgtMTdjLTAuMjc2IDAtMC41IDAuMjI0LTAuNSAwLjVzMC4yMjQgMC41IDAuNSAwLjVoMTd6TTI1LjUgMmgtMTljLTMuMjgzIDAtNC44MTMgMS4xNjYtNS41MTkgMi4xNDQtMC44NjQgMS4yLTEuMDI2IDIuODAzLTAuNDU2IDQuNTE0IDAuODgyIDIuNjQ2IDMuODQ2IDUuODI1IDUuNjY4IDcuMjM3IDAuMDkyIDAuMDcxIDAuMiAwLjEwNSAwLjMwNyAwLjEwNSAwLjE0OSAwIDAuMjk3LTAuMDY2IDAuMzk2LTAuMTk0IDAuMTY5LTAuMjE4IDAuMTI5LTAuNTMyLTAuMDg5LTAuNzAyLTEuNjc0LTEuMjk2LTQuNTI1LTQuMzQyLTUuMzMyLTYuNzYzLTAuNDY3LTEuMzk3LTAuMzU0LTIuNjggMC4zMTgtMy42MTIgMC44MTUtMS4xMzEgMi40NDItMS43MjkgNC43MDctMS43MjloMS41djkuNWMwIDYuNzI2IDYuNjczIDEwLjYwMSA4LjAzNiAxMS4zMjItMC4wMjEgMC4wNTYtMC4wMzYgMC4xMTUtMC4wMzYgMC4xNzh2Mi41YzAgMC4yNzYgMC4yMjQgMC41IDAuNSAwLjVzMC41LTAuMjI0IDAuNS0wLjV2LTIuNWMwLTAuMDU4LTAuMDE1LTAuMTEyLTAuMDMzLTAuMTY0IDEuNTE0LTAuNzEyIDkuMDMzLTQuNTg2IDkuMDMzLTExLjMzNnYtOS41aDEuNWMyLjI2NSAwIDMuODkyIDAuNTk4IDQuNzA3IDEuNzI5IDAuNjcyIDAuOTMyIDAuNzg1IDIuMjE1IDAuMzE4IDMuNjEzLTAuNjMgMS44OTEtMy43NjkgNS41NTMtNS4zMzIgNi43NjMtMC4yMTggMC4xNjktMC4yNTggMC40ODMtMC4wODkgMC43MDIgMC4wOTkgMC4xMjcgMC4yNDcgMC4xOTMgMC4zOTYgMC4xOTMgMC4xMDcgMCAwLjIxNS0wLjAzNCAwLjMwNy0wLjEwNCAxLjczMi0xLjM0MiA0Ljk2MS01LjExOSA1LjY2OC03LjIzNyAwLjU3LTEuNzExIDAuNDA4LTMuMzE0LTAuNDU2LTQuNTE0LTAuNzA2LTAuOTc5LTIuMjM2LTIuMTQ1LTUuNTE5LTIuMTQ1aC0yek0yNSAxMi41YzAgNi4wOTMtNy4xNTIgOS44MDQtOC40ODYgMTAuNDQzLTEuMjI4LTAuNjY1LTcuNTE0LTQuMzY3LTcuNTE0LTEwLjQ0M3YtOS41aDE2djkuNXpNMTEgMjhjLTEuMTQxIDAtMiAwLjg2LTIgMnYxLjVjMCAwLjI3NiAwLjIyNCAwLjUgMC41IDAuNWgxNGMwLjI3NiAwIDAuNS0wLjIyNCAwLjUtMC41di0xLjQyNWMwLTEuMTYzLTAuODc5LTIuMDc1LTItMi4wNzVoLTExek0yMyAzMC4wNzV2MC45MjVoLTEzdi0xYzAtMC41ODkgMC40MTEtMSAxLTFoMTFjMC41NyAwIDEgMC40NjIgMSAxLjA3NXoiPjwvcGF0aD48L3N2Zz4=); 188 | } 189 | 190 | .FancyTabs-panelInner { 191 | transition: transform 0.3s ease; 192 | } 193 | 194 | .FancyTabs-panelInner.is-enter { 195 | transform: translateY(-120%); 196 | } 197 | 198 | .FancyTabs-panelInner.is-enter-active, 199 | .FancyTabs-panelInner.is-leave { 200 | transform: translateY(0); 201 | } 202 | 203 | .FancyTabs-panelInner.is-leave-active { 204 | transform: translateY(120%); 205 | } 206 | --------------------------------------------------------------------------------