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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------