├── .babelrc.js ├── .eslintrc ├── .github ├── renovate.json └── workflows │ ├── ci.yml │ ├── shipjs-manual-prepare.yml │ └── shipjs-trigger.yml ├── .gitignore ├── changelog.md ├── example ├── package.json ├── src │ ├── App.js │ ├── accordion.css │ ├── accordion.js │ ├── animated-tabs.js │ ├── app.css │ ├── autoplayed-tabs.js │ ├── height-tabs.js │ ├── index.html │ ├── main.js │ ├── simple-tabs.js │ ├── tabs.css │ └── useHover.js └── yarn.lock ├── package-lock.json ├── package.json ├── readme.md ├── rollup.config.js ├── ship.config.js ├── src └── index.js └── test └── index.test.js /.babelrc.js: -------------------------------------------------------------------------------- 1 | const isTest = process.env.NODE_ENV === 'test' 2 | 3 | module.exports = { 4 | presets: [isTest && '@babel/preset-env', '@babel/preset-react'].filter( 5 | Boolean 6 | ) 7 | } 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["standard", "eslint-config-standard-jsx"], 3 | "plugins": [ 4 | "react-hooks" 5 | ], 6 | "rules": { 7 | "react-hooks/rules-of-hooks": "error", 8 | "react-hooks/exhaustive-deps": "warn" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:js-lib", "schedule:weekly", ":automergeMinor"], 3 | "reviewers": ["jeetiss"], 4 | "timezone": "Europe/Moscow", 5 | "packageRules": [ 6 | { 7 | "depTypeList": ["devDependencies"], 8 | "updateTypes": ["patch", "minor"], 9 | "groupName": "dev dependencies (non-major)" 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "*" 10 | 11 | jobs: 12 | build: 13 | name: build and test 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: actions/setup-node@v1 18 | - run: npm ci 19 | - run: npm run build 20 | - run: npm test 21 | - run: npm run size 22 | -------------------------------------------------------------------------------- /.github/workflows/shipjs-manual-prepare.yml: -------------------------------------------------------------------------------- 1 | name: Ship js Manual Prepare 2 | on: 3 | issue_comment: 4 | types: [created] 5 | jobs: 6 | manual_prepare: 7 | if: | 8 | github.event_name == 'issue_comment' && 9 | (github.event.comment.author_association == 'member' || github.event.comment.author_association == 'owner') && 10 | startsWith(github.event.comment.body, '@shipjs prepare') 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | ref: master 17 | - uses: actions/setup-node@v1 18 | - run: | 19 | if [ -f "yarn.lock" ]; then 20 | yarn install 21 | else 22 | npm install 23 | fi 24 | - run: | 25 | git config --global user.email "jeetiss@yandex.ru" 26 | git config --global user.name "jeetiss" 27 | - run: npm run release:prepare -- --yes --no-browse 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | SLACK_INCOMING_HOOK: ${{ secrets.SLACK_INCOMING_HOOK }} 31 | 32 | create_done_comment: 33 | if: success() 34 | needs: manual_prepare 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/github@master 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | with: 41 | args: comment "@${{ github.actor }} `shipjs prepare` done" 42 | 43 | create_fail_comment: 44 | if: cancelled() || failure() 45 | needs: manual_prepare 46 | runs-on: ubuntu-latest 47 | steps: 48 | - uses: actions/github@master 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | with: 52 | args: comment "@${{ github.actor }} `shipjs prepare` fail" 53 | -------------------------------------------------------------------------------- /.github/workflows/shipjs-trigger.yml: -------------------------------------------------------------------------------- 1 | name: Ship js trigger 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | with: 13 | ref: master 14 | - uses: actions/setup-node@v1 15 | with: 16 | registry-url: "https://registry.npmjs.org" 17 | - run: | 18 | if [ -f "yarn.lock" ]; then 19 | yarn install 20 | else 21 | npm install 22 | fi 23 | - run: npm test 24 | - run: npm run release:trigger 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} 28 | SLACK_INCOMING_HOOK: ${{ secrets.SLACK_INCOMING_HOOK }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | .DS_Store 5 | yarn.lock 6 | coverage 7 | -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # [0.2.0](https://github.com/jeetiss/tabs/compare/0.1.3...0.2.0) (2019-11-16) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * security vulnerability in example ([#9](https://github.com/jeetiss/tabs/issues/9)) ([93635ba](https://github.com/jeetiss/tabs/commit/93635ba8374401d16b60ff573d5c309afc0af18c)) 7 | 8 | 9 | ### Features 10 | 11 | * add umd bundle ([#11](https://github.com/jeetiss/tabs/issues/11)) ([2b560d4](https://github.com/jeetiss/tabs/commit/2b560d44e4d8745bd9cc16251260c1b06b6d7dcb)) 12 | 13 | ## [0.1.3](https://github.com/jeetiss/tabs/compare/v0.1.2...v0.1.3) (2019-08-03) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * provide cjs module for jest ([#7](https://github.com/jeetiss/tabs/issues/7)) ([a35d491](https://github.com/jeetiss/tabs/commit/a35d491)) 19 | * travis config ([#8](https://github.com/jeetiss/tabs/issues/8)) ([f092b23](https://github.com/jeetiss/tabs/commit/f092b23)) 20 | 21 | 22 | 23 | ## [0.1.2](https://github.com/jeetiss/tabs/compare/v0.1.1...v0.1.2) (2019-07-29) 24 | 25 | 26 | 27 | ## [0.1.1](https://github.com/jeetiss/tabs/compare/0.1.0...v0.1.1) (2019-07-19) 28 | 29 | 30 | 31 | # 0.1.0 (2019-07-19) 32 | 33 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parcel-react-example", 3 | "description": "Simple React example", 4 | "version": "1.0.0", 5 | "main": "src/index.html", 6 | "license": "MIT", 7 | "scripts": { 8 | "start": "parcel src/index.html --open", 9 | "build": "parcel build src/index.html", 10 | "predeploy": "parcel build src/index.html --public-url /tabs", 11 | "deploy": "gh-pages -d dist" 12 | }, 13 | "dependencies": { 14 | "@bumaga/tabs": "0.2.0", 15 | "@restart/hooks": "^0.5.0", 16 | "framer-motion": "^1.2.3", 17 | "react": "^16.8.6", 18 | "react-dom": "^16.8.6" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "7.22.5", 22 | "@babel/preset-react": "7.22.5", 23 | "gh-pages": "2.2.0", 24 | "parcel-bundler": "1.12.5" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import './app.css' 3 | import './tabs.css' 4 | 5 | import SimpleTabs from './simple-tabs' 6 | import AnimatedTabs from './animated-tabs' 7 | import AutoplayedTabs from './autoplayed-tabs' 8 | import HeightTabs from './height-tabs' 9 | import Accordion from './accordion' 10 | 11 | export default () => { 12 | return ( 13 | <> 14 | 15 | 16 | 17 | 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /example/src/accordion.css: -------------------------------------------------------------------------------- 1 | .accordion { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | width: 450px; 6 | 7 | border: 1px solid #f1f1f1; 8 | background-color: #fff; 9 | box-shadow: 0px 2px 24px 0px rgba(0, 0, 0, 0.1); 10 | 11 | margin: 80px 0; 12 | } 13 | 14 | .accordion-tab { 15 | outline: none; 16 | cursor: pointer; 17 | border: none; 18 | font-size: 16px; 19 | line-height: 24px; 20 | padding: 8px 16px; 21 | color: #492796; 22 | background-color: #fff; 23 | border: 1px solid #f1f1f1; 24 | box-shadow: 0px 2px 16px 0px rgba(0, 0, 0, 0.1); 25 | 26 | transition: color 0.16s ease-in-out, background-color 0.16s ease-in-out, 27 | border-color 0.16s ease-in-out; 28 | } 29 | 30 | .accordion-tab:focus, 31 | .accordion-tab:focus-visible { 32 | box-shadow: 0 0 0 4px rgba(73, 39, 150, 0.5); 33 | } 34 | 35 | .accordion-tab:focus:not(:focus-visible) { 36 | box-shadow: none; 37 | } 38 | 39 | .accordion-tab.active { 40 | background-color: #492796; 41 | border-color: #492796; 42 | color: white; 43 | cursor: default; 44 | } 45 | 46 | .accordion-panel { 47 | box-sizing: border-box; 48 | overflow: hidden; 49 | } 50 | 51 | .accordion-panel > p { 52 | padding: 24px 32px; 53 | } 54 | -------------------------------------------------------------------------------- /example/src/accordion.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tabs, useTabState, usePanelState } from '@bumaga/tabs' 3 | import { motion } from 'framer-motion' 4 | 5 | import './accordion.css' 6 | 7 | const cn = (...args) => args.filter(Boolean).join(' ') 8 | 9 | const Tab = ({ children }) => { 10 | const { isActive, onClick } = useTabState() 11 | 12 | return ( 13 | 19 | ) 20 | } 21 | 22 | const panel = { 23 | hidden: { height: 0 }, 24 | visible: { height: 'auto' } 25 | } 26 | 27 | const Panel = ({ children }) => { 28 | const isActive = usePanelState() 29 | 30 | return ( 31 | 36 | {children} 37 | 38 | ) 39 | } 40 | 41 | export default () => { 42 | return ( 43 | 44 |
45 | Tab 1 46 | 47 |

48 | Creates a MotionValue that, when set, will use a spring animation to 49 | animate to its new state. 50 |

51 |
52 | 53 | Tab 2 54 | 55 |

56 | In sociology, anthropology, and linguistics, structuralism is the 57 | methodology that implies elements of human culture must be 58 | understood by way of their relationship to a broader, overarching 59 | system or structure. It works to uncover the structures that 60 | underlie all the things that humans do, think, perceive, and feel. 61 | Alternatively, as summarized by philosopher Simon Blackburn, 62 | structuralism is "the belief that phenomena of human life are not 63 | intelligible except through their interrelations. These relations 64 | constitute a structure, and behind local variations in the surface 65 | phenomena there are constant laws of abstract structure". 66 |

67 |
68 | 69 | Tab 3 70 | 71 |

72 | The input range must be a linear series of numbers. The output range 73 | can be any value type supported by Framer Motion: numbers, colors, 74 | shadows, etc. 75 |

76 |
77 |
78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /example/src/animated-tabs.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, cloneElement } from 'react' 2 | import { Tabs, useTabState, Panel } from '@bumaga/tabs' 3 | import { motion, AnimatePresence } from 'framer-motion' 4 | 5 | const cn = (...args) => args.filter(Boolean).join(' ') 6 | 7 | const Tab = ({ children }) => { 8 | const { isActive, onClick } = useTabState() 9 | 10 | return ( 11 | 14 | ) 15 | } 16 | 17 | const PanelList = ({ state, children }) => { 18 | const panelRef = useRef() 19 | const [height, set] = useState(0) 20 | const [activeIndex] = state 21 | 22 | useEffect(() => { 23 | panelRef.current && set(panelRef.current.offsetHeight) 24 | }, [activeIndex, set]) 25 | 26 | return ( 27 | 28 | 29 | 37 | {cloneElement(children[activeIndex], { active: true })} 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | export default () => { 45 | const state = useState(0) 46 | 47 | return ( 48 | 49 |
50 |
51 | Tab 1 52 | 53 | Tab 2 54 | 55 | Tab 3 56 |
57 | 58 |
59 | 60 | 61 | 62 |

animations with framer/motion

63 |
64 | 65 | 66 |

is pure

67 |
68 | 69 | 70 |

❤️

71 |
72 |
73 |
74 | 75 | ) 76 | } 77 | -------------------------------------------------------------------------------- /example/src/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 80px 0; 3 | margin: 0; 4 | } 5 | 6 | body { 7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, 8 | sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 9 | background-image: linear-gradient(135deg, #aeafe8 0%, #e68ca7 100%); 10 | background-repeat: no-repeat; 11 | } 12 | 13 | div#app { 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | align-items: center; 18 | } 19 | 20 | p { 21 | margin: 0; 22 | padding: 16px 0; 23 | } -------------------------------------------------------------------------------- /example/src/autoplayed-tabs.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react' 2 | import { Tabs, useTabState, Panel } from '@bumaga/tabs' 3 | import { useInterval } from '@restart/hooks' 4 | import { motion } from 'framer-motion' 5 | 6 | import { useHover } from './useHover' 7 | 8 | const cn = (...args) => args.filter(Boolean).join(' ') 9 | 10 | const Tab = ({ children }) => { 11 | const { isActive, onClick } = useTabState() 12 | 13 | return ( 14 | 17 | ) 18 | } 19 | 20 | const duration = 2 21 | const variants = { 22 | pd: { scaleX: 1 }, 23 | pl: { scaleX: 0 } 24 | } 25 | 26 | export default () => { 27 | const tabsRef = useRef() 28 | const [index, setIndex] = useState(0) 29 | const [paused, stop] = useState(false) 30 | 31 | useHover(tabsRef, () => stop(true), () => stop(false)) 32 | useInterval(() => setIndex(index => (index + 1) % 3), duration * 1000, paused) 33 | 34 | return ( 35 | 36 |
37 |
38 | Tab 1 39 | 40 | Tab 2 41 | 42 | Tab 3 43 |
44 | 45 | 57 | 58 | 59 |

Hello World 📦 🚀

60 |
61 | 62 | 63 |

Tabs with hooks 🎣

64 |
65 | 66 | 67 |

So nice 🚨

68 |
69 |
70 |
71 | ) 72 | } 73 | -------------------------------------------------------------------------------- /example/src/height-tabs.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect, cloneElement } from 'react' 2 | import { Tabs, useTabState, Panel } from '@bumaga/tabs' 3 | import { motion } from 'framer-motion' 4 | 5 | const cn = (...args) => args.filter(Boolean).join(' ') 6 | 7 | const Tab = ({ children }) => { 8 | const { isActive, onClick } = useTabState() 9 | 10 | return ( 11 | 14 | ) 15 | } 16 | 17 | const PanelList = ({ state, children }) => { 18 | const panelRef = useRef() 19 | const [height, set] = useState(0) 20 | const [activeIndex] = state 21 | 22 | useEffect(() => { 23 | set(panelRef.current.offsetHeight) 24 | }, [activeIndex, set]) 25 | 26 | return ( 27 | 28 |
29 | {cloneElement(children[activeIndex], { active: true })} 30 |
31 |
32 | ) 33 | } 34 | 35 | export default () => { 36 | const state = useState(0) 37 | 38 | return ( 39 | 40 |
41 |
42 | Tab 1 43 | 44 | Tab 2 45 | 46 | Tab 3 47 |
48 | 49 |
50 | 51 | 52 | 53 |

54 | In sociology, anthropology, and linguistics, structuralism is the 55 | methodology that implies elements of human culture must be 56 | understood by way of their relationship to a broader, overarching 57 | system or structure. It works to uncover the structures that 58 | underlie all the things that humans do, think, perceive, and feel. 59 | Alternatively, as summarized by philosopher Simon Blackburn, 60 | structuralism is "the belief that phenomena of human life are not 61 | intelligible except through their interrelations. These relations 62 | constitute a structure, and behind local variations in the surface 63 | phenomena there are constant laws of abstract structure". 64 |

65 |
66 | 67 | 68 |

69 | The input range must be a linear series of numbers. The output 70 | range can be any value type supported by Framer Motion: numbers, 71 | colors, shadows, etc. 72 |

73 |
74 | 75 | 76 |

77 | Creates a MotionValue that, when set, will use a spring animation 78 | to animate to its new state. 79 |

80 |
81 |
82 |
83 | 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tabs 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import App from './App' 4 | 5 | render(, document.getElementById('app')) 6 | -------------------------------------------------------------------------------- /example/src/simple-tabs.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Tabs, useTabState, Panel } from '@bumaga/tabs' 3 | 4 | const cn = (...args) => args.filter(Boolean).join(' ') 5 | 6 | const Tab = ({ children }) => { 7 | const { isActive, onClick } = useTabState() 8 | 9 | return ( 10 | 13 | ) 14 | } 15 | 16 | export default () => ( 17 | 18 |
19 |
20 | Tab 1 21 | 22 | Tab 2 23 | 24 | Tab 3 25 |
26 | 27 |
28 | 29 | 30 |

31 | In sociology, anthropology, and linguistics, structuralism is the 32 | methodology that implies elements of human culture must be understood 33 | by way of their relationship to a broader, overarching system or 34 | structure. It works to uncover the structures that underlie all the 35 | things that humans do, think, perceive, and feel. Alternatively, as 36 | summarized by philosopher Simon Blackburn, structuralism is "the 37 | belief that phenomena of human life are not intelligible except 38 | through their interrelations. These relations constitute a structure, 39 | and behind local variations in the surface phenomena there are 40 | constant laws of abstract structure". 41 |

42 |
43 | 44 | 45 |

46 | The input range must be a linear series of numbers. The output range 47 | can be any value type supported by Framer Motion: numbers, colors, 48 | shadows, etc. 49 |

50 |
51 | 52 | 53 |

54 | Creates a MotionValue that, when set, will use a spring animation to 55 | animate to its new state. 56 |

57 |
58 |
59 | 60 | ) 61 | -------------------------------------------------------------------------------- /example/src/tabs.css: -------------------------------------------------------------------------------- 1 | .tabs { 2 | box-sizing: border-box; 3 | position: relative; 4 | display: flex; 5 | flex-direction: column; 6 | width: 450px; 7 | 8 | padding: 24px 32px; 9 | border: 1px solid #f1f1f1; 10 | background-color: #fff; 11 | box-shadow: 0px 2px 24px 0px rgba(0, 0, 0, 0.1); 12 | 13 | margin: 80px 0; 14 | } 15 | 16 | .tab-list { 17 | display: flex; 18 | 19 | padding-bottom: 24px; 20 | } 21 | 22 | .tab-progress { 23 | position: absolute; 24 | left: -1px; 25 | right: -1px; 26 | top: 66px; 27 | height: 4px; 28 | z-index: 0; 29 | 30 | background-color: #492796; 31 | transform-origin: 0%; 32 | } 33 | 34 | .tab { 35 | outline: none; 36 | cursor: pointer; 37 | border: none; 38 | font-size: 16px; 39 | line-height: 24px; 40 | padding: 8px 16px; 41 | color: #492796; 42 | background-color: #fff; 43 | border: 1px solid #f1f1f1; 44 | box-shadow: 0px 2px 16px 0px rgba(0, 0, 0, 0.1); 45 | margin-right: 24px; 46 | 47 | transition: color 0.16s ease-in-out, background-color 0.16s ease-in-out, 48 | border-color 0.16s ease-in-out; 49 | } 50 | 51 | .tab:focus, 52 | .tab:focus-visible { 53 | box-shadow: 0 0 0 4px rgba(73, 39, 150, 0.5); 54 | } 55 | 56 | .tab:focus:not(:focus-visible) { 57 | box-shadow: none; 58 | } 59 | 60 | .tab.active { 61 | background-color: #492796; 62 | border-color: #492796; 63 | color: white; 64 | cursor: default; 65 | } 66 | 67 | .panel-list { 68 | list-style: none; 69 | margin: 0; 70 | padding: 0; 71 | 72 | position: relative; 73 | } 74 | 75 | .panel { 76 | position: absolute; 77 | } 78 | -------------------------------------------------------------------------------- /example/src/useHover.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useEventCallback } from '@restart/hooks' 3 | 4 | export const useHover = (ref, onHoverStart, onHoverEnd) => { 5 | const start = useEventCallback(onHoverStart) 6 | const end = useEventCallback(onHoverEnd) 7 | useEffect(() => { 8 | const element = ref.current 9 | if (!element) return 10 | element.addEventListener('mouseenter', start) 11 | element.addEventListener('mouseleave', end) 12 | return () => { 13 | element.removeEventListener('mouseenter', start) 14 | element.removeEventListener('mouseleave', end) 15 | } 16 | }, [end, ref, start]) 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bumaga/tabs", 3 | "version": "0.2.0", 4 | "main": "dist/index.cjs.js", 5 | "module": "dist/index.js", 6 | "files": [ 7 | "dist" 8 | ], 9 | "repository": "jeetiss/tabs", 10 | "scripts": { 11 | "build": "rollup -c", 12 | "size": "size-limit", 13 | "test": "NODE_ENV=test jest --verbose --coverage", 14 | "release:prepare": "shipjs prepare", 15 | "release:trigger": "shipjs trigger" 16 | }, 17 | "dependencies": { 18 | "use-constant": "^1.0.0" 19 | }, 20 | "peerDependencies": { 21 | "react": "^16.8.0", 22 | "react-dom": "^16.8.0" 23 | }, 24 | "devDependencies": { 25 | "@babel/cli": "7.22.5", 26 | "@babel/core": "7.22.5", 27 | "@babel/preset-env": "7.22.5", 28 | "@babel/preset-react": "7.22.5", 29 | "@rollup/plugin-node-resolve": "7.1.3", 30 | "@size-limit/preset-small-lib": "4.12.0", 31 | "@testing-library/jest-dom": "5.16.5", 32 | "@testing-library/react": "9.5.0", 33 | "eslint": "6.8.0", 34 | "eslint-config-standard": "14.1.1", 35 | "eslint-config-standard-jsx": "8.1.0", 36 | "eslint-plugin-import": "2.27.5", 37 | "eslint-plugin-node": "11.1.0", 38 | "eslint-plugin-promise": "4.3.1", 39 | "eslint-plugin-react": "7.32.2", 40 | "eslint-plugin-react-hooks": "2.5.1", 41 | "eslint-plugin-standard": "4.1.0", 42 | "jest": "25.5.4", 43 | "react": "16.14.0", 44 | "react-dom": "16.14.0", 45 | "rollup": "2.79.1", 46 | "rollup-plugin-babel": "4.4.0", 47 | "rollup-plugin-terser": "5.3.1", 48 | "shipjs": "0.26.3", 49 | "size-limit": "4.12.0" 50 | }, 51 | "size-limit": [ 52 | { 53 | "path": "dist/index.js", 54 | "limit": "400 B" 55 | } 56 | ], 57 | "author": "Ivakhnenko Dmitry ", 58 | "license": "ISC" 59 | } 60 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # @bumaga/tabs 2 | 3 | Headless tabs component for React 4 | 5 | ## Features 6 | 7 | - 📦 super small, 381 B vs 3.5kB [react-tabs](https://github.com/reactjs/react-tabs) 8 | - 🚫 no styles, just logic. Style what you want, as you want 9 | - 🎣 components and hooks API 10 | 11 | ## Install 12 | 13 | ``` 14 | npm install @bumaga/tabs 15 | ``` 16 | 17 | ``` 18 | yarn add @bumaga/tabs 19 | ``` 20 | 21 | ## Usage 22 | 23 | ### With components 24 | 25 | ```jsx 26 | import React from 'react' 27 | import { Tabs, Tab, Panel } from '@bumaga/tabs' 28 | 29 | export default () => ( 30 | 31 |
32 | 33 | 34 | 35 |
36 | 37 |

Panel 1

38 |

Panel 2

39 |

panel 3

40 |
41 | ); 42 | ``` 43 | 44 | ### With hooks 45 | 46 | ```jsx 47 | import React from "react"; 48 | import { Tabs, useTabState, usePanelState } from "@bumaga/tabs"; 49 | 50 | const Tab = ({ children }) => { 51 | const { onClick } = useTabState(); 52 | 53 | return ; 54 | }; 55 | 56 | const Panel = ({ children }) => { 57 | const isActive = usePanelState(); 58 | 59 | return isActive ?

{children}

: null; 60 | }; 61 | 62 | export default () => ( 63 | 64 |
65 | Tab 1 66 | Tab 2 67 |
68 | 69 | Panel 1 70 | Panel 2 71 |
72 | ); 73 | ``` 74 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import { terser } from 'rollup-plugin-terser' 4 | 5 | export default [ 6 | { 7 | input: 'src/index.js', 8 | external: ['react', 'use-constant'], 9 | output: { 10 | format: 'esm', 11 | file: 'dist/index.js', 12 | sourcemap: false 13 | }, 14 | plugins: [babel()] 15 | }, 16 | { 17 | input: 'src/index.js', 18 | external: ['react', 'use-constant'], 19 | output: { 20 | format: 'cjs', 21 | file: 'dist/index.cjs.js', 22 | sourcemap: false 23 | }, 24 | plugins: [babel()] 25 | }, 26 | { 27 | input: 'src/index.js', 28 | external: ['react'], 29 | output: { 30 | format: 'umd', 31 | name: 'bumaga', 32 | file: 'dist/index.umd.js', 33 | globals: { react: 'React' }, 34 | sourcemap: false 35 | }, 36 | plugins: [babel(), resolve()] 37 | }, 38 | { 39 | input: 'src/index.js', 40 | external: ['react'], 41 | output: { 42 | format: 'umd', 43 | name: 'bumaga', 44 | file: 'dist/index.umd.min.js', 45 | globals: { react: 'React' }, 46 | sourcemap: false 47 | }, 48 | plugins: [babel(), resolve(), terser()] 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /ship.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publishCommand: ({ defaultCommand }) => `${defaultCommand} --access public`, 3 | mergeStrategy: { toSameBranch: ['master'] } 4 | } 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | useState, 4 | useMemo, 5 | useContext, 6 | cloneElement, 7 | isValidElement 8 | } from 'react' 9 | 10 | import useConstant from 'use-constant' 11 | 12 | const TabsState = createContext() 13 | const Elements = createContext() 14 | 15 | export const Tabs = ({ state: outerState, children }) => { 16 | const innerState = useState(0) 17 | const elements = useConstant(() => ({ tabs: 0, panels: 0 })) 18 | const state = outerState || innerState 19 | 20 | return ( 21 | 22 | {children} 23 | 24 | ) 25 | } 26 | 27 | export const useTabState = () => { 28 | const [activeIndex, setActive] = useContext(TabsState) 29 | const elements = useContext(Elements) 30 | 31 | const tabIndex = useConstant(() => { 32 | const currentIndex = elements.tabs 33 | elements.tabs += 1 34 | 35 | return currentIndex 36 | }) 37 | 38 | const onClick = useConstant(() => () => setActive(tabIndex)) 39 | 40 | const state = useMemo( 41 | () => ({ 42 | isActive: activeIndex === tabIndex, 43 | onClick 44 | }), 45 | [activeIndex, onClick, tabIndex] 46 | ) 47 | 48 | return state 49 | } 50 | 51 | export const usePanelState = () => { 52 | const [activeIndex] = useContext(TabsState) 53 | const elements = useContext(Elements) 54 | 55 | const panelIndex = useConstant(() => { 56 | const currentIndex = elements.panels 57 | elements.panels += 1 58 | 59 | return currentIndex 60 | }) 61 | 62 | return panelIndex === activeIndex 63 | } 64 | 65 | export const Tab = ({ children }) => { 66 | const state = useTabState() 67 | 68 | if (typeof children === 'function') { 69 | return children(state) 70 | } 71 | 72 | return isValidElement(children) ? cloneElement(children, state) : children 73 | } 74 | 75 | export const Panel = ({ active, children }) => { 76 | const isActive = usePanelState() 77 | 78 | return isActive || active ? children : null 79 | } 80 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | /* eslint-env jest */ 2 | 3 | import '@testing-library/jest-dom/extend-expect' 4 | 5 | import React from 'react' 6 | import { render, fireEvent } from '@testing-library/react' 7 | import { Panel, Tab, Tabs } from '../src' 8 | 9 | const Button = ({ isActive, onClick, children }) => ( 10 | 13 | ) 14 | 15 | const Testing = () => ( 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | content 1 27 | content 2 28 |
29 | ) 30 | 31 | test('renders and change tabs', () => { 32 | const { container, queryByText } = render() 33 | 34 | expect(container).toHaveTextContent('content 1') 35 | expect(container).not.toHaveTextContent('content 2') 36 | 37 | fireEvent.click(queryByText('tab 2')) 38 | 39 | expect(container).not.toHaveTextContent('content 1') 40 | expect(container).toHaveTextContent('content 2') 41 | }) 42 | --------------------------------------------------------------------------------