├── .babelrc ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .huskyrc.json ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__ ├── .eslintrc.js ├── Container.test.js ├── NodeHeader.test.js ├── TreeNode.test.js ├── Treebeard.test.js ├── __snapshots__ │ └── Treebeard.test.js.snap ├── mocks │ └── data.js └── setup.js ├── commitlint.config.js ├── example ├── Header.js ├── NodeViewer.js ├── app.js ├── data.js ├── filter.js ├── index.html ├── styles.js └── webpack.config.js ├── index.js ├── jest.config.js ├── package-lock.json ├── package.json └── src ├── components ├── Decorators │ ├── Container.js │ ├── Header.js │ ├── Loading.js │ ├── Toggle.js │ └── index.js ├── NodeHeader.js ├── TreeNode │ ├── Drawer.js │ ├── Loading.js │ └── index.js ├── common │ └── index.js └── index.js ├── index.js ├── themes ├── animations.js └── default.js └── util ├── index.js └── randomString.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'es6': true, 5 | 'node': true 6 | }, 7 | 'extends': ['eslint:recommended', 'plugin:react/recommended'], 8 | 'globals': { 9 | 'Atomics': 'readonly', 10 | 'SharedArrayBuffer': 'readonly' 11 | }, 12 | 'settings': { 13 | 'react': { 14 | 'version': 'detect' 15 | } 16 | }, 17 | 'parserOptions': { 18 | 'ecmaFeatures': { 19 | 'jsx': true 20 | }, 21 | 'ecmaVersion': 2018, 22 | 'sourceType': 'module' 23 | }, 24 | 'plugins': [ 25 | 'react' 26 | ], 27 | 'rules': { 28 | 'indent': [ 29 | 'error', 30 | 4 31 | ], 32 | 'linebreak-style': [ 33 | 'error', 34 | 'unix' 35 | ], 36 | 'quotes': [ 37 | 'error', 38 | 'single' 39 | ], 40 | 'semi': [ 41 | 'error', 42 | 'always' 43 | ], 44 | "max-len": [ 45 | "error", 46 | 120 47 | ], 48 | "react/jsx-indent": [ 49 | "error", 50 | 4 51 | ], 52 | "react/jsx-indent-props": [ 53 | "error", 54 | 4 55 | ], 56 | 'react/jsx-uses-react': 'error', 57 | 'react/jsx-uses-vars': 'error' 58 | } 59 | }; 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE Folders 2 | **/.*/ 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git 30 | node_modules 31 | 32 | dist/ 33 | -------------------------------------------------------------------------------- /.huskyrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "commit-msg": "commitlint -e", 4 | "pre-push": "npm test" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | **/.*/ 2 | .* 3 | __tests__/ 4 | coverage/ 5 | example/ 6 | node_modules/ 7 | src/ 8 | *.log 9 | commitlint.config.js 10 | jest.config.js 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - "8" 5 | - "10" 6 | - "12" 7 | 8 | after_script: 9 | - npm install coveralls && cat ./coverage/lcov.info | ./node_modules/.bin/coveralls 10 | 11 | script: 12 | - npm run lint 13 | - npm run test 14 | - commitlint-travis 15 | 16 | notifications: 17 | email: false 18 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ### v3.2.3 4 | - Fix background when node is active [PR #196](https://github.com/storybooks/react-treebeard/pull/196) 5 | - Add unit testing (Treebeard, TreeNode and NodeHeader) 6 | - Add pre-push to husky for run test 7 | 8 | ### v3.2.2 9 | - Fix merge styles and destruct styles [PR #194](https://github.com/storybooks/react-treebeard/pull/194) 10 | 11 | ### v3.2.1 12 | - Fix merge styles broken on chrome v74 [PR #118](https://github.com/storybooks/react-treebeard/pull/118) 13 | 14 | ### v3.2.0 15 | - Fix active link [PR #147](https://github.com/storybooks/react-treebeard/pull/147) 16 | - Fix not change toggle when animations are false [PR #174](https://github.com/storybooks/react-treebeard/pull/174) 17 | - Upgrade dependencies and change Component to PureComponent [PR #168](https://github.com/storybooks/react-treebeard/pull/168) 18 | - Move components to different directories and upgrade @emotion/styles dependency [PR #178](https://github.com/storybooks/react-treebeard/pull/178) 19 | 20 | ### v2.1.0 21 | - Add `React 16.0` to peerDependencies [PR #102](https://github.com/alexcurtis/react-treebeard/pull/102) 22 | 23 | ### v2.0.3 24 | - Update `.babelrc` to fix some issues with Travis CI [PR #83](https://github.com/alexcurtis/react-treebeard/pull/83) 25 | 26 | ### v2.0.2 27 | - Update Babel, Webpack, Mocha & Karma dependencies 28 | - Fix ESLint issue 29 | 30 | ### v2.0.1 31 | - Fix bug where package wasn't exported properly [PR #67 (comment)](https://github.com/alexcurtis/react-treebeard/pull/67#issuecomment-312475622) 32 | 33 | ### v2.0.0 34 | - **BREAKING:** The `peerDependencies` range (for both `react` & `react-dom`)has been changed from `^0.14 || ^15.0` to `^15.5.4`. 35 | - Uses `prop-types` package instead of `React.PropTypes` 36 | - Fixes dependencies for `velocity-react` & `radium` 37 | - Uses ES6 classes instead of `React.createClass` in tests 38 | - Uses `react-dom/test-utils` package instead of `react-addons-test-utils` in tests 39 | - Some code clean-up` 40 | - Deletes deprecated tests (`reactid` isn't used anymore since `React v15.0`) 41 | 42 | ### v1.1.0 43 | - **BREAKING:** [Toggle is now completely data-driven.](https://github.com/alexcurtis/react-treebeard/issues/14) There is no self-aware state. 44 | - Node Headers are now optimised via `shouldComponentUpdate`. This cuts down render time with large trees. 45 | - [Container Decorator Available](https://github.com/alexcurtis/react-treebeard/issues/9). Increased flexibility by allowing you to create your own node containers. Found in `decorators.Container`. 46 | - [Turn Off All Animations](https://github.com/alexcurtis/react-treebeard/issues/15). This will remove all Velocity components from the tree. Simply set `animations` to `false` in the props. 47 | 48 | ### v1.0.14 49 | - [Derived Terminal Attribute](https://github.com/alexcurtis/react-treebeard/issues/11) 50 | - Optional `id` can be defined in data and used as a component key. 51 | 52 | ### v1.0.13 53 | - Remove Hyperlink. Reverted ES-Lint Script Reporting. 54 | 55 | ### v1.0.12 56 | - [# HRef Fix](https://github.com/alexcurtis/react-treebeard/issues/6) 57 | 58 | ### v1.0.11 59 | - [Support for Multiple Nodes @ Root Level](https://github.com/alexcurtis/react-treebeard/issues/4) 60 | - Fixed non-critical animation errors in tests. 61 | 62 | ### v1.0.10 63 | - [Support for NPM 2.x](https://github.com/alexcurtis/react-treebeard/issues/1) 64 | 65 | ### v1.0.9 66 | - Initial Release 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Alex Curtis 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-treebeard 2 | 3 | [![Build Status](https://travis-ci.org/storybookjs/react-treebeard.svg?branch=master)](https://travis-ci.org/storybookjs/react-treebeard) [![Coverage Status](https://coveralls.io/repos/storybookjs/react-treebeard/badge.svg?branch=master&service=github)](https://coveralls.io/github/storybookjs/react-treebeard?branch=master) 4 | 5 | React Tree View Component. Data-Driven, Fast, Efficient and Customisable. 6 | 7 | ### Install 8 | 9 | ``` 10 | npm install react-treebeard --save 11 | ``` 12 | 13 | ### [Example](http://storybookjs.github.io/react-treebeard/) 14 | 15 | An online example from the `/example` directory can be found here: [Here](http://storybookjs.github.io/react-treebeard/) 16 | 17 | ### Quick Start 18 | ```javascript 19 | import React, {PureComponent} from 'react'; 20 | import ReactDOM from 'react-dom'; 21 | import {Treebeard} from 'react-treebeard'; 22 | 23 | const data = { 24 | name: 'root', 25 | toggled: true, 26 | children: [ 27 | { 28 | name: 'parent', 29 | children: [ 30 | { name: 'child1' }, 31 | { name: 'child2' } 32 | ] 33 | }, 34 | { 35 | name: 'loading parent', 36 | loading: true, 37 | children: [] 38 | }, 39 | { 40 | name: 'parent', 41 | children: [ 42 | { 43 | name: 'nested parent', 44 | children: [ 45 | { name: 'nested child 1' }, 46 | { name: 'nested child 2' } 47 | ] 48 | } 49 | ] 50 | } 51 | ] 52 | }; 53 | 54 | class TreeExample extends PureComponent { 55 | constructor(props){ 56 | super(props); 57 | this.state = {data}; 58 | this.onToggle = this.onToggle.bind(this); 59 | } 60 | 61 | onToggle(node, toggled){ 62 | const {cursor, data} = this.state; 63 | if (cursor) { 64 | this.setState(() => ({cursor, active: false})); 65 | } 66 | node.active = true; 67 | if (node.children) { 68 | node.toggled = toggled; 69 | } 70 | this.setState(() => ({cursor: node, data: Object.assign({}, data)})); 71 | } 72 | 73 | render(){ 74 | const {data} = this.state; 75 | return ( 76 | 80 | ); 81 | } 82 | } 83 | 84 | const content = document.getElementById('content'); 85 | ReactDOM.render(, content); 86 | ``` 87 | 88 | If you use react-hooks you should do something like this: 89 | ```javascript 90 | import React, {useState} from 'react'; 91 | const TreeExample = () => { 92 | const [data, setData] = useState(data); 93 | const [cursor, setCursor] = useState(false); 94 | 95 | const onToggle = (node, toggled) => { 96 | if (cursor) { 97 | cursor.active = false; 98 | } 99 | node.active = true; 100 | if (node.children) { 101 | node.toggled = toggled; 102 | } 103 | setCursor(node); 104 | setData(Object.assign({}, data)) 105 | } 106 | 107 | return ( 108 | 109 | ) 110 | } 111 | 112 | const content = document.getElementById('content'); 113 | ReactDOM.render(, content); 114 | ``` 115 | 116 | ### Prop Values 117 | 118 | #### data 119 | `PropTypes.oneOfType([PropTypes.object,PropTypes.array]).isRequired` 120 | 121 | Data that drives the tree view. State-driven effects can be built by manipulating the attributes in this object. Also supports an array for multiple nodes at the root level. An example can be found in `example/data.js` 122 | 123 | #### onToggle 124 | `PropTypes.func` 125 | 126 | Callback function when a node is toggled / clicked. Passes 2 attributes: the data node and it's toggled boolean state. 127 | 128 | #### style 129 | `PropTypes.object` 130 | 131 | Sets the treeview styling. Defaults to `src/themes/default`. 132 | 133 | #### animations 134 | `PropTypes.oneOfType([PropTypes.object, PropTypes.bool])` 135 | 136 | Sets the treeview animations. Set to `false` if you want to turn off animations. See [velocity-react](https://github.com/twitter-fabric/velocity-react) for more details. Defaults to `src/themes/animations`. 137 | 138 | #### decorators 139 | `PropTypes.object` 140 | 141 | Decorates the treeview. Here you can use your own Container, Header, Toggle and Loading components. Defaults to `src/decorators`. See example below: 142 | 143 | ```javascript 144 | const decorators = { 145 | Loading: (props) => { 146 | return ( 147 |
148 | loading... 149 |
150 | ); 151 | }, 152 | Toggle: (props) => { 153 | return ( 154 |
155 | 156 | // Vector Toggle Here 157 | 158 |
159 | ); 160 | }, 161 | Header: (props) => { 162 | return ( 163 |
164 | {props.node.name} 165 |
166 | ); 167 | }, 168 | Container: (props) => { 169 | return ( 170 |
171 | // Hide Toggle When Terminal Here 172 | 173 | 174 |
175 | ); 176 | } 177 | }; 178 | 179 | 180 | ``` 181 | 182 | ### Data Attributes 183 | 184 | ```javascript 185 | { 186 | id: '[optional] string', 187 | name: 'string', 188 | children: '[optional] array', 189 | toggled: '[optional] boolean', 190 | active: '[optional] boolean', 191 | loading: '[optional] boolean', 192 | decorators: '[optional] object', 193 | animations: '[optional] object' 194 | }, 195 | ``` 196 | #### id 197 | The component key. If not defined, an auto-generated index is used. 198 | 199 | #### name 200 | The name prop passed into the Header component. 201 | 202 | #### children 203 | The children attached to the node. This value populates the subtree at the specific node. Each child is built from the same basic data structure. Tip: Make this an empty array, if you want to asynchronously load a potential parent. 204 | 205 | #### toggled 206 | Toggled flag. Sets the visibility of a node's children. It also sets the state for the toggle decorator. 207 | 208 | #### active 209 | Active flag. If active, the node will be highlighted. The highlight is derived from the `node.activeLink` style object in the theme. 210 | 211 | #### loading 212 | Loading flag. It will populate the treeview with the loading component. Useful when asynchronously pulling the data into the treeview. 213 | 214 | #### decorators / animations 215 | Attach specific decorators / animations to a node. Provides the low level functionality to create visuals on a node-by-node basis. These structures are the same as the top level props, described above. 216 | -------------------------------------------------------------------------------- /__tests__/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /__tests__/Container.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import {decorators} from '../src'; 5 | import Container from '../src/components/Decorators/Container'; 6 | import animations from '../src/themes/animations'; 7 | import style from '../src/themes/default'; 8 | import data from './mocks/data'; 9 | 10 | const onClick = jest.fn(); 11 | const onSelect = jest.fn(); 12 | 13 | const renderComponent = (props = {}) => { 14 | const wrapper = shallow(); 23 | wrapper.Toggle = () => wrapper.find('Toggle'); 24 | wrapper.VelocityComponent = () => wrapper.find('VelocityComponent'); 25 | return wrapper; 26 | }; 27 | 28 | describe('', () => { 29 | describe('when terminal is true', () => { 30 | it('should contains a decorators.Header into their children', () => { 31 | const wrapper = renderComponent({terminal: true}); 32 | expect( 33 | wrapper 34 | .children() 35 | .contains( 36 | 37 | ) 38 | ).toBe(true); 39 | }); 40 | }); 41 | describe('when terminal is false', () => { 42 | const wrapper = renderComponent({terminal: false}); 43 | it('should exists VelocityComponent', () => { 44 | expect(wrapper.VelocityComponent().exists()).toBe(true); 45 | }); 46 | describe('and animations are false', () => { 47 | it('should exists Toggle component', () => { 48 | expect(wrapper.Toggle().exists()).toBe(true); 49 | }); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /__tests__/NodeHeader.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import NodeHeader from '../src/components/NodeHeader'; 5 | import defaultTheme from '../src/themes/default'; 6 | import defaultDecorators from '../src/components/Decorators'; 7 | import defaultAnimations from '../src/themes/animations'; 8 | import data from './mocks/data'; 9 | 10 | const onClick = jest.fn(); 11 | 12 | const renderComponent = (props = {}) => { 13 | const wrapper = shallow( 14 | 22 | ); 23 | wrapper.container = () => wrapper.find('Container'); 24 | 25 | return wrapper; 26 | }; 27 | 28 | const style = {...defaultTheme.tree.node, ...{activeLink: {background: 'red'}}}; 29 | 30 | describe('', () => { 31 | describe('when node.active is false', () => { 32 | it('should set Container\'s style prop to own style prop', () => { 33 | const wrapper = renderComponent({ 34 | node: {...data, active: false}, 35 | style 36 | }); 37 | expect(wrapper.container().props().style).toBe(style); 38 | }); 39 | }); 40 | 41 | describe('when node.active is true', () => { 42 | it('should set Container\'s style prop to active link', () => { 43 | const wrapper = renderComponent({ 44 | node: {...data, active: true}, 45 | style 46 | }); 47 | expect(wrapper.container().props().style).toEqual({ 48 | ...style, 49 | container: {...style.link, ...style.activeLink} 50 | }); 51 | }); 52 | }); 53 | 54 | describe('when node not contains children', () => { 55 | it('should set Container\'s terminal prop to true', () => { 56 | const wrapper = renderComponent({ 57 | node: {...data, children: null}, 58 | style 59 | }); 60 | expect(wrapper.container().props().terminal).toBe(true); 61 | }); 62 | }); 63 | 64 | describe('when node contains children', () => { 65 | it('should set Container\'s terminal prop to false', () => { 66 | const wrapper = renderComponent({style}); 67 | expect(wrapper.container().props().terminal).toBe(false); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /__tests__/TreeNode.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import TreeNode from '../src/components/TreeNode'; 5 | import defaultTheme from '../src/themes/default'; 6 | import defaultAnimations from '../src/themes/animations'; 7 | import defaultDecorators from '../src/components/Decorators'; 8 | import data from './mocks/data'; 9 | 10 | const onToggle = jest.fn(); 11 | 12 | const renderComponent = (props = {}) => { 13 | const wrapper = shallow( 14 | 22 | ); 23 | wrapper.nodeHeader = () => wrapper.find('NodeHeader'); 24 | wrapper.drawer = () => wrapper.find('Drawer'); 25 | wrapper.loading = () => wrapper.drawer().find('Loading'); 26 | wrapper.simulateClickOnHeader = () => wrapper.nodeHeader().simulate('click'); 27 | return wrapper; 28 | }; 29 | 30 | describe('', () => { 31 | describe('when NodeHeader is clicked', () => { 32 | it('should call onToggle', () => { 33 | const wrapper = renderComponent(); 34 | wrapper.simulateClickOnHeader(); 35 | expect(onToggle).toHaveBeenCalled(); 36 | }); 37 | describe('and node.toggle is true', () => { 38 | it('should return the selected node and toggled in false', () => { 39 | const wrapper = renderComponent(); 40 | wrapper.simulateClickOnHeader(); 41 | expect(onToggle).toBeCalledWith(data, false); 42 | }); 43 | }); 44 | describe('and node.toggle is false', () => { 45 | it('should return the selected node and toggled in true', () => { 46 | const node = {...data, toggled: false}; 47 | const wrapper = renderComponent({node}); 48 | wrapper.simulateClickOnHeader(); 49 | expect(onToggle).toBeCalledWith(node, true); 50 | }); 51 | }); 52 | }); 53 | 54 | describe('', () => { 55 | describe('when toggle is false', () => { 56 | it('should have children.size to be 0', () => { 57 | const wrapper = renderComponent({ 58 | node: {...data, toggled: false} 59 | }); 60 | expect(wrapper.drawer().children().length).toBe(0); 61 | }); 62 | }); 63 | 64 | describe('when node has property loading in true', () => { 65 | it('should render a Loading component', () => { 66 | const wrapper = renderComponent({ 67 | node: {id: 1, name: 'test', toggled: true, loading: true} 68 | }); 69 | expect(wrapper.loading().exists()).toBe(true); 70 | }); 71 | }); 72 | 73 | it('should return seven TreeNode children', () => { 74 | const wrapper = renderComponent(); 75 | const ul = wrapper.drawer().children(); 76 | expect(ul.children()).toHaveLength(7); 77 | }); 78 | }); 79 | 80 | describe('animations', () => { 81 | describe('when animations flag is false', () => { 82 | describe('and toggled is false', () => { 83 | describe('and animations is called', () => { 84 | it('should return an animation object with rotateZ=0 and duration=0', () => { 85 | const wrapper = renderComponent({ 86 | animations: false, 87 | node: {...data, toggled: false} 88 | }); 89 | const animations = wrapper.instance().animations(); 90 | expect(animations).toEqual({ 91 | toggle: { 92 | animation: {rotateZ: 0}, 93 | duration: 0 94 | } 95 | }); 96 | }); 97 | }); 98 | }); 99 | 100 | describe('and toggled is true', () => { 101 | describe('and animations is called', () => { 102 | it('should return an animation object with rotateZ=90 and duration=0', () => { 103 | const wrapper = renderComponent({animations: false}); 104 | const animations = wrapper.instance().animations(); 105 | expect(animations).toEqual({ 106 | toggle: { 107 | animation: {rotateZ: 90}, 108 | duration: 0 109 | } 110 | }); 111 | }); 112 | }); 113 | }); 114 | }); 115 | }); 116 | 117 | describe('decorators', () => { 118 | describe('when node decorators not exists', () => { 119 | describe('and decorators is called', () => { 120 | it('should return defaultDecorators', () => { 121 | const wrapper = renderComponent({animations: false}); 122 | const decorators = wrapper.instance().decorators(); 123 | expect(decorators).toEqual(defaultDecorators); 124 | }); 125 | }); 126 | }); 127 | }); 128 | }); 129 | -------------------------------------------------------------------------------- /__tests__/Treebeard.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {shallow} from 'enzyme'; 3 | 4 | import Treebeard from '../src/components'; 5 | import data from './mocks/data'; 6 | 7 | const renderComponent = (props = {}) => { 8 | const wrapper = shallow( 9 | 10 | ); 11 | wrapper.treeNode = () => wrapper.find('TreeNode'); 12 | return wrapper; 13 | }; 14 | 15 | describe('', () => { 16 | it('should match default snapshot', () => { 17 | const wrapper = renderComponent(); 18 | expect(wrapper).toMatchSnapshot(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/Treebeard.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` should match default snapshot 1`] = ` 4 | 17 | 189 | 190 | `; 191 | -------------------------------------------------------------------------------- /__tests__/mocks/data.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'react-treebeard', 3 | id: 1, 4 | toggled: true, 5 | children: [ 6 | { 7 | name: 'example', 8 | children: [ 9 | { name: 'app.js' }, 10 | { name: 'data.js' }, 11 | { name: 'index.html' }, 12 | { name: 'styles.js' }, 13 | { name: 'webpack.config.js' } 14 | ] 15 | }, 16 | { 17 | name: 'node_modules', 18 | loading: true, 19 | children: [] 20 | }, 21 | { 22 | name: 'src', 23 | children: [ 24 | { 25 | name: 'components', 26 | children: [ 27 | { name: 'decorators.js' }, 28 | { name: 'treebeard.js' } 29 | ] 30 | }, 31 | { name: 'index.js' } 32 | ] 33 | }, 34 | { 35 | name: 'themes', 36 | children: [ 37 | { name: 'animations.js' }, 38 | { name: 'default.js' } 39 | ] 40 | }, 41 | { name: 'gulpfile.js' }, 42 | { name: 'index.js' }, 43 | { name: 'package.json' } 44 | ] 45 | }; 46 | -------------------------------------------------------------------------------- /__tests__/setup.js: -------------------------------------------------------------------------------- 1 | import {configure} from 'enzyme'; 2 | import Adapter from 'enzyme-adapter-react-16'; 3 | 4 | configure({adapter: new Adapter()}); 5 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: { 3 | 'body-leading-blank': [1, 'always'], 4 | 'footer-leading-blank': [1, 'always'], 5 | 'header-max-length': [2, 'always', 72], 6 | 'body-max-line-length': [2, 'always', 80], 7 | 'footer-max-line-length': [2, 'always', 80], 8 | 'scope-case': [2, 'always', 'camel-case'], 9 | 'subject-case': [ 10 | 2, 11 | 'never', 12 | ['sentence-case', 'start-case', 'pascal-case', 'upper-case'] 13 | ], 14 | 'subject-empty': [2, 'never'], 15 | 'subject-full-stop': [2, 'never', '.'], 16 | 'type-case': [2, 'always', 'lower-case'], 17 | 'type-empty': [2, 'never'], 18 | 'type-enum': [ 19 | 2, 20 | 'always', 21 | [ 22 | 'build', 23 | 'ci', 24 | 'chore', 25 | 'docs', 26 | 'feat', 27 | 'fix', 28 | 'perf', 29 | 'refactor', 30 | 'revert', 31 | 'style', 32 | 'test' 33 | ] 34 | ] 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /example/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import {Div} from '../src/components/common'; 5 | 6 | // Example: Customising The Header Decorator To Include Icons 7 | const Header = ({onSelect, style, customStyles, node}) => { 8 | const iconType = node.children ? 'folder' : 'file-text'; 9 | const iconClass = `fa fa-${iconType}`; 10 | const iconStyle = {marginRight: '5px'}; 11 | 12 | return ( 13 |
14 |
15 | 16 | {node.name} 17 |
18 |
19 | ); 20 | }; 21 | 22 | Header.propTypes = { 23 | onSelect: PropTypes.func, 24 | node: PropTypes.object, 25 | style: PropTypes.object, 26 | customStyles: PropTypes.object 27 | }; 28 | 29 | Header.defaultProps = { 30 | customStyles: {} 31 | }; 32 | 33 | export default Header; 34 | -------------------------------------------------------------------------------- /example/NodeViewer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import {Div} from '../src/components/common'; 5 | import styles from './styles'; 6 | 7 | const HELP_MSG = 'Select A Node To See Its Data Structure Here...'; 8 | 9 | const NodeViewer = ({node}) => { 10 | const style = styles.viewer; 11 | let json = JSON.stringify(node, null, 4); 12 | 13 | if (!json) { 14 | json = HELP_MSG; 15 | } 16 | 17 | return
{json}
; 18 | }; 19 | 20 | NodeViewer.propTypes = { 21 | node: PropTypes.object 22 | }; 23 | 24 | export default NodeViewer; 25 | -------------------------------------------------------------------------------- /example/app.js: -------------------------------------------------------------------------------- 1 | import React, {Fragment, PureComponent} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {includes} from 'lodash'; 4 | 5 | import {Treebeard, decorators} from '../src'; 6 | import {Div} from '../src/components/common'; 7 | import data from './data'; 8 | import styles from './styles'; 9 | import * as filters from './filter'; 10 | import Header from './Header'; 11 | import NodeViewer from './NodeViewer'; 12 | 13 | class DemoTree extends PureComponent { 14 | constructor(props) { 15 | super(props); 16 | this.state = {data}; 17 | this.onToggle = this.onToggle.bind(this); 18 | this.onSelect = this.onSelect.bind(this); 19 | } 20 | 21 | onToggle(node, toggled) { 22 | const {cursor, data} = this.state; 23 | 24 | if (cursor) { 25 | this.setState(() => ({cursor, active: false})); 26 | } 27 | 28 | node.active = true; 29 | if (node.children) { 30 | node.toggled = toggled; 31 | } 32 | 33 | this.setState(() => ({cursor: node, data: Object.assign({}, data)})); 34 | } 35 | 36 | onSelect(node) { 37 | const {cursor, data} = this.state; 38 | 39 | if (cursor) { 40 | this.setState(() => ({cursor, active: false})); 41 | if (!includes(cursor.children, node)) { 42 | cursor.toggled = false; 43 | cursor.selected = false; 44 | } 45 | } 46 | 47 | node.selected = true; 48 | 49 | this.setState(() => ({cursor: node, data: Object.assign({}, data)})); 50 | } 51 | 52 | onFilterMouseUp({target: {value}}) { 53 | const filter = value.trim(); 54 | if (!filter) { 55 | return this.setState(() => ({data})); 56 | } 57 | let filtered = filters.filterTree(data, filter); 58 | filtered = filters.expandFilteredNodes(filtered, filter); 59 | this.setState(() => ({data: filtered})); 60 | } 61 | 62 | render() { 63 | const {data, cursor} = this.state; 64 | return ( 65 | 66 |
67 |
68 | 69 | 70 | 71 | 77 |
78 |
79 |
80 | 93 |
94 |
95 | 96 |
97 |
98 | ); 99 | } 100 | } 101 | 102 | const content = document.getElementById('content'); 103 | ReactDOM.render(, content); 104 | -------------------------------------------------------------------------------- /example/data.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'react-treebeard', 3 | id: 1, 4 | toggled: true, 5 | children: [ 6 | { 7 | name: 'example', 8 | children: [ 9 | { name: 'app.js' }, 10 | { name: 'data.js' }, 11 | { name: 'index.html' }, 12 | { name: 'styles.js' }, 13 | { name: 'webpack.config.js' } 14 | ] 15 | }, 16 | { 17 | name: 'node_modules', 18 | loading: true, 19 | children: [] 20 | }, 21 | { 22 | name: 'src', 23 | children: [ 24 | { 25 | name: 'components', 26 | children: [ 27 | { name: 'decorators.js' }, 28 | { name: 'treebeard.js' } 29 | ] 30 | }, 31 | { name: 'index.js' } 32 | ] 33 | }, 34 | { 35 | name: 'themes', 36 | children: [ 37 | { name: 'animations.js' }, 38 | { name: 'default.js' } 39 | ] 40 | }, 41 | { name: 'gulpfile.js' }, 42 | { name: 'index.js' }, 43 | { name: 'package.json' } 44 | ] 45 | }; 46 | -------------------------------------------------------------------------------- /example/filter.js: -------------------------------------------------------------------------------- 1 | // Helper functions for filtering 2 | export const defaultMatcher = (filterText, node) => { 3 | return node.name.toLowerCase().indexOf(filterText.toLowerCase()) !== -1; 4 | }; 5 | 6 | export const findNode = (node, filter, matcher) => { 7 | return matcher(filter, node) || // i match 8 | (node.children && // or i have decendents and one of them match 9 | node.children.length && 10 | !!node.children.find(child => findNode(child, filter, matcher))); 11 | }; 12 | 13 | export const filterTree = (node, filter, matcher = defaultMatcher) => { 14 | // If im an exact match then all my children get to stay 15 | if (matcher(filter, node) || !node.children) { 16 | return node; 17 | } 18 | // If not then only keep the ones that match or have matching descendants 19 | const filtered = node.children 20 | .filter(child => findNode(child, filter, matcher)) 21 | .map(child => filterTree(child, filter, matcher)); 22 | return Object.assign({}, node, {children: filtered}); 23 | }; 24 | 25 | export const expandFilteredNodes = (node, filter, matcher = defaultMatcher) => { 26 | let children = node.children; 27 | if (!children || children.length === 0) { 28 | return Object.assign({}, node, {toggled: false}); 29 | } 30 | const childrenWithMatches = node.children.filter(child => findNode(child, filter, matcher)); 31 | const shouldExpand = childrenWithMatches.length > 0; 32 | // If im going to expand, go through all the matches and see if thier children need to expand 33 | if (shouldExpand) { 34 | children = childrenWithMatches.map(child => { 35 | return expandFilteredNodes(child, filter, matcher); 36 | }); 37 | } 38 | return Object.assign({}, node, { 39 | children: children, 40 | toggled: shouldExpand 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 8 | 9 | React Treebeard Example 10 | 11 | 12 | 13 | 14 | 23 | 24 | 25 | 28 |
29 |

If you can see this, something is broken (or JS is not enabled)!

30 |
31 | 32 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /example/styles.js: -------------------------------------------------------------------------------- 1 | export default { 2 | component: { 3 | width: '50%', 4 | display: 'inline-block', 5 | verticalAlign: 'top', 6 | padding: '20px', 7 | '@media (max-width: 640px)': { 8 | width: '100%', 9 | display: 'block' 10 | } 11 | }, 12 | searchBox: { 13 | padding: '20px 20px 0 20px' 14 | }, 15 | viewer: { 16 | base: { 17 | fontSize: '12px', 18 | whiteSpace: 'pre-wrap', 19 | backgroundColor: '#282C34', 20 | border: 'solid 1px black', 21 | padding: '20px', 22 | color: '#9DA5AB', 23 | minHeight: '250px' 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | 3 | module.exports = { 4 | entry: [ 5 | 'webpack-dev-server/client?http://0.0.0.0:8080', 6 | 'webpack/hot/only-dev-server', 7 | './example/app.js' 8 | ], 9 | output: { 10 | path: __dirname, 11 | filename: 'main.js', 12 | publicPath: '/assets/' 13 | }, 14 | cache: true, 15 | devtool: false, 16 | stats: { 17 | colors: true, 18 | reasons: true 19 | }, 20 | resolve: { 21 | extensions: ['.js', '.jsx'] 22 | }, 23 | module: { 24 | rules: [{ 25 | test: /\.js$/, 26 | exclude: [/node_modules/], 27 | use: ['babel-loader', 'eslint-loader'] 28 | }] 29 | }, 30 | plugins: [ 31 | new webpack.HotModuleReplacementPlugin(), 32 | new webpack.NoEmitOnErrorsPlugin() 33 | ] 34 | }; 35 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./dist'); 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: [''], 3 | setupFiles: ['/__tests__/setup.js'], 4 | testMatch: [ 5 | '**/__tests__/**/*.test.[jt]s?(x)' 6 | ], 7 | testPathIgnorePatterns: [ 8 | '/node_modules/', '/__tests__/setup.js' 9 | ], 10 | snapshotSerializers: ['enzyme-to-json/serializer'], 11 | clearMocks: true 12 | }; 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-treebeard", 3 | "version": "3.2.4", 4 | "description": "React Tree View Component", 5 | "main": "index.js", 6 | "scripts": { 7 | "prepublish": "npm run build", 8 | "build": "rimraf dist && babel src/ --out-dir dist/", 9 | "test": "jest --config=jest.config.js", 10 | "test:watch": "jest --config=jest.config.js --watch", 11 | "test:coverage": "jest --config=jest.config.js --coverage", 12 | "lint": "eslint .", 13 | "example": "webpack-dev-server --content-base ./example/ --config ./example/webpack.config.js", 14 | "build-example": "webpack --content-base ./example/ --config ./example/webpack.config.js --output ./dist/example.js" 15 | }, 16 | "peerDependencies": { 17 | "@babel/runtime": ">=7.0.0", 18 | "@emotion/styled": "^10.0.10", 19 | "prop-types": ">=15.7.2", 20 | "react": ">=16.7.0", 21 | "react-dom": ">=16.7.0" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/storybookjs/react-treebeard.git" 26 | }, 27 | "keywords": [ 28 | "react", 29 | "treeview", 30 | "data-driven", 31 | "customisable", 32 | "fast" 33 | ], 34 | "author": "Alex Curtis", 35 | "license": "MIT", 36 | "bugs": { 37 | "url": "https://github.com/storybookjs/react-treebeard/issues" 38 | }, 39 | "homepage": "https://github.com/storybooks/react-treebeard#readme", 40 | "devDependencies": { 41 | "@babel/cli": "^7.6.0", 42 | "@babel/core": "^7.6.0", 43 | "@babel/preset-env": "^7.6.0", 44 | "@babel/preset-react": "^7.0.0", 45 | "@babel/runtime": "^7.6.0", 46 | "@commitlint/cli": "^8.1.0", 47 | "@commitlint/travis-cli": "^8.1.0", 48 | "babel-eslint": "^10.0.3", 49 | "babel-jest": "^24.9.0", 50 | "babel-loader": "^8.0.6", 51 | "babel-preset-react": "^6.24.1", 52 | "enzyme": "^3.10.0", 53 | "enzyme-adapter-react-16": "^1.14.0", 54 | "enzyme-to-json": "^3.4.0", 55 | "eslint": "^6.3.0", 56 | "eslint-loader": "^3.0.0", 57 | "eslint-plugin-react": "^7.14.3", 58 | "husky": "^3.0.5", 59 | "jest": "^24.9.0", 60 | "lodash": "^4.17.15", 61 | "prop-types": "^15.7.2", 62 | "react": "^16.9.0", 63 | "react-dom": "^16.9.0", 64 | "react-hot-loader": "^4.12.12", 65 | "rimraf": "^3.0.0", 66 | "webpack": "^4.39.3", 67 | "webpack-cli": "^3.3.8", 68 | "webpack-dev-server": "^3.8.0" 69 | }, 70 | "dependencies": { 71 | "@emotion/core": "^10.0.17", 72 | "@emotion/styled": "^10.0.17", 73 | "deep-equal": "^1.1.0", 74 | "shallowequal": "^1.1.0", 75 | "velocity-react": "^1.4.3" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/components/Decorators/Container.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {VelocityComponent} from 'velocity-react'; 4 | 5 | class Container extends PureComponent { 6 | renderToggle() { 7 | const {animations} = this.props; 8 | 9 | if (!animations) { 10 | return this.renderToggleDecorator(); 11 | } 12 | 13 | return ( 14 | 15 | {this.renderToggleDecorator()} 16 | 17 | ); 18 | } 19 | 20 | renderToggleDecorator() { 21 | const {style, decorators, onClick} = this.props; 22 | return ; 23 | } 24 | 25 | render() { 26 | const { 27 | style, decorators, terminal, node, onSelect, customStyles 28 | } = this.props; 29 | return ( 30 |
31 | {!terminal ? this.renderToggle() : null} 32 | 33 |
34 | ); 35 | } 36 | } 37 | 38 | Container.propTypes = { 39 | customStyles: PropTypes.object, 40 | style: PropTypes.object.isRequired, 41 | decorators: PropTypes.object.isRequired, 42 | terminal: PropTypes.bool.isRequired, 43 | onClick: PropTypes.func.isRequired, 44 | onSelect: PropTypes.func, 45 | animations: PropTypes.oneOfType([ 46 | PropTypes.object, 47 | PropTypes.bool 48 | ]).isRequired, 49 | node: PropTypes.object.isRequired 50 | }; 51 | 52 | Container.defaultProps = { 53 | onSelect: null, 54 | customStyles: {} 55 | }; 56 | 57 | export default Container; 58 | -------------------------------------------------------------------------------- /src/components/Decorators/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import {Div} from '../common'; 5 | 6 | const Header = ({onSelect, node, style, customStyles}) => ( 7 |
8 |
9 | {node.name} 10 |
11 |
12 | ); 13 | 14 | Header.propTypes = { 15 | onSelect: PropTypes.func, 16 | style: PropTypes.object, 17 | customStyles: PropTypes.object, 18 | node: PropTypes.object.isRequired 19 | }; 20 | 21 | Header.defaultProps = { 22 | customStyles: {} 23 | }; 24 | 25 | export default Header; 26 | -------------------------------------------------------------------------------- /src/components/Decorators/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from '@emotion/styled'; 4 | 5 | const Loading = styled(({className}) => ( 6 |
loading...
7 | ))(({style}) => style); 8 | 9 | Loading.propTypes = { 10 | style: PropTypes.object 11 | }; 12 | 13 | export default Loading; 14 | -------------------------------------------------------------------------------- /src/components/Decorators/Toggle.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from '@emotion/styled'; 4 | 5 | import {Div} from '../common'; 6 | 7 | const Polygon = styled('polygon', { 8 | shouldForwardProp: prop => ['className', 'children', 'points'].indexOf(prop) !== -1 9 | })((({style}) => style)); 10 | 11 | const Toggle = ({style, onClick}) => { 12 | const {height, width} = style; 13 | const midHeight = height * 0.5; 14 | const points = `0,0 0,${height} ${width},${midHeight}`; 15 | 16 | return ( 17 |
18 |
19 | 20 | 21 | 22 |
23 |
24 | ); 25 | }; 26 | 27 | Toggle.propTypes = { 28 | onClick: PropTypes.func.isRequired, 29 | style: PropTypes.object 30 | }; 31 | 32 | export default Toggle; 33 | -------------------------------------------------------------------------------- /src/components/Decorators/index.js: -------------------------------------------------------------------------------- 1 | import Container from './Container'; 2 | import Header from './Header'; 3 | import Loading from './Loading'; 4 | import Toggle from './Toggle'; 5 | 6 | export default { 7 | Container, 8 | Header, 9 | Loading, 10 | Toggle 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/NodeHeader.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import shallowEqual from 'shallowequal'; 4 | import deepEqual from 'deep-equal'; 5 | 6 | class NodeHeader extends Component { 7 | shouldComponentUpdate(nextProps) { 8 | const props = this.props; 9 | const nextPropKeys = Object.keys(nextProps); 10 | 11 | for (let i = 0; i < nextPropKeys.length; i++) { 12 | const key = nextPropKeys[i]; 13 | if (key === 'animations') { 14 | continue; 15 | } 16 | 17 | const isEqual = shallowEqual(props[key], nextProps[key]); 18 | if (!isEqual) { 19 | return true; 20 | } 21 | } 22 | 23 | return !deepEqual(props.animations, nextProps.animations, {strict: true}); 24 | } 25 | 26 | render() { 27 | const { 28 | animations, decorators, node, onClick, style, onSelect, customStyles 29 | } = this.props; 30 | const {active, children} = node; 31 | const terminal = !children; 32 | let styles; 33 | if (active) { 34 | styles = Object.assign(style, {container: {...style.link, ...style.activeLink}}); 35 | } else { 36 | styles = style; 37 | } 38 | return ( 39 | 49 | ); 50 | } 51 | } 52 | 53 | NodeHeader.propTypes = { 54 | style: PropTypes.object.isRequired, 55 | customStyles: PropTypes.object, 56 | decorators: PropTypes.object.isRequired, 57 | animations: PropTypes.oneOfType([ 58 | PropTypes.object, 59 | PropTypes.bool 60 | ]).isRequired, 61 | node: PropTypes.object.isRequired, 62 | onClick: PropTypes.func, 63 | onSelect: PropTypes.func 64 | }; 65 | 66 | NodeHeader.defaultProps = { 67 | customStyles: {} 68 | }; 69 | 70 | export default NodeHeader; 71 | -------------------------------------------------------------------------------- /src/components/TreeNode/Drawer.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {VelocityTransitionGroup} from 'velocity-react'; 4 | 5 | const Drawer = ({restAnimationInfo, children}) => ( 6 | 7 | {children} 8 | 9 | ); 10 | 11 | Drawer.propTypes = { 12 | restAnimationInfo: PropTypes.shape({}).isRequired, 13 | children: PropTypes.oneOfType([ 14 | PropTypes.func, 15 | PropTypes.arrayOf(PropTypes.func, PropTypes.shape({})), 16 | PropTypes.shape({}) 17 | ]) 18 | }; 19 | 20 | export default Drawer; 21 | -------------------------------------------------------------------------------- /src/components/TreeNode/Loading.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import {Ul} from '../common'; 5 | 6 | const Loading = ({style, decorators}) => ( 7 |
    8 |
  • 9 | 10 |
  • 11 |
12 | ); 13 | 14 | Loading.propTypes = { 15 | decorators: PropTypes.object.isRequired, 16 | style: PropTypes.object.isRequired 17 | }; 18 | 19 | export default Loading; 20 | -------------------------------------------------------------------------------- /src/components/TreeNode/index.js: -------------------------------------------------------------------------------- 1 | import React, {PureComponent} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styled from '@emotion/styled'; 4 | import {isArray, isFunction} from 'lodash'; 5 | 6 | import defaultAnimations from '../../themes/animations'; 7 | import {randomString} from '../../util'; 8 | import {Ul} from '../common'; 9 | import NodeHeader from '../NodeHeader'; 10 | import Drawer from './Drawer'; 11 | import Loading from './Loading'; 12 | 13 | const Li = styled('li', { 14 | shouldForwardProp: prop => ['className', 'children', 'ref'].indexOf(prop) !== -1 15 | })(({style}) => style); 16 | 17 | class TreeNode extends PureComponent { 18 | onClick() { 19 | const {node, onToggle} = this.props; 20 | if (onToggle) { 21 | onToggle(node, !node.toggled); 22 | } 23 | } 24 | 25 | animations() { 26 | const {animations, node} = this.props; 27 | if (!animations) { 28 | return { 29 | toggle: defaultAnimations.toggle(this.props, 0) 30 | }; 31 | } 32 | const animation = Object.assign({}, animations, node.animations); 33 | return { 34 | toggle: animation.toggle(this.props), 35 | drawer: animation.drawer(this.props) 36 | }; 37 | } 38 | 39 | decorators() { 40 | const {decorators, node} = this.props; 41 | let nodeDecorators = node.decorators || {}; 42 | 43 | return Object.assign({}, decorators, nodeDecorators); 44 | } 45 | 46 | renderChildren(decorators) { 47 | const { 48 | animations, decorators: propDecorators, node, style, onToggle, onSelect, customStyles 49 | } = this.props; 50 | 51 | if (node.loading) { 52 | return ( 53 | 54 | ); 55 | } 56 | 57 | let children = node.children; 58 | if (!isArray(children)) { 59 | children = children ? [children] : []; 60 | } 61 | 62 | return ( 63 |
    64 | {children.map(child => ( 65 | 75 | ))} 76 |
77 | ); 78 | } 79 | 80 | render() { 81 | const { 82 | node, style, onSelect, customStyles 83 | } = this.props; 84 | const decorators = this.decorators(); 85 | const animations = this.animations(); 86 | const {...restAnimationInfo} = animations.drawer; 87 | return ( 88 |
  • 89 | this.onClick()} 96 | onSelect={isFunction(onSelect) ? (() => onSelect(node)) : undefined} 97 | /> 98 | 99 | {node.toggled ? this.renderChildren(decorators, animations) : null} 100 | 101 |
  • 102 | ); 103 | } 104 | } 105 | 106 | TreeNode.propTypes = { 107 | onSelect: PropTypes.func, 108 | onToggle: PropTypes.func, 109 | style: PropTypes.object.isRequired, 110 | customStyles: PropTypes.object, 111 | node: PropTypes.object.isRequired, 112 | decorators: PropTypes.object.isRequired, 113 | animations: PropTypes.oneOfType([ 114 | PropTypes.object, 115 | PropTypes.bool 116 | ]).isRequired 117 | }; 118 | 119 | TreeNode.defaultProps = { 120 | customStyles: {} 121 | }; 122 | 123 | export default TreeNode; 124 | -------------------------------------------------------------------------------- /src/components/common/index.js: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | 3 | export const Div = styled('div', { 4 | shouldForwardProp: prop => ['className', 'children'].indexOf(prop) !== -1 5 | })((({style}) => style)); 6 | 7 | export const Ul = styled('ul', { 8 | shouldForwardProp: prop => ['className', 'children'].indexOf(prop) !== -1 9 | })((({style}) => style)); 10 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import {castArray} from 'lodash'; 4 | 5 | import defaultTheme from '../themes/default'; 6 | import defaultAnimations from '../themes/animations'; 7 | import {randomString} from '../util'; 8 | import {Ul} from './common'; 9 | import defaultDecorators from './Decorators'; 10 | import TreeNode from './TreeNode'; 11 | 12 | const TreeBeard = ({ 13 | animations, decorators, data, onToggle, style, onSelect, customStyles 14 | }) => ( 15 |
      16 | {castArray(data).map(node => ( 17 | 27 | ))} 28 |
    29 | ); 30 | 31 | TreeBeard.propTypes = { 32 | style: PropTypes.object, 33 | customStyles: PropTypes.object, 34 | data: PropTypes.oneOfType([ 35 | PropTypes.object, 36 | PropTypes.array 37 | ]).isRequired, 38 | animations: PropTypes.oneOfType([ 39 | PropTypes.object, 40 | PropTypes.bool 41 | ]), 42 | onToggle: PropTypes.func, 43 | onSelect: PropTypes.func, 44 | decorators: PropTypes.object 45 | }; 46 | 47 | TreeBeard.defaultProps = { 48 | style: defaultTheme, 49 | animations: defaultAnimations, 50 | decorators: defaultDecorators, 51 | customStyles: {} 52 | }; 53 | 54 | export default TreeBeard; 55 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Treebeard from './components'; 2 | import decorators from './components/Decorators'; 3 | import animations from './themes/animations'; 4 | import theme from './themes/default'; 5 | 6 | export { 7 | Treebeard, 8 | decorators, 9 | animations, 10 | theme 11 | }; 12 | -------------------------------------------------------------------------------- /src/themes/animations.js: -------------------------------------------------------------------------------- 1 | export default { 2 | toggle: ({node: {toggled}}, duration = 300) => ({ 3 | animation: {rotateZ: toggled ? 90 : 0}, 4 | duration: duration 5 | }), 6 | drawer: (/* props */) => ({ 7 | enter: { 8 | animation: 'slideDown', 9 | duration: 300 10 | }, 11 | leave: { 12 | animation: 'slideUp', 13 | duration: 300 14 | } 15 | }) 16 | }; 17 | -------------------------------------------------------------------------------- /src/themes/default.js: -------------------------------------------------------------------------------- 1 | export default { 2 | tree: { 3 | base: { 4 | listStyle: 'none', 5 | backgroundColor: '#21252B', 6 | margin: 0, 7 | padding: 0, 8 | color: '#9DA5AB', 9 | fontFamily: 'lucida grande ,tahoma,verdana,arial,sans-serif', 10 | fontSize: '14px' 11 | }, 12 | node: { 13 | base: { 14 | position: 'relative' 15 | }, 16 | link: { 17 | cursor: 'pointer', 18 | position: 'relative', 19 | padding: '0px 5px', 20 | display: 'block' 21 | }, 22 | activeLink: { 23 | background: '#31363F' 24 | }, 25 | toggle: { 26 | base: { 27 | position: 'relative', 28 | display: 'inline-block', 29 | verticalAlign: 'top', 30 | marginLeft: '-5px', 31 | height: '24px', 32 | width: '24px' 33 | }, 34 | wrapper: { 35 | position: 'absolute', 36 | top: '50%', 37 | left: '50%', 38 | margin: '-7px 0 0 -7px', 39 | height: '14px' 40 | }, 41 | height: 14, 42 | width: 14, 43 | arrow: { 44 | fill: '#9DA5AB', 45 | strokeWidth: 0 46 | } 47 | }, 48 | header: { 49 | base: { 50 | display: 'inline-block', 51 | verticalAlign: 'top', 52 | color: '#9DA5AB' 53 | }, 54 | connector: { 55 | width: '2px', 56 | height: '12px', 57 | borderLeft: 'solid 2px black', 58 | borderBottom: 'solid 2px black', 59 | position: 'absolute', 60 | top: '0px', 61 | left: '-21px' 62 | }, 63 | title: { 64 | lineHeight: '24px', 65 | verticalAlign: 'middle' 66 | } 67 | }, 68 | subtree: { 69 | listStyle: 'none', 70 | paddingLeft: '19px' 71 | }, 72 | loading: { 73 | color: '#E2C089' 74 | } 75 | } 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | import randomString from './randomString'; 2 | 3 | export {randomString}; 4 | -------------------------------------------------------------------------------- /src/util/randomString.js: -------------------------------------------------------------------------------- 1 | const randomString = () => Math.random().toString(36).substring(7); 2 | 3 | export default randomString; 4 | --------------------------------------------------------------------------------