├── Procfile ├── .babelrc ├── .gitignore ├── index.js ├── demo.gif ├── .editorconfig ├── src ├── Tabs │ ├── prefixer.js │ ├── ListBorder.js │ ├── TabListItem.js │ ├── TabList.js │ ├── Animator.js │ ├── test.js │ └── index.js └── index.js ├── .eslintrc ├── index.html ├── server.js ├── webpack.config.js ├── LICENSE ├── webpack.prod.config.js ├── package.json ├── bin └── doc.js └── README.md /Procfile: -------------------------------------------------------------------------------- 1 | web: npm start 2 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | npm-debug.log 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./build/react-swipeable-tabs'); -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kareemaly/react-swipeable-tabs/HEAD/demo.gif -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = false 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /src/Tabs/prefixer.js: -------------------------------------------------------------------------------- 1 | import Prefixer from 'inline-style-prefixer' 2 | 3 | const prefixer = new Prefixer(); 4 | 5 | export default (styles) => prefixer.prefix(styles); -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import TabsTest from './Tabs/test'; 4 | 5 | // Needed for onTouchTap 6 | import injectTapEventPlugin from 'react-tap-event-plugin'; 7 | injectTapEventPlugin(); 8 | 9 | ReactDOM.render(, document.getElementById("root")); -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "ecmaFeatures": { 3 | "jsx": true, 4 | "modules": true 5 | }, 6 | "env": { 7 | "browser": true, 8 | "node": true 9 | }, 10 | "parser": "babel-eslint", 11 | "rules": { 12 | "quotes": [2, "single"], 13 | "strict": [2, "never"], 14 | "react/jsx-uses-react": 2, 15 | "react/jsx-uses-vars": 2, 16 | "react/react-in-jsx-scope": 2 17 | }, 18 | "plugins": [ 19 | "react" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | React Swipeable Tabs 10 | 11 | 12 | 13 |
14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var express = require('express'); 4 | var config = require('./webpack.config'); 5 | 6 | var app = express(); 7 | var compiler = webpack(config); 8 | 9 | app.use(require('webpack-dev-middleware')(compiler, { 10 | publicPath: config.output.publicPath, 11 | noInfo: true, 12 | silent: true, 13 | stats: 'errors-only', 14 | })); 15 | 16 | app.use(require('webpack-hot-middleware')(compiler)); 17 | 18 | app.get('*', function(req, res) { 19 | res.sendFile(path.join(__dirname, 'index.html')); 20 | }); 21 | 22 | const port = process.env.PORT || 3000; 23 | 24 | app.listen(port, function(err) { 25 | if (err) { 26 | return console.error(err); 27 | } 28 | 29 | console.log('Listening at http://localhost:' + port); 30 | }) 31 | -------------------------------------------------------------------------------- /src/Tabs/ListBorder.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import autoprefixer from './prefixer'; 3 | 4 | export default class ListBorder extends React.Component { 5 | static propTypes = { 6 | borderThickness: React.PropTypes.number, 7 | borderColor: React.PropTypes.string, 8 | borderWidth: React.PropTypes.number, 9 | borderTranslateX: React.PropTypes.number, 10 | }; 11 | 12 | getBorderStyle() { 13 | return autoprefixer({ 14 | height: this.props.borderThickness, 15 | background: this.props.borderColor, 16 | width: this.props.borderWidth, 17 | transform: `translate(${this.props.borderTranslateX}px, 0)`, 18 | }); 19 | } 20 | 21 | render() { 22 | return ( 23 |
25 |
26 | ); 27 | } 28 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | 4 | module.exports = { 5 | devtool: 'eval', 6 | entry: [ 7 | 'webpack-hot-middleware/client', 8 | path.resolve(__dirname, 'src/index.js') 9 | ], 10 | output: { 11 | path: path.resolve(__dirname, 'build'), 12 | filename: 'bundle.js', 13 | publicPath: '/static/' 14 | }, 15 | plugins: [ 16 | new webpack.HotModuleReplacementPlugin() 17 | ], 18 | module: { 19 | loaders: [{ 20 | test: /\.js$/, 21 | exclude: /node_modules/, 22 | loaders: ['react-hot', 'babel'] 23 | }] 24 | }, 25 | resolve: { 26 | modules: ['src', 'node_modules'], 27 | extensions: [ 28 | '', 29 | '.js', 30 | ], 31 | mainFields: [ 32 | 'jsnext:main', 33 | 'main', 34 | ], 35 | }, 36 | target: 'web', // Make web variables accessible to webpack, e.g. window 37 | stats: true, // Don't show stats in the console 38 | progress: false, 39 | }; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Kareem Mohamed 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 | -------------------------------------------------------------------------------- /webpack.prod.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var webpackUMDExternal = require('webpack-umd-external'); 4 | 5 | module.exports = { 6 | devtool: 'sourcemap', 7 | entry: { 8 | index: './src/Tabs/index.js' 9 | }, 10 | output: { 11 | path: path.join(__dirname, 'build'), 12 | publicPath: 'build/', 13 | filename: 'react-swipeable-tabs.js', 14 | sourceMapFilename: 'react-swipeable-tabs.map', 15 | library: 'ReactSwipeableTabs', 16 | libraryTarget: 'umd' 17 | }, 18 | plugins: [ 19 | new webpack.optimize.UglifyJsPlugin({ 20 | compress: { warnings: false }, 21 | output: { comments: false } 22 | }) 23 | ], 24 | module: { 25 | loaders: [{ 26 | test: /\.js$/, 27 | exclude: /node_modules/, 28 | loader: 'babel' 29 | }] 30 | }, 31 | resolve: { 32 | extensions: ['', '.js', '.jsx'] 33 | }, 34 | externals: webpackUMDExternal({ 35 | 'react': 'React', 36 | 'react-hammerjs': 'Hammerjs', 37 | 'react-motion': 'ReactMotion', 38 | 'lodash.differenceby': 'LodashDifferenceBy', 39 | "inline-style-prefixer": "InlineStylePrefixer", 40 | }), 41 | }; 42 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-swipeable-tabs", 3 | "version": "2.2.0", 4 | "description": "React Swipeable tabs", 5 | "homepage": "https://github.com/kareem3d/react-swipeable-tabs", 6 | "main": "index.js", 7 | "license": "MIT", 8 | "files": [ 9 | "src/", 10 | "build/" 11 | ], 12 | "author": "Kareem Mohamed ", 13 | "repository": { 14 | "type": "git", 15 | "url": "git://github.com/kareem3d/react-swipeable-tabs.git" 16 | }, 17 | "scripts": { 18 | "test": "echo \"Error: no test specified\" && exit 1", 19 | "doc": "node ./bin/doc.js", 20 | "prebuild": "npm run doc", 21 | "build": "webpack --config webpack.prod.config --progress --colors -p", 22 | "start": "node server.js", 23 | "lint": "eslint src" 24 | }, 25 | "keywords": [ 26 | "react-swipeable-tabs", 27 | "react-tabs", 28 | "react-component", 29 | "tab", 30 | "swipe", 31 | "scrollable" 32 | ], 33 | "devDependencies": { 34 | "babel-core": "^6.0.20", 35 | "babel-eslint": "^4.1.3", 36 | "babel-loader": "^6.0.1", 37 | "babel-preset-es2015": "^6.0.15", 38 | "babel-preset-react": "^6.0.15", 39 | "babel-preset-stage-0": "^6.0.15", 40 | "cross-env": "^3.1.3", 41 | "eslint": "^1.10.3", 42 | "eslint-plugin-react": "^3.6.2", 43 | "express": "^4.13.4", 44 | "glob": "^7.1.1", 45 | "material-ui": "^0.16.1", 46 | "react-docgen": "^2.14.1", 47 | "react-dom": "15.3.2", 48 | "react-hot-loader": "^1.3.0", 49 | "react-tap-event-plugin": "^1.0.0", 50 | "style-loader": "^0.13.1", 51 | "webpack": "^1.12.2", 52 | "webpack-dev-middleware": "^1.6.1", 53 | "webpack-hot-middleware": "^2.10.0", 54 | "webpack-umd-external": "^1.0.2" 55 | }, 56 | "dependencies": { 57 | "inline-style-prefixer": "^2.0.5", 58 | "lodash.differenceby": "^4.8.0", 59 | "react": "15.3.2", 60 | "react-hammerjs": "^0.5.0", 61 | "react-measure": "^2.0.2", 62 | "react-motion": "^0.4.5" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /bin/doc.js: -------------------------------------------------------------------------------- 1 | const reactDocs = require('react-docgen'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const _ = require('lodash'); 5 | const glob = require("glob"); 6 | 7 | glob(path.join(__dirname, '../src/*/index.js'), function(err, files) { 8 | if(files.length === 0) { 9 | throw new Error("No index file for the component!"); 10 | } 11 | if(files.length > 1) { 12 | throw new Error("More than index file found for the component!"); 13 | } 14 | const component = fs.readFileSync(files[0]).toString(); 15 | const readmeProps = getComponentReadmeProps(component); 16 | 17 | const actualReadme = fs.readFileSync(path.join(__dirname, '../README.md')).toString(); 18 | const pieces = actualReadme.split('Contributing'); 19 | const before = pieces[0].split('| Property')[0]; 20 | 21 | fs.writeFileSync(path.join(__dirname, '../README.md'), 22 | `${before}${readmeProps} 23 | 24 | Contributing${pieces[1]}`); 25 | }); 26 | 27 | 28 | const propTypeToString = (type) => { 29 | let propertyType = type.name; 30 | 31 | if(type.name === 'arrayOf') { 32 | propertyType += ` (${propTypeToString(type.value)})`; 33 | } 34 | 35 | if(type.name === 'shape') { 36 | const shapeString = _.keys(type.value).map(key => '`' + key + ': ' + type.value[key].name + '`').join('
'); 37 | propertyType += ` {
${shapeString}
}`; 38 | } 39 | 40 | if(type.name === 'enum') { 41 | propertyType += ` (${type.value.map(val => val.value).join(', ')})`; 42 | } 43 | 44 | if(type.name === 'union') { 45 | propertyType += ` (
${type.value.map(propTypeToString).join(',
')}
)`; 46 | } 47 | 48 | return propertyType; 49 | } 50 | 51 | const getComponentReadmeProps = (component) => { 52 | const componentInfo = reactDocs.parse(component); 53 | 54 | let readmeProps = `| Property | Type | Default | Description | 55 | | --- | --- | --- | --- |`; 56 | 57 | for(const propertyName in componentInfo.props) { 58 | const propInfo = componentInfo.props[propertyName]; 59 | const propertyRequired = propInfo.required; 60 | const propertyDefault = propInfo.defaultValue ? propInfo.defaultValue.value : ''; 61 | const propertyDescription = propInfo.description; 62 | const propertyType = propTypeToString(propInfo.type); 63 | 64 | readmeProps += ` 65 | | ${propertyName}${propertyRequired ? '*' : ''} | ${propertyType} | ${propertyDefault} | ${propertyDescription.replace('|', ':').replace(/\n/g,'
')} |`; 66 | } 67 | 68 | return readmeProps; 69 | } 70 | -------------------------------------------------------------------------------- /src/Tabs/TabListItem.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import autoprefixer from './prefixer'; 3 | 4 | export default class TabListItem extends React.Component { 5 | static propTypes = { 6 | item: React.PropTypes.shape({ 7 | element: React.PropTypes.element.isRequired, 8 | width: React.PropTypes.number.isRequired, 9 | left: React.PropTypes.number.isRequired, 10 | }).isRequired, 11 | onChange: React.PropTypes.func.isRequired, 12 | style: React.PropTypes.object, 13 | fitInContainer: React.PropTypes.bool, 14 | onClick: React.PropTypes.func.isRequired, 15 | noLeftPadding: React.PropTypes.bool, 16 | noRightPadding: React.PropTypes.bool, 17 | className: React.PropTypes.string, 18 | isItemActive: React.PropTypes.func.isRequired, 19 | activeStyle: React.PropTypes.object.isRequired, 20 | }; 21 | 22 | checkChanged = (width, left) => { 23 | return this.props.item.width !== width || this.props.item.left !== left; 24 | } 25 | 26 | refListItemDetector = (ref) => { 27 | if(! ref) { 28 | return; 29 | } 30 | // New change has happened 31 | if(this.checkChanged(ref.clientWidth, ref.offsetLeft)) { 32 | this.props.onChange(this.props.item, ref.clientWidth, ref.offsetLeft); 33 | } 34 | } 35 | 36 | getItemStyle() { 37 | let style = {...this.props.style}; 38 | 39 | // Fitting item in the container 40 | if(this.props.fitInContainer) { 41 | style.flexShrink = 1; 42 | style.flexGrow = 1; 43 | } else { 44 | style.flexShrink = 0; 45 | } 46 | 47 | // Remove left padding 48 | if(this.props.noLeftPadding) { 49 | style.paddingLeft = 0; 50 | style.justifyContent = 'flex-start'; 51 | } 52 | 53 | // Remove right padding 54 | if(this.props.noRightPadding) { 55 | style.paddingRight = 0; 56 | style.justifyContent = 'flex-end'; 57 | } 58 | 59 | if(this.props.isItemActive(this.props.item)) { 60 | style = { ...style, ...this.props.activeStyle }; 61 | } 62 | 63 | const mainItemStyle = { 64 | padding: '20px', 65 | cursor: 'pointer', 66 | userSelect: 'none', 67 | display: 'flex', 68 | alignItems: 'center', 69 | justifyContent: 'center', 70 | } 71 | 72 | return autoprefixer({ 73 | ...mainItemStyle, 74 | ...style, 75 | }); 76 | } 77 | 78 | render() { 79 | return ( 80 |
  • this.props.onClick(this.props.item)} 83 | style={this.getItemStyle()} 84 | className={this.props.className} 85 | > 86 | {this.props.item.element} 87 |
  • 88 | ); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Tabs/TabList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import autoprefixer from './prefixer'; 3 | import TabListItem from './TabListItem'; 4 | 5 | export default class TabList extends React.Component { 6 | 7 | static propTypes = { 8 | items: React.PropTypes.arrayOf(React.PropTypes.shape({ 9 | element: React.PropTypes.element.isRequired, 10 | width: React.PropTypes.number.isRequired, 11 | left: React.PropTypes.number.isRequired, 12 | })).isRequired, 13 | alignCenter: React.PropTypes.bool, 14 | onItemChange: React.PropTypes.func.isRequired, 15 | itemStyle: React.PropTypes.object, 16 | fitItems: React.PropTypes.bool, 17 | onItemClick: React.PropTypes.func.isRequired, 18 | noFirstLeftPadding: React.PropTypes.bool, 19 | noLastRightPadding: React.PropTypes.bool, 20 | itemClassName: React.PropTypes.string, 21 | containerWidth: React.PropTypes.number, 22 | activeStyle: React.PropTypes.object.isRequired, 23 | isItemActive: React.PropTypes.func.isRequired, 24 | }; 25 | 26 | /** 27 | * Return true if the item is the first one in the list 28 | * @param {object} item 29 | * @return {Boolean} 30 | */ 31 | isFirstItem(item) { 32 | return this.props.items[0] === item; 33 | } 34 | 35 | /** 36 | * Return true if the item is the last one 37 | * @param {object} item 38 | * @return {Boolean} 39 | */ 40 | isLastItem(item) { 41 | return this.props.items.indexOf(item) === this.props.items.length - 1; 42 | } 43 | 44 | /** 45 | * Calculate list width from its elements 46 | * @return {number} 47 | */ 48 | getListWidth() { 49 | let totalWidth = 0; 50 | this.props.items.forEach(item => totalWidth += item.width); 51 | return totalWidth; 52 | } 53 | 54 | /** 55 | * Return true if the list width is smaller than window 56 | * @return {Boolean} 57 | */ 58 | isListSmallerThanWindow() { 59 | return this.getListWidth() < this.props.containerWidth; 60 | } 61 | 62 | getListStyle() { 63 | return autoprefixer({ 64 | listStyle: 'none', 65 | display: 'flex', 66 | flexDirection: 'row', 67 | margin: 0, 68 | padding: 0, 69 | justifyContent: this.props.alignCenter && this.isListSmallerThanWindow() ? 'center' : undefined, 70 | }); 71 | } 72 | 73 | renderListItems() { 74 | return this.props.items.map((item, key) => ( 75 | 88 | )); 89 | } 90 | 91 | render() { 92 | return ( 93 |
      94 | {this.renderListItems()} 95 |
    96 | ); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | react-swipeable-tabs 2 | --------------- 3 | 4 | Installing 5 | ------------ 6 | ``` 7 | $ npm install react-swipeable-tabs --save 8 | ``` 9 | 10 | [Demos](http://www.bitriddler.com/playground/swipeable-tabs) 11 | -------------- 12 | 13 | 14 | 15 | Example 16 | -------------- 17 | 18 | ```javascript 19 | import React from 'react'; 20 | import SwipeableTabs from 'react-swipeable-tabs'; 21 | 22 | export default class TestTabs extends React.Component { 23 | componentWillMount() { 24 | this.setState({ 25 | activeItemIndex: 2, 26 | items: [ 27 | { title: "Item 1" }, 28 | { title: "Item 2" }, 29 | { title: "Item 3" }, 30 | { title: "Item 4" }, 31 | { title: "Item 5" }, 32 | { title: "Item 6" }, 33 | { title: "Item 7" }, 34 | { title: "Item 8" }, 35 | { title: "Item 9" }, 36 | { title: "Item 10" }, 37 | { title: "Item 11" }, 38 | { title: "Item 12" }, 39 | ] 40 | }); 41 | } 42 | 43 | render() { 44 | return ( 45 | this.setState({ activeItemIndex: index })} 53 | items={this.state.items} 54 | borderPosition="top" 55 | borderThickness={2} 56 | borderColor="#4dc4c0" 57 | activeStyle={{ 58 | color: '#4dc4c0' 59 | }} 60 | /> 61 | ); 62 | } 63 | } 64 | ``` 65 | 66 | 67 | 68 | | Property | Type | Default | Description | 69 | | --- | --- | --- | --- | 70 | | items* | arrayOf (element) | | Array of tabs to render. | 71 | | onItemClick* | func | | When an item is clicked, this is called with `(item, index)`. | 72 | | activeItemIndex | number | 0 | This is only useful if you want to control the active item index from outside. | 73 | | itemClassName | string | | Item class name. | 74 | | itemStyle | object | {} | Item style. | 75 | | activeStyle | object | {} | Active item style. | 76 | | alignCenter | bool | true | Whether or not to align center if items total width smaller than container width. | 77 | | fitItems | bool | false | This option will fit all items on desktop | 78 | | noFirstLeftPadding | bool | false | This prop defines if the first item doesnt have left padding. 79 |
    We use this to calculate the border position for the first element. | 80 | | noLastRightPadding | bool | false | This prop defines if the last item doesnt have right padding. 81 |
    We use this to calculate the border position for the last element. | 82 | | borderPosition | enum ('top', 'bottom') | 'bottom' | Border position. | 83 | | borderColor | string | '#333' | Border color. | 84 | | borderThickness | number | 2 | Border thickness in pixels. | 85 | | borderWidthRatio | number | 1 | Border width ratio from the tab width. 86 |
    Setting this to 1 will set border width to exactly the tab width. | 87 | | safeMargin | number | 100 | This value is used when user tries to drag the tabs far to right or left. 88 |
    Setting this to 100 for example user will be able to drag the tabs 100px 89 |
    far to right and left. | 90 | | initialTranslation | number | 0 | Initial translation. Ignore this. | 91 | | stiffness | number | 170 | React motion configurations. 92 |
    [More about this here](https://github.com/chenglou/react-motion#--spring-val-number-config-springhelperconfig--opaqueconfig) | 93 | | damping | number | 26 | React motion configurations. 94 |
    [More about this here](https://github.com/chenglou/react-motion#--spring-val-number-config-springhelperconfig--opaqueconfig) | 95 | | resistanceCoeffiecent | number | 0.5 | Drag resistance coeffiecent. 96 |
    Higher resitance tougher the user can drag the tabs. | 97 | | gravityAccelarion | number | 9.8 | Gravity acceleration. 98 |
    Higher resitance tougher the user can drag the tabs. | 99 | | dragCoefficient | number | 0.04 | [Learn more](https://en.wikipedia.org/wiki/Drag_coefficient) | 100 | 101 | Contributing 102 | -------------- 103 | To contribute, follow these steps: 104 | - Fork this repo. 105 | - Clone your fork. 106 | - Run `npm install` 107 | - Run `npm start` 108 | - Goto `localhost:3000` 109 | - Add your patch then push to your fork and submit a pull request 110 | 111 | License 112 | --------- 113 | MIT 114 | -------------------------------------------------------------------------------- /src/Tabs/Animator.js: -------------------------------------------------------------------------------- 1 | const getCurrentMillis = () => new Date().getTime(); 2 | 3 | export default class Animator { 4 | 5 | constructor(items) { 6 | this.setItems(items); 7 | // Give it initial value 8 | this.containerWidth = 100; 9 | } 10 | 11 | setItems(items) { 12 | this.items = items; 13 | } 14 | 15 | setContainerWidth(containerWidth) { 16 | this.containerWidth = containerWidth; 17 | } 18 | 19 | getContainerWidth() { 20 | return this.containerWidth; 21 | } 22 | 23 | setBorderWidthRatio(borderWidthRatio) { 24 | this.borderWidthRatio = borderWidthRatio; 25 | } 26 | 27 | setSafeMargin(safeMargin) { 28 | this.safeMargin = safeMargin; 29 | } 30 | 31 | setInitialTranslation(initialTranslation) { 32 | this.initialTranslation = initialTranslation; 33 | } 34 | 35 | setNoFirstLeftPadding(noFirstLeftPadding) { 36 | this.noFirstLeftPadding = noFirstLeftPadding; 37 | } 38 | 39 | setNoLastRightPadding(noLastRightPadding) { 40 | this.noLastRightPadding = noLastRightPadding; 41 | } 42 | 43 | setResistanceCoeffiecent(resistanceCoeffiecent) { 44 | this.resistanceCoeffiecent = resistanceCoeffiecent; 45 | } 46 | 47 | setCurrentTranslateX(translateX) { 48 | this.currentTranslateX = translateX; 49 | } 50 | 51 | startDrag() { 52 | this.startDragMillis = getCurrentMillis(); 53 | this.startTranslateX = this.currentTranslateX; 54 | } 55 | 56 | endDrag() { 57 | this.endDragMillis = getCurrentMillis(); 58 | } 59 | 60 | 61 | /** 62 | * Calculate list width from its items 63 | * @return {number} 64 | */ 65 | getListWidth() { 66 | let totalWidth = 0; 67 | this.items.forEach(item => totalWidth += item.width); 68 | return totalWidth; 69 | } 70 | 71 | getFirstItemLeft() { 72 | return this.items[0].left; 73 | } 74 | 75 | getItemLeft(item) { 76 | return this.isFirstItem(item) ? 0 : item.left - this.getFirstItemLeft(); 77 | } 78 | 79 | getItemWidth(item) { 80 | return item.width; 81 | } 82 | 83 | getBorderWidth(item) { 84 | return this.getItemWidth(item) * this.borderWidthRatio; 85 | } 86 | 87 | /** 88 | * Return true if the item is the first one in the list 89 | * @param {object} item 90 | * @return {Boolean} 91 | */ 92 | isFirstItem(item) { 93 | return this.items[0] === item; 94 | } 95 | 96 | /** 97 | * Return true if the item is the last one 98 | * @param {object} item 99 | * @return {Boolean} 100 | */ 101 | isLastItem(item) { 102 | return this.items.indexOf(item) === this.items.length - 1; 103 | } 104 | 105 | /** 106 | * Return true if the list has gone far left 107 | * @return {Boolean} 108 | */ 109 | isFarLeft(translateX, useSafeMargin = false) { 110 | return translateX > (useSafeMargin ? this.safeMargin : 0); 111 | } 112 | 113 | /** 114 | * Return true if the list has gone far right 115 | * @param {Boolean} useSafeMargin 116 | * @return {Boolean} 117 | */ 118 | isFarRight(translateX, useSafeMargin = false) { 119 | const safeMargin = useSafeMargin ? this.safeMargin : 0; 120 | 121 | // If the newtranslate + window width is more than list width then stop translating 122 | return (translateX * -1) + this.containerWidth > (this.getListWidth() + safeMargin); 123 | } 124 | 125 | isListSmallerContainer() { 126 | return this.getListWidth() < this.containerWidth; 127 | } 128 | 129 | getFarLeftTranslation(useSafeMargin) { 130 | return this.initialTranslation + 0 + (useSafeMargin ? this.safeMargin : 0); 131 | } 132 | 133 | getFarRightTranslation(useSafeMargin) { 134 | return this.initialTranslation - this.getListWidth() + this.containerWidth - (useSafeMargin ? this.safeMargin : 0); 135 | } 136 | 137 | checkAndGetTranslateX(translateX, useSafeMargin) { 138 | if(this.isFarLeft(translateX, useSafeMargin)) { 139 | return this.getFarLeftTranslation(useSafeMargin); 140 | } 141 | 142 | if(this.isFarRight(translateX, useSafeMargin)) { 143 | return this.getFarRightTranslation(useSafeMargin); 144 | } 145 | 146 | return translateX; 147 | } 148 | 149 | calculateSwipeNextDistance(deltaX) { 150 | // Swipping distance 151 | const di = Math.abs(deltaX); 152 | // Swipping time 153 | const ti = this.endDragMillis - this.startDragMillis; 154 | // Gravity acceleration (constant) 155 | const g = 9.8; 156 | // Drag coefficient (constant) 157 | const u = 0.05; 158 | // Initial acceleration (from swipping) 159 | const ai = (2 * di) / (Math.pow(ti, 2)); 160 | // Result acceleration by removing the resistive force 161 | // since F(resistive) = (drag coefficient) * F(norm) 162 | // since F(norm) = m * g 163 | const ar = Math.abs(ai - (g * u)) * -1; 164 | // Initial velocity (which is the final velocity from the swipping) 165 | const vi = (2 * di) / ti; 166 | // We can calculate distance from this equation 167 | // vf^2 = vi^2 + 2 * ar * distance 168 | // Now knowing the final velocity is equal to zero (vf = 0) 169 | const distance = (Math.pow(vi, 2) / (2 * ar)) * -1 * (1 / this.resistanceCoeffiecent); 170 | 171 | return deltaX > 0 ? distance : -1 * distance; 172 | } 173 | 174 | /** 175 | * Calculate active item translation 176 | */ 177 | calculateItemTranslateX(item) { 178 | if(this.isListSmallerContainer()) { 179 | return this.currentTranslateX; 180 | } 181 | const itemLeft = this.getItemLeft(item); 182 | const halfContainerWidth = this.containerWidth / 2; 183 | const halfItemWidth = this.getItemWidth(item) / 2; 184 | 185 | let centerX = this.initialTranslation - itemLeft + halfContainerWidth - halfItemWidth; 186 | 187 | return this.checkAndGetTranslateX(centerX, false); 188 | } 189 | 190 | calculateSwipeReleaseTranslateX(deltaX) { 191 | if(this.isListSmallerContainer()) { 192 | return this.currentTranslateX; 193 | } 194 | const distance = this.calculateSwipeNextDistance(deltaX); 195 | return this.checkAndGetTranslateX(distance + this.currentTranslateX, false); 196 | } 197 | 198 | calculateBorderTranslateX(item) { 199 | const itemWidth = this.getItemWidth(item); 200 | const itemLeft = this.getItemLeft(item); 201 | const borderWidth = this.getBorderWidth(item); 202 | 203 | // If the first item is active and required not to add left padding for first item 204 | // then the translateX = itemLeft 205 | if(this.noFirstLeftPadding && this.isFirstItem(item)) { 206 | return itemLeft; 207 | } 208 | 209 | // If the last item is active and required not to add right padding for last item 210 | // then the translateX = itemLeft + itemWidth - borderWidth 211 | else if(this.noLastRightPadding && this.isLastItem(item)) { 212 | return itemLeft + itemWidth - borderWidth; 213 | } 214 | 215 | // Otherwise then center the border on the element 216 | // translateX = itemLeft + half itemWidth - half borderWidth 217 | return itemLeft + (itemWidth / 2) - (borderWidth / 2); 218 | } 219 | 220 | calculateSwipeTranslateX(deltaX) { 221 | if(this.isListSmallerContainer()) { 222 | return this.currentTranslateX; 223 | } 224 | return this.checkAndGetTranslateX(deltaX + this.startTranslateX, true); 225 | } 226 | } -------------------------------------------------------------------------------- /src/Tabs/test.js: -------------------------------------------------------------------------------- 1 | import Tabs from './index'; 2 | import React from 'react'; 3 | import { Slider, RaisedButton } from 'material-ui'; 4 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; 5 | 6 | const CubesIcon = props => ( 7 | 8 | 9 | 10 | ); 11 | 12 | const GithubIcon = props => ( 13 | 14 | 15 | 16 | ); 17 | 18 | const maxStiffness = 300; 19 | const maxResistanceCoefficenet = 1; 20 | const maxDamping = 50; 21 | const maxSafeMargin = 200; 22 | 23 | const getRandomInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; 24 | 25 | const createRandomItems = (no) => { 26 | const items = []; 27 | for (var i = 1; i <= no; i++) { 28 | items.push({Math.round(Math.random()) === 0 ? : }Item {i}) 29 | } 30 | return items; 31 | } 32 | 33 | export default class TabsTest extends React.Component { 34 | 35 | componentWillMount() { 36 | this.setState({ 37 | activeItemIndex: 2, 38 | stiffness: 170, 39 | resistanceCoeffiecent: 0.5, 40 | damping: 26, 41 | safeMargin: 60, 42 | items: createRandomItems(14) 43 | }) 44 | } 45 | 46 | 47 | renderTabs() { 48 | return ( 49 |
    50 | this.setState({ activeItemIndex: index })} 61 | items={this.state.items} 62 | borderPosition="top" 63 | borderThickness={2} 64 | borderColor="#4dc4c0" 65 | activeStyle={{ 66 | color: '#4dc4c0' 67 | }} 68 | /> 69 |
    70 | ); 71 | } 72 | 73 | getToolsContainerStyle() { 74 | return { justifyContent: 'space-around', display: 'flex' }; 75 | } 76 | 77 | getToolStyle() { 78 | return { background: '#EEE', padding: '20px 5px' }; 79 | } 80 | 81 | renderGlobalTools() { 82 | return ( 83 |
    84 |
    85 | Stiffness
    86 | this.setState({ stiffness: value*maxStiffness })} 90 | value={this.state.stiffness/maxStiffness} /> 91 |
    92 | {this.state.stiffness.toFixed(1)} 93 |
    94 |
    95 | Damping
    96 | this.setState({ damping: value*maxDamping })} 100 | value={this.state.damping/maxDamping} /> 101 |
    102 | {this.state.damping.toFixed(1)} 103 |
    104 |
    105 | ); 106 | } 107 | 108 | renderSwipeTools() { 109 | return ( 110 |
    111 |
    112 | Resistance
    113 | this.setState({ resistanceCoeffiecent: value*maxResistanceCoefficenet })} 117 | value={this.state.resistanceCoeffiecent/maxResistanceCoefficenet} /> 118 |
    119 | {this.state.resistanceCoeffiecent.toFixed(3)} 120 |
    121 |
    122 | SafeMargin
    123 | this.setState({ safeMargin: value*maxSafeMargin })} 127 | value={this.state.safeMargin/maxSafeMargin} /> 128 |
    129 | {this.state.safeMargin.toFixed(1)} 130 |
    131 |
    132 | ); 133 | } 134 | 135 | changeItems = () => { 136 | this.setState({ 137 | items: createRandomItems(10), 138 | }) 139 | } 140 | 141 | changeActiveItem3 = () => { 142 | this.setState({ 143 | activeItemIndex: 3 144 | }) 145 | } 146 | 147 | renderDynamicTools() { 148 | const style = { margin: 12 }; 149 | return ( 150 |
    151 | 152 | 153 |
    154 | ); 155 | } 156 | 157 | renderTools() { 158 | const titleStyle = { textAlign: 'center', backgroundColor: '#EEE', padding: 20 }; 159 | 160 | return ( 161 |
    162 |

    Dynamic?

    163 | {this.renderDynamicTools()} 164 |

    Animation values

    165 | {this.renderGlobalTools()} 166 |

    Swipe values

    167 | {this.renderSwipeTools()} 168 |
    169 | ); 170 | } 171 | 172 | render() { 173 | return ( 174 | 175 |
    176 | {this.renderTabs()} 177 |
    178 |
    179 |
    180 | {this.renderTools()} 181 |
    182 |
    183 | ); 184 | } 185 | } -------------------------------------------------------------------------------- /src/Tabs/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Hammer from 'react-hammerjs'; 3 | import Measure from 'react-measure'; 4 | import { Motion, spring } from 'react-motion'; 5 | import differenceBy from 'lodash.differenceby'; 6 | import ListBorder from './ListBorder'; 7 | import TabList from './TabList'; 8 | import autoprefixer from './prefixer'; 9 | import Animator from './Animator'; 10 | 11 | export default class Tabs extends React.Component { 12 | static propTypes = { 13 | /** 14 | * Array of tabs to render. 15 | */ 16 | items: React.PropTypes.arrayOf(React.PropTypes.element).isRequired, 17 | /** 18 | * When an item is clicked, this is called with `(item, index)`. 19 | */ 20 | onItemClick: React.PropTypes.func.isRequired, 21 | /** 22 | * This is only useful if you want to control the active item index from outside. 23 | */ 24 | activeItemIndex: React.PropTypes.number, 25 | /** 26 | * Item class name. 27 | */ 28 | itemClassName: React.PropTypes.string, 29 | /** 30 | * Item style. 31 | */ 32 | itemStyle: React.PropTypes.object, 33 | /** 34 | * Active item style. 35 | */ 36 | activeStyle: React.PropTypes.object, 37 | /** 38 | * Whether or not to align center if items total width smaller than container width. 39 | */ 40 | alignCenter: React.PropTypes.bool, 41 | /** 42 | * This option will fit all items on desktop 43 | */ 44 | fitItems: React.PropTypes.bool, 45 | /** 46 | * This prop defines if the first item doesnt have left padding. 47 | * We use this to calculate the border position for the first element. 48 | */ 49 | noFirstLeftPadding: React.PropTypes.bool, 50 | /** 51 | * This prop defines if the last item doesnt have right padding. 52 | * We use this to calculate the border position for the last element. 53 | */ 54 | noLastRightPadding: React.PropTypes.bool, 55 | /** 56 | * Border position. 57 | */ 58 | borderPosition: React.PropTypes.oneOf(['top', 'bottom']), 59 | /** 60 | * Border color. 61 | */ 62 | borderColor: React.PropTypes.string, 63 | /** 64 | * Border thickness in pixels. 65 | */ 66 | borderThickness: React.PropTypes.number, 67 | /** 68 | * Border width ratio from the tab width. 69 | * Setting this to 1 will set border width to exactly the tab width. 70 | */ 71 | borderWidthRatio: React.PropTypes.number, 72 | /** 73 | * This value is used when user tries to drag the tabs far to right or left. 74 | * Setting this to 100 for example user will be able to drag the tabs 100px 75 | * far to right and left. 76 | */ 77 | safeMargin: React.PropTypes.number, 78 | /** 79 | * Initial translation. Ignore this. 80 | */ 81 | initialTranslation: React.PropTypes.number, 82 | /** 83 | * React motion configurations. 84 | * [More about this here](https://github.com/chenglou/react-motion#--spring-val-number-config-springhelperconfig--opaqueconfig) 85 | */ 86 | stiffness: React.PropTypes.number, 87 | /** 88 | * React motion configurations. 89 | * [More about this here](https://github.com/chenglou/react-motion#--spring-val-number-config-springhelperconfig--opaqueconfig) 90 | */ 91 | damping: React.PropTypes.number, 92 | /** 93 | * Drag resistance coeffiecent. 94 | * Higher resitance tougher the user can drag the tabs. 95 | */ 96 | resistanceCoeffiecent: React.PropTypes.number, 97 | /** 98 | * Gravity acceleration. 99 | * Higher resitance tougher the user can drag the tabs. 100 | */ 101 | gravityAccelarion: React.PropTypes.number, 102 | /** 103 | * [Learn more](https://en.wikipedia.org/wiki/Drag_coefficient) 104 | */ 105 | dragCoefficient: React.PropTypes.number, 106 | }; 107 | 108 | static defaultProps = { 109 | resistanceCoeffiecent: 0.5, 110 | gravityAccelarion: 9.8, 111 | dragCoefficient: 0.04, 112 | stiffness: 170, 113 | damping: 26, 114 | 115 | activeItemIndex: 0, 116 | safeMargin: 100, 117 | borderPosition: 'bottom', 118 | borderColor: '#333', 119 | borderThickness: 2, 120 | borderWidthRatio: 1, 121 | alignCenter: true, 122 | activeStyle: {}, 123 | noFirstLeftPadding: false, 124 | noLastRightPadding: false, 125 | fitItems: false, 126 | itemStyle: {}, 127 | initialTranslation: 0, 128 | }; 129 | 130 | constructor(props) { 131 | super(props); 132 | this.currentFrame = { 133 | translateX: 0, 134 | }; 135 | 136 | const items = this.formatItems(this.props.items); 137 | 138 | this.animator = new Animator(items); 139 | this.updateAnimatorFromProps(this.props); 140 | 141 | this.state = { 142 | items, 143 | activeItemIndex: this.props.activeItemIndex, 144 | translateX: 0, 145 | borderWidth: 0, 146 | borderTranslateX: 0, 147 | }; 148 | } 149 | 150 | componentWillReceiveProps(nextProps) { 151 | this.updateAnimatorFromProps(nextProps); 152 | 153 | if(nextProps.activeItemIndex !== this.props.activeItemIndex) { 154 | this.setState({ activeItemIndex: nextProps.activeItemIndex }); 155 | } 156 | 157 | if(!this.checkEqualItems(nextProps.items, this.props.items)) { 158 | const items = this.formatItems(nextProps.items); 159 | this.animator.setItems(items); 160 | this.setState({ items }); 161 | } 162 | } 163 | 164 | componentWillUpdate(nextProps, nextState) { 165 | if(nextState.activeItemIndex !== this.state.activeItemIndex 166 | || nextState.requestCenterActiveItem 167 | || nextState.items !== this.state.items) { 168 | // 169 | this.setState({ 170 | ...this.getCenterItemState(nextState.items, nextState.activeItemIndex), 171 | requestCenterActiveItem: false, 172 | }); 173 | } 174 | } 175 | 176 | onResize = () => { 177 | // Force center active item on resize 178 | this.setState({ 179 | requestCenterActiveItem: true 180 | }); 181 | } 182 | 183 | formatItems = (items) => { 184 | return items.map(element => ({ element, width: 0, left: 0})); 185 | } 186 | 187 | checkEqualItems = (items1, items2) => { 188 | return items1 === items2; 189 | } 190 | 191 | onPanStart = (e) => { 192 | this.animator.startDrag(); 193 | } 194 | 195 | onPanEnd = (e) => { 196 | this.animator.endDrag(); 197 | 198 | // 199 | this.setState({ 200 | translateX: this.animator.calculateSwipeReleaseTranslateX(e.deltaX), 201 | }); 202 | } 203 | 204 | onPan = (e) => { 205 | this.setState({ 206 | translateX: this.animator.calculateSwipeTranslateX(e.deltaX), 207 | }); 208 | } 209 | 210 | getTranslateStyle = (translateX) => { 211 | this.animator.setCurrentTranslateX(translateX); 212 | return { 213 | transform: `translate(${translateX}px, 0)` 214 | }; 215 | } 216 | 217 | getContainerStyle = () => { 218 | return autoprefixer({ 219 | position: 'relative', 220 | width: '100%', 221 | overflow: 'hidden', 222 | textAlign: 'center', 223 | }); 224 | } 225 | 226 | getCenterItemState = (items, activeItemIndex) => { 227 | let item = items[activeItemIndex]; 228 | // If item doesnt exist then revert to the first item and call the onItemClick 229 | if(! item) { 230 | item = items[0]; 231 | this.onItemClick(item, activeItemIndex); 232 | } 233 | return { 234 | borderWidth: this.animator.getBorderWidth(item), 235 | translateX: this.animator.calculateItemTranslateX(item), 236 | borderTranslateX: this.animator.calculateBorderTranslateX(item), 237 | }; 238 | } 239 | 240 | updateAnimatorFromProps = (props) => { 241 | this.animator.setBorderWidthRatio(props.borderWidthRatio); 242 | this.animator.setSafeMargin(props.safeMargin); 243 | this.animator.setInitialTranslation(props.initialTranslation); 244 | this.animator.setNoFirstLeftPadding(props.noFirstLeftPadding); 245 | this.animator.setNoLastRightPadding(props.noLastRightPadding); 246 | this.animator.setResistanceCoeffiecent(props.resistanceCoeffiecent); 247 | } 248 | 249 | getInitialFrame = () => { 250 | return { 251 | translateX: this.state.translateX, 252 | borderWidth: this.state.borderWidth, 253 | borderTranslateX: this.state.borderTranslateX, 254 | }; 255 | } 256 | 257 | calculateNextFrame = () => { 258 | const options = { 259 | stiffness: this.props.stiffness, 260 | damping: this.props.damping, 261 | }; 262 | return { 263 | translateX: spring(this.state.translateX, options), 264 | borderTranslateX: spring(this.state.borderTranslateX, options), 265 | borderWidth: spring(this.state.borderWidth, options), 266 | }; 267 | } 268 | 269 | refContainerWidthDetector = (ref) => { 270 | if(ref) { 271 | this.animator.setContainerWidth(ref.clientWidth); 272 | } 273 | } 274 | 275 | onItemClick = (item) => { 276 | const index = this.getItemIndex(item); 277 | this.setState({ 278 | requestToCenterItem: true, 279 | activeItemIndex: index, 280 | }); 281 | this.props.onItemClick(item, index); 282 | } 283 | 284 | onItemChange = (item, width, left) => { 285 | const index = this.state.items.indexOf(item); 286 | 287 | const items = [ 288 | ...this.state.items.slice(0, index), 289 | { ...item, width, left }, 290 | ...this.state.items.slice(index + 1), 291 | ]; 292 | 293 | this.animator.setItems(items); 294 | this.setState({ items }); 295 | } 296 | 297 | isItemActive = (item) => { 298 | return this.state.activeItemIndex === this.getItemIndex(item); 299 | } 300 | 301 | getItemIndex = (item) => { 302 | return this.state.items.indexOf(item); 303 | } 304 | 305 | renderList(translateX, borderTranslateX, borderWidth) { 306 | const borderElement = ( 307 | 313 | ); 314 | 315 | return ( 316 |
    317 | {this.props.borderPosition === 'top' ? borderElement : null} 318 | 319 | 333 | 334 | {this.props.borderPosition === 'bottom' ? borderElement : null} 335 |
    336 | ); 337 | } 338 | 339 | render() { 340 | return ( 341 | 345 | {({ measureRef }) => ( 346 |
    347 | 352 |
    355 | 358 | {({ translateX, borderTranslateX, borderWidth }) => 359 | this.renderList(translateX, borderTranslateX, borderWidth)} 360 | 361 |
    362 |
    363 |
    364 | )} 365 |
    366 | ); 367 | } 368 | } --------------------------------------------------------------------------------