├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── examples ├── .env ├── .eslintcache ├── .gitignore ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ └── manifest.json ├── src │ ├── App.js │ ├── App.test.js │ ├── Basic │ │ ├── index.js │ │ └── styles.css │ ├── TabsRemoval │ │ ├── index.js │ │ └── styles.css │ ├── dummyData.js │ ├── index.css │ └── index.js └── yarn.lock ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── components │ ├── InkBar.js │ ├── ShowMore.js │ ├── Tab.js │ └── TabPanel.js └── index.js ├── styles.css └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-class-properties", 5 | [ 6 | "transform-imports", 7 | { 8 | "lodash": { 9 | "transform": "lodash/${member}", 10 | "preventFullImport": true 11 | } 12 | } 13 | ], 14 | [ 15 | "module-resolver", 16 | { 17 | "root": ["./", "./src"] 18 | } 19 | ] 20 | ], 21 | "env": { 22 | "esm": { 23 | "presets": [ 24 | [ 25 | "@babel/env", 26 | { 27 | "modules": false 28 | } 29 | ], 30 | "@babel/react" 31 | ], 32 | "plugins": [ 33 | [ 34 | "transform-imports", 35 | { 36 | "lodash": { 37 | "transform": "lodash-es/${member}", 38 | "preventFullImport": true 39 | } 40 | } 41 | ] 42 | ] 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/dist/** 2 | **/lib/** 3 | **/node_modules/** 4 | webpack.config.js 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "node": true, 7 | "es6": true 8 | }, 9 | "rules": { 10 | "no-underscore-dangle": 0, 11 | "react/jsx-filename-extension": 0, 12 | "import/no-named-default": 0, 13 | "jsx-a11y/no-static-element-interactions": 0, 14 | "no-return-assign": 0, 15 | "comma-dangle": 0, 16 | "import/no-named-as-default": 0, 17 | "object-curly-newline": 0, 18 | "jsx-a11y/click-events-have-key-events": 0, 19 | "react/jsx-props-no-spreading": 0, 20 | "max-len": ["error", 120], 21 | "operator-linebreak": ["error", "after"], 22 | "arrow-parens": [ 23 | "error", 24 | "as-needed", 25 | { 26 | "requireForBlockBody": false 27 | } 28 | ] 29 | }, 30 | "settings": { 31 | "import/resolver": { 32 | "node": { 33 | "paths": [".", "src", "node_modules"], 34 | "extensions": [".js", ".jsx"] 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | */**/dist 3 | *.log 4 | .DS_Store 5 | build 6 | yarn.lock 7 | lib 8 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | node_modules 4 | src 5 | examples 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.singleQuote": true, 3 | "prettier.arrowParens": "avoid", 4 | "prettier.printWidth": 120 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Denis Rul 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React responsive tabs 2 | 3 | #### [Live demo](http://maslianok.github.io/react-responsive-tabs/) 4 | 5 | ### Your feedback is highly appreciated! 6 | 7 | Please, file an issue if something went wrong or let me know via Twitter @maslianok 8 | 9 | #### Responsive 10 | 11 | - Hide tabs under the 'Show more' option when they don't fit into the screen 12 | - Transform tabs into the accordion when the wrapper width reaches the `transformWidth` value 13 | 14 | ![Responsive tabs](https://cloud.githubusercontent.com/assets/3485490/11324577/f6536f2c-913d-11e5-80b0-8755a2ec11cb.gif) 15 | 16 | #### Accessible 17 | 18 | The component outputs HTML code that follows accessibility principles (aka [WAI-ARIA](https://en.wikipedia.org/wiki/WAI-ARIA)) and uses ARIA attributes such as `role`, `aria-selected`, `aria-controls`, `aria-labeledby` etc. 19 | 20 | ![Accessible tabs](https://cloud.githubusercontent.com/assets/3485490/11324576/f4775a4c-913d-11e5-9ec2-f13beb8bd578.gif) 21 | 22 | #### Fast 23 | 24 | We are using [`react-resize-detector`](https://github.com/maslianok/react-resize-detector). No timers. Just pure event-based element resize detection. 25 | 26 | ## Installation 27 | 28 | `npm install react-responsive-tabs` 29 | 30 | ## Demo 31 | 32 | #### [Live demo](http://maslianok.github.io/react-responsive-tabs/) 33 | 34 | Local demo 35 | 36 | ```sh 37 | # 1. clone the repository 38 | git clone https://github.com/maslianok/react-responsive-tabs.git 39 | 40 | # 2. Install react-responsive-tabs dependencies. You must do it because we use raw library code in the example 41 | cd react-responsive-tabs 42 | npm install 43 | 44 | # 3. Install dependencies to run the example 45 | cd examples 46 | npm install 47 | 48 | # 4. Finally run the example 49 | npm start 50 | ``` 51 | 52 | ## Example 53 | 54 | ```javascript 55 | import React, { Component } from 'react'; 56 | import { render } from 'react-dom'; 57 | import Tabs from 'react-responsive-tabs'; 58 | 59 | // IMPORTANT you need to include the default styles 60 | import 'react-responsive-tabs/styles.css'; 61 | 62 | const presidents = [ 63 | { name: 'George Washington', biography: '...' }, 64 | { name: 'Theodore Roosevelt', biography: '...' }, 65 | ]; 66 | 67 | function getTabs() { 68 | return presidents.map((president, index) => ({ 69 | title: president.name, 70 | getContent: () => president.biography, 71 | /* Optional parameters */ 72 | key: index, 73 | tabClassName: 'tab', 74 | panelClassName: 'panel', 75 | })); 76 | } 77 | 78 | const App = () => ; 79 | 80 | render(, document.getElementById('root')); 81 | ``` 82 | 83 | ## API 84 | 85 | All entities listed below should be used as props to the `Tabs` component. 86 | 87 | | Prop | Type | Description | Default | 88 | | ---------------- | ------------- | ---------------------------------------------------------------------------- | --------------- | 89 | | items | Array | Tabs data | [Item](#Item)[] | 90 | | beforeChange | Function | Fires right before a tab changes. Return `false` to prevent the tab change | undefined | 91 | | onChange | Function | onChange callback | undefined | 92 | | selectedTabKey | Number/String | Selected tab | undefined | 93 | | showMore | Bool | Whether to show `Show more` or not | `true` | 94 | | showMoreLabel | String/Node | `Show more` tab name | `...` | 95 | | transform | Bool | Transform to accordion when the wrapper width is less than `transformWidth`. | `true` | 96 | | transformWidth | Number | Transform width. | 800 | 97 | | unmountOnExit | Bool | Whether to unmount inactive tabs from DOM tree or not | `true` | 98 | | tabsWrapperClass | String | Wrapper class | undefined | 99 | | tabClassName | String | Tab class | undefined | 100 | | panelClassName | String | Tab panel class | undefined | 101 | | allowRemove | Bool | Allows tabs removal. | `false` | 102 | | removeActiveOnly | Bool | Only active tab has removal option | `false` | 103 | | showInkBar | Bool | Add MaterialUI InkBar effect | `false` | 104 | | uid | any | An optional external id. The component rerenders when it changes | undefined | 105 | 106 | #### Item 107 | 108 | | Prop | Type | Description | 109 | | -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | 110 | | title | String | Tab title | 111 | | getContent | Function | A function that returns data that will be rendered when tab become active | 112 | | content | String | Use this prop insted of getContent. This is a sync version of `getContent`. The data will be always rendered in a hidden div. Sometimes it may be useful for SEO | 113 | | key | Number | A uniq tab id | 114 | | tabClassName | String | Tab class name | 115 | | panelClassName | String | Panel class name | 116 | 117 | ### License 118 | 119 | MIT 120 | -------------------------------------------------------------------------------- /examples/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /examples/.eslintcache: -------------------------------------------------------------------------------- 1 | [{"/Users/maslianok/www/react-responsive-tabs/examples/src/index.js":"1","/Users/maslianok/www/react-responsive-tabs/examples/src/App.js":"2","/Users/maslianok/www/react-responsive-tabs/examples/src/TabsRemoval/index.js":"3","/Users/maslianok/www/react-responsive-tabs/examples/src/Basic/index.js":"4","/Users/maslianok/www/react-responsive-tabs/examples/src/dummyData.js":"5"},{"size":147,"mtime":1591348102709,"results":"6","hashOfConfig":"7"},{"size":2547,"mtime":1612256266175,"results":"8","hashOfConfig":"7"},{"size":1749,"mtime":1612256415867,"results":"9","hashOfConfig":"7"},{"size":2509,"mtime":1612256325201,"results":"10","hashOfConfig":"7"},{"size":1195,"mtime":1591348102708,"results":"11","hashOfConfig":"7"},{"filePath":"12","messages":"13","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"v91sf1",{"filePath":"14","messages":"15","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"16","messages":"17","errorCount":0,"warningCount":1,"fixableErrorCount":0,"fixableWarningCount":0,"source":null},{"filePath":"18","messages":"19","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},{"filePath":"20","messages":"21","errorCount":0,"warningCount":0,"fixableErrorCount":0,"fixableWarningCount":0},"/Users/maslianok/www/react-responsive-tabs/examples/src/index.js",[],"/Users/maslianok/www/react-responsive-tabs/examples/src/App.js",[],"/Users/maslianok/www/react-responsive-tabs/examples/src/TabsRemoval/index.js",["22"],"/Users/maslianok/www/react-responsive-tabs/examples/src/Basic/index.js",[],"/Users/maslianok/www/react-responsive-tabs/examples/src/dummyData.js",[],{"ruleId":"23","severity":1,"message":"24","line":36,"column":7,"nodeType":"25","messageId":"26","endLine":36,"endColumn":48},"no-alert","Unexpected alert.","CallExpression","unexpected"] -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "classnames": "^2.3.1", 7 | "linklocal": "^2.8.2", 8 | "react": "^17.0.2", 9 | "react-dom": "^17.0.2", 10 | "react-ga": "^3.3.0", 11 | "react-helmet": "^6.1.0", 12 | "react-responsive-tabs": "file:../", 13 | "react-scripts": "4.0.3" 14 | }, 15 | "scripts": { 16 | "start": "linklocal && react-scripts start", 17 | "build": "react-scripts build", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": "react-app" 22 | }, 23 | "browserslist": { 24 | "production": [ 25 | ">0.2%", 26 | "not dead", 27 | "not op_mini all" 28 | ], 29 | "development": [ 30 | "last 1 chrome version", 31 | "last 1 firefox version", 32 | "last 1 safari version" 33 | ] 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /examples/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maslianok/react-responsive-tabs/ca3a9fa2adc48b7fbf0fa0a16a4795f2b10d82ed/examples/public/favicon.ico -------------------------------------------------------------------------------- /examples/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | React responsive tabs 9 | 14 | 18 | 19 | 28 | React App 29 | 30 | 31 | 32 |
33 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /examples/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /examples/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import ReactGA from 'react-ga'; 3 | import { Helmet } from 'react-helmet'; 4 | import cs from 'classnames'; 5 | 6 | import 'react-responsive-tabs/styles.css'; 7 | import './index.css'; 8 | 9 | import BasicExample from './Basic'; 10 | import TabsRemovalExample from './TabsRemoval'; 11 | 12 | ReactGA.initialize('UA-94085609-1'); 13 | ReactGA.set({ page: window.location.pathname }); 14 | ReactGA.pageview(window.location.pathname); 15 | 16 | class App extends PureComponent { 17 | constructor(props) { 18 | super(props); 19 | 20 | this.state = { 21 | active: 'basic', 22 | }; 23 | } 24 | 25 | onChangeExample = type => () => this.setState({ active: type }); 26 | 27 | render() { 28 | const { active } = this.state; 29 | return ( 30 |
31 | 48 |
49 |
react-responsive-tabs
50 | 64 |
65 |
66 |
72 | basic usage 73 |
74 |
80 | tabs removal 81 |
82 |
83 | 84 | {active === 'basic' && } 85 | {active === 'removal' && } 86 |
87 | ); 88 | } 89 | } 90 | 91 | export default App; 92 | -------------------------------------------------------------------------------- /examples/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /examples/src/Basic/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import React, { PureComponent } from 'react'; 3 | import Tabs from 'react-responsive-tabs'; 4 | 5 | import dummyData from '../dummyData'; 6 | 7 | import './styles.css'; 8 | 9 | export class Basic extends PureComponent { 10 | constructor(props) { 11 | super(props); 12 | 13 | this.state = { 14 | showMore: true, 15 | transform: true, 16 | showInkBar: false, 17 | items: this.getSimpleTabs(), 18 | selectedTabKey: 0, 19 | unmountOnExit: true, 20 | }; 21 | } 22 | 23 | onChangeProp = propsName => evt => { 24 | this.setState({ [propsName]: evt.target.type === 'checkbox' ? evt.target.checked : +evt.target.value }); 25 | }; 26 | 27 | getSimpleTabs = () => 28 | dummyData.map(({ name, biography }, index) => ({ 29 | key: index, 30 | title: name, 31 | getContent: () => biography, 32 | })); 33 | 34 | render() { 35 | const { showMore, transform, showInkBar, selectedTabKey, unmountOnExit } = this.state; 36 | 37 | return ( 38 |
39 |
40 |
41 | 45 |
46 |
47 | 51 |
52 |
53 | 57 |
58 |
59 | 63 |
64 |
65 | 75 |
76 |
77 |
78 | 79 |
80 |
81 | ); 82 | } 83 | } 84 | 85 | export default Basic; 86 | -------------------------------------------------------------------------------- /examples/src/Basic/styles.css: -------------------------------------------------------------------------------- 1 | /* basic example */ 2 | .basic__wrapper { 3 | display: flex; 4 | flex-grow: 1; 5 | } 6 | 7 | .basic__props { 8 | width: 260px; 9 | flex-shrink: 0; 10 | padding: 10px; 11 | border-right: 1px solid #f0f0f0; 12 | } 13 | 14 | .basic__prop { 15 | margin: 10px 0; 16 | } 17 | 18 | .basic__input { 19 | width: 40px; 20 | } 21 | 22 | .basic__tabs { 23 | padding: 10px; 24 | flex-grow: 1; 25 | } 26 | -------------------------------------------------------------------------------- /examples/src/TabsRemoval/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import Tabs from 'react-responsive-tabs'; 3 | 4 | import dummyData from '../dummyData'; 5 | 6 | import './styles.css'; 7 | 8 | export class TabsRemoval extends PureComponent { 9 | constructor(props) { 10 | super(props); 11 | 12 | this.state = { 13 | items: this.getTabs(), 14 | selectedTabKey: 0, 15 | }; 16 | } 17 | 18 | onChangeProp = propsName => evt => { 19 | this.setState({ [propsName]: evt.target.type === 'checkbox' ? evt.target.checked : +evt.target.value }); 20 | }; 21 | 22 | onRemoveTab = (key, evt) => { 23 | evt.stopPropagation(); 24 | 25 | // current tabs 26 | const { items } = this.state; 27 | 28 | // find index to remove 29 | const indexToRemove = items.findIndex(tab => tab.key === key); 30 | 31 | // create a new array without [indexToRemove] item 32 | const newTabs = [...items.slice(0, indexToRemove), ...items.slice(indexToRemove + 1)]; 33 | 34 | const nextSelectedIndex = newTabs[indexToRemove] ? indexToRemove : indexToRemove - 1; 35 | if (!newTabs[nextSelectedIndex]) { 36 | alert('You can not delete the last tab!'); 37 | return; 38 | } 39 | 40 | this.setState({ items: newTabs, selectedTabKey: newTabs[nextSelectedIndex].key }); 41 | }; 42 | 43 | getTabs = () => 44 | // eslint-disable-next-line 45 | dummyData.map(({ name, biography }, i) => ({ 46 | key: i, 47 | title: ( 48 |
49 |
{name}
50 |
51 | ), 52 | getContent: () => biography, 53 | tabClassName: 'tab-wrapper', 54 | })); 55 | 56 | render() { 57 | const { items, selectedTabKey } = this.state; 58 | return ( 59 |
60 | 61 |
62 | ); 63 | } 64 | } 65 | 66 | export default TabsRemoval; 67 | -------------------------------------------------------------------------------- /examples/src/TabsRemoval/styles.css: -------------------------------------------------------------------------------- 1 | .itemRemoval__wrapper { 2 | padding: 10px; 3 | } 4 | 5 | .tab-wrapper { 6 | display: flex; 7 | align-items: center; 8 | padding: 0; 9 | } 10 | 11 | .tab-container { 12 | position: relative; 13 | } 14 | 15 | .tab-name { 16 | margin-right: 10px; 17 | padding: 10px; 18 | } 19 | 20 | .tab-cross-icon { 21 | font-size: 12px; 22 | position: absolute; 23 | top: 0; 24 | bottom: 0; 25 | right: 0; 26 | width: 20px; 27 | display: flex; 28 | align-items: center; 29 | justify-content: center; 30 | font-weight: bold; 31 | } -------------------------------------------------------------------------------- /examples/src/dummyData.js: -------------------------------------------------------------------------------- 1 | /* eslint max-len: 0 */ 2 | 3 | export const presidents = [ 4 | { 5 | name: 'George Washington', 6 | biography: 'George Washington (February 22, 1732 – December 14, 1799) was the first President of the United States...', 7 | }, 8 | { 9 | name: 'Thomas Jefferson', 10 | biography: 'Thomas Jefferson (April 13 1743 – July 4, 1826) was an American lawyer', 11 | }, 12 | { 13 | name: 'Abraham Lincoln', 14 | biography: 'Abraham Lincoln (February 12, 1809 – April 15, 1865) was the 16th President of the United States', 15 | }, 16 | { 17 | name: 'Benjamin Harrison', 18 | biography: 'Benjamin Harrison (August 20, 1833 – March 13, 1901) was the 23rd President of the United States', 19 | }, 20 | { 21 | name: 'William McKinley', 22 | biography: 'William McKinley (January 29, 1843 – September 14, 1901) was the 25th President of the United States', 23 | }, 24 | { 25 | name: 'Franklin D. Roosevelt', 26 | biography: 'Franklin Delano Roosevelt (January 30, 1882 – April 12, 1945), commonly known by his initials FDR, was an American statesman', 27 | }, 28 | { 29 | name: 'Theodore Roosevelt', 30 | biography: 'Theodore Roosevelt (October 27, 1858 – January 6, 1919), often referred to as Teddy or TR...', 31 | }, 32 | ]; 33 | 34 | export default presidents; 35 | -------------------------------------------------------------------------------- /examples/src/index.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | min-height: 100%; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | padding: 0; 8 | font-family: sans-serif; 9 | } 10 | 11 | .container { 12 | display: flex; 13 | flex-direction: column; 14 | height: 100%; 15 | } 16 | 17 | .jumbotron { 18 | display: flex; 19 | align-items: center; 20 | justify-content: center; 21 | flex-direction: column; 22 | height: 300px; 23 | background-color: #6f5499; 24 | color: #cdbfe3; 25 | 26 | font-size: 32px; 27 | } 28 | 29 | .github-link { 30 | margin-top: 20px; 31 | } 32 | 33 | .menu { 34 | display: flex; 35 | align-items: stretch; 36 | justify-content: center; 37 | color: #563d7c; 38 | border-bottom: 1px solid #f0f0f0; 39 | } 40 | 41 | .menu-item { 42 | font-size: 18px; 43 | font-variant: small-caps; 44 | padding: 18px; 45 | cursor: pointer; 46 | } 47 | 48 | .menu-item:hover, 49 | .menu-item--active { 50 | background-color: #f9f9f9; 51 | text-decoration: underline; 52 | } 53 | 54 | -------------------------------------------------------------------------------- /examples/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './App'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Vitalii Maslianok (https://github.com/maslianok)", 3 | "version": "4.4.3", 4 | "name": "react-responsive-tabs", 5 | "description": "React responsive tabs", 6 | "license": "MIT", 7 | "main": "lib/index.js", 8 | "bugs": { 9 | "url": "https://github.com/maslianok/react-responsive-tabs/issues" 10 | }, 11 | "directories": { 12 | "example": "examples" 13 | }, 14 | "homepage": "https://github.com/maslianok/react-responsive-tabs", 15 | "keywords": [ 16 | "react", 17 | "responsive", 18 | "tabs", 19 | "tab" 20 | ], 21 | "maintainers": [ 22 | "maslianok " 23 | ], 24 | "repository": { 25 | "type": "git", 26 | "url": "git+https://github.com/maslianok/react-responsive-tabs.git" 27 | }, 28 | "peerDependencies": { 29 | "react": "^15.1.0 || ^16.0.0 || ^17.0.0", 30 | "react-dom": "^15.1.0 || ^16.0.0 || ^17.0.0" 31 | }, 32 | "dependencies": { 33 | "classnames": "^2.3.1", 34 | "lodash.throttle": "^4.1.1", 35 | "prop-types": "^15.7.2", 36 | "react-resize-detector": "^6.7.5" 37 | }, 38 | "devDependencies": { 39 | "@babel/cli": "^7.14.8", 40 | "@babel/core": "^7.15.0", 41 | "@babel/plugin-proposal-class-properties": "^7.14.5", 42 | "@babel/preset-env": "^7.15.0", 43 | "@babel/preset-react": "^7.14.5", 44 | "babel-eslint": "^10.1.0", 45 | "babel-plugin-module-resolver": "^4.1.0", 46 | "babel-plugin-transform-imports": "^2.0.0", 47 | "cross-env": "^7.0.3", 48 | "eslint": "^7.32.0", 49 | "eslint-config-airbnb": "^18.2.1", 50 | "eslint-plugin-import": "^2.24.0", 51 | "eslint-plugin-jsx-a11y": "^6.4.1", 52 | "eslint-plugin-react": "^7.24.0", 53 | "react": "^17.0.2", 54 | "react-dom": "^17.0.2", 55 | "rimraf": "^3.0.2" 56 | }, 57 | "scripts": { 58 | "build": "npm run build:cjs && npm run build:esm", 59 | "build:cjs": "babel src --out-dir lib", 60 | "build:esm": "cross-env BABEL_ENV=esm babel src --out-dir lib/esm", 61 | "clean": "rimraf lib", 62 | "lint": "eslint -c .eslintrc src", 63 | "test": "npm run lint && npm run clean && npm run build" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/maslianok/react-responsive-tabs/ca3a9fa2adc48b7fbf0fa0a16a4795f2b10d82ed/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 16 | React App 17 | 18 | 19 |
20 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/components/InkBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export const InkBar = ({ left, width }) => ( 5 |
6 |
7 |
8 | ); 9 | 10 | export default InkBar; 11 | 12 | InkBar.propTypes = { 13 | left: PropTypes.number, 14 | width: PropTypes.number 15 | }; 16 | 17 | InkBar.defaultProps = { 18 | left: 0, 19 | width: 0 20 | }; 21 | -------------------------------------------------------------------------------- /src/components/ShowMore.js: -------------------------------------------------------------------------------- 1 | /* eslint jsx-a11y/no-noninteractive-element-interactions: 0, jsx-a11y/no-noninteractive-tabindex: 0 */ 2 | 3 | import React, { Component } from 'react'; 4 | import classNames from 'classnames'; 5 | import PropTypes from 'prop-types'; 6 | 7 | export default class ShowMore extends Component { 8 | constructor() { 9 | super(); 10 | 11 | this.state = { 12 | isFocused: false, 13 | isHidden: true 14 | }; 15 | } 16 | 17 | componentDidMount() { 18 | if (typeof window !== 'undefined') { 19 | window.addEventListener('click', this.close); 20 | window.addEventListener('keydown', this.onKeyDown); 21 | } 22 | } 23 | 24 | shouldComponentUpdate(nextProps, nextState) { 25 | const { children, isShown, hasChildSelected } = this.props; 26 | return ( 27 | children.length !== nextProps.children.length || 28 | isShown !== nextProps.isShown || 29 | hasChildSelected !== nextProps.hasChildSelected || 30 | this.state !== nextState 31 | ); 32 | } 33 | 34 | componentWillUnmount() { 35 | if (typeof window !== 'undefined') { 36 | window.removeEventListener('click', this.close); 37 | window.removeEventListener('keydown', this.onKeyDown); 38 | } 39 | } 40 | 41 | onFocus = () => this.setState({ isFocused: true }); 42 | 43 | onBlur = () => this.setState({ isFocused: false }); 44 | 45 | onKeyDown = event => { 46 | const { isFocused, isHidden } = this.state; 47 | if (event.keyCode === 13) { 48 | if (isFocused) { 49 | this.setState({ isHidden: !isHidden }); 50 | } else if (!isHidden) { 51 | this.setState({ isHidden: true }); 52 | } 53 | } 54 | }; 55 | 56 | close = () => { 57 | const { isHidden } = this.state; 58 | if (!isHidden) { 59 | this.setState({ isHidden: true }); 60 | } 61 | }; 62 | 63 | toggleVisibility = event => { 64 | const { isHidden } = this.state; 65 | event.stopPropagation(); 66 | this.setState({ isHidden: !isHidden }); 67 | }; 68 | 69 | render() { 70 | const { isShown, children, onShowMoreChanged, hasChildSelected, label } = this.props; 71 | const { isHidden } = this.state; 72 | if (!isShown || !children || !children.length) { 73 | return null; 74 | } 75 | 76 | const isListHidden = isHidden; 77 | const showMoreStyles = classNames({ 78 | RRT__showmore: true, 79 | 'RRT__showmore--selected': hasChildSelected 80 | }); 81 | 82 | const listStyles = classNames({ 83 | 'RRT__showmore-list': true, 84 | 'RRT__showmore-list--opened': !isListHidden 85 | }); 86 | 87 | const showMoreLabelStyles = classNames({ 88 | 'RRT__showmore-label': true, 89 | 'RRT__showmore-label--selected': !isListHidden 90 | }); 91 | 92 | return ( 93 |
102 |
{label}
103 |
104 | {children} 105 |
106 |
107 | ); 108 | } 109 | } 110 | 111 | ShowMore.propTypes = { 112 | children: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.string]), 113 | hasChildSelected: PropTypes.bool, 114 | isShown: PropTypes.bool.isRequired, 115 | onShowMoreChanged: PropTypes.func, 116 | label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]) 117 | }; 118 | 119 | ShowMore.defaultProps = { 120 | children: undefined, 121 | hasChildSelected: false, 122 | label: '...', 123 | onShowMoreChanged: () => null 124 | }; 125 | -------------------------------------------------------------------------------- /src/components/Tab.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class Tab extends Component { 5 | shouldComponentUpdate(nextProps) { 6 | const { children, selected, classNames } = this.props; 7 | return children !== nextProps.children || selected !== nextProps.selected || classNames !== nextProps.classNames; 8 | } 9 | 10 | onTabClick = evt => { 11 | const { onClick, originalKey } = this.props; 12 | onClick(originalKey, evt); 13 | }; 14 | 15 | renderRemovableTab = () => { 16 | const { children, onRemove } = this.props; 17 | return ( 18 |
19 |
{children}
20 |
21 | x 22 |
23 |
24 | ); 25 | }; 26 | 27 | renderTab = () => { 28 | const { children, allowRemove } = this.props; 29 | 30 | if (allowRemove) { 31 | return this.renderRemovableTab(); 32 | } 33 | 34 | return children; 35 | }; 36 | 37 | render() { 38 | const { id, classNames, selected, disabled, panelId, onFocus, onBlur, originalKey } = this.props; 39 | 40 | return ( 41 |
(this.tab = e)} 43 | role="tab" 44 | className={classNames} 45 | id={id} 46 | aria-selected={selected ? 'true' : 'false'} 47 | aria-expanded={selected ? 'true' : 'false'} 48 | aria-disabled={disabled ? 'true' : 'false'} 49 | aria-controls={panelId} 50 | tabIndex="0" 51 | onClick={this.onTabClick} 52 | onFocus={onFocus(originalKey)} 53 | onBlur={onBlur} 54 | > 55 | {this.renderTab()} 56 |
57 | ); 58 | } 59 | } 60 | 61 | Tab.propTypes = { 62 | children: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.string]), 63 | disabled: PropTypes.bool, 64 | 65 | // generic props 66 | panelId: PropTypes.string.isRequired, 67 | selected: PropTypes.bool.isRequired, 68 | onClick: PropTypes.func.isRequired, 69 | onRemove: PropTypes.func, 70 | onFocus: PropTypes.func.isRequired, 71 | onBlur: PropTypes.func.isRequired, 72 | allowRemove: PropTypes.bool, 73 | id: PropTypes.string.isRequired, 74 | originalKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, 75 | classNames: PropTypes.string.isRequired, 76 | }; 77 | 78 | Tab.defaultProps = { 79 | children: undefined, 80 | onRemove: () => {}, 81 | allowRemove: false, 82 | disabled: false, 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/TabPanel.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class TabPanel extends Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { 8 | renderedAtLeastOnce: !props.isHidden, 9 | }; 10 | } 11 | 12 | static getDerivedStateFromProps(props, state) { 13 | return { 14 | renderedAtLeastOnce: state.renderedAtLeastOnce || !props.isHidden, 15 | }; 16 | } 17 | 18 | shouldComponentUpdate(nextProps) { 19 | const { children, getContent, classNames, isHidden } = this.props; 20 | return ( 21 | getContent !== nextProps.getContent || 22 | children !== nextProps.children || 23 | classNames !== nextProps.classNames || 24 | isHidden !== nextProps.isHidden 25 | ); 26 | } 27 | 28 | render() { 29 | const { classNames, id, tabId, children, getContent, isHidden } = this.props; 30 | const { renderedAtLeastOnce } = this.state; 31 | 32 | return ( 33 |
34 | {getContent && renderedAtLeastOnce && getContent()} 35 | {!getContent && children} 36 |
37 | ); 38 | } 39 | } 40 | 41 | TabPanel.propTypes = { 42 | getContent: PropTypes.func, 43 | children: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.string]), 44 | id: PropTypes.string.isRequired, 45 | isHidden: PropTypes.bool, 46 | 47 | // generic props 48 | classNames: PropTypes.string.isRequired, 49 | tabId: PropTypes.string.isRequired, 50 | }; 51 | 52 | TabPanel.defaultProps = { 53 | getContent: undefined, 54 | children: undefined, 55 | isHidden: false, 56 | }; 57 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component, createRef } from 'react'; 2 | import ResizeDetector from 'react-resize-detector/build/withPolyfill'; 3 | import cs from 'classnames'; 4 | import throttle from 'lodash.throttle'; 5 | import PropTypes from 'prop-types'; 6 | import ShowMore from './components/ShowMore'; 7 | import Tab from './components/Tab'; 8 | import TabPanel from './components/TabPanel'; 9 | import InkBar from './components/InkBar'; 10 | 11 | const tabPrefix = 'tab-'; 12 | const panelPrefix = 'panel-'; 13 | 14 | export default class Tabs extends Component { 15 | constructor(props) { 16 | super(props); 17 | 18 | this.tabRefs = {}; 19 | this.tabsWrapper = createRef(); 20 | this.selectedTabKeyProp = props.selectedTabKey; 21 | 22 | this.state = { 23 | tabDimensions: {}, 24 | blockWidth: 0, 25 | tabsTotalWidth: 0, 26 | showMoreWidth: 40, 27 | selectedTabKey: props.selectedTabKey, 28 | focusedTabKey: null, 29 | }; 30 | 31 | this.onResizeThrottled = throttle(this.onResize, props.resizeThrottle, { trailing: true }); 32 | } 33 | 34 | componentDidMount() { 35 | this.setTabsDimensions(); 36 | } 37 | 38 | shouldComponentUpdate(nextProps, nextState) { 39 | const { selectedTabKey, tabsTotalWidth, blockWidth, showMoreWidth } = this.state; 40 | const { items, transform, showMore, showInkBar, allowRemove, removeActiveOnly, uid } = this.props; 41 | 42 | return ( 43 | items !== nextProps.items || 44 | nextProps.uid !== uid || 45 | nextProps.transform !== transform || 46 | nextProps.showMore !== showMore || 47 | nextProps.showInkBar !== showInkBar || 48 | nextProps.allowRemove !== allowRemove || 49 | nextProps.removeActiveOnly !== removeActiveOnly || 50 | nextState.tabsTotalWidth !== tabsTotalWidth || 51 | nextState.blockWidth !== blockWidth || 52 | nextState.showMoreWidth !== showMoreWidth || 53 | nextProps.selectedTabKey !== this.selectedTabKeyProp || 54 | nextState.selectedTabKey !== selectedTabKey 55 | ); 56 | } 57 | 58 | componentDidUpdate(prevProps) { 59 | const { uid, items, selectedTabKey } = this.props; 60 | 61 | if (this.selectedTabKeyProp !== selectedTabKey) { 62 | // eslint-disable-next-line react/no-did-update-set-state 63 | this.setState({ selectedTabKey }); 64 | } 65 | 66 | if ( 67 | uid !== prevProps.uid || 68 | items.length !== prevProps.items.length || 69 | items.every((item, i) => item.title !== prevProps.items[i].title) 70 | ) { 71 | this.setTabsDimensions(); 72 | } 73 | 74 | this.selectedTabKeyProp = selectedTabKey; 75 | } 76 | 77 | onResize = () => { 78 | if (this.tabsWrapper.current) { 79 | const currentIsCollapsed = this.getIsCollapsed(); 80 | this.setState({ blockWidth: this.tabsWrapper.current.offsetWidth }, () => { 81 | const { items } = this.props; 82 | const { selectedTabKey } = this.state; 83 | const nextIsCollapsed = this.getIsCollapsed(); 84 | if (currentIsCollapsed && !nextIsCollapsed && selectedTabKey === -1 && items && items.length) { 85 | const firstTabKey = items[0].key || 0; 86 | this.setState({ selectedTabKey: firstTabKey }); 87 | } 88 | }); 89 | } 90 | }; 91 | 92 | onChangeTab = (nextTabKey, evt) => { 93 | const { beforeChange, onChange } = this.props; 94 | const { selectedTabKey } = this.state; 95 | 96 | if (typeof beforeChange === 'function') { 97 | const beforeChangeRes = beforeChange({ selectedTabKey, nextTabKey }); 98 | if (beforeChangeRes === false) { 99 | evt.preventDefault(); 100 | return; 101 | } 102 | } 103 | 104 | const isCollapsed = this.getIsCollapsed(); 105 | if (isCollapsed && selectedTabKey === nextTabKey) { 106 | // hide on mobile 107 | this.setState({ selectedTabKey: -1 }); 108 | } else { 109 | // change active tab 110 | this.setState({ selectedTabKey: nextTabKey }); 111 | } 112 | 113 | if (onChange) { 114 | onChange(nextTabKey); 115 | } 116 | }; 117 | 118 | onFocusTab = focusedTabKey => () => this.setState({ focusedTabKey }); 119 | 120 | onBlurTab = () => this.setState({ focusedTabKey: null }); 121 | 122 | onKeyDown = event => { 123 | const { focusedTabKey } = this.state; 124 | if (event.keyCode === 13 && focusedTabKey !== null) { 125 | this.setState({ selectedTabKey: focusedTabKey }); 126 | } 127 | }; 128 | 129 | setTabsDimensions = () => { 130 | if (!this.tabsWrapper.current) { 131 | // it shouldn't happen ever. Just a paranoic check 132 | return; 133 | } 134 | 135 | const { tabDimensions } = this.state; 136 | 137 | // initial wrapper width calculation 138 | const blockWidth = this.tabsWrapper.current.offsetWidth; 139 | 140 | // calculate width and offset for each tab 141 | let tabsTotalWidth = 0; 142 | const tabDimensionsNext = {}; 143 | Object.keys(this.tabRefs).forEach(key => { 144 | if (this.tabRefs[key]) { 145 | const tabKey = key.replace(tabPrefix, ''); 146 | const width = this.tabRefs[key].tab.offsetWidth; 147 | if (width) { 148 | tabDimensionsNext[tabKey] = { width, offset: tabsTotalWidth }; 149 | } else { 150 | tabDimensionsNext[tabKey] = tabDimensions[tabKey]; 151 | } 152 | 153 | tabsTotalWidth += tabDimensionsNext[tabKey].width; 154 | } 155 | }); 156 | 157 | this.setState({ tabDimensions: tabDimensionsNext, tabsTotalWidth, blockWidth }); 158 | }; 159 | 160 | getTabs = () => { 161 | const { showMore, transform, transformWidth, items, allowRemove, removeActiveOnly, onRemove } = this.props; 162 | const { blockWidth, tabsTotalWidth, tabDimensions, showMoreWidth } = this.state; 163 | const selectedTabKey = this.getSelectedTabKey(); 164 | const collapsed = blockWidth && transform && blockWidth < transformWidth; 165 | 166 | let tabIndex = 0; 167 | let availableWidth = blockWidth - (tabsTotalWidth > blockWidth ? showMoreWidth : 0); 168 | 169 | return items.reduce( 170 | (result, item, index) => { 171 | const { key = index, title, content, getContent, disabled, tabClassName, panelClassName } = item; 172 | 173 | const selected = selectedTabKey === key; 174 | const payload = { tabIndex, collapsed, selected, disabled, key }; 175 | const tabPayload = { 176 | ...payload, 177 | title, 178 | onRemove: evt => { 179 | if (typeof onRemove === 'function') { 180 | onRemove(key, evt); 181 | } 182 | }, 183 | allowRemove: allowRemove && (!removeActiveOnly || selected), 184 | className: tabClassName, 185 | }; 186 | 187 | const panelPayload = { 188 | ...payload, 189 | content, 190 | getContent, 191 | className: panelClassName, 192 | }; 193 | 194 | const tabWidth = tabDimensions[key] ? tabDimensions[key].width : 0; 195 | 196 | tabIndex += 1; 197 | 198 | /* eslint-disable no-param-reassign */ 199 | if ( 200 | // don't need to `Show more` button 201 | !showMore || 202 | // initial call 203 | !blockWidth || 204 | // collapsed mode 205 | collapsed || 206 | // all tabs are fit into the block 207 | blockWidth > tabsTotalWidth || 208 | // current tab fit into the block 209 | availableWidth - tabWidth > 0 210 | ) { 211 | result.tabsVisible.push(tabPayload); 212 | } else { 213 | result.tabsHidden.push(tabPayload); 214 | if (selected) result.isSelectedTabHidden = true; 215 | } 216 | /* eslint-enable no-param-reassign */ 217 | 218 | result.panels[key] = panelPayload; // eslint-disable-line no-param-reassign 219 | availableWidth -= tabWidth; 220 | 221 | return result; 222 | }, 223 | { tabsVisible: [], tabsHidden: [], panels: {}, isSelectedTabHidden: false }, 224 | ); 225 | }; 226 | 227 | getTabProps = ({ title, key, selected, collapsed, tabIndex, disabled, className, onRemove, allowRemove }) => ({ 228 | selected, 229 | allowRemove, 230 | children: title, 231 | key: tabPrefix + key, 232 | id: tabPrefix + key, 233 | ref: e => (this.tabRefs[tabPrefix + key] = e), 234 | originalKey: key, 235 | onClick: this.onChangeTab, 236 | onFocus: this.onFocusTab, 237 | onBlur: this.onBlurTab, 238 | onRemove, 239 | panelId: panelPrefix + key, 240 | classNames: this.getClassNamesFor('tab', { 241 | selected, 242 | collapsed, 243 | tabIndex, 244 | disabled, 245 | className, 246 | }), 247 | }); 248 | 249 | getPanelProps = ({ key, content, getContent, className }, isHidden) => ({ 250 | getContent, 251 | children: content, 252 | key: panelPrefix + key, 253 | id: panelPrefix + key, 254 | tabId: tabPrefix + key, 255 | classNames: this.getClassNamesFor('panel', { className, isHidden }), 256 | isHidden, 257 | }); 258 | 259 | getShowMoreProps = (isShown, isSelectedTabHidden, showMoreLabel) => ({ 260 | onShowMoreChanged: this.showMoreChanged, 261 | isShown, 262 | label: showMoreLabel, 263 | hasChildSelected: isSelectedTabHidden, 264 | }); 265 | 266 | getClassNamesFor = (type, { selected, collapsed, tabIndex, disabled, className = '', isHidden }) => { 267 | const { tabClass, panelClass } = this.props; 268 | switch (type) { 269 | case 'tab': 270 | return cs('RRT__tab', className, tabClass, { 271 | 'RRT__tab--first': !tabIndex, 272 | 'RRT__tab--selected': selected, 273 | 'RRT__tab--disabled': disabled, 274 | 'RRT__tab--collapsed': collapsed, 275 | }); 276 | case 'panel': 277 | return cs('RRT__panel', className, panelClass, { 'RRT__panel--hidden': isHidden }); 278 | default: 279 | return ''; 280 | } 281 | }; 282 | 283 | getSelectedTabKey = () => { 284 | const { items } = this.props; 285 | const { selectedTabKey } = this.state; 286 | 287 | if (typeof selectedTabKey === 'undefined') { 288 | if (!items[0]) { 289 | return undefined; 290 | } 291 | 292 | return items[0].key || 0; 293 | } 294 | 295 | return selectedTabKey; 296 | }; 297 | 298 | getIsCollapsed = () => { 299 | const { transform, transformWidth } = this.props; 300 | const { blockWidth } = this.state; 301 | return blockWidth && transform && blockWidth < transformWidth; 302 | }; 303 | 304 | showMoreChanged = element => { 305 | if (!element) { 306 | return; 307 | } 308 | 309 | const { showMoreWidth } = this.state; 310 | const { offsetWidth } = element; 311 | if (showMoreWidth === offsetWidth) { 312 | return; 313 | } 314 | 315 | this.setState({ 316 | showMoreWidth: offsetWidth, 317 | }); 318 | }; 319 | 320 | getExpandedTabs = (panels, selectedTabKey, isCollapsed) => { 321 | const { unmountOnExit } = this.props; 322 | if (isCollapsed) { 323 | return undefined; 324 | } 325 | 326 | if (!unmountOnExit) { 327 | // render all tabs if unmountOnExit === false (inactive are hidden) 328 | return Object.keys(panels).map(key => ( 329 | 330 | )); 331 | } 332 | 333 | if (panels[selectedTabKey]) { 334 | // render only active tab if unmountOnExit === true 335 | return ; 336 | } 337 | 338 | return undefined; 339 | }; 340 | 341 | render() { 342 | const { showInkBar, containerClass, tabsWrapperClass, showMore, transform, showMoreLabel, unmountOnExit } = 343 | this.props; 344 | const { tabDimensions } = this.state; 345 | const { tabsVisible, tabsHidden, panels, isSelectedTabHidden } = this.getTabs(); 346 | const isCollapsed = this.getIsCollapsed(); 347 | const selectedTabKey = this.getSelectedTabKey(); 348 | const selectedTabDimensions = tabDimensions[selectedTabKey] || {}; 349 | 350 | const containerClasses = cs('RRT__container', containerClass); 351 | const tabsClasses = cs('RRT__tabs', tabsWrapperClass, { RRT__accordion: isCollapsed }); 352 | 353 | const handleResize = showMore || transform; 354 | 355 | return ( 356 | 362 | {() => ( 363 |
364 |
365 | {tabsVisible.reduce((result, tab) => { 366 | result.push(); 367 | 368 | if (isCollapsed && (!unmountOnExit || selectedTabKey === tab.key)) { 369 | result.push(); 370 | } 371 | return result; 372 | }, [])} 373 | 374 | {!isCollapsed && ( 375 | 376 | {tabsHidden.map(tab => ( 377 | 378 | ))} 379 | 380 | )} 381 |
382 | 383 | {showInkBar && !isCollapsed && !isSelectedTabHidden && ( 384 | 385 | )} 386 | 387 | {this.getExpandedTabs(panels, selectedTabKey, isCollapsed)} 388 |
389 | )} 390 |
391 | ); 392 | } 393 | } 394 | 395 | Tabs.propTypes = { 396 | /* eslint-disable react/no-unused-prop-types */ 397 | // list of tabs 398 | items: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), 399 | /* eslint-enable react/no-unused-prop-types */ 400 | // selected tab key 401 | selectedTabKey: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 402 | // show 'X' and remove tab 403 | allowRemove: PropTypes.bool, 404 | // show 'X' closing element only for active tab 405 | removeActiveOnly: PropTypes.bool, 406 | // move tabs to the special `Show more` tab if they don't fit into a screen 407 | showMore: PropTypes.bool, 408 | // materialUI-like rail under the selected tab 409 | showInkBar: PropTypes.bool, 410 | // transform to the accordion on small screens 411 | transform: PropTypes.bool, 412 | // tabs will be transformed to accodrion for screen sizes below `transformWidth`px 413 | transformWidth: PropTypes.number, 414 | // beforeChange callback: return false to prevent tab change 415 | beforeChange: PropTypes.func, 416 | // onChange active tab callback 417 | onChange: PropTypes.func, 418 | // onRemove callback 419 | onRemove: PropTypes.func, 420 | // frequency of onResize recalculation fires 421 | resizeThrottle: PropTypes.number, 422 | // unmounts the tab when it gets inactive (unselected) 423 | unmountOnExit: PropTypes.bool, 424 | // classnames 425 | containerClass: PropTypes.string, 426 | tabsWrapperClass: PropTypes.string, 427 | tabClass: PropTypes.string, 428 | panelClass: PropTypes.string, 429 | // optional external id. Force rerender when it changes 430 | // eslint-disable-next-line react/forbid-prop-types 431 | uid: PropTypes.any, 432 | // labels 433 | showMoreLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.node]), 434 | }; 435 | 436 | Tabs.defaultProps = { 437 | items: [], 438 | uid: undefined, 439 | selectedTabKey: undefined, 440 | showMore: true, 441 | showInkBar: false, 442 | allowRemove: false, 443 | removeActiveOnly: false, 444 | transform: true, 445 | transformWidth: 800, 446 | resizeThrottle: 100, 447 | containerClass: undefined, 448 | tabsWrapperClass: undefined, 449 | tabClass: undefined, 450 | panelClass: undefined, 451 | showMoreLabel: '...', 452 | unmountOnExit: true, 453 | beforeChange: undefined, 454 | onChange: () => null, 455 | onRemove: () => null, 456 | }; 457 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | .RRT__container { 2 | position: relative; 3 | } 4 | 5 | /****************************/ 6 | /******** tab styles ********/ 7 | /****************************/ 8 | .RRT__tabs { 9 | display: flex; 10 | flex-wrap: wrap; 11 | } 12 | 13 | .RRT__accordion { 14 | flex-direction: column; 15 | } 16 | 17 | .RRT__tab { 18 | background: #eee; 19 | border-style: solid; 20 | border-color: #ddd; 21 | border-width: 1px 1px 1px 0; 22 | cursor: pointer; 23 | z-index: 1; 24 | white-space: nowrap; 25 | padding: 0.7em 1em; 26 | } 27 | 28 | .RRT__tab:focus { 29 | outline: 0; 30 | background-color: #e6e6e6; 31 | } 32 | 33 | .RRT__accordion .RRT__tab { 34 | border-left-width: 1px; 35 | } 36 | 37 | .RRT__tab--first { 38 | border-left-width: 1px; 39 | } 40 | 41 | .RRT__tab--selected { 42 | background: #fff; 43 | border-color: #ddd #ddd #fff; 44 | } 45 | 46 | .RRT__tab--selected:focus { 47 | background-color: #fff; 48 | } 49 | 50 | .RRT__tab--disabled { 51 | opacity: 0.5; 52 | cursor: not-allowed; 53 | } 54 | 55 | .RRT__tab:focus { 56 | z-index: 2; 57 | } 58 | 59 | .RRT__tab--selected .RRT__removable { 60 | position: relative; 61 | } 62 | 63 | .RRT__tab--selected .RRT__removable-text { 64 | margin-right: 10px; 65 | } 66 | 67 | .RRT__tab--selected .RRT__removable-icon { 68 | position: absolute; 69 | font-size: 18px; 70 | right: 0.5em; 71 | top: 0.2em; 72 | } 73 | 74 | /****************************/ 75 | /********* panel styles *****/ 76 | /****************************/ 77 | .RRT__panel { 78 | margin-top: -1px; 79 | padding: 1em; 80 | border: 1px solid #ddd; 81 | } 82 | 83 | .RRT__panel--hidden { 84 | display: none; 85 | } 86 | 87 | .RRT__accordion .RRT__panel { 88 | margin-top: 0; 89 | } 90 | 91 | /****************************/ 92 | /******* showmore control ***/ 93 | /****************************/ 94 | .RRT__showmore { 95 | background: #eee; 96 | border: 1px solid #ddd; 97 | cursor: pointer; 98 | z-index: 1; 99 | white-space: nowrap; 100 | margin-left: -1px; 101 | position: relative; 102 | } 103 | 104 | .RRT__showmore--selected { 105 | background: white; 106 | border-bottom: none; 107 | } 108 | 109 | .RRT__showmore-label { 110 | padding: 0.7em 1em; 111 | position: relative; 112 | bottom: -1px; 113 | z-index: 1; 114 | } 115 | 116 | .RRT__showmore-label--selected { 117 | background-color: #eee; 118 | } 119 | 120 | .RRT__showmore-list { 121 | position: absolute; 122 | right: -1px; 123 | top: 100%; 124 | display: none; 125 | } 126 | 127 | .RRT__showmore-list--opened { 128 | display: block; 129 | } 130 | 131 | /****************************/ 132 | /********** inkbar **********/ 133 | /****************************/ 134 | .RRT__inkbar-wrapper { 135 | width: 100%; 136 | } 137 | 138 | .RRT__inkbar { 139 | position: relative; 140 | bottom: 0; 141 | height: 2px; 142 | margin-top: -2px; 143 | background-color: deepskyblue; 144 | transition: left 800ms cubic-bezier(0.23, 1, 0.32, 1); 145 | z-index: 2; 146 | } 147 | --------------------------------------------------------------------------------