├── .eslintrc.js
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .travis.yml
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── jest.config.js
├── jest.transform.js
├── nwb.config.js
├── package.json
├── src
├── Node.js
├── ToggleController.js
├── Treefold.js
└── index.js
├── storybook
├── .babelrc
├── .eslintrc.js
├── Animated.js
├── Async
│ ├── AsyncTree.js
│ ├── Controller.js
│ ├── data.js
│ └── index.js
├── Dropdown
│ ├── Dropdown.js
│ ├── filterTree.js
│ ├── index.js
│ └── style.css
├── FamilyTree
│ ├── README.md
│ ├── data.js
│ ├── index.js
│ └── styles.css
├── Layout
│ ├── index.js
│ └── styles.css
├── Selector.js
├── Table.js
├── UnorderedList.js
├── config.js
├── data.js
├── index.js
├── package.json
├── preview-head.html
├── styles.css
└── utils.js
├── tests
├── ToggleController.test.js
├── Treefold.test.js
└── setup.js
└── yarn.lock
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | parser: 'babel-eslint',
3 | extends: [
4 | 'eslint:recommended',
5 | 'plugin:import/recommended',
6 | 'plugin:react/recommended',
7 | ],
8 | parserOptions: {
9 | ecmaVersion: 6,
10 | sourceType: 'module',
11 | ecmaFeatures: {
12 | jsx: true,
13 | experimentalObjectRestSpread: true,
14 | },
15 | },
16 | env: {
17 | browser: true,
18 | node: true,
19 | jest: true,
20 | },
21 | rules: {
22 | 'valid-jsdoc': 2,
23 | 'react/prop-types': 0,
24 | 'react/jsx-uses-react': 1,
25 | 'react/jsx-no-undef': 2,
26 | 'react/display-name': 0,
27 | 'import/no-unresolved': ['error', { ignore: ['^react$'] }],
28 | 'import/unambiguous': 0,
29 | 'react/jsx-key': 0,
30 | },
31 | plugins: ['import', 'react'],
32 | };
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | /coverage
3 | /es
4 | /lib
5 | /umd
6 | /storybook-static
7 | /storybook/yarn.lock
8 | /storybook/package-lock.json
9 | node_modules
10 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | package.json
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5",
3 | "singleQuote": true
4 | }
5 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | sudo: false
2 |
3 | language: node_js
4 | node_js:
5 | - 8
6 |
7 | before_install:
8 | - npm install codecov.io coveralls
9 |
10 | after_success:
11 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js
12 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js
13 |
14 | branches:
15 | only:
16 | - master
17 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | ## Prerequisites
2 |
3 | [Node.js](http://nodejs.org/) >= v8 must be installed.
4 |
5 | ## Installation
6 |
7 | * Running `yarn install` in the component's root directory will install everything you need for development.
8 |
9 | ## Demo
10 |
11 | * `yarn storybook` will run a [storybook](https://storybook.js.org/) server with the component's demo views at [http://localhost:9001](http://localhost:9001).
12 |
13 | ## Running Tests
14 |
15 | * `yarn test` will run on every change, only the tests related to what has changed
16 |
17 | * `yarn test:all` will run all the tests once
18 |
19 | ## Building
20 |
21 | * `yarn build` will build the component for publishing to npm.
22 |
23 | * `yarn clean` will delete built resources.
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 | Copyright (c) 2018 Ernesto García
3 |
4 | Permission is hereby granted, free of charge, to any person obtaining a copy
5 | of this software and associated documentation files (the "Software"), to deal
6 | in the Software without restriction, including without limitation the rights
7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8 | copies of the Software, and to permit persons to whom the Software is
9 | furnished to do so, subject to the following conditions:
10 |
11 | The above copyright notice and this permission notice shall be included in all
12 | copies or substantial portions of the Software.
13 |
14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
20 | SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Treefold
2 |
3 | [](https://github.com/prettier/prettier)
4 |
5 | A renderless tree React component for your hierarchical views.
6 |
7 | ## The problem
8 |
9 | You need to show hierarchical data in different ways. You know how you want to show the information for each individual data item. But you don't want to repeat over and over again the logic about how to traverse the data, how to assemble it all to make it look like a tree, how to expand/collapse nodes, etc.
10 |
11 | ## This solution
12 |
13 | This is a component that abstracts away some of these repetitive details during a tree-rendering process. You just specify how you want to render each individual data item, and `Treefold` takes care of everything else.
14 |
15 | The component itself is stateless and controlled by default, so you can control any aspects of it from the outside. The expand/collapse state of each individual node is normally also controlled, but if no props for controlling it are specified, the component auto-manages the state for this on its own.
16 |
17 | You can see a live demo [here](http://treefold.netlify.com/).
18 |
19 | ## Installation
20 |
21 | This module is distributed via [npm](https://www.npmjs.com/) which is bundled with [node](https://nodejs.org) and should be installed as one of your project's `dependencies`:
22 |
23 | ```
24 | npm install --save react-treefold
25 | ```
26 |
27 | or if you use [yarn](https://yarnpkg.com/):
28 |
29 | ```
30 | yarn add react-treefold
31 | ```
32 |
33 | > This package also depends on `react` and `prop-types`. Please make sure you have those installed as well.
34 |
35 | ## Usage
36 |
37 | Use the `Treefold` component by passing to it the hierarchical data (`nodes` prop) and how to render each node in the tree (`render` prop):
38 |
39 | ```jsx
40 | import React from 'react';
41 | import Treefold from 'react-treefold';
42 |
43 | const MyTreeView = ({ nodes }) => (
44 |
45 |
(
56 | <>
57 |
58 | {isFolder && (
59 |
60 |
64 |
65 | )}
66 | {node.name}
67 |
68 | {isExpanded &&
69 | (hasChildNodes ? (
70 | renderChildNodes()
71 | ) : (
72 |
76 | This folder is empty
77 |
78 | ))}
79 | >
80 | )}
81 | />
82 |
83 | );
84 | ```
85 |
86 | The `render` function receives the data for the node it needs to render, in addition to a set of extra props describing several different aspects of how the node needs to be rendered.
87 |
88 | ## Treefold props
89 |
90 | ### nodes
91 |
92 | > `array` | required
93 |
94 | The list of root nodes in the tree. Each node in the list should be an object with a unique `id` attribute (string or numeric), and a `children` array attribute containing the child nodes of that node. If no `children` is specified, the node is assumed to be a leaf.
95 |
96 | Note: the names of these node attributes is customizable. See [getNodeId](#getNodeId) and [getNodeChildren](#getNodeChildren).
97 |
98 | ### render
99 |
100 | > `function({/* see below */}): element` | required
101 |
102 | A function that renders a single node of the tree.
103 |
104 | This is called with an object argument. Read more about the properties of this object in the section "[Rendering a single node](#rendering-a-single-node)".
105 |
106 | ### isNodeExpanded
107 |
108 | > `function(node: object): boolean` | optional
109 |
110 | A function that receives a node and returns `true` if that node should be expanded, or `false` otherwise.
111 |
112 | It must be provided alongside [onToggleExpand](#onToggleExpand), in order to control the tree from the outside (i.e. to make it behave as a controlled component).
113 |
114 | If these two props are not provided, the tree controls its expand/collapse state of nodes on its own (i.e. it behaves as an uncontrolled component).
115 |
116 | ### onToggleExpand
117 |
118 | > `function(node: object)` | optional
119 |
120 | A function that receives a node and toggles the state of that node being expanded or collapsed.
121 |
122 | It must be provided alongside [isNodeExpanded](#isNodeExpanded), in order to control the tree from the outside. If these two props are not provided, the tree controls its expand/collapse state on its own.
123 |
124 | ### getNodeId
125 |
126 | > `function(node: object): number | string` | optional, defaults to `node => node.id`
127 |
128 | A function that receives a node in the tree and returns what to use as a unique id for that node.
129 |
130 | ### getNodeChildren
131 |
132 | > `function(node: object): array?` | optional, defaults to `node => node.children`
133 |
134 | A function that receives a node in the tree and returns the array of child nodes of that node, or nothing if the node is a leaf.
135 |
136 | Note that there can be non-leaf nodes that have no child nodes. These are the ones with an empty array of child nodes. For a node to really be considered a leaf, the resultof this function must be `null` or `undefined`.
137 |
138 | ## Rendering a single node
139 |
140 | The most important thing you have to tell to `Treefold` besides the actual tree data to render, is how to render it. You do so primarily by providing a prop called `render` which receives all the information necessary about that node, and returns the jsx element that represents it.
141 |
142 | ```jsx
143 | (
146 | /* you render the node here */
147 | )}
148 | />
149 | ```
150 |
151 | > You can also pass it as the `children` prop if you prefer to do things that way `{/* right here*/} `
152 |
153 | `Treefold` takes care of calling this function as it traverses the tree, and passes to it an object (`props` in the example just above) with the properties documented below:
154 |
155 | ### node
156 |
157 | > `object` | required
158 |
159 | The data for the node that is being rendered.
160 |
161 | ### level
162 |
163 | > `number` | required
164 |
165 | The level of depth of the node in the tree. Root nodes have level 0. Child nodes of a given node have the level of their parent node plus 1.
166 |
167 | ### isFolder
168 |
169 | > `boolean` | required
170 |
171 | Wether the node is a folder (meaning it has or may have child nodes under it) or not.
172 |
173 | A node is considered to be a folder if it has a collection of child nodes, even if that collection is empty. See [getNodeChildren](#getNodeChildren) to see how `Treefold` determines what is the collection of child nodes for a given node.
174 |
175 | ### isExpanded
176 |
177 | > `boolean` | required
178 |
179 | Wether the node is to be rendered expanded, with its child nodes visible, or not.
180 |
181 | This property will always be `false` for leaf nodes. It may be `true` for non-leaf nodes that have an empty list of child nodes.
182 |
183 | ### hasChildNodes
184 |
185 | > `boolean` | required
186 |
187 | Wether the node to be rendered has a non-empty list of child nodes or not.
188 |
189 | This property will always be `false` for leaf nodes. It may also be `false` for non-leaf nodes that have an empty list of child nodes.
190 |
191 | ### getToggleProps
192 |
193 | > function(props: object): object | required for folder nodes, `null` for leaf nodes
194 |
195 | This is a [prop getter](prop-getters) that returns all the props that you need to apply to any expand/collapse toggle elements in your node. These are the elements that are meant to be activated by the user to toggle that folder node expand/collapse state.
196 |
197 | By definition this function is provided only to non-leaf nodes, **even if the node has an empty list of child nodes**. For leaf nodes this prop is `null`.
198 |
199 | ### renderChildNodes
200 |
201 | > `function(): element?` | required for folder nodes, `null` for leaf nodes
202 |
203 | A function that **you need to call for non-leaf nodes** so that its child nodes are rendered.
204 |
205 | #### Why do I need to take care of this?
206 |
207 | You might think that this is something that `Treefold` should do itself. But if it did so, it would hinder your freedom in achieving different outcomes.
208 |
209 | > TODO: Document this more in depth
210 |
211 | ## TO-DO
212 |
213 | * Ability to control child nodes visibility via css instead of removing them from the DOM.
214 | * Full WAI-ARIA compliance.
215 | * Improve customization to expand the use cases where this can be applied.
216 |
217 | Feature requests and suggestions are very welcome. This is a very young project not yet applied to a wide variety of situations, so I know it has a long road of changes and improvements ahead.
218 |
219 | ## LICENCE
220 |
221 | MIT
222 |
223 | [prop-getters]: https://blog.kentcdodds.com/how-to-give-rendering-control-to-users-with-prop-getters-549eaef76acf
224 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | collectCoverage: true,
3 | collectCoverageFrom: ['src/**/*.{js,jsx}', '!**/node_modules/**'],
4 | coverageDirectory: 'coverage',
5 | setupTestFrameworkScriptFile: './tests/setup.js',
6 | transform: {
7 | '^.+\\.js$': '/jest.transform.js',
8 | },
9 | moduleFileExtensions: ['js', 'json', 'jsx'],
10 | };
11 |
--------------------------------------------------------------------------------
/jest.transform.js:
--------------------------------------------------------------------------------
1 | module.exports = require('babel-jest').createTransformer({
2 | presets: ['es2015', 'react', 'stage-1'],
3 | });
4 |
--------------------------------------------------------------------------------
/nwb.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | type: 'react-component',
3 | npm: {
4 | esModules: true,
5 | umd: {
6 | global: 'ReactTreefold',
7 | externals: {
8 | react: 'React',
9 | },
10 | },
11 | },
12 | };
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-treefold",
3 | "version": "1.0.0",
4 | "description": "A renderless tree component for your hierarchical React views",
5 | "author": "Ernesto Garcia (http://github.com/gnapse)",
6 | "license": "MIT",
7 | "main": "lib/index.js",
8 | "module": "es/index.js",
9 | "files": [
10 | "css",
11 | "es",
12 | "lib",
13 | "umd"
14 | ],
15 | "scripts": {
16 | "build": "nwb build-react-component --no-demo",
17 | "clean": "nwb clean-module",
18 | "test": "jest --watch",
19 | "test:all": "jest",
20 | "lint": "eslint src tests demo",
21 | "prettify": "prettier --write *.{md,js,json,css}",
22 | "precommit": "yarn lint && lint-staged",
23 | "storybook": "start-storybook -p 9001 -c storybook",
24 | "storybook:setup": "cd storybook && yarn",
25 | "storybook:build": "build-storybook -c storybook"
26 | },
27 | "lint-staged": {
28 | "*.{js,md,json}": [
29 | "prettier --write",
30 | "git add"
31 | ]
32 | },
33 | "homepage": "https://github.com/gnapse/react-treefold#readme",
34 | "repository": {
35 | "type": "git",
36 | "url": "https://github.com/gnapse/react-treefold.git"
37 | },
38 | "bugs": {
39 | "url": "https://github.com/gnapse/react-treefold/issues"
40 | },
41 | "keywords": [
42 | "react",
43 | "reactjs",
44 | "components",
45 | "tree",
46 | "treeview",
47 | "hierarchy",
48 | "render-props",
49 | "renderless",
50 | "accessibility",
51 | "WAI-ARIA"
52 | ],
53 | "dependencies": {
54 | "warning": "^3.0.0"
55 | },
56 | "peerDependencies": {
57 | "react": "16.x"
58 | },
59 | "devDependencies": {
60 | "@storybook/react": "^3.3.13",
61 | "babel-eslint": "^8.2.1",
62 | "babel-jest": "^22.2.2",
63 | "babel-preset-es2015": "^6.24.1",
64 | "babel-preset-react": "^6.24.1",
65 | "enzyme": "^3.3.0",
66 | "enzyme-adapter-react-16": "^1.1.1",
67 | "eslint": "^4.18.0",
68 | "eslint-plugin-import": "^2.8.0",
69 | "eslint-plugin-react": "^7.6.1",
70 | "husky": "^0.14.3",
71 | "jest": "^22.3.0",
72 | "jest-enzyme": "^4.2.0",
73 | "lint-staged": "^6.1.1",
74 | "nwb": "0.21.x",
75 | "prettier": "^1.10.2",
76 | "react": "^16.2.0",
77 | "react-dom": "^16.2.0",
78 | "react-test-renderer": "^16.2.0"
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/Node.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const Node = props => {
5 | const {
6 | node,
7 | level,
8 | render,
9 | getNodeId,
10 | getNodeChildren,
11 | isNodeExpanded,
12 | onToggleExpand,
13 | ...extraProps
14 | } = props;
15 | const childNodes = getNodeChildren(node);
16 | const isFolder = Array.isArray(childNodes);
17 | const hasChildNodes = isFolder && childNodes.length > 0;
18 | const isExpanded = isFolder && isNodeExpanded(node);
19 | return render({
20 | ...extraProps,
21 | node,
22 | level,
23 | isFolder,
24 | isExpanded,
25 | hasChildNodes,
26 | getToggleProps: isFolder
27 | ? (props = {}) => ({
28 | tabIndex: '0',
29 | role: 'button',
30 | ...props,
31 | onClick(event) {
32 | event.preventDefault();
33 | event.stopPropagation();
34 | onToggleExpand(node);
35 | },
36 | onKeyDown(event) {
37 | if (event.keyCode === 32 || event.keyCode === 13) {
38 | event.preventDefault();
39 | event.stopPropagation();
40 | onToggleExpand(node);
41 | }
42 | },
43 | })
44 | : null,
45 | renderChildNodes: isFolder
46 | ? () =>
47 | childNodes.map(childNode => (
48 |
54 | ))
55 | : null,
56 | });
57 | };
58 |
59 | Node.propTypes = {
60 | node: PropTypes.object.isRequired,
61 | level: PropTypes.number,
62 | render: PropTypes.func.isRequired,
63 | getNodeId: PropTypes.func.isRequired,
64 | getNodeChildren: PropTypes.func.isRequired,
65 | isNodeExpanded: PropTypes.func.isRequired,
66 | onToggleExpand: PropTypes.func.isRequired,
67 | };
68 |
69 | Node.defaultProps = {
70 | level: 0,
71 | };
72 |
73 | export default Node;
74 |
--------------------------------------------------------------------------------
/src/ToggleController.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 |
4 | const toggle = (set, id) => {
5 | const isOn = set[id] || false;
6 | return { ...set, [id]: !isOn };
7 | };
8 |
9 | export default class ToggleController extends Component {
10 | static propTypes = {
11 | getId: PropTypes.func,
12 | children: PropTypes.func.isRequired,
13 | };
14 |
15 | static defaultProps = {
16 | getId: item => item.id,
17 | };
18 |
19 | state = { set: {} };
20 |
21 | onToggle = item => {
22 | const id = this.props.getId(item);
23 | this.setState(({ set }) => ({ set: toggle(set, id) }));
24 | };
25 |
26 | isOn = item => {
27 | const id = this.props.getId(item);
28 | return this.state.set[id] === true;
29 | };
30 |
31 | render() {
32 | const { children, ...props } = this.props;
33 | const { onToggle, isOn } = this;
34 | return children({ onToggle, isOn, ...props });
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/Treefold.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import PropTypes from 'prop-types';
3 | import warning from 'warning';
4 | import Node from './Node';
5 | import ToggleController from './ToggleController';
6 |
7 | class Treefold extends Component {
8 | static propTypes = {
9 | nodes: PropTypes.arrayOf(PropTypes.object.isRequired).isRequired,
10 | render: PropTypes.func,
11 | children: PropTypes.func,
12 | isNodeExpanded: PropTypes.func,
13 | onToggleExpand: PropTypes.func,
14 | getNodeId: PropTypes.func,
15 | getNodeChildren: PropTypes.func,
16 | };
17 |
18 | static defaultProps = {
19 | getNodeId: item => item.id,
20 | getNodeChildren: item => item.children,
21 | };
22 |
23 | constructor(props) {
24 | super(props);
25 | checkProps(props);
26 | }
27 |
28 | componentDidUpdate() {
29 | checkProps(this.props);
30 | }
31 |
32 | internalRender = ({ isOn, onToggle }) => {
33 | const { nodes, getNodeId, getNodeChildren } = this.props;
34 | const render = getRenderProp(this.props);
35 | return render
36 | ? nodes.map(node =>
37 | React.createElement(Node, {
38 | key: getNodeId(node),
39 | node,
40 | render,
41 | getNodeId,
42 | getNodeChildren,
43 | isNodeExpanded: isOn,
44 | onToggleExpand: onToggle,
45 | })
46 | )
47 | : null;
48 | };
49 |
50 | render() {
51 | const { isNodeExpanded, onToggleExpand, getNodeId } = this.props;
52 | const isControlled =
53 | typeof isNodeExpanded === 'function' &&
54 | typeof onToggleExpand === 'function';
55 | return isControlled ? (
56 | this.internalRender({ isOn: isNodeExpanded, onToggle: onToggleExpand })
57 | ) : (
58 |
59 | {this.internalRender}
60 |
61 | );
62 | }
63 | }
64 |
65 | function getRenderProp({ render, children }) {
66 | return typeof render === 'function'
67 | ? render
68 | : typeof children === 'function' ? children : null;
69 | }
70 |
71 | function checkProps({ isNodeExpanded, onToggleExpand, render, children }) {
72 | warning(
73 | typeof isNodeExpanded === typeof onToggleExpand,
74 | 'Treefold: You must pass both isNodeExpanded and onToggleExpand, or none.'
75 | );
76 | warning(
77 | !(typeof render === 'function' && typeof children === 'function'),
78 | 'You should not use and {children} in the same Treefold component; `children` will be ignored'
79 | );
80 | warning(
81 | !(typeof render !== 'function' && typeof children !== 'function'),
82 | 'You should specify one of or {children} ; your component will render nothing unless you do so'
83 | );
84 | }
85 |
86 | export default Treefold;
87 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import Treefold from './Treefold';
2 | import ToggleController from './ToggleController';
3 |
4 | export default Treefold;
5 | export { ToggleController };
6 |
--------------------------------------------------------------------------------
/storybook/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | [
4 | "env",
5 | {
6 | "targets": {
7 | "browsers": "ie >= 11"
8 | }
9 | }
10 | ],
11 | "react"
12 | ],
13 | "plugins": ["transform-class-properties", "transform-object-rest-spread"]
14 | }
15 |
--------------------------------------------------------------------------------
/storybook/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | rules: {
3 | 'no-console': 'off',
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/storybook/Animated.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import { Collapse } from 'react-collapse';
3 | import Treefold from '../src';
4 | import { getStyle, getIcon } from './utils';
5 |
6 | const hasNestedFolders = ({ children = [] }) =>
7 | !!children.find(n => n.children);
8 |
9 | const Animated = ({ nodes }) => (
10 | (
21 |
22 |
23 | {isFolder && (
24 |
25 |
26 |
27 | )}
28 | {node.name}
29 |
30 | {isFolder && (
31 |
36 | {hasChildNodes ? (
37 | renderChildNodes()
38 | ) : (
39 | This folder is empty
40 | )}
41 |
42 | )}
43 |
44 | )}
45 | />
46 | );
47 |
48 | export default Animated;
49 |
--------------------------------------------------------------------------------
/storybook/Async/AsyncTree.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import Treefold from '../../src';
3 | import { getStyle } from '../utils';
4 |
5 | export const getIcon = (isLoading, isExpanded) =>
6 | isLoading
7 | ? 'fa fa-fw fa-pulse fa-spinner'
8 | : `fa fa-fw fa-${isExpanded ? 'caret-down' : 'caret-right'}`;
9 |
10 | const Async = ({ isNodeLoading, ...props }) => (
11 |
12 | {({
13 | node,
14 | level,
15 | isFolder,
16 | isExpanded,
17 | getToggleProps,
18 | hasChildNodes,
19 | renderChildNodes,
20 | }) => (
21 |
22 |
23 | {isFolder && (
24 |
25 |
29 |
30 | )}
31 | {node.name}
32 |
33 | {isExpanded &&
34 | (hasChildNodes ? (
35 | renderChildNodes()
36 | ) : (
37 | This folder is empty
38 | ))}
39 |
40 | )}
41 |
42 | );
43 |
44 | export default Async;
45 |
--------------------------------------------------------------------------------
/storybook/Async/Controller.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 |
3 | const NODE_UNLOADED = undefined;
4 | const NODE_LOADING = 'loading';
5 | const NODE_COLLAPSED = 'collapsed';
6 | const NODE_EXPANDED = 'expanded';
7 |
8 | const ASYNC_DELAY = 1000;
9 |
10 | export default class AsyncController extends Component {
11 | static defaultProps = {
12 | getId: node => node.name,
13 | };
14 |
15 | state = { nodeStatus: {} };
16 |
17 | componentWillMount() {
18 | this.cancelCallback();
19 | }
20 |
21 | cancelCallback() {
22 | if (this.asyncCallback) {
23 | clearTimeout(this.asyncCallback);
24 | this.asyncCallback = undefined;
25 | }
26 | }
27 |
28 | reset = () => {
29 | this.cancelCallback();
30 | this.setState({ nodeStatus: {} });
31 | };
32 |
33 | setNodeStatus = (id, status) => {
34 | this.setState(({ nodeStatus }) => ({
35 | nodeStatus: { ...nodeStatus, [id]: status },
36 | }));
37 | };
38 |
39 | onToggleExpand = node => {
40 | const id = this.props.getId(node);
41 | const nodeStatus = this.state.nodeStatus[id];
42 | switch (nodeStatus) {
43 | case NODE_UNLOADED:
44 | this.asyncCallback = setTimeout(() => {
45 | this.asyncCallback = undefined;
46 | this.setNodeStatus(id, NODE_EXPANDED);
47 | }, ASYNC_DELAY);
48 | this.setNodeStatus(id, NODE_LOADING);
49 | return;
50 | case NODE_LOADING:
51 | return;
52 | case NODE_COLLAPSED:
53 | this.setNodeStatus(id, NODE_EXPANDED);
54 | return;
55 | case NODE_EXPANDED:
56 | this.setNodeStatus(id, NODE_COLLAPSED);
57 | return;
58 | default:
59 | throw new Error(`Invalid node status '${nodeStatus}'`);
60 | }
61 | };
62 |
63 | isNodeExpanded = node => {
64 | const id = this.props.getId(node);
65 | return this.state.nodeStatus[id] === NODE_EXPANDED;
66 | };
67 |
68 | isNodeLoading = node => {
69 | const id = this.props.getId(node);
70 | return this.state.nodeStatus[id] === NODE_LOADING;
71 | };
72 |
73 | render() {
74 | const { children, ...props } = this.props;
75 | const { reset, isNodeLoading, isNodeExpanded, onToggleExpand } = this;
76 | return children({
77 | reset,
78 | isNodeLoading,
79 | isNodeExpanded,
80 | onToggleExpand,
81 | ...props,
82 | });
83 | }
84 | }
85 |
--------------------------------------------------------------------------------
/storybook/Async/data.js:
--------------------------------------------------------------------------------
1 | const injectNames = obj =>
2 | Object.keys(obj).reduce(
3 | (result, name) => ({ ...result, [name]: { name, ...obj[name] } }),
4 | {}
5 | );
6 |
7 | // Source: http://slideplayer.com/slide/6378912/22/images/19/Language+Tree.jpg
8 | const allLanguages = injectNames({
9 | 'Proto-Indo-European': {
10 | children: [
11 | 'Germanic',
12 | 'Romance',
13 | 'Balto-Slavic',
14 | 'Celtic',
15 | 'Indo-Iranian',
16 | 'Greek',
17 | 'Albanian',
18 | 'Armenian',
19 | ],
20 | },
21 | Germanic: {
22 | children: ['North Germanic', 'West Germanic'],
23 | },
24 | 'North Germanic': {
25 | children: ['Danish', 'Norwegian', 'Swedish'],
26 | },
27 | 'West Germanic': {
28 | children: ['English', 'German', 'Dutch', 'Afrikaans'],
29 | },
30 | Romance: {
31 | children: [
32 | 'Spanish',
33 | 'French',
34 | 'Italian',
35 | 'Portuguese',
36 | 'Catalan',
37 | 'Romanian',
38 | ],
39 | },
40 | 'Balto-Slavic': {
41 | children: ['South Slavic', 'West Slavic', 'East Slavic'],
42 | },
43 | 'South Slavic': {
44 | children: ['Bulgarian', 'Servo-Croatian'],
45 | },
46 | 'West Slavic': {
47 | children: ['Polish', 'Czech', 'Slavic'],
48 | },
49 | 'East Slavic': {
50 | children: ['Russian', 'Bielo-Russian', 'Ukranian'],
51 | },
52 | Celtic: {
53 | children: ['Breton', 'Scottish', 'Caelic', 'Irish', 'Gaelic', 'Welsh'],
54 | },
55 | 'Indo-Iranian': {
56 | children: ['Sanskrit', 'Old Persian'],
57 | },
58 | Sanskrit: {
59 | children: ['Hindi', 'Bengali', 'Punjabi', 'Urdu'],
60 | },
61 | 'Old Persian': {
62 | children: ['Persian', 'Kurdish', 'Iranian'],
63 | },
64 | Greek: {},
65 | Albanian: {},
66 | Armenian: {},
67 | Danish: {},
68 | Norwegian: {},
69 | Swedish: {},
70 | English: {},
71 | German: {},
72 | Dutch: {},
73 | Afrikaans: {},
74 | Spanish: {},
75 | French: {},
76 | Italian: {},
77 | Portuguese: {},
78 | Catalan: {},
79 | Romanian: {},
80 | Breton: {},
81 | Scottish: {},
82 | Caelic: {},
83 | Irish: {},
84 | Gaelic: {},
85 | Welsh: {},
86 | Bulgarian: {},
87 | 'Servo-Croatian': {},
88 | Polish: {},
89 | Czech: {},
90 | Slavic: {},
91 | Russian: {},
92 | 'Bielo-Russian': {},
93 | Ukranian: {},
94 | Hindi: {},
95 | Bengali: {},
96 | Punjabi: {},
97 | Urdu: {},
98 | Persian: {},
99 | Kurdish: {},
100 | Iranian: {},
101 | });
102 |
103 | export const getId = node => node.name;
104 |
105 | export const getChildren = ({ children }) =>
106 | children ? children.map(name => allLanguages[name]) : undefined;
107 |
108 | export const languages = [allLanguages['Proto-Indo-European']];
109 |
--------------------------------------------------------------------------------
/storybook/Async/index.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import AsyncTree from './AsyncTree';
3 | import Controller from './Controller';
4 | import { getId, getChildren, languages } from './data';
5 |
6 | const Async = () => (
7 |
8 | {({ reset, isNodeLoading, isNodeExpanded, onToggleExpand }) => (
9 |
10 |
11 |
12 | Reset
13 |
14 |
15 |
23 |
24 | )}
25 |
26 | );
27 |
28 | export default Async;
29 |
--------------------------------------------------------------------------------
/storybook/Dropdown/Dropdown.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import './style.css';
3 | import Downshift from 'downshift';
4 | import { getStyle, getIcon } from '../utils';
5 | import Treefold from '../../src';
6 |
7 | const placeholder = 'Hint: type "san"';
8 | const itemToString = node => (node ? node.name : '');
9 |
10 | function getCounter() {
11 | let counter = 0;
12 | return () => counter++;
13 | }
14 |
15 | const Dropdown = ({
16 | nodes,
17 | inputValue,
18 | isNodeExpanded,
19 | onToggleExpand,
20 | onChange,
21 | onInputValueChange,
22 | }) => {
23 | return (
24 | {
37 | const getIndex = getCounter();
38 | return (
39 |
40 |
Location
41 |
42 |
open
43 |
47 |
{
60 | const index = getIndex();
61 | const className =
62 | index === highlightedIndex ? 'highlighted item' : 'item';
63 | return (
64 |
65 |
70 | {isFolder && (
71 |
72 |
76 |
77 | )}
78 | {node.name}
79 |
80 | {isExpanded &&
81 | (hasChildNodes ? (
82 | renderChildNodes()
83 | ) : (
84 |
85 | This folder is empty
86 |
87 | ))}
88 |
89 | );
90 | }}
91 | />
92 |
93 |
94 | );
95 | }}
96 | />
97 | );
98 | };
99 |
100 | export default Dropdown;
101 |
--------------------------------------------------------------------------------
/storybook/Dropdown/filterTree.js:
--------------------------------------------------------------------------------
1 | function isMatch(name, searchTerm) {
2 | return name.toLowerCase().includes(searchTerm);
3 | }
4 |
5 | function filterItems(items, searchTerm) {
6 | function filterNode(node) {
7 | const hasChildItems = node.children != null && node.children.length > 0;
8 | const matchingChildItems = filterItems(node.children, searchTerm);
9 | if (hasChildItems && matchingChildItems.length === node.children.length) {
10 | return node;
11 | }
12 | if (matchingChildItems == null || matchingChildItems.length === 0) {
13 | return isMatch(node.name, searchTerm)
14 | ? { ...node, children: matchingChildItems }
15 | : null;
16 | }
17 | return {
18 | ...node,
19 | children: matchingChildItems,
20 | };
21 | }
22 |
23 | if (items == null || items.length === 0) {
24 | return items;
25 | }
26 | const filteredItems = items.map(filterNode).filter(item => item != null);
27 | if (items.every((item, index) => item === filteredItems[index])) {
28 | return items;
29 | }
30 | return filteredItems;
31 | }
32 |
33 | export function filterTree(items, searchTerm) {
34 | const normalizedSearchTerm = searchTerm.trim().toLowerCase();
35 | if (normalizedSearchTerm.length === 0) {
36 | return items;
37 | }
38 | return filterItems(items, normalizedSearchTerm);
39 | }
40 |
--------------------------------------------------------------------------------
/storybook/Dropdown/index.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import update from 'immutability-helper';
3 | import { filterTree } from './filterTree';
4 | import Dropdown from './Dropdown';
5 |
6 | const bool = (val, ifUndefined) => (val !== undefined ? val : ifUndefined);
7 |
8 | export default class DropdownController extends Component {
9 | state = {
10 | inputValue: '',
11 | expanded: {
12 | standard: {},
13 | filtered: {},
14 | },
15 | };
16 |
17 | onInputValueChange = inputValue => {
18 | this.setState(state =>
19 | update(state, {
20 | inputValue: { $set: inputValue },
21 | expanded: { filtered: { $set: {} } },
22 | })
23 | );
24 | };
25 |
26 | onChange = (selectedItem, stateAndHelpers) => {
27 | console.log('onChange', selectedItem, stateAndHelpers);
28 | };
29 |
30 | handlers = {
31 | standard: this.createHandlers('standard'),
32 | filtered: this.createHandlers('filtered'),
33 | };
34 |
35 | createHandlers(key) {
36 | const defaultExpanded = key === 'filtered';
37 | return {
38 | isNodeExpanded: ({ id }) =>
39 | bool(this.state.expanded[key][id], defaultExpanded),
40 | onToggleExpand: ({ id }) => {
41 | this.setState(state =>
42 | update(state, {
43 | expanded: {
44 | [key]: {
45 | [id]: val => !bool(val, defaultExpanded),
46 | },
47 | },
48 | })
49 | );
50 | },
51 | };
52 | }
53 |
54 | render() {
55 | const { inputValue } = this.state;
56 | const { nodes } = this.props;
57 | const items = filterTree(nodes, inputValue);
58 | const isFiltered = items !== nodes;
59 | const handlers = this.handlers[isFiltered ? 'filtered' : 'standard'];
60 | return (
61 |
68 | );
69 | }
70 | }
71 |
--------------------------------------------------------------------------------
/storybook/Dropdown/style.css:
--------------------------------------------------------------------------------
1 | .dropdown-form label {
2 | display: block;
3 | }
4 |
5 | .dropdown-form input, .dropdown {
6 | width: 300px;
7 | }
8 |
9 | .dropdown {
10 | max-height: 200px;
11 | overflow-y: scroll;
12 | border: 1px solid black;
13 | }
14 |
15 | .dropdown .item {
16 | cursor: default;
17 | }
18 |
19 | .dropdown .highlighted {
20 | color: #fff;
21 | background: #003366;
22 | }
23 |
--------------------------------------------------------------------------------
/storybook/FamilyTree/README.md:
--------------------------------------------------------------------------------
1 | # Family Tree
2 |
3 | This entire example is inspired on [this blog post](https://jwcooney.com/2016/08/21/example-pure-css-family-tree-markup/) by Justin Cooney. The entire credit on the tree markup, styles and even the family data goes to him.
4 |
--------------------------------------------------------------------------------
/storybook/FamilyTree/data.js:
--------------------------------------------------------------------------------
1 | export const familyTree = [
2 | {
3 | id: '1',
4 | he: {
5 | name: 'Joe Mighty',
6 | born: 1601,
7 | died: 1681,
8 | },
9 | },
10 | {
11 | id: '2',
12 | she: {
13 | name: 'Sue Mighty',
14 | born: 1607,
15 | died: 1685,
16 | },
17 | he: {
18 | name: 'Jim Snow',
19 | born: 1633,
20 | died: 1697,
21 | },
22 | children: [
23 | {
24 | id: '3',
25 | she: {
26 | name: 'Lily Sight',
27 | born: 1633,
28 | died: 1697,
29 | },
30 | he: {
31 | name: 'Sam Snow',
32 | born: 1631,
33 | died: 1695,
34 | },
35 | children: [
36 | {
37 | id: '4',
38 | he: {
39 | name: 'Ralf Snow',
40 | born: 1651,
41 | },
42 | },
43 | {
44 | id: '5',
45 | she: {
46 | name: 'Brie Snow',
47 | born: 1653,
48 | },
49 | },
50 | ],
51 | },
52 | {
53 | id: '6',
54 | she: {
55 | name: 'Zoe Blue',
56 | born: 1633,
57 | died: 1697,
58 | },
59 | he: {
60 | name: 'Jim Snow',
61 | born: 1633,
62 | died: 1697,
63 | },
64 | children: [
65 | {
66 | id: '7',
67 | she: {
68 | name: 'Sally Bern',
69 | born: 1653,
70 | },
71 | he: {
72 | name: 'Ralf Snow',
73 | born: 1651,
74 | },
75 | children: [
76 | {
77 | id: '8',
78 | she: {
79 | name: 'Magna Snow',
80 | born: 1697,
81 | },
82 | },
83 | ],
84 | },
85 | {
86 | id: '9',
87 | she: {
88 | name: 'Brie Snow',
89 | born: 1653,
90 | },
91 | },
92 | ],
93 | },
94 | {
95 | id: '10',
96 | he: {
97 | name: 'John Snow',
98 | born: 1635,
99 | died: 1699,
100 | },
101 | },
102 | ],
103 | },
104 | ];
105 |
--------------------------------------------------------------------------------
/storybook/FamilyTree/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Treefold from '../../src';
3 | import './styles.css';
4 | import { familyTree } from './data';
5 |
6 | const renderPerson = ({ name, born, died }, gender) => (
7 |
8 | {name}
9 |
10 | {born} - {died}
11 |
12 | );
13 |
14 | const FamilyTree = () => (
15 |
16 |
17 | (
26 |
27 |
31 | {node.he && renderPerson(node.he, 'male')}
32 | {node.he && node.she && }
33 | {node.she && renderPerson(node.she, 'female')}
34 |
35 | {isExpanded && }
36 |
37 | )}
38 | />
39 |
40 |
41 | );
42 |
43 | export default FamilyTree;
44 |
--------------------------------------------------------------------------------
/storybook/FamilyTree/styles.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif, Arial;
5 | font-size: 10pt;
6 | }
7 |
8 | .tree {
9 | white-space: nowrap;
10 | min-width: 800px;
11 | min-height: 500px;
12 | }
13 |
14 | .tree ul {
15 | padding-top: 20px;
16 | position: relative;
17 | transition: all 0.5s;
18 | -webkit-transition: all 0.5s;
19 | -moz-transition: all 0.5s;
20 | }
21 |
22 | .tree li {
23 | float: left;
24 | text-align: center;
25 | list-style-type: none;
26 | position: relative;
27 | padding: 20px 5px 0 5px;
28 | transition: all 0.5s;
29 | -webkit-transition: all 0.5s;
30 | -moz-transition: all 0.5s;
31 | }
32 |
33 | /*We will use ::before and ::after to draw the connectors*/
34 | .tree li::before,
35 | .tree li::after {
36 | content: '';
37 | position: absolute;
38 | top: 0;
39 | right: 50%;
40 | border-top: 1px solid #ccc;
41 | width: 50%;
42 | height: 20px;
43 | }
44 |
45 | .tree li::after {
46 | right: auto;
47 | left: 50%;
48 | border-left: 1px solid #ccc;
49 | }
50 |
51 | /*We need to remove left-right connectors from elements without any siblings*/
52 | .tree li:only-child::after,
53 | .tree li:only-child::before {
54 | display: none;
55 | }
56 |
57 | /*Remove space from the top of single children*/
58 | .tree li:only-child {
59 | padding-top: 0;
60 | }
61 |
62 | /*Remove left connector from first child and right connector from last child*/
63 | .tree li:first-child::before,
64 | .tree li:last-child::after {
65 | border: 0 none;
66 | }
67 |
68 | /*Adding back the vertical connector to the last nodes*/
69 | .tree li:last-child::before {
70 | border-right: 1px solid #ccc;
71 | border-radius: 0 5px 0 0;
72 | -webkit-border-radius: 0 5px 0 0;
73 | -moz-border-radius: 0 5px 0 0;
74 | }
75 |
76 | .tree li:first-child::after {
77 | border-radius: 5px 0 0 0;
78 | -webkit-border-radius: 5px 0 0 0;
79 | -moz-border-radius: 5px 0 0 0;
80 | }
81 |
82 | /*Time to add downward connectors from parents*/
83 | .tree ul ul::before {
84 | content: '';
85 | position: absolute;
86 | top: 0;
87 | left: 50%;
88 | border-left: 1px solid #ccc;
89 | width: 0;
90 | height: 20px;
91 | }
92 |
93 | .tree li div {
94 | border: 1px solid #ccc;
95 | padding: 5px 10px;
96 | text-decoration: none;
97 | color: #666;
98 | font-family: arial, verdana, tahoma;
99 | font-size: 11px;
100 | display: inline-block;
101 | min-width: 80px;
102 | min-height: 30px;
103 | border-radius: 5px;
104 | -webkit-border-radius: 5px;
105 | -moz-border-radius: 5px;
106 | transition: all 0.5s;
107 | -webkit-transition: all 0.5s;
108 | -moz-transition: all 0.5s;
109 | }
110 |
111 | .tree li div .male {
112 | background-color: lightblue;
113 | display: inline-block;
114 | width: 90px;
115 | padding: 10px;
116 | border-radius: 5px;
117 | -webkit-border-radius: 5px;
118 | -moz-border-radius: 5px;
119 | }
120 |
121 | .tree li div .female {
122 | background-color: lightpink;
123 | display: inline-block;
124 | width: 90px;
125 | padding: 10px;
126 | border-radius: 5px;
127 | -webkit-border-radius: 5px;
128 | -moz-border-radius: 5px;
129 | }
130 |
131 | .tree li div .spacer {
132 | background-color: lightblue;
133 | display: inline-block;
134 | width: 10px;
135 | }
136 |
137 | .tree li div.leaf {
138 | cursor: default;
139 | }
140 |
141 | .tree li div.non-leaf {
142 | cursor: pointer;
143 | }
144 |
145 | /*Time for some hover effects*/
146 | /*We will apply the hover effect to the lineage of the element also*/
147 | .tree li div.non-leaf:hover,
148 | .tree li div.non-leaf:hover + ul li div {
149 | background: #c8e4f8;
150 | color: #000;
151 | border: 1px solid #94a0b4;
152 | }
153 |
154 | /*Connector styles on hover*/
155 | .tree li div.non-leaf:hover + ul li::after,
156 | .tree li div.non-leaf:hover + ul li::before,
157 | .tree li div.non-leaf:hover + ul::before,
158 | .tree li div.non-leaf:hover + ul ul::before {
159 | border-color: #94a0b4;
160 | }
161 |
--------------------------------------------------------------------------------
/storybook/Layout/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './styles.css';
3 |
4 | const Layout = storyFn => (
5 |
6 |
7 | react-treefold
8 |
13 |
14 | {storyFn()}
15 |
16 | );
17 |
18 | export default Layout;
19 |
--------------------------------------------------------------------------------
/storybook/Layout/styles.css:
--------------------------------------------------------------------------------
1 | .layout {
2 | padding: 10px;
3 | }
4 |
5 | .layout > header {
6 | padding: 10px;
7 | margin-bottom: 50px;
8 | color: #828282;
9 | background-color: #f7f7f7;
10 | border: 1px solid #c1c1c1;
11 | width: calc(100% - 20px);
12 | }
13 |
14 | .layout > header > * {
15 | display: inline-block;
16 | width: 50%;
17 | }
18 |
19 | .layout > header > div {
20 | text-align: right;
21 | }
22 |
--------------------------------------------------------------------------------
/storybook/Selector.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import Treefold from '../src';
3 | import { getStyle, getIcon } from './utils';
4 |
5 | const Selector = ({
6 | nodes,
7 | isOn: isNodeSelected,
8 | onToggle: onToggleSelected,
9 | }) => (
10 |
11 |
(
22 |
23 | onToggleSelected(node)}
25 | className={isNodeSelected(node) ? 'selected' : null}
26 | style={getStyle(level + (isFolder ? 0 : 1))}
27 | tabIndex="0"
28 | role="button"
29 | >
30 | {isFolder && (
31 |
32 |
33 |
34 | )}
35 | {node.name}
36 |
37 | {isExpanded &&
38 | (hasChildNodes ? (
39 | renderChildNodes()
40 | ) : (
41 |
42 | This folder is empty
43 |
44 | ))}
45 |
46 | )}
47 | />
48 |
49 | );
50 |
51 | export default Selector;
52 |
--------------------------------------------------------------------------------
/storybook/Table.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from 'react';
2 | import Treefold from '../src/Treefold';
3 | import { getStyle, getIcon } from './utils';
4 | import './styles.css';
5 |
6 | const lengthOf = list => (list && list.length > 0 ? list.length : '—');
7 |
8 | const Table = props => (
9 |
10 |
11 |
12 | Name
13 | Name length
14 | Number of child locations
15 |
16 |
17 |
18 | (
29 |
30 |
31 |
32 | {isFolder && (
33 |
34 |
35 |
36 | )}
37 | {node.name}
38 |
39 | {lengthOf(node.name)}
40 | {lengthOf(node.children)}
41 |
42 | {isExpanded &&
43 | (hasChildNodes ? (
44 | renderChildNodes()
45 | ) : (
46 |
47 |
48 | This folder is empty
49 |
50 |
51 | ))}
52 |
53 | )}
54 | />
55 |
56 |
57 | );
58 |
59 | export default Table;
60 |
--------------------------------------------------------------------------------
/storybook/UnorderedList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Treefold from '../src';
3 | import './styles.css';
4 |
5 | const UnorderedList = props => (
6 |
7 |
8 | {({
9 | node,
10 | isFolder,
11 | isExpanded,
12 | getToggleProps,
13 | hasChildNodes,
14 | renderChildNodes,
15 | }) => (
16 |
17 | {isFolder ? (
18 |
19 | {node.name}
20 |
21 | ) : (
22 | node.name
23 | )}
24 | {isExpanded && (
25 |
26 | {hasChildNodes ? (
27 | renderChildNodes()
28 | ) : (
29 | Empty node
30 | )}
31 |
32 | )}
33 |
34 | )}
35 |
36 |
37 | );
38 |
39 | export default UnorderedList;
40 |
--------------------------------------------------------------------------------
/storybook/config.js:
--------------------------------------------------------------------------------
1 | import { configure } from '@storybook/react';
2 |
3 | function loadStories() {
4 | require('./index.js');
5 | }
6 |
7 | configure(loadStories, module);
8 |
--------------------------------------------------------------------------------
/storybook/data.js:
--------------------------------------------------------------------------------
1 | export const geoData = [
2 | {
3 | id: 'north-america',
4 | name: 'North America',
5 | children: [
6 | {
7 | id: 'usa',
8 | name: 'USA',
9 | children: [
10 | {
11 | id: 'calif',
12 | name: 'California',
13 | children: [
14 | { id: 'LA', name: 'Los Angeles' },
15 | { id: 'SF', name: 'San Francisco' },
16 | ],
17 | },
18 | {
19 | id: 'texas',
20 | name: 'Texas',
21 | children: [
22 | { id: 'dallas', name: 'Dallas' },
23 | { id: 'houston', name: 'Houston' },
24 | ],
25 | },
26 | ],
27 | },
28 | {
29 | id: 'canada',
30 | name: 'Canada',
31 | children: [
32 | { id: 'montreal', name: 'Montreal' },
33 | { id: 'toronto', name: 'Toronto' },
34 | ],
35 | },
36 | {
37 | id: 'mx',
38 | name: 'Mexico',
39 | children: [
40 | { id: 'mx-city', name: 'Mexico City' },
41 | { id: 'monterrey', name: 'Monterrey' },
42 | { id: 'guadalajara', name: 'Guadalajara' },
43 | ],
44 | },
45 | ],
46 | },
47 | {
48 | id: 'caribbean',
49 | name: 'Caribbean',
50 | children: [
51 | { id: 'cuba', name: 'Cuba', children: [{ id: 'hav', name: 'Havana' }] },
52 | { id: 'jc', name: 'Jamaica' },
53 | { id: 'pr', name: 'Puerto Rico', children: [] },
54 | ],
55 | },
56 | {
57 | id: 'south-america',
58 | name: 'South America',
59 | children: [
60 | {
61 | id: 'cl',
62 | name: 'Chile',
63 | children: [
64 | { id: 'stg', name: 'Santiago' },
65 | { id: 'valpo', name: 'Valparaíso' },
66 | { id: 'punta-arenas', name: 'Punta Arenas' },
67 | ],
68 | },
69 | {
70 | id: 'peru',
71 | name: 'Peru',
72 | children: [
73 | { id: 'lima', name: 'Lima' },
74 | { id: 'ica', name: 'Ica' },
75 | { id: 'iquitos', name: 'Iquitos' },
76 | ],
77 | },
78 | ],
79 | },
80 | ];
81 |
--------------------------------------------------------------------------------
/storybook/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 |
4 | import Layout from './Layout';
5 | import ToggleController from '../src/ToggleController';
6 | import { geoData } from './data';
7 | import { SingleSelection } from './utils';
8 | import Table from './Table';
9 | import UnorderedList from './UnorderedList';
10 | import Selector from './Selector';
11 | import FamilyTree from './FamilyTree';
12 | import Animated from './Animated';
13 | import Async from './Async';
14 | import Dropdown from './Dropdown';
15 |
16 | storiesOf('Treefold', module)
17 | .addDecorator(Layout)
18 | .add('family tree', () => )
19 | .add('animated expand/collapse', () => )
20 | .add('async load child nodes', () => )
21 | .add('dropdown select', () => )
22 | .add('controlled', () => (
23 |
24 | {({ isOn, onToggle }) => (
25 |
30 | )}
31 |
32 | ))
33 | .add('uncontrolled', () => )
34 | .add('with multiple selection', () => (
35 | {Selector}
36 | ))
37 | .add('with single selection', () => (
38 | {Selector}
39 | ))
40 | .add('table', () => );
41 |
--------------------------------------------------------------------------------
/storybook/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "storybook",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "index.js",
6 | "scripts": {},
7 | "author": "",
8 | "license": "MIT",
9 | "dependencies": {
10 | "downshift": "^1.28.1",
11 | "immutability-helper": "^2.6.5",
12 | "react": "^16.2.0",
13 | "react-collapse": "^4.0.3",
14 | "react-dom": "^16.2.0",
15 | "react-motion": "^0.5.2"
16 | },
17 | "devDependencies": {
18 | "babel-macros": "^1.2.0"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/storybook/preview-head.html:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/storybook/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | font-family: Arial, Helvetica, sans-serif;
3 | }
4 |
5 | button {
6 | padding: 3px 5px;
7 | }
8 |
9 | .toolbar {
10 | margin-bottom: 1em;
11 | }
12 |
13 | .unordered-list, .selector {
14 | padding: 1em;
15 | width: 300px;
16 | height: 300px;
17 | overflow: scroll;
18 | border: 1px dotted black;
19 | }
20 |
21 | .table {
22 | width: 100%;
23 | border: 1px solid #ccc;
24 | }
25 |
26 | .selector > div:nth-child(even),
27 | .table tbody tr:nth-child(even) {
28 | background-color: transparent;
29 | }
30 |
31 | .selector > div:nth-child(odd),
32 | .table tbody tr:nth-child(odd) {
33 | background-color: #eee;
34 | }
35 |
36 | .selector > div {
37 | cursor: pointer;
38 | }
39 |
40 | .selector > div.selected {
41 | background-color: #666;
42 | color: #fff;
43 | }
44 |
45 | .table th {
46 | text-align: left;
47 | }
48 |
49 | .table .toggle-icon {
50 | display: inline-block;
51 | width: 20px;
52 | cursor: pointer;
53 | }
54 |
55 | .align-right {
56 | text-align: right !important;
57 | }
58 |
59 | .empty {
60 | color: #888;
61 | font-style: italic;
62 | }
63 |
--------------------------------------------------------------------------------
/storybook/utils.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 |
3 | export const getStyle = level => ({ paddingLeft: `${level * 20}px` });
4 |
5 | export const getIcon = isExpanded =>
6 | `fa fa-fw fa-${isExpanded ? 'caret-down' : 'caret-right'}`;
7 |
8 | export const event = callback => e => {
9 | e.preventDefault();
10 | e.stopPropagation();
11 | callback && callback();
12 | };
13 |
14 | export class SingleSelection extends Component {
15 | state = { selectedId: null };
16 |
17 | isOn = item => this.state.selectedId === item.id;
18 |
19 | onToggle = item => {
20 | this.setState({ selectedId: item.id });
21 | };
22 |
23 | render() {
24 | const { isOn, onToggle } = this;
25 | return this.props.children({ isOn, onToggle, ...this.props });
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/tests/ToggleController.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mount } from 'enzyme';
3 | import ToggleController from '../src/ToggleController';
4 |
5 | const items = [
6 | { id: 'cl', name: 'Chile', capitalCity: 'Santiago' },
7 | { id: 'cu', name: 'Cuba', capitalCity: 'Havana' },
8 | { id: 'us', name: 'USA', capitalCity: 'Washington' },
9 | ];
10 |
11 | const render = ({ isOn, onToggle, getLabel }) =>
12 | items.map(item => {
13 | const isChecked = isOn(item);
14 | return (
15 | onToggle(item)}
19 | >
20 | {getLabel ? getLabel(item) : item.name}
21 |
22 | );
23 | });
24 |
25 | describe('ToggleController', () => {
26 | it('toggles on and off per id', () => {
27 | const wrapper = mount({render} );
28 | expect(wrapper.find('.checked')).toHaveLength(0);
29 | wrapper
30 | .find('label')
31 | .at(0)
32 | .simulate('click');
33 | expect(wrapper.find('.checked')).toHaveLength(1);
34 | expect(wrapper.find('.checked').text()).toEqual('Chile');
35 | wrapper
36 | .find('label')
37 | .at(2)
38 | .simulate('click');
39 | expect(wrapper.find('.checked')).toHaveLength(2);
40 | expect(wrapper.find('.unchecked').text()).toEqual('Cuba');
41 | wrapper
42 | .find('label')
43 | .at(0)
44 | .simulate('click');
45 | expect(wrapper.find('.checked').text()).toEqual('USA');
46 | });
47 |
48 | it('passes on any extra props to the render prop', () => {
49 | const wrapper = mount(
50 | item.capitalCity}>
51 | {render}
52 |
53 | );
54 | expect(
55 | wrapper
56 | .find('label')
57 | .at(0)
58 | .text()
59 | ).toEqual('Santiago');
60 | });
61 | });
62 |
--------------------------------------------------------------------------------
/tests/Treefold.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { mount } from 'enzyme';
3 | import Treefold from '../src/Treefold';
4 |
5 | const buildNodes = ({ idKey = 'id', childrenKey = 'children' } = {}) => [
6 | {
7 | [idKey]: 'html',
8 | name: 'Html document',
9 | [childrenKey]: [
10 | {
11 | [idKey]: 'head',
12 | name: 'Html head',
13 | [childrenKey]: [
14 | { [idKey]: 'meta', name: 'meta' },
15 | { [idKey]: 'title', name: 'title' },
16 | ],
17 | },
18 | {
19 | [idKey]: 'body',
20 | name: 'Html body',
21 | [childrenKey]: [
22 | {
23 | [idKey]: 'h1.title',
24 | name: 'Page title',
25 | [childrenKey]: [
26 | { [idKey]: 'title.link', name: 'Title link', [childrenKey]: [] },
27 | ],
28 | },
29 | {
30 | [idKey]: 'h2.subtitle',
31 | name: 'Page subtitle',
32 | [childrenKey]: [],
33 | },
34 | {
35 | [idKey]: 'ul',
36 | name: 'List of items',
37 | [childrenKey]: [
38 | { [idKey]: 'list-item-1', name: 'Hello' },
39 | { [idKey]: 'list-item-2', name: 'World' },
40 | ],
41 | },
42 | {
43 | [idKey]: 'div.footer',
44 | name: 'Page footer',
45 | },
46 | ],
47 | },
48 | ],
49 | },
50 | ];
51 |
52 | const defaultNodes = buildNodes();
53 |
54 | const Node = ({
55 | node,
56 | isFolder,
57 | isExpanded,
58 | getToggleProps,
59 | hasChildNodes,
60 | renderChildNodes,
61 | }) => (
62 |
63 | {isFolder ? (
64 |
72 | {node.name}
73 |
74 | ) : (
75 | {node.name}
76 | )}
77 | {isExpanded && (
78 |
79 | {hasChildNodes ? (
80 | renderChildNodes()
81 | ) : (
82 | Empty node
83 | )}
84 |
85 | )}
86 |
87 | );
88 |
89 | const Tree = props => ;
90 |
91 | const setup = ({ expanded = [], nodes = defaultNodes, ...props } = {}) => {
92 | const isNodeExpanded = item =>
93 | expanded.includes(props.getNodeId ? props.getNodeId(item) : item.id);
94 | const onToggleExpand = jest.fn();
95 | const wrapper = mount(
96 |
102 | );
103 | return { wrapper, onToggleExpand };
104 | };
105 |
106 | describe('Treefold', () => {
107 | describe('expanding and collapsing nodes', () => {
108 | const testExpandCollapse = testWithEvent => {
109 | it('allows to expand and collapse nodes using the mouse', () => {
110 | testWithEvent(item => item.simulate('click'));
111 | });
112 |
113 | it('allows to expand and collapse nodes using the keyboard', () => {
114 | testWithEvent(item => item.simulate('keydown', { keyCode: 13 }));
115 | testWithEvent(item => item.simulate('keydown', { keyCode: 32 }));
116 | });
117 | };
118 |
119 | describe('uncontrolled', () => {
120 | testExpandCollapse(toggleEvent => {
121 | const wrapper = mount( );
122 | expect(wrapper.find('.item')).toHaveLength(1);
123 | toggleEvent(wrapper.find('.item'));
124 | expect(wrapper.find('.item')).toHaveLength(3);
125 | toggleEvent(wrapper.find('.item').at(0));
126 | expect(wrapper.find('.item')).toHaveLength(1);
127 | });
128 | });
129 |
130 | describe('controlled', () => {
131 | testExpandCollapse(toggleEvent => {
132 | const { wrapper, onToggleExpand } = setup();
133 | expect(wrapper.find('.item')).toHaveLength(1);
134 | toggleEvent(wrapper.find('.item'));
135 | expect(onToggleExpand.mock.calls.length).toEqual(1);
136 | expect(onToggleExpand.mock.calls[0][0]).toMatchObject({ id: 'html' });
137 | });
138 | });
139 |
140 | it('renders the nodes collapsed or expanded accordingly', () => {
141 | const { wrapper } = setup({ expanded: ['html', 'body'] });
142 | expect(wrapper.find('.item')).toHaveLength(7);
143 | expect(wrapper.find('.item.folder')).toHaveLength(6);
144 | expect(wrapper.find('.item.folder.expanded')).toHaveLength(2);
145 | expect(wrapper.find('.item.folder.collapsed')).toHaveLength(4);
146 | expect(wrapper.find('.item.leaf')).toHaveLength(1);
147 | });
148 |
149 | it('renders empty folders if possible', () => {
150 | const { wrapper } = setup({ expanded: ['html', 'body', 'h2.subtitle'] });
151 | expect(wrapper.find('.empty')).toHaveLength(1);
152 | });
153 | });
154 |
155 | describe('working with custom data structures', () => {
156 | it('supports using a custom field as id', () => {
157 | const { wrapper } = setup({
158 | expanded: ['Html document', 'body'],
159 | getNodeId: item => item.name,
160 | });
161 | expect(wrapper.find('.item.expanded')).toHaveLength(1);
162 | });
163 |
164 | it('supports using a custom field as children', () => {
165 | const nodes = buildNodes({ childrenKey: 'items' });
166 | const { wrapper } = setup({
167 | expanded: ['html'],
168 | getNodeChildren: item => item.items,
169 | nodes,
170 | });
171 | expect(wrapper.find('.item')).toHaveLength(3);
172 | });
173 | });
174 |
175 | describe('render prop', () => {
176 | it('can be given via the children prop', () => {
177 | const wrapper = mount(
178 | {}}
181 | isNodeExpanded={() => true}
182 | >
183 | {Node}
184 |
185 | );
186 | expect(wrapper.find('.item')).toHaveLength(12);
187 | });
188 | });
189 | });
190 |
--------------------------------------------------------------------------------
/tests/setup.js:
--------------------------------------------------------------------------------
1 | import 'jest-enzyme';
2 | import Enzyme from 'enzyme';
3 | import Adapter from 'enzyme-adapter-react-16';
4 |
5 | Enzyme.configure({ adapter: new Adapter() });
6 |
--------------------------------------------------------------------------------