├── .babelrc ├── .editorconfig ├── .gitignore ├── .prettierignore ├── .travis.yml ├── README.md ├── config ├── rollup.config.js └── setup.js ├── package.json ├── src ├── Match.js ├── Pattern.js ├── __tests__ │ └── Pattern-test.js ├── index.js └── matchers.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["env", "stage-1", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | max_line_length = 80 11 | trim_trailing_whitespace = true 12 | 13 | # Markdown override allow for spaces at end of line 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore generated folders 2 | node_modules 3 | lib 4 | umd 5 | 6 | # Logs 7 | *.log 8 | 9 | # Editor 10 | .DS_Store 11 | *.swp 12 | .idea 13 | .vscode 14 | 15 | # Coverage directory used by tools like Istanbul 16 | coverage 17 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | umd 3 | lib 4 | package.json 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '8' 4 | 5 | cache: 6 | yarn: true 7 | directories: 8 | - node_modules 9 | 10 | script: 11 | - yarn ci-check 12 | 13 | notifications: 14 | email: false 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `react-pattern-matching` 2 | 3 | [![Build Status](https://travis-ci.org/joshblack/react-pattern-matching.svg?branch=master)](https://travis-ci.org/joshblack/react-pattern-matching) 4 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 5 | 6 | > `Pattern` and `Match` components, with generic matching tools, to 7 | > assist you in all your conditional rendering needs. 8 | 9 | ## Install 10 | 11 | Run the following command using [npm](https://www.npmjs.com/): 12 | 13 | ```bash 14 | npm install react-pattern-matching --save 15 | ``` 16 | 17 | If you prefer [Yarn](https://yarnpkg.com/en/), use the following command 18 | instead: 19 | 20 | ```bash 21 | yarn add react-pattern-matching 22 | ``` 23 | 24 | ## Usage 25 | 26 | The main component exported by `react-pattern-matching` is the `` component. 27 | `Pattern` takes in a couple props, namely: 28 | 29 | * `match` which is an object that contains a mapping of the props and their 30 | values that you want to match against. 31 | * `first`: by default, `Pattern` will render _every_ child that matches the 32 | `match` criteria with the given matcher. If you don't want this behavior, 33 | setting the `first` prop to true will make `Pattern` render only the first match. 34 | * `isMatch` an optional prop that allows you to implement your own matching 35 | logic, or use one of the strategies exported in `matchers`. 36 | 37 | We can use `Pattern` in the following way: 38 | 39 | ```js 40 | import { Pattern, Match } from 'react-pattern-matching'; 41 | const App = () => ( 42 | 43 | Bar 44 | Baz 45 | 46 | ); 47 | ``` 48 | 49 | In this example, it would only render the first `Match` with `foo="bar"`. This 50 | matches the object given to the `match` prop for `Pattern`, where the name of the 51 | prop, `foo`, matches the value of the prop, `'bar'`. 52 | 53 | `Pattern` also can be used to render all matches of the `match` prop, or just the 54 | first match. This is what the `first` prop is used for. For example: 55 | 56 | ```js 57 | import { Pattern, Match } from 'react-pattern-matching'; 58 | 59 | // In this case, `Pattern` renders both `Bar 1` and `Bar 2` 60 | const AllMatches = () => ( 61 | 62 | Bar 1 63 | Bar 2 64 | 65 | ); 66 | 67 | // In this case, `Pattern` renders only `Bar 1` because of the `first` prop 68 | const FirstMatch = () => ( 69 | 70 | Bar 1 71 | Bar 2 72 | 73 | ); 74 | ``` 75 | 76 | ### Inspiration 77 | 78 | * https://www.youtube.com/watch?v=MkdV2-U16tc 79 | -------------------------------------------------------------------------------- /config/rollup.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import path from 'path'; 4 | import babel from 'rollup-plugin-babel'; 5 | import commonjs from 'rollup-plugin-commonjs'; 6 | import nodeResolve from 'rollup-plugin-node-resolve'; 7 | 8 | const babelConfig = { 9 | babelrc: false, 10 | presets: [['env', { modules: false }], 'stage-1', 'react'], 11 | plugins: ['external-helpers'], 12 | externalHelpers: true, 13 | }; 14 | 15 | export default { 16 | input: path.resolve(__dirname, '../src/index.js'), 17 | output: { 18 | name: 'react-pattern-matching', 19 | file: path.resolve(__dirname, '../umd/react-pattern-matching.js'), 20 | format: 'umd', 21 | globals: { 22 | react: 'React', 23 | 'prop-types': 'PropTypes', 24 | }, 25 | }, 26 | external: ['react', 'prop-types'], 27 | plugins: [ 28 | nodeResolve({ 29 | main: true, 30 | jsnext: true, 31 | next: true, 32 | }), 33 | babel(babelConfig), 34 | commonjs({ 35 | namedExports: { 36 | 'node_modules/react/index.js': [ 37 | 'Children', 38 | 'Component', 39 | 'createElement', 40 | ], 41 | }, 42 | }), 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /config/setup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | global.requestAnimationFrame = function(callback) { 4 | setTimeout(callback, 0); 5 | }; 6 | 7 | const enzyme = require('enzyme'); 8 | const Adapter = require('enzyme-adapter-react-16'); 9 | 10 | enzyme.configure({ adapter: new Adapter() }); 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-pattern-matching", 3 | "version": "0.0.2", 4 | "description": "Generic Pattern and Match components to help with all your conditional rendering needs.", 5 | "repository": "joshblack/react-pattern-matching", 6 | "main": "lib/index.js", 7 | "module": "src/index.js", 8 | "umd": "umd/react-pattern-matching", 9 | "license": "MIT", 10 | "bugs": { 11 | "url": "https://github.com/joshblack/react-pattern-matching/issues" 12 | }, 13 | "files": [ 14 | "lib", 15 | "src", 16 | "umd" 17 | ], 18 | "keywords": [ 19 | "react", 20 | "conditional", 21 | "rendering", 22 | "components", 23 | "pattern", 24 | "matching" 25 | ], 26 | "scripts": { 27 | "build": "yarn clean && yarn build:cjs && yarn build:umd", 28 | "build:cjs": "babel src -d lib --ignore __tests__", 29 | "build:umd": "rollup -c config/rollup.config.js", 30 | "ci-check": "yarn format:diff && yarn test --runInBand", 31 | "clean": "rimraf lib umd", 32 | "commitmsg": "commitlint -e $GIT_PARAMS", 33 | "format": "prettier --write \"**/*.{scss,css,js,md,ts}\"", 34 | "format:diff": "prettier --list-different \"**/*.{scss,css,js,md,ts}\"", 35 | "precommit": "lint-staged", 36 | "prepare": "yarn build", 37 | "prettier": "prettier --write **/*.js", 38 | "test": "jest" 39 | }, 40 | "dependencies": { 41 | "lodash.isequal": "^4.5.0", 42 | "lodash.pick": "^4.4.0" 43 | }, 44 | "peerDependencies": { 45 | "prop-types": "^15.6.1", 46 | "react": "^16.3.2" 47 | }, 48 | "devDependencies": { 49 | "@commitlint/cli": "^6.2.0", 50 | "@commitlint/config-angular": "^6.1.3", 51 | "babel-cli": "^6.26.0", 52 | "babel-core": "^6.26.3", 53 | "babel-jest": "^22.4.3", 54 | "babel-plugin-external-helpers": "^6.22.0", 55 | "babel-preset-env": "^1.6.1", 56 | "babel-preset-react": "^6.24.1", 57 | "babel-preset-stage-1": "^6.24.1", 58 | "commitizen": "^2.9.6", 59 | "cz-conventional-changelog": "^2.1.0", 60 | "enzyme": "^3.3.0", 61 | "enzyme-adapter-react-16": "^1.1.1", 62 | "enzyme-to-json": "^3.3.3", 63 | "husky": "^0.14.3", 64 | "jest": "^22.4.3", 65 | "lint-staged": "^7.0.5", 66 | "prettier": "^1.12.1", 67 | "prop-types": "^15.6.1", 68 | "react": "^16.3.2", 69 | "react-dom": "^16.3.2", 70 | "react-test-renderer": "^16.3.2", 71 | "rimraf": "^2.6.2", 72 | "rollup": "^0.58.0", 73 | "rollup-plugin-babel": "^3.0.4", 74 | "rollup-plugin-commonjs": "^9.1.3", 75 | "rollup-plugin-node-resolve": "^3.3.0" 76 | }, 77 | "peerDependencies": { 78 | "prop-types": "^15.6.0", 79 | "react": "^16.0.0" 80 | }, 81 | "config": { 82 | "commitizen": { 83 | "path": "cz-conventional-changelog" 84 | } 85 | }, 86 | "commitlint": { 87 | "extends": [ 88 | "@commitlint/config-angular" 89 | ] 90 | }, 91 | "lint-staged": { 92 | "*.js": [ 93 | "prettier", 94 | "git add" 95 | ] 96 | }, 97 | "jest": { 98 | "setupFiles": [ 99 | "/config/setup.js" 100 | ], 101 | "snapshotSerializers": [ 102 | "enzyme-to-json/serializer" 103 | ] 104 | }, 105 | "prettier": { 106 | "jsxBracketSameLine": true, 107 | "printWidth": 80, 108 | "singleQuote": true, 109 | "trailingComma": "es5" 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Match.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | const Match = ({ children, render, ...rest }) => { 5 | if (render) { 6 | return render(rest); 7 | } 8 | 9 | if (children) { 10 | return children(rest); 11 | } 12 | 13 | return null; 14 | }; 15 | 16 | Match.propTypes = { 17 | children: PropTypes.func, 18 | render: PropTypes.func, 19 | }; 20 | 21 | export default Match; 22 | -------------------------------------------------------------------------------- /src/Pattern.js: -------------------------------------------------------------------------------- 1 | import React, { Children } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Match from './Match'; 4 | import { shallowMatch } from './matchers'; 5 | 6 | export default class Pattern extends React.Component { 7 | static propTypes = { 8 | children: PropTypes.node, 9 | isMatch: PropTypes.func, 10 | match: PropTypes.object.isRequired, 11 | first: PropTypes.bool, 12 | }; 13 | 14 | static defaultProps = { 15 | isMatch: shallowMatch, 16 | first: false, 17 | }; 18 | 19 | render() { 20 | const { children, match, first, isMatch } = this.props; 21 | const matched = Children.toArray(children).filter(child => { 22 | if (child.type === Match) { 23 | return isMatch(match, child.props); 24 | } 25 | return true; 26 | }); 27 | 28 | // If we don't have to worry about rendering the first match, just render 29 | // the entire matched array. 30 | if (!first) { 31 | return matched; 32 | } 33 | 34 | // Otherwise, we need to filter out every instance of Match besides the 35 | // first one, and then render the array of children. 36 | let found = false; 37 | return matched.filter(child => { 38 | if (child.type === Match) { 39 | if (found) { 40 | return false; 41 | } 42 | found = true; 43 | return true; 44 | } 45 | return true; 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/__tests__/Pattern-test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | 4 | describe('Pattern component', () => { 5 | let Pattern; 6 | let Match; 7 | let matchers; 8 | 9 | beforeEach(() => { 10 | Pattern = require('../Pattern').default; 11 | Match = require('../Match').default; 12 | matchers = require('../matchers'); 13 | }); 14 | 15 | it('should render no items if there are no matches', () => { 16 | let wrapper = mount(); 17 | 18 | expect(wrapper.children().length).toBe(0); 19 | 20 | wrapper = mount( 21 | 22 | 1} /> 23 | 24 | ); 25 | 26 | expect(wrapper.children().length).toBe(0); 27 | 28 | wrapper = mount( 29 | 30 | 1} /> 31 | 32 | ); 33 | 34 | expect(wrapper.children().length).toBe(0); 35 | 36 | wrapper = mount( 37 | 38 | 0} /> 39 | 1} /> 40 | 2} /> 41 | 42 | ); 43 | 44 | expect(wrapper.children().length).toBe(0); 45 | }); 46 | 47 | it('should render each match that exists', () => { 48 | let wrapper = mount( 49 | 50 | 0} /> 51 | 52 | ); 53 | 54 | expect(wrapper.children().length).toBe(1); 55 | 56 | wrapper = mount( 57 | 58 | 0} /> 59 | 1} /> 60 | 2} /> 61 | 62 | ); 63 | 64 | expect(wrapper.children().length).toBe(3); 65 | }); 66 | 67 | it('should render the first match if `first` is true', () => { 68 | const wrapper = mount( 69 | 70 |

0

} /> 71 |

1

} /> 72 |

2

} /> 73 |
74 | ); 75 | 76 | expect(wrapper.find(Match).text()).toBe('0'); 77 | expect(wrapper.children().length).toBe(1); 78 | }); 79 | 80 | it('should use the custom `isMatch` provided', () => { 81 | const isMatch = jest.fn(() => true); 82 | const match = { foo: 'bar' }; 83 | const children = [ 84 | { 85 | child: 0, 86 | render: () =>

0

, 87 | }, 88 | { 89 | child: 1, 90 | render: () =>

1

, 91 | }, 92 | { 93 | child: 2, 94 | render: () =>

2

, 95 | }, 96 | ]; 97 | const wrapper = mount( 98 | 99 | {children.map(props => )} 100 | 101 | ); 102 | 103 | expect(wrapper.children().length).toBe(3); 104 | expect(isMatch).toHaveBeenCalledTimes(3); 105 | 106 | children.forEach(props => { 107 | expect(isMatch).toHaveBeenCalledWith(match, props); 108 | }); 109 | }); 110 | 111 | it('should render non-Match components', () => { 112 | const wrapper = mount( 113 | 114 |

115 |

} /> 116 |

117 |

} /> 118 |

119 |
} /> 120 |
121 | 122 | ); 123 | 124 | expect(wrapper.find('h1').length).toBe(1); 125 | expect(wrapper.find('h2').length).toBe(1); 126 | expect(wrapper.find('h3').length).toBe(1); 127 | expect(wrapper.find('h4').length).toBe(1); 128 | expect(wrapper.find('h5').length).toBe(2); 129 | }); 130 | 131 | it('should support nested matches with a custom matcher', () => { 132 | const match = { 133 | foo: { 134 | bar: 'baz', 135 | }, 136 | }; 137 | let wrapper = mount( 138 | 139 |
} /> 140 | 141 | ); 142 | 143 | expect(wrapper.find('div').length).toBe(0); 144 | 145 | wrapper = mount( 146 | 147 |
} /> 148 | 149 | ); 150 | 151 | expect(wrapper.find('div').length).toBe(1); 152 | }); 153 | }); 154 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as Match } from './Match'; 2 | export { default as Pattern } from './Pattern'; 3 | export * as matchers from './matchers'; 4 | -------------------------------------------------------------------------------- /src/matchers.js: -------------------------------------------------------------------------------- 1 | import isEqual from 'lodash.isequal'; 2 | import pick from 'lodash.pick'; 3 | 4 | export const shallowMatch = (target, props) => { 5 | for (let key in target) { 6 | if (target[key] !== props[key]) { 7 | return false; 8 | } 9 | } 10 | return true; 11 | }; 12 | 13 | export const deepMatch = (target, props) => { 14 | return isEqual(target, pick(props, Object.keys(target))); 15 | }; 16 | 17 | export const truthyMatcher = (target, props) => { 18 | for (let key in target) { 19 | if (target[key] !== !!props[key]) { 20 | return false; 21 | } 22 | } 23 | return true; 24 | }; 25 | --------------------------------------------------------------------------------