├── .babelrc.js ├── .gitignore ├── .prettierignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── assets └── logo.svg ├── codecov.yml ├── get-babel-config.js ├── package-lock.json ├── package.json ├── rollup.config.js └── src ├── Context.js ├── OnOff.js ├── OnOffCollection.js ├── OnOffItem.js ├── __tests__ ├── OnOff.js ├── OnOffCollection.js └── OnOffItem.js ├── index.js └── utils ├── generate-id.js └── noop.js /.babelrc.js: -------------------------------------------------------------------------------- 1 | const getBabelConfig = require("./get-babel-config"); 2 | 3 | module.exports = getBabelConfig(); 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | coverage/ 4 | dist/ 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | package.json 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | before_install: 5 | - npm install -g greenkeeper-lockfile 6 | install: 7 | - npm install 8 | before_script: 9 | - greenkeeper-lockfile-update 10 | scripts: 11 | - npm test 12 | after_script: 13 | - greenkeeper-lockfile-upload 14 | after_success: 15 | - npx codecov 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018 Can Göktas 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | react-on-off 3 |

react-on-off

4 |

5 | Flexible React components to manage on/off states 6 |

7 |

8 | 9 | 10 |   11 |   12 | 13 |

14 |

15 | 16 | ## Table of contents 17 | 18 | * [Motivation](#motivation) 19 | * [Inspiration](#inspiration) 20 | * [Installation](#installation) 21 | * [Components](#components) 22 | * [OnOff](#onoff) 23 | * [OnOffCollection](#onoffcollection) 24 | * [OnOffItem](#onoffitem) 25 | * [Related](#related) 26 | * [License](#license) 27 | 28 | ## Motivation 29 | 30 | Many UI components either have a single or multiple on/off states and require 31 | you to write the same type of stateful React components over and over again. 32 | It's not hard, but it takes time and duplicates code. Instead, we can extract 33 | common state requirements into generic, flexible and well-tested components 34 | and reuse them. This is what `react-on-off` is. 35 | 36 | ## Inspiration 37 | 38 | All credit for the design and API should go to 39 | [`react-toggled`](https://github.com/kentcdodds/react-toggled). If you only need 40 | to render a toggle component, go with `react-toggled` since it comes with 41 | functions that help with accessibility. If you're handling accessibility by 42 | yourself or you need to manage multiple on/off states, `react-on-off` is for 43 | you. 44 | 45 | ## Installation 46 | 47 | ```sh 48 | npm install --save react-on-off 49 | # or 50 | yarn add react-on-off 51 | ``` 52 | 53 | You can also use UMD builds from unpkg: 54 | 55 | ```html 56 | 57 | 58 | ``` 59 | 60 | ## Components 61 | 62 | ### `OnOff` 63 | 64 | Manages a single, independent on/off state. Useful whenever you need to render 65 | something with two states. 66 | 67 | #### Usage 68 | 69 | ```js 70 | import React from "react"; 71 | import { render } from "react-dom"; 72 | import { OnOff } from "react-on-off"; 73 | 74 | render( 75 | 76 | {({ on, toggle }) => ( 77 | <> 78 |

{on ? "Red" : "Blue"}

79 | 80 | 81 | )} 82 |
, 83 | document.getElementById("root") 84 | ); 85 | ``` 86 | 87 | #### Props 88 | 89 | | Prop | Type | Default | Description | 90 | | --------------------------- | ---------- | ------- | ---------------------------------------------------------------------------------------- | 91 | | `defaultOn`
_optional_ | `boolean` | `false` | The initial `on` state. | 92 | | `on`
_optional_ | `boolean` | – | Control prop if you want to control the state by yourself. | 93 | | `onChange`
_optional_ | `function` | – | Called whenever the state changes with the new `on` state. | 94 | | `children`
_required_ | `function` | – | A render prop. This is where you render whatever you want based on the state of `OnOff`. | 95 | 96 | #### Render object 97 | 98 | `OnOff` expects the `children` prop to be a function. It is called with a 99 | single argument, an object with the following properties: 100 | 101 | | Property | Type | Description | 102 | | -------- | ---------- | ---------------------------------------------------------------------- | 103 | | `on` | `boolean` | `true` if the state is on, `false` otherwise. | 104 | | `off` | `boolean` | Convenience property if you need `!on`. | 105 | | `setOn` | `function` | Sets the state to on. | 106 | | `setOff` | `function` | Sets the state to off. | 107 | | `toggle` | `function` | Toggles the state (i.e. when it's on, will set to off and vice versa). | 108 | 109 | ### `OnOffCollection` 110 | 111 | Manages multiple on/off states where only one state can be on at all times. To 112 | render the indivial on/off states, use `OnOffItem` anywhere inside an 113 | `OnOffCollection` parent. 114 | 115 | #### Usage 116 | 117 | ```js 118 | import React from "react"; 119 | import { render } from "react-dom"; 120 | import { OnOffCollection, OnOffItem } from "react-on-off"; 121 | 122 | render( 123 | 124 | 135 | , 136 | document.getElementById("root") 137 | ); 138 | ``` 139 | 140 | #### Props 141 | 142 | | Prop | Type | Default | Description | 143 | | --------------------------- | ---------- | ------- | ------------------------------------------------------------------------------------------------------------ | 144 | | `defaultOn`
_optional_ | `string` | – | The item id that should be on initially. | 145 | | `on`
_optional_ | `string` | – | Control prop if you want to control which item should be on. Either an id or `null` if no item should be on. | 146 | | `onChange`
_optional_ | `function` | – | Called whenever the state changes with the item id that is on. | 147 | 148 | ### `OnOffItem` 149 | 150 | Represents a single on/off state that is coupled to other on/off states. Doesn't 151 | do anything without an `OnOffCollection` parent. 152 | 153 | #### Usage 154 | 155 | See [`OnOffCollection` Usage](#usage-1) 156 | 157 | #### Props 158 | 159 | | Prop | Type | Default | Description | 160 | | -------------------------- | ---------- | ----------- | ------------------------------------------------------------------------------------------------------------------- | 161 | | `id`
_optional_ | `string` | a unique id | Only useful in combination with the `defaultOn` or `onChange` prop from `OnOffCollection`. Defaults to a unique id. | 162 | | `children`
_required_ | `function` | – | A render prop. This is where you render whatever you want based on the state of the `OnOffItem`. | 163 | 164 | #### Render object 165 | 166 | `OnOffItem` expects the `children` prop to be a function. It is called with a 167 | single argument, an object with the following properties: 168 | 169 | | Property | Type | Description | 170 | | -------- | ---------- | ---------------------------------------------------------------------------------- | 171 | | `id` | `string` | The state id you set yourself or a generated unique id. | 172 | | `on` | `boolean` | `true` if the state is on, `false` otherwise. | 173 | | `off` | `boolean` | Convenience property if you need `!on`. | 174 | | `setOn` | `function` | Sets the state of the item to on. | 175 | | `setOff` | `function` | Sets the state of the item to off. | 176 | | `toggle` | `function` | Toggles the state of the item (i.e. when it's on, will set to off and vice versa). | 177 | 178 | ## Related 179 | 180 | * [react-toggled](https://github.com/kentcdodds/react-toggled) 181 | * [react-powerplug](https://github.com/renatorib/react-powerplug) 182 | 183 | ## License 184 | 185 | MIT 186 | -------------------------------------------------------------------------------- /assets/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: 2 | require_changes: yes 3 | -------------------------------------------------------------------------------- /get-babel-config.js: -------------------------------------------------------------------------------- 1 | const getBabelConfig = (envConfig = { modules: "commonjs" }) => ({ 2 | presets: [["@babel/env", envConfig], "@babel/react"], 3 | plugins: [ 4 | "@babel/plugin-proposal-class-properties", 5 | "@babel/plugin-proposal-object-rest-spread" 6 | ], 7 | env: { 8 | production: { 9 | plugins: [ 10 | [ 11 | "transform-react-remove-prop-types", 12 | { mode: "remove", removeImport: true } 13 | ] 14 | ] 15 | } 16 | } 17 | }); 18 | 19 | module.exports = getBabelConfig; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-on-off", 3 | "version": "1.0.4", 4 | "description": "Flexible React components to manage on/off states", 5 | "main": "dist/index.umd.js", 6 | "module": "dist/index.esm.js", 7 | "files": [ 8 | "dist" 9 | ], 10 | "scripts": { 11 | "clean": "rimraf dist", 12 | "build": "rollup --config rollup.config.js", 13 | "test": "jest --bail", 14 | "posttest": "bundlesize", 15 | "precommit": "lint-staged", 16 | "prepare": "npm-run-all clean build" 17 | }, 18 | "dependencies": {}, 19 | "peerDependencies": { 20 | "react": ">=16.3", 21 | "prop-types": ">=15.5.7" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.0.0-beta.46", 25 | "@babel/plugin-proposal-class-properties": "^7.0.0-beta.46", 26 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0-beta.46", 27 | "@babel/preset-env": "^7.0.0-beta.46", 28 | "@babel/preset-react": "^7.0.0-beta.46", 29 | "babel-core": "^7.0.0-bridge.0", 30 | "babel-jest": "^23.0.1", 31 | "babel-plugin-transform-react-remove-prop-types": "^0.4.13", 32 | "bundlesize": "^0.17.0", 33 | "codecov": "^3.0.0", 34 | "husky": "^0.14.3", 35 | "jest": "^22.4.3", 36 | "lint-staged": "^7.0.4", 37 | "npm-run-all": "^4.1.2", 38 | "prettier": "^1.12.0", 39 | "prop-types": "^15.6.1", 40 | "react": "^16.3.2", 41 | "react-dom": "^16.3.2", 42 | "rimraf": "^2.6.2", 43 | "rollup": "^0.64.0", 44 | "rollup-plugin-babel": "^4.0.0-beta.4", 45 | "rollup-plugin-babel-minify": "^5.0.0", 46 | "rollup-plugin-node-resolve": "^3.3.0" 47 | }, 48 | "author": "Can Göktas ", 49 | "license": "MIT", 50 | "keywords": [ 51 | "react", 52 | "on", 53 | "off", 54 | "state", 55 | "toggle" 56 | ], 57 | "repository": { 58 | "type": "git", 59 | "url": "https://github.com/cangoektas/react-on-off" 60 | }, 61 | "bugs": { 62 | "url": "https://github.com/cangoektas/react-on-off/issues" 63 | }, 64 | "lint-staged": { 65 | "*.{js,json,md}": [ 66 | "prettier --write", 67 | "git add" 68 | ] 69 | }, 70 | "jest": { 71 | "coverageDirectory": "coverage", 72 | "collectCoverage": true, 73 | "collectCoverageFrom": [ 74 | "src/**/*.js" 75 | ] 76 | }, 77 | "bundlesize": [ 78 | { 79 | "path": "dist/index.umd.min.js", 80 | "maxSize": "2 kB" 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import resolve from "rollup-plugin-node-resolve"; 3 | import minify from "rollup-plugin-babel-minify"; 4 | 5 | const getBabelConfig = require("./get-babel-config"); 6 | 7 | const devConfig = { 8 | input: "src/index.js", 9 | external: ["react", "prop-types"], 10 | plugins: [babel(getBabelConfig({ modules: false })), resolve()], 11 | output: [ 12 | { 13 | file: "dist/index.esm.js", 14 | format: "esm" 15 | }, 16 | { 17 | file: "dist/index.umd.js", 18 | format: "umd", 19 | name: "OnOff", 20 | globals: { 21 | react: "React", 22 | "prop-types": "PropTypes" 23 | } 24 | } 25 | ], 26 | watch: { 27 | include: "src/**" 28 | } 29 | }; 30 | const prodConfig = { 31 | input: "src/index.js", 32 | external: ["react", "prop-types"], 33 | plugins: [ 34 | babel(getBabelConfig({ modules: false })), 35 | resolve(), 36 | minify({ comments: false, sourceMap: false }) 37 | ], 38 | output: { 39 | file: "dist/index.umd.min.js", 40 | format: "umd", 41 | name: "OnOff", 42 | globals: { 43 | react: "React" 44 | } 45 | } 46 | }; 47 | 48 | export default [devConfig, prodConfig]; 49 | -------------------------------------------------------------------------------- /src/Context.js: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | 3 | import noop from "./utils/noop"; 4 | 5 | const defaultContext = { 6 | on: undefined, 7 | setOn: noop, 8 | setOff: noop, 9 | toggle: noop, 10 | registerItem: id => id || "_internalDefaultId", 11 | unregisterItem: noop 12 | }; 13 | const { Provider, Consumer } = createContext(defaultContext); 14 | 15 | export { Provider, Consumer }; 16 | -------------------------------------------------------------------------------- /src/OnOff.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import noop from "./utils/noop"; 5 | 6 | export default class OnOff extends Component { 7 | static propTypes = { 8 | defaultOn: PropTypes.bool, 9 | on: PropTypes.bool, 10 | onChange: PropTypes.func, 11 | children: PropTypes.func.isRequired 12 | }; 13 | 14 | static defaultProps = { 15 | defaultOn: false, 16 | onChange: noop 17 | }; 18 | 19 | state = { on: Boolean(this.props.defaultOn) }; 20 | 21 | shouldComponentUpdate(nextProps, nextState) { 22 | return this.isControlled() 23 | ? this.props.on !== nextProps.on 24 | : this.state.on !== nextState.on; 25 | } 26 | 27 | isControlled() { 28 | return this.props.on !== undefined; 29 | } 30 | 31 | getOnState = () => { 32 | return this.isControlled() ? Boolean(this.props.on) : this.state.on; 33 | }; 34 | 35 | setOnState = nextOn => { 36 | const prevOn = this.getOnState(); 37 | const stateChanged = prevOn !== nextOn; 38 | const isControlled = this.isControlled(); 39 | 40 | if (isControlled && stateChanged) { 41 | this.props.onChange(nextOn); 42 | } else if (!isControlled && stateChanged) { 43 | this.setState({ on: nextOn }, () => this.props.onChange(nextOn)); 44 | } 45 | }; 46 | 47 | setOn = () => this.setOnState(true); 48 | setOff = () => this.setOnState(false); 49 | toggle = () => this.setOnState(!this.getOnState()); 50 | 51 | render() { 52 | const on = this.getOnState(); 53 | 54 | return this.props.children({ 55 | on: on, 56 | off: !on, 57 | setOn: this.setOn, 58 | setOff: this.setOff, 59 | toggle: this.toggle 60 | }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/OnOffCollection.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { Provider } from "./Context"; 5 | import generateId from "./utils/generate-id"; 6 | import noop from "./utils/noop"; 7 | 8 | export default class OnOffCollection extends Component { 9 | static propTypes = { 10 | defaultOn: PropTypes.string, 11 | on: PropTypes.string, 12 | onChange: PropTypes.func 13 | }; 14 | 15 | static defaultProps = { 16 | defaultOn: null, 17 | onChange: noop 18 | }; 19 | 20 | shouldComponentUpdate(nextProps, nextState) { 21 | return this.isControlled() 22 | ? this.props.on !== nextProps.on 23 | : this.state.on !== nextState.on; 24 | } 25 | 26 | isControlled() { 27 | return this.props.on !== undefined; 28 | } 29 | 30 | getOnState = () => { 31 | return this.isControlled() ? this.props.on : this.state.on; 32 | }; 33 | 34 | setOnState = nextOnId => { 35 | const prevOnId = this.getOnState(); 36 | const stateChanged = prevOnId !== nextOnId; 37 | const isControlled = this.isControlled(); 38 | 39 | if (isControlled && stateChanged) { 40 | this.props.onChange(nextOnId); 41 | } else if (!isControlled && stateChanged) { 42 | this.setState({ on: nextOnId }, () => this.props.onChange(nextOnId)); 43 | } 44 | }; 45 | 46 | setOn = nextOnId => this.setOnState(nextOnId); 47 | 48 | setOff = nextOnId => { 49 | if (nextOnId === this.state.on) { 50 | this.setOnState(null); 51 | } 52 | }; 53 | 54 | toggle = nextOnId => { 55 | const prevOnId = this.getOnState(); 56 | this.setOnState(prevOnId !== nextOnId ? nextOnId : null); 57 | }; 58 | 59 | registerItem = itemId => itemId || String(this.idGenerator()); 60 | 61 | unregisterItem = itemId => { 62 | if (itemId === this.getOnState()) { 63 | this.setOnState(null); 64 | } 65 | }; 66 | 67 | idGenerator = generateId; 68 | 69 | state = { 70 | on: this.props.defaultOn, 71 | setOn: this.setOn, 72 | setOff: this.setOff, 73 | toggle: this.toggle, 74 | registerItem: this.registerItem, 75 | unregisterItem: this.unregisterItem 76 | }; 77 | 78 | render() { 79 | return ( 80 | 81 | {this.props.children} 82 | 83 | ); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/OnOffItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | import { Consumer } from "./Context"; 5 | 6 | class OnOffItemImpl extends Component { 7 | static propTypes = { 8 | id: PropTypes.string, 9 | children: PropTypes.func.isRequired, 10 | context: PropTypes.shape({ 11 | on: PropTypes.string, 12 | setOn: PropTypes.func.isRequired, 13 | setOff: PropTypes.func.isRequired, 14 | toggle: PropTypes.func.isRequired, 15 | registerItem: PropTypes.func.isRequired, 16 | unregisterItem: PropTypes.func.isRequired 17 | }).isRequired 18 | }; 19 | 20 | state = { 21 | id: this.props.context.registerItem(this.props.id) 22 | }; 23 | 24 | componentWillUnmount() { 25 | this.props.context.unregisterItem(this.props.id); 26 | } 27 | 28 | shouldComponentUpdate(nextProps) { 29 | const id = this.state.id; 30 | const prevOnId = this.props.context.on; 31 | const nextOnId = nextProps.context.on; 32 | const itemIsOn = prevOnId === id; 33 | 34 | return (itemIsOn && nextOnId !== id) || (!itemIsOn && nextOnId === id); 35 | } 36 | 37 | setOn = () => this.props.context.setOn(this.state.id); 38 | setOff = () => this.props.context.setOff(this.state.id); 39 | toggle = () => this.props.context.toggle(this.state.id); 40 | 41 | render() { 42 | const id = this.state.id; 43 | const on = this.props.context.on === id; 44 | 45 | return this.props.children({ 46 | id, 47 | on, 48 | off: !on, 49 | setOn: this.setOn, 50 | setOff: this.setOff, 51 | toggle: this.toggle 52 | }); 53 | } 54 | } 55 | 56 | const OnOffItem = props => ( 57 | 58 | {contextValue => } 59 | 60 | ); 61 | 62 | OnOffItem.propTypes = { 63 | id: PropTypes.string, 64 | children: PropTypes.func.isRequired 65 | }; 66 | 67 | export default OnOffItem; 68 | -------------------------------------------------------------------------------- /src/__tests__/OnOff.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import TestUtils from "react-dom/test-utils"; 4 | 5 | import { OnOff } from ".."; 6 | 7 | let container; 8 | beforeEach(() => { 9 | container = document.createElement("div"); 10 | }); 11 | 12 | test("renders without crashing", () => { 13 | expect(() => 14 | render({() => Hello, world!}, container) 15 | ).not.toThrow(); 16 | }); 17 | 18 | test("initial state is off", () => { 19 | const root = render( 20 | 21 | {({ on, off }) => ( 22 | <> 23 | {String(on)} 24 | {String(off)} 25 | 26 | )} 27 | , 28 | container 29 | ); 30 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 31 | const [spanOn, spanOff] = spans; 32 | 33 | expect(spanOn.textContent).toEqual("false"); 34 | expect(spanOff.textContent).toEqual("true"); 35 | }); 36 | 37 | test("initial state can be set", () => { 38 | const root = render( 39 | {({ on }) => {String(on)}}, 40 | container 41 | ); 42 | const span = TestUtils.findRenderedDOMComponentWithTag(root, "span"); 43 | 44 | expect(span.textContent).toEqual("true"); 45 | }); 46 | 47 | test("state can be controlled", () => { 48 | const root = render( 49 | 50 | {({ on }) => {String(on)}} 51 | , 52 | container 53 | ); 54 | const span = TestUtils.findRenderedDOMComponentWithTag(root, "span"); 55 | 56 | expect(span.textContent).toEqual("true"); 57 | render( 58 | {({ on }) => {String(on)}}, 59 | container 60 | ); 61 | expect(span.textContent).toEqual("false"); 62 | }); 63 | 64 | test("`setOn` updates the state to on", () => { 65 | const root = render( 66 | 67 | {({ on, setOn }) => ( 68 | <> 69 | {String(on)} 70 | 71 | 72 | )} 73 | , 74 | container 75 | ); 76 | const span = TestUtils.findRenderedDOMComponentWithTag(root, "span"); 77 | const setOnButton = TestUtils.findRenderedDOMComponentWithTag(root, "button"); 78 | 79 | expect(span.textContent).toEqual("false"); 80 | TestUtils.Simulate.click(setOnButton); 81 | expect(span.textContent).toEqual("true"); 82 | }); 83 | 84 | test("`setOff` updates the state to off", () => { 85 | const root = render( 86 | 87 | {({ on, setOff }) => ( 88 | <> 89 | {String(on)} 90 | 91 | 92 | )} 93 | , 94 | container 95 | ); 96 | const span = TestUtils.findRenderedDOMComponentWithTag(root, "span"); 97 | const button = TestUtils.findRenderedDOMComponentWithTag(root, "button"); 98 | 99 | expect(span.textContent).toEqual("true"); 100 | TestUtils.Simulate.click(button); 101 | expect(span.textContent).toEqual("false"); 102 | }); 103 | 104 | test("`toggle` toggles the state", () => { 105 | const root = render( 106 | 107 | {({ on, toggle }) => ( 108 | <> 109 | {String(on)} 110 | 111 | 112 | )} 113 | , 114 | container 115 | ); 116 | const span = TestUtils.findRenderedDOMComponentWithTag(root, "span"); 117 | const toggleButton = TestUtils.findRenderedDOMComponentWithTag( 118 | root, 119 | "button" 120 | ); 121 | 122 | expect(span.textContent).toEqual("false"); 123 | TestUtils.Simulate.click(toggleButton); 124 | expect(span.textContent).toEqual("true"); 125 | TestUtils.Simulate.click(toggleButton); 126 | expect(span.textContent).toEqual("false"); 127 | }); 128 | 129 | test("state doesn't change when component is controlled", () => { 130 | const root = render( 131 | 132 | {({ on, setOn }) => ( 133 | <> 134 | {String(on)} 135 | 136 | 137 | )} 138 | , 139 | container 140 | ); 141 | const span = TestUtils.findRenderedDOMComponentWithTag(root, "span"); 142 | const setOnButton = TestUtils.findRenderedDOMComponentWithTag(root, "button"); 143 | 144 | expect(span.textContent).toEqual("false"); 145 | TestUtils.Simulate.click(setOnButton); 146 | expect(span.textContent).toEqual("false"); 147 | }); 148 | 149 | test("`onChange` is called only when state changes", () => { 150 | const onChange = jest.fn(); 151 | const root = render( 152 | 153 | {({ setOn, setOff, toggle }) => ( 154 | <> 155 | 156 | 157 | 158 | 159 | )} 160 | , 161 | container 162 | ); 163 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 164 | const [setOnButton, setOffButton, toggleButton] = buttons; 165 | 166 | expect(onChange).toHaveBeenCalledTimes(0); 167 | TestUtils.Simulate.click(toggleButton); 168 | expect(onChange).toHaveBeenCalledTimes(1); 169 | expect(onChange).toHaveBeenLastCalledWith(true); 170 | TestUtils.Simulate.click(setOffButton); 171 | TestUtils.Simulate.click(setOffButton); 172 | expect(onChange).toHaveBeenCalledTimes(2); 173 | expect(onChange).toHaveBeenLastCalledWith(false); 174 | TestUtils.Simulate.click(setOnButton); 175 | TestUtils.Simulate.click(setOnButton); 176 | expect(onChange).toHaveBeenCalledTimes(3); 177 | expect(onChange).toHaveBeenLastCalledWith(true); 178 | }); 179 | 180 | test("doesn't re-render when the state doesn't change", () => { 181 | const onRender = jest.fn(); 182 | const root = render( 183 | 184 | {({ setOn, setOff }) => { 185 | onRender(); 186 | 187 | return ( 188 | <> 189 | 190 | 191 | 192 | ); 193 | }} 194 | , 195 | container 196 | ); 197 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 198 | const [setOnButton, setOffButton] = buttons; 199 | 200 | expect(onRender).toHaveBeenCalledTimes(1); 201 | TestUtils.Simulate.click(setOffButton); 202 | TestUtils.Simulate.click(setOffButton); 203 | expect(onRender).toHaveBeenCalledTimes(1); 204 | TestUtils.Simulate.click(setOnButton); 205 | TestUtils.Simulate.click(setOnButton); 206 | expect(onRender).toHaveBeenCalledTimes(2); 207 | }); 208 | 209 | test("doesn't re-render when the `on` prop doesn't change", () => { 210 | const onRender = jest.fn(); 211 | render( 212 | {}}> 213 | {({ on }) => { 214 | onRender(); 215 | 216 | return {String(on)}; 217 | }} 218 | , 219 | container 220 | ); 221 | render( 222 | {}}> 223 | {({ on }) => { 224 | onRender(); 225 | 226 | return {String(on)}; 227 | }} 228 | , 229 | container 230 | ); 231 | render( 232 | 233 | {({ on }) => { 234 | onRender(); 235 | 236 | return {String(on)}; 237 | }} 238 | , 239 | container 240 | ); 241 | 242 | expect(onRender).toHaveBeenCalledTimes(2); 243 | }); 244 | -------------------------------------------------------------------------------- /src/__tests__/OnOffCollection.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render } from "react-dom"; 3 | import TestUtils from "react-dom/test-utils"; 4 | 5 | import { OnOff, OnOffCollection, OnOffItem } from ".."; 6 | 7 | let container; 8 | beforeEach(() => { 9 | container = document.createElement("div"); 10 | }); 11 | 12 | test("renders without crashing", () => { 13 | expect(() => 14 | render( 15 | 16 | Hello, world! 17 | , 18 | container 19 | ) 20 | ).not.toThrow(); 21 | }); 22 | 23 | test("item states default to off", () => { 24 | const root = render( 25 | 26 | {({ on }) => {String(on)}} 27 | {({ on }) => {String(on)}} 28 | , 29 | container 30 | ); 31 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 32 | const [span1, span2] = spans; 33 | 34 | expect(span1.textContent).toEqual("false"); 35 | expect(span2.textContent).toEqual("false"); 36 | }); 37 | 38 | test("item id's default to unique id's", () => { 39 | const root = render( 40 | 41 | {({ id }) => {id}} 42 | {({ id }) => {id}} 43 | , 44 | container 45 | ); 46 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 47 | const [span1, span2] = spans; 48 | 49 | expect(span1.textContent).not.toEqual(span2.textContent); 50 | }); 51 | 52 | test("initial state can be set", () => { 53 | const root = render( 54 | 55 | {({ on }) => {String(on)}} 56 | {({ on }) => {String(on)}} 57 | , 58 | container 59 | ); 60 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 61 | const [span1, span2] = spans; 62 | 63 | expect(span1.textContent).toEqual("true"); 64 | expect(span2.textContent).toEqual("false"); 65 | }); 66 | 67 | test("state can be controlled", () => { 68 | const root = render( 69 | 70 | {({ on }) => {String(on)}} 71 | {({ on }) => {String(on)}} 72 | , 73 | container 74 | ); 75 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 76 | const [span1, span2] = spans; 77 | 78 | expect(span1.textContent).toEqual("false"); 79 | expect(span2.textContent).toEqual("true"); 80 | render( 81 | 82 | {({ on }) => {String(on)}} 83 | {({ on }) => {String(on)}} 84 | , 85 | container 86 | ); 87 | expect(span1.textContent).toEqual("true"); 88 | expect(span2.textContent).toEqual("false"); 89 | render( 90 | 91 | {({ on }) => {String(on)}} 92 | {({ on }) => {String(on)}} 93 | , 94 | container 95 | ); 96 | expect(span1.textContent).toEqual("false"); 97 | expect(span2.textContent).toEqual("false"); 98 | }); 99 | 100 | test("`setOff` sets item states to off", () => { 101 | const root = render( 102 | 103 | 104 | {({ on, setOff }) => ( 105 | <> 106 | {String(on)} 107 | 108 | 109 | )} 110 | 111 | 112 | {({ on, setOn, setOff }) => ( 113 | <> 114 | {String(on)} 115 | 116 | 117 | 118 | )} 119 | 120 | , 121 | container 122 | ); 123 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 124 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 125 | const [spanItem1, spanItem2] = spans; 126 | const [setOffButtonItem1, setOnButtonItem2, setOffButtonItem2] = buttons; 127 | 128 | expect(spanItem1.textContent).toEqual("true"); 129 | expect(spanItem2.textContent).toEqual("false"); 130 | TestUtils.Simulate.click(setOffButtonItem1); 131 | expect(spanItem1.textContent).toEqual("false"); 132 | expect(spanItem2.textContent).toEqual("false"); 133 | TestUtils.Simulate.click(setOnButtonItem2); 134 | expect(spanItem1.textContent).toEqual("false"); 135 | expect(spanItem2.textContent).toEqual("true"); 136 | TestUtils.Simulate.click(setOffButtonItem2); 137 | expect(spanItem1.textContent).toEqual("false"); 138 | expect(spanItem2.textContent).toEqual("false"); 139 | }); 140 | 141 | test("`setOn` sets item states to on", () => { 142 | const root = render( 143 | 144 | 145 | {({ on, setOn }) => ( 146 | <> 147 | {String(on)} 148 | 149 | 150 | )} 151 | 152 | 153 | {({ on, setOn }) => ( 154 | <> 155 | {String(on)} 156 | 157 | 158 | )} 159 | 160 | , 161 | container 162 | ); 163 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 164 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 165 | const [spanItem1, spanItem2] = spans; 166 | const [setOnButtonItem1, setOnButtonItem2] = buttons; 167 | 168 | expect(spanItem1.textContent).toEqual("false"); 169 | expect(spanItem2.textContent).toEqual("false"); 170 | TestUtils.Simulate.click(setOnButtonItem1); 171 | expect(spanItem1.textContent).toEqual("true"); 172 | expect(spanItem2.textContent).toEqual("false"); 173 | TestUtils.Simulate.click(setOnButtonItem2); 174 | expect(spanItem1.textContent).toEqual("false"); 175 | expect(spanItem2.textContent).toEqual("true"); 176 | }); 177 | 178 | test("`toggle` toggles the item states", () => { 179 | const root = render( 180 | 181 | 182 | {({ on, toggle }) => ( 183 | <> 184 | {String(on)} 185 | 186 | 187 | )} 188 | 189 | 190 | {({ on, toggle }) => ( 191 | <> 192 | {String(on)} 193 | 194 | 195 | )} 196 | 197 | , 198 | container 199 | ); 200 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 201 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 202 | const [spanItem1, spanItem2] = spans; 203 | const [toggleButtonItem1, toggleButtonItem2] = buttons; 204 | 205 | expect(spanItem1.textContent).toEqual("true"); 206 | expect(spanItem2.textContent).toEqual("false"); 207 | TestUtils.Simulate.click(toggleButtonItem2); 208 | expect(spanItem1.textContent).toEqual("false"); 209 | expect(spanItem2.textContent).toEqual("true"); 210 | TestUtils.Simulate.click(toggleButtonItem2); 211 | expect(spanItem1.textContent).toEqual("false"); 212 | expect(spanItem2.textContent).toEqual("false"); 213 | TestUtils.Simulate.click(toggleButtonItem1); 214 | expect(spanItem1.textContent).toEqual("true"); 215 | expect(spanItem2.textContent).toEqual("false"); 216 | }); 217 | 218 | test("state doesn't change when component is controlled", () => { 219 | const root = render( 220 | 221 | 222 | {({ on, toggle }) => ( 223 | <> 224 | {String(on)} 225 | 226 | 227 | )} 228 | 229 | 230 | {({ on, toggle }) => ( 231 | <> 232 | {String(on)} 233 | 234 | 235 | )} 236 | 237 | , 238 | container 239 | ); 240 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 241 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 242 | const [spanItem1, spanItem2] = spans; 243 | const [toggleButtonItem1, toggleButtonItem2] = buttons; 244 | 245 | expect(spanItem1.textContent).toEqual("true"); 246 | expect(spanItem2.textContent).toEqual("false"); 247 | TestUtils.Simulate.click(toggleButtonItem2); 248 | expect(spanItem1.textContent).toEqual("true"); 249 | expect(spanItem2.textContent).toEqual("false"); 250 | }); 251 | 252 | test("`onChange` is called only when state changes", () => { 253 | const onChange = jest.fn(); 254 | const root = render( 255 | 256 | {["1", "2"].map(stateId => ( 257 | 258 | {({ setOn, setOff, toggle }) => ( 259 | <> 260 | 261 | 262 | 263 | 264 | )} 265 | 266 | ))} 267 | , 268 | container 269 | ); 270 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 271 | const [ 272 | setOnButtonItem1, 273 | setOffButtonItem1, 274 | toggleButtonItem1, 275 | setOnButtonItem2, 276 | setOffButtonItem2, 277 | toggleButtonItem2 278 | ] = buttons; 279 | 280 | expect(onChange).toHaveBeenCalledTimes(0); 281 | TestUtils.Simulate.click(setOnButtonItem1); 282 | expect(onChange).toHaveBeenCalledTimes(0); 283 | TestUtils.Simulate.click(setOnButtonItem2); 284 | expect(onChange).toHaveBeenCalledTimes(1); 285 | expect(onChange).toHaveBeenLastCalledWith("2"); 286 | TestUtils.Simulate.click(setOffButtonItem2); 287 | expect(onChange).toHaveBeenCalledTimes(2); 288 | expect(onChange).toHaveBeenLastCalledWith(null); 289 | TestUtils.Simulate.click(setOffButtonItem2); 290 | expect(onChange).toHaveBeenCalledTimes(2); 291 | TestUtils.Simulate.click(toggleButtonItem1); 292 | expect(onChange).toHaveBeenCalledTimes(3); 293 | expect(onChange).toHaveBeenLastCalledWith("1"); 294 | TestUtils.Simulate.click(toggleButtonItem2); 295 | expect(onChange).toHaveBeenCalledTimes(4); 296 | expect(onChange).toHaveBeenLastCalledWith("2"); 297 | }); 298 | 299 | test("resets the state when items unmount", () => { 300 | const root = render( 301 | 302 | {["1", "2"].map(stateId => ( 303 | 304 | {({ on: isItemVisible, toggle: toggleItem }) => ( 305 | <> 306 | {isItemVisible ? ( 307 | 308 | {({ on }) => {String(on)}} 309 | 310 | ) : null} 311 | 312 | 313 | )} 314 | 315 | ))} 316 | , 317 | container 318 | ); 319 | 320 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 321 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 322 | const [spanItem1, spanItem2] = spans; 323 | const [toggleButtonItem1, toggleButtonItem2] = buttons; 324 | 325 | expect(spanItem1.textContent).toEqual("true"); 326 | expect(spanItem2.textContent).toEqual("false"); 327 | // Unmount first item 328 | TestUtils.Simulate.click(toggleButtonItem1); 329 | // Mount first item 330 | TestUtils.Simulate.click(toggleButtonItem1); 331 | const spansUpdated = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 332 | const [spanItem1Updated, spanItem2Updated] = spansUpdated; 333 | expect(spanItem1Updated.textContent).toEqual("false"); 334 | expect(spanItem2Updated.textContent).toEqual("false"); 335 | }); 336 | 337 | test("doesn't re-render when the state doesn't change", () => { 338 | const onRender = jest.fn(); 339 | const root = render( 340 | 341 | 342 | {({ on, setOn, setOff, toggle }) => { 343 | onRender(); 344 | 345 | return ( 346 | <> 347 | {String(on)} 348 | 349 | 350 | 351 | 352 | ); 353 | }} 354 | 355 | , 356 | container 357 | ); 358 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 359 | const [setOnButton, setOffButton, toggleButton] = buttons; 360 | 361 | expect(onRender).toHaveBeenCalledTimes(1); 362 | TestUtils.Simulate.click(setOffButton); 363 | TestUtils.Simulate.click(setOffButton); 364 | expect(onRender).toHaveBeenCalledTimes(1); 365 | TestUtils.Simulate.click(setOnButton); 366 | TestUtils.Simulate.click(setOnButton); 367 | expect(onRender).toHaveBeenCalledTimes(2); 368 | }); 369 | 370 | test("doesn't re-render when the `on` prop doesn't change", () => { 371 | const onRender = jest.fn(); 372 | render( 373 | {}}> 374 | 375 | {({ on }) => { 376 | onRender(); 377 | 378 | return {String(on)}; 379 | }} 380 | 381 | , 382 | container 383 | ); 384 | render( 385 | {}}> 386 | 387 | {({ on }) => { 388 | onRender(); 389 | 390 | return {String(on)}; 391 | }} 392 | 393 | , 394 | container 395 | ); 396 | render( 397 | 398 | 399 | {({ on }) => { 400 | onRender(); 401 | 402 | return {String(on)}; 403 | }} 404 | 405 | , 406 | container 407 | ); 408 | 409 | expect(onRender).toHaveBeenCalledTimes(2); 410 | }); 411 | -------------------------------------------------------------------------------- /src/__tests__/OnOffItem.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import { render } from "react-dom"; 3 | import TestUtils from "react-dom/test-utils"; 4 | 5 | import { OnOffItem } from ".."; 6 | 7 | class TestWrapper extends Component { 8 | render() { 9 | return this.props.children; 10 | } 11 | } 12 | let container; 13 | beforeEach(() => { 14 | container = document.createElement("div"); 15 | }); 16 | 17 | test("renders without crashing", () => { 18 | expect(() => 19 | render({() => Hello, world!}, container) 20 | ).not.toThrow(); 21 | }); 22 | 23 | test("state defaults to off", () => { 24 | const root = render( 25 | 26 | 27 | {({ on, off }) => ( 28 | <> 29 | {String(on)} 30 | {String(off)} 31 | 32 | )} 33 | 34 | , 35 | container 36 | ); 37 | const spans = TestUtils.scryRenderedDOMComponentsWithTag(root, "span"); 38 | const [spanOn, spanOff] = spans; 39 | 40 | expect(spanOn.textContent).toEqual("false"); 41 | expect(spanOff.textContent).toEqual("true"); 42 | }); 43 | 44 | test("passes on id prop", () => { 45 | const root = render( 46 | 47 | {({ id }) => {String(id)}} 48 | , 49 | container 50 | ); 51 | const span = TestUtils.findRenderedDOMComponentWithTag(root, "span"); 52 | 53 | expect(span.textContent).toEqual("foo"); 54 | }); 55 | 56 | test("state does not change without OnOffCollection", () => { 57 | const root = render( 58 | 59 | 60 | {({ on, setOn, setOff, toggle }) => ( 61 | <> 62 | {String(on)} 63 | 64 | 65 | 66 | 67 | )} 68 | 69 | , 70 | container 71 | ); 72 | const span = TestUtils.findRenderedDOMComponentWithTag(root, "span"); 73 | const buttons = TestUtils.scryRenderedDOMComponentsWithTag(root, "button"); 74 | const [setOnButton, setOffButton, toggleButton] = buttons; 75 | 76 | TestUtils.Simulate.click(setOnButton); 77 | expect(span.textContent).toEqual("false"); 78 | TestUtils.Simulate.click(setOffButton); 79 | expect(span.textContent).toEqual("false"); 80 | TestUtils.Simulate.click(toggleButton); 81 | expect(span.textContent).toEqual("false"); 82 | }); 83 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import OnOff from "./OnOff"; 2 | import OnOffCollection from "./OnOffCollection"; 3 | import OnOffItem from "./OnOffItem"; 4 | 5 | export { OnOff, OnOffCollection, OnOffItem }; 6 | -------------------------------------------------------------------------------- /src/utils/generate-id.js: -------------------------------------------------------------------------------- 1 | const getNextIndex = index => () => index++; 2 | const generateId = getNextIndex(0); 3 | 4 | export default generateId; 5 | -------------------------------------------------------------------------------- /src/utils/noop.js: -------------------------------------------------------------------------------- 1 | const noop = () => {}; 2 | 3 | export default noop; 4 | --------------------------------------------------------------------------------