├── 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 |
10 | );
11 |
12 | const GithubIcon = props => (
13 |
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 | }
--------------------------------------------------------------------------------