├── .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 |
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 | Switch pill
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 |
125 | {["Home", "About", "Contact"].map(stateId => (
126 |
127 | {({ id, on, setOn }) => (
128 |
129 | {id}
130 |
131 | )}
132 |
133 | ))}
134 |
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 | setOn
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 | setOff
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 | toggle
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 | setOn
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 | setOn
156 | setOff
157 | toggle
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 | setOn
190 | setOff
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 | setOff
108 | >
109 | )}
110 |
111 |
112 | {({ on, setOn, setOff }) => (
113 | <>
114 | {String(on)}
115 | setOn
116 | setOff
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 | Update
149 | >
150 | )}
151 |
152 |
153 | {({ on, setOn }) => (
154 | <>
155 | {String(on)}
156 | setOn
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 | toggle
186 | >
187 | )}
188 |
189 |
190 | {({ on, toggle }) => (
191 | <>
192 | {String(on)}
193 | toggle
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 | toggle
226 | >
227 | )}
228 |
229 |
230 | {({ on, toggle }) => (
231 | <>
232 | {String(on)}
233 | toggle
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 | setOn
261 | setOff
262 | toggle
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 | toggleItem
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 | setOn
349 | setOff
350 | toggle
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 | setOn
64 | setOn
65 | toggle
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 |
--------------------------------------------------------------------------------