├── src
├── typings.d.ts
├── react-app-env.d.ts
├── index.tsx
├── TreeSelect
│ ├── TreeUtils.test.js
│ ├── TreeUtils.js
│ └── hooks
│ │ └── useTreeSelect.ts
└── stories
│ └── TreeSelect.stories.js
├── .travis.yml
├── .eslintignore
├── example
├── public
│ ├── favicon.ico
│ ├── manifest.json
│ └── index.html
├── src
│ ├── index.js
│ ├── index.css
│ └── App.js
├── README.md
└── package.json
├── tsconfig.test.json
├── .editorconfig
├── .storybook
└── main.js
├── .prettierrc
├── .gitignore
├── .github
└── workflows
│ └── main.yml
├── .eslintrc
├── tsconfig.json
├── LICENSE
├── README.md
└── package.json
/src/typings.d.ts:
--------------------------------------------------------------------------------
1 | // TODO declare typings
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - 12
4 | - 10
5 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | build/
2 | dist/
3 | node_modules/
4 | .snapshots/
5 | *.min.js
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import { useTreeSelect } from './TreeSelect/hooks/useTreeSelect';
2 |
3 | export { useTreeSelect };
4 |
--------------------------------------------------------------------------------
/example/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bjoernWahle/react-tree-select-hook/HEAD/example/public/favicon.ico
--------------------------------------------------------------------------------
/tsconfig.test.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "module": "commonjs"
5 | }
6 | }
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | charset = utf-8
5 | indent_style = space
6 | indent_size = 2
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
--------------------------------------------------------------------------------
/example/src/index.js:
--------------------------------------------------------------------------------
1 | import './index.css'
2 |
3 | import React from 'react'
4 | import ReactDOM from 'react-dom'
5 | import App from './App'
6 |
7 | ReactDOM.render(, document.getElementById('root'))
8 |
--------------------------------------------------------------------------------
/.storybook/main.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | stories: ['../src/**/*.stories.js'],
3 | addons: [
4 | '@storybook/preset-create-react-app',
5 | '@storybook/addon-actions',
6 | '@storybook/addon-links',
7 | ],
8 | };
9 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "jsxSingleQuote": true,
4 | "semi": true,
5 | "tabWidth": 2,
6 | "bracketSpacing": true,
7 | "jsxBracketSameLine": false,
8 | "arrowParens": "always",
9 | "trailingComma": "none"
10 | }
11 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | #Simple example
2 |
3 | This is a simple example for the react-tree-select-hook. You can find more examples in the storybook at https://react-tree-select-hook.now.sh
4 |
5 | ## Install
6 | ```
7 | npm install
8 | ```
9 |
10 | ## Try it out
11 | ```
12 | npm start
13 | ```
14 |
--------------------------------------------------------------------------------
/example/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "react-tree-select-hook-example",
3 | "name": "react-tree-select-hook-example",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | }
10 | ],
11 | "start_url": ".",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/example/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
5 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
6 | sans-serif;
7 | -webkit-font-smoothing: antialiased;
8 | -moz-osx-font-smoothing: grayscale;
9 | }
10 |
11 | code {
12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
13 | monospace;
14 | }
15 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | # See https://help.github.com/ignore-files/ for more about ignoring files.
3 |
4 | # dependencies
5 | node_modules
6 |
7 | # builds
8 | build
9 | dist
10 | .rpt2_cache
11 | #storybook
12 | /storybook-static
13 |
14 | # misc
15 | .DS_Store
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 |
27 |
28 | # ides
29 | .idea
30 |
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on: push
3 | jobs:
4 | test:
5 | runs-on: ubuntu-latest
6 | steps:
7 | - uses: actions/checkout@v2
8 | - uses: actions/setup-node@v1
9 | with:
10 | node-version: '12'
11 | - run: npm ci
12 | - run: npm test
13 | - name: Release
14 | env:
15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
16 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
17 | run: npx semantic-release
18 |
--------------------------------------------------------------------------------
/src/TreeSelect/TreeUtils.test.js:
--------------------------------------------------------------------------------
1 | import { buildNodeIndex } from './TreeUtils';
2 |
3 | describe('buildNodeIndex', () => {
4 | it('should create an object with the ids of each node as the key and the node as the value', () => {
5 | const treeNodes = [
6 | { id: '1', label: 'L1-1', children: [{ id: '2', label: 'L2-1' }] },
7 | { id: '3', label: 'L1-2' }
8 | ];
9 | const nodeIndex = buildNodeIndex(treeNodes);
10 | expect(Object.keys(nodeIndex)).toEqual(['1', '2', '3']);
11 | expect(nodeIndex['1'].label).toBe('L1-1');
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tree-select-hook-example",
3 | "homepage": "https://github.com/bjoernWahle/react-tree-select-hook",
4 | "version": "0.0.1",
5 | "private": true,
6 | "dependencies": {
7 | "react": "16.13.1",
8 | "react-dom": "16.13.1",
9 | "react-scripts": "latest",
10 | "react-tree-select-hook": "1.0.0"
11 | },
12 | "scripts": {
13 | "start": "react-scripts start",
14 | "build": "react-scripts build"
15 | },
16 | "eslintConfig": {
17 | "extends": "react-app"
18 | },
19 | "browserslist": [
20 | ">0.2%",
21 | "not dead",
22 | "not ie <= 11",
23 | "not op_mini all"
24 | ]
25 | }
26 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "plugins": [
4 | "@typescript-eslint",
5 | "prettier"
6 | ],
7 | "extends": [
8 | "plugin:prettier/recommended",
9 | "plugin:@typescript-eslint/recommended",
10 | "plugin:@typescript-eslint/eslint-recommended"
11 | ],
12 | "env": {
13 | "node": true
14 | },
15 | "parserOptions": {
16 | "ecmaVersion": 2020,
17 | "ecmaFeatures": {
18 | "legacyDecorators": true,
19 | "jsx": true
20 | }
21 | },
22 | "settings": {
23 | "react": {
24 | "version": "16"
25 | }
26 | },
27 | "rules": {
28 | "space-before-function-paren": 0,
29 | "react/prop-types": 0,
30 | "react/jsx-handler-names": 0,
31 | "react/jsx-fragments": 0,
32 | "react/no-unused-prop-types": 0,
33 | "import/export": 0
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "dist",
4 | "module": "esnext",
5 | "lib": [
6 | "dom",
7 | "esnext"
8 | ],
9 | "moduleResolution": "node",
10 | "jsx": "react",
11 | "sourceMap": true,
12 | "declaration": true,
13 | "esModuleInterop": true,
14 | "noImplicitReturns": true,
15 | "noImplicitThis": true,
16 | "noImplicitAny": true,
17 | "strictNullChecks": true,
18 | "suppressImplicitAnyIndexErrors": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "allowSyntheticDefaultImports": true,
22 | "allowJs": true,
23 | "target": "es5",
24 | "skipLibCheck": true,
25 | "strict": true,
26 | "forceConsistentCasingInFileNames": true,
27 | "resolveJsonModule": true,
28 | "isolatedModules": true,
29 | "noEmit": true
30 | },
31 | "include": [
32 | "src"
33 | ],
34 | "exclude": [
35 | "node_modules",
36 | "dist",
37 | "example"
38 | ]
39 | }
40 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Björn Wahle
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/stories/TreeSelect.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useTreeSelect } from '..';
3 |
4 | export default { title: 'TreeSelect' };
5 |
6 | const drinksAndSnacksNodes = [
7 | {
8 | label: 'Drinks',
9 | children: [
10 | {
11 | label: 'Coke'
12 | },
13 | {
14 | label: 'Water'
15 | }
16 | ]
17 | },
18 | {
19 | label: 'Snacks',
20 | children: [
21 | {
22 | label: 'Cookies'
23 | }
24 | ]
25 | }
26 | ];
27 |
28 | export const Standard = () => {
29 | const {
30 | nodes,
31 | getCheckboxProps,
32 | getExpandButtonProps,
33 | isExpanded,
34 | simplifiedSelection
35 | } = useTreeSelect(drinksAndSnacksNodes);
36 |
37 | const TreeSelectNode = ({ node }) => {
38 | return (
39 |
40 |
49 | {node.children && isExpanded(node.id) && (
50 |
51 | {node.children.map((node) => (
52 |
53 | ))}
54 |
55 | )}
56 |
57 | );
58 | };
59 |
60 | return (
61 |
62 |
{simplifiedSelection.map((el) => el.label).join(', ')}
63 |
64 | {nodes.map((node) => {
65 | return ;
66 | })}
67 |
68 |
69 | );
70 | };
71 |
--------------------------------------------------------------------------------
/example/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useTreeSelect } from 'react-tree-select-hook'
3 |
4 | const drinksAndSnacksNodes = [
5 | {
6 | label: 'Drinks',
7 | children: [
8 | {
9 | label: 'Coke'
10 | },
11 | {
12 | label: 'Water'
13 | }
14 | ]
15 | },
16 | {
17 | label: 'Snacks',
18 | children: [
19 | {
20 | label: 'Cookies'
21 | }
22 | ]
23 | }
24 | ]
25 |
26 | const App = () => {
27 | const {
28 | nodes,
29 | getCheckboxProps,
30 | getExpandButtonProps,
31 | isExpanded,
32 | simplifiedSelection
33 | } = useTreeSelect(drinksAndSnacksNodes)
34 |
35 | // Since the tree can have a variable number of levels, let's define a component that renders
36 | // in a recursive way
37 | const TreeSelectNode = ({ node }) => {
38 | return (
39 |
40 |
49 | {node.children && isExpanded(node.id) && (
50 |
51 | {node.children.map((node) => (
52 |
53 | ))}
54 |
55 | )}
56 |
57 | )
58 | }
59 |
60 | return (
61 |
62 |
{simplifiedSelection.map((el) => el.label).join(', ')}
63 |
64 | {nodes.map((node) => {
65 | return
66 | })}
67 |
68 |
69 | )
70 | }
71 |
72 | export default App
73 |
--------------------------------------------------------------------------------
/example/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
10 |
11 |
12 |
16 |
17 |
18 |
27 | react-tree-select-box
28 |
29 |
30 |
31 |
34 |
35 |
36 |
37 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-tree-select-hook
2 |
3 | > A headless tree select utility using hooks.
4 |
5 | [](https://www.npmjs.com/package/react-tree-select-box) [](https://standardjs.com)
6 |
7 | ## Install
8 |
9 | ```bash
10 | npm install --save react-tree-select-hook
11 | ```
12 |
13 | ## Usage
14 |
15 | ```tsx
16 | import React from 'react'
17 | import { useTreeSelect } from 'react-tree-select-hook'
18 |
19 | const drinksAndSnacksNodes = [
20 | {
21 | label: 'Drinks',
22 | children: [
23 | {
24 | label: 'Coke'
25 | },
26 | {
27 | label: 'Water'
28 | }
29 | ]
30 | },
31 | {
32 | label: 'Snacks',
33 | children: [
34 | {
35 | label: 'Cookies'
36 | }
37 | ]
38 | }
39 | ]
40 |
41 | export const Standard = () => {
42 | const {
43 | nodes,
44 | getCheckboxProps,
45 | getExpandButtonProps,
46 | isExpanded,
47 | simplifiedSelection
48 | } = useTreeSelect(drinksAndSnacksNodes)
49 |
50 | // Since the tree can have a variable number of levels, let's define a component that renders
51 | // in a recursive way
52 | const TreeSelectNode = ({ node }) => {
53 | return (
54 |
55 |
64 | {node.children && isExpanded(node.id) && (
65 |
66 | {node.children.map((node) => (
67 |
68 | ))}
69 |
70 | )}
71 |
72 | )
73 | }
74 |
75 | return (
76 |
77 |
{simplifiedSelection.map((el) => el.label).join(', ')}
78 |
79 | {nodes.map((node) => {
80 | return
81 | })}
82 |
83 |
84 | )
85 | }
86 |
87 |
88 | ```
89 |
90 | ## License
91 |
92 | MIT © [Björn Wahle](https://github.com/bjoernWahle)
93 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-tree-select-hook",
3 | "version": "0.0.0-development",
4 | "description": "A headless tree select utility using hooks.",
5 | "author": "Björn Wahle",
6 | "license": "MIT",
7 | "repository": "bjoernWahle/react-tree-select-hook",
8 | "main": "dist/index.js",
9 | "module": "dist/index.modern.js",
10 | "source": "src/index.tsx",
11 | "engines": {
12 | "node": ">=10"
13 | },
14 | "scripts": {
15 | "build": "microbundle-crl --no-compress --format modern,cjs",
16 | "start": "microbundle-crl watch --no-compress --format modern,cjs",
17 | "test": "run-s test:unit test:lint test:build",
18 | "test:build": "run-s build",
19 | "test:lint": "eslint .",
20 | "test:unit": "cross-env CI=1 react-scripts test --env=jsdom",
21 | "test:watch": "react-scripts test --env=jest-environment-jsdom-sixteen",
22 | "predeploy": "cd example && npm install && npm run build",
23 | "deploy": "gh-pages -d example/build",
24 | "storybook": "start-storybook -p 9009",
25 | "build-storybook": "build-storybook",
26 | "commit": "git-cz",
27 | "semantic-release": "semantic-release"
28 | },
29 | "peerDependencies": {
30 | "react": "^16.8.0"
31 | },
32 | "devDependencies": {
33 | "@storybook/addon-actions": "^5.3.18",
34 | "@storybook/addon-links": "^5.3.18",
35 | "@storybook/addons": "^5.3.18",
36 | "@storybook/preset-create-react-app": "^2.1.1",
37 | "@storybook/react": "^5.3.18",
38 | "@testing-library/jest-dom": "^5.7.0",
39 | "@testing-library/react": "^10.0.4",
40 | "@types/jest": "^25.1.4",
41 | "@types/react": "16.9.35",
42 | "@typescript-eslint/eslint-plugin": "3.0.0",
43 | "@typescript-eslint/parser": "3.0.0",
44 | "babel-eslint": "^10.0.3",
45 | "commitizen": "^4.1.2",
46 | "cross-env": "^7.0.2",
47 | "cz-conventional-changelog": "^3.2.0",
48 | "eslint": "6.6.0",
49 | "eslint-config-prettier": "^6.11.0",
50 | "eslint-config-standard": "^14.1.1",
51 | "eslint-config-standard-react": "^9.2.0",
52 | "eslint-plugin-import": "^2.18.2",
53 | "eslint-plugin-node": "^11.0.0",
54 | "eslint-plugin-prettier": "^3.1.3",
55 | "eslint-plugin-promise": "^4.2.1",
56 | "eslint-plugin-react": "^7.17.0",
57 | "eslint-plugin-standard": "^4.0.1",
58 | "gh-pages": "^2.2.0",
59 | "jest-environment-jsdom-sixteen": "^1.0.3",
60 | "microbundle-crl": "^0.13.10",
61 | "node-sass": "^4.14.1",
62 | "npm-run-all": "^4.1.5",
63 | "prettier": "^2.0.5",
64 | "react": "^16.13.1",
65 | "react-dom": "^16.13.1",
66 | "react-scripts": "^3.4.1",
67 | "semantic-release": "^17.0.7"
68 | },
69 | "files": [
70 | "dist"
71 | ],
72 | "czConfig": {
73 | "path": "node_modules/cz-conventional-changelog"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/TreeSelect/TreeUtils.js:
--------------------------------------------------------------------------------
1 | export function flatCollectCheckedNodes(nodes, checked, acc = []) {
2 | return nodes.reduce((acc, node) => {
3 | if (checked[node.id]) {
4 | return [...acc, node];
5 | } else {
6 | if (node.children && node.children.length > 0) {
7 | return flatCollectCheckedNodes(node.children, checked, acc);
8 | } else {
9 | return acc;
10 | }
11 | }
12 | }, acc);
13 | }
14 |
15 | export function copyNodes(nodes) {
16 | return nodes.map((node) => ({
17 | ...node,
18 | children: node.children ? copyNodes(node.children) : undefined
19 | }));
20 | }
21 |
22 | export function buildNodeIndex(nodes, index = {}) {
23 | nodes.forEach((node) => {
24 | index[node.id] = node;
25 | if (node.children) {
26 | index = buildNodeIndex(node.children, index);
27 | }
28 | });
29 | return index;
30 | }
31 |
32 | function updateParents(node, checkedState, updates) {
33 | if (node.parent) {
34 | const siblings = node.parent.children.filter(
35 | (child) => child.id !== node.id
36 | );
37 | const allChecked = siblings.every((sibling) => checkedState[sibling.id]);
38 | updates[node.parent.id] = allChecked;
39 | if (allChecked) {
40 | updateParents(node.parent, checkedState, updates);
41 | } else {
42 | uncheckParents(node.parent, updates);
43 | }
44 | }
45 | }
46 |
47 | function uncheckParents(node, updates) {
48 | updates[node.id] = false;
49 | if (node.parent) {
50 | uncheckParents(node.parent, updates);
51 | }
52 | }
53 |
54 | function updateChildren(node, updates, value) {
55 | if (node.children) {
56 | for (const child of node.children) {
57 | updates[child.id] = value;
58 | updateChildren(child, updates, value);
59 | }
60 | }
61 | }
62 |
63 | export function checkAndUpdateTree(treeNodes, checkedState) {
64 | let allChecked = true;
65 | treeNodes.forEach((treeNode) => {
66 | if (treeNode.children) {
67 | checkedState[treeNode.id] = checkAndUpdateTree(
68 | treeNode.children,
69 | checkedState
70 | );
71 | }
72 | allChecked = allChecked && checkedState[treeNode.id];
73 | });
74 | return allChecked;
75 | }
76 |
77 | export function checkAndUpdate(nodeIndex, checkedState, id, newValue) {
78 | const updates = { [id]: newValue };
79 | updateChildren(nodeIndex[id], updates, newValue);
80 | if (!newValue) {
81 | if (nodeIndex[id].parent) {
82 | uncheckParents(nodeIndex[id], updates);
83 | }
84 | } else {
85 | updateParents(nodeIndex[id], checkedState, updates);
86 | }
87 | return updates;
88 | }
89 |
90 | export function addParentsAndIds(nodes, parent) {
91 | for (const node of nodes) {
92 | if (parent) {
93 | node.parent = parent;
94 | node.id = parent.id + '/' + node.label;
95 | } else {
96 | node.id = node.label;
97 | }
98 | if (node.children) {
99 | node.children = addParentsAndIds(node.children, node);
100 | }
101 | }
102 | return nodes;
103 | }
104 |
105 | export function treeToMap(options, value, acc = {}) {
106 | return options.reduce((acc, option) => {
107 | acc[option.id] = value;
108 | treeToMap(option.children || [], value, acc);
109 | return acc;
110 | }, acc);
111 | }
112 |
--------------------------------------------------------------------------------
/src/TreeSelect/hooks/useTreeSelect.ts:
--------------------------------------------------------------------------------
1 | import {
2 | addParentsAndIds,
3 | buildNodeIndex,
4 | checkAndUpdate,
5 | copyNodes,
6 | flatCollectCheckedNodes,
7 | treeToMap
8 | } from '../TreeUtils';
9 | import { Reducer, useMemo, useReducer } from 'react';
10 |
11 | function buildInitialState(nodes: NodeLike[]): TreeSelectState {
12 | const preparedNodes: Node[] = addParentsAndIds(copyNodes(nodes));
13 | const initialState = treeToMap(preparedNodes, true);
14 | const nodeIndex = buildNodeIndex(preparedNodes);
15 | const expanded = treeToMap(preparedNodes, false);
16 | return { checked: initialState, nodeIndex, expanded };
17 | }
18 |
19 | type TreeSelectReducer = Reducer;
20 |
21 | enum actionTypes {
22 | toggleNode = 'toggle_node',
23 | checkAll = 'check_all',
24 | checkNone = 'check_none',
25 | setNodes = 'set_nodes',
26 | toggleExpanded = 'toggle_expanded'
27 | }
28 |
29 | interface ToggleNodeAction {
30 | type: actionTypes.toggleNode;
31 | payload: ToggleNodePayload;
32 | }
33 |
34 | interface SetNodesAction {
35 | type: actionTypes.setNodes;
36 | payload: NodesPayload;
37 | }
38 |
39 | interface CheckAllAction {
40 | type: typeof actionTypes.checkAll;
41 | }
42 |
43 | interface CheckNoneAction {
44 | type: typeof actionTypes.checkNone;
45 | }
46 |
47 | interface ToggleExpandedAction {
48 | type: typeof actionTypes.toggleExpanded;
49 | payload: ToggleNodePayload;
50 | }
51 |
52 | type TreeSelectActionTypes =
53 | | ToggleNodeAction
54 | | CheckAllAction
55 | | CheckNoneAction
56 | | SetNodesAction
57 | | ToggleExpandedAction;
58 |
59 | interface NodesPayload {
60 | nodes: NodeLike[];
61 | }
62 |
63 | interface ToggleNodePayload {
64 | id: string;
65 | }
66 |
67 | interface TreeSelectState {
68 | checked: Record;
69 | expanded: Record;
70 | nodeIndex: Record;
71 | }
72 |
73 | interface NodeLike {
74 | label: string;
75 | children?: NodeLike;
76 | }
77 |
78 | interface Node {
79 | id: string;
80 | label: string;
81 | children?: Node[];
82 | parent?: Node;
83 | }
84 |
85 | export const treeSelectReducer = (
86 | state: TreeSelectState,
87 | action: TreeSelectActionTypes
88 | ): TreeSelectState => {
89 | const { checked, nodeIndex } = state;
90 | switch (action.type) {
91 | case actionTypes.toggleNode: {
92 | const id = (action as ToggleNodeAction).payload.id;
93 | const newValue = !checked[id];
94 | const updates = checkAndUpdate(nodeIndex, checked, id, newValue);
95 | const newCheckedState = {};
96 | for (const [nodeId, nodeChecked] of Object.entries(checked)) {
97 | newCheckedState[nodeId] =
98 | updates[nodeId] !== undefined ? updates[nodeId] : nodeChecked;
99 | }
100 | return { ...state, nodeIndex, checked: newCheckedState };
101 | }
102 | case actionTypes.checkAll: {
103 | const allChecked = Object.keys(nodeIndex).reduce(
104 | (acc: Record, id) => {
105 | acc[id] = true;
106 | return acc;
107 | },
108 | {}
109 | );
110 | return { ...state, nodeIndex, checked: allChecked };
111 | }
112 | case actionTypes.checkNone: {
113 | const noneChecked = Object.keys(nodeIndex).reduce(
114 | (acc: Record, id) => {
115 | acc[id] = false;
116 | return acc;
117 | },
118 | {}
119 | );
120 | return { ...state, nodeIndex, checked: noneChecked };
121 | }
122 | case actionTypes.setNodes: {
123 | return buildInitialState((action as SetNodesAction).payload.nodes);
124 | }
125 | case actionTypes.toggleExpanded: {
126 | return {
127 | ...state,
128 | expanded: {
129 | ...state.expanded,
130 | [(action as ToggleExpandedAction).payload.id]: !state.expanded[
131 | (action as ToggleExpandedAction).payload.id
132 | ]
133 | }
134 | };
135 | }
136 | }
137 | return state;
138 | };
139 |
140 | export function useTreeSelect(
141 | rawNodes: NodeLike[],
142 | reducer: TreeSelectReducer = treeSelectReducer
143 | ): {
144 | toggleChecked: (id: string) => void;
145 | state: TreeSelectState;
146 | selectAll: () => void;
147 | selectNone: () => void;
148 | setNodes: (nodes: NodeLike[]) => void;
149 | nodes: Node[];
150 | isExpanded: (id: string) => boolean;
151 | getExpandButtonProps: (
152 | id: string
153 | ) => {
154 | onClick: () => void;
155 | };
156 | getCheckboxProps: (
157 | id: string
158 | ) => {
159 | checked: boolean;
160 | onChange: () => void;
161 | type: string;
162 | };
163 | simplifiedSelection: Node[];
164 | } {
165 | const [state, dispatch] = useReducer(
166 | reducer,
167 | rawNodes,
168 | buildInitialState
169 | );
170 | const toggleChecked = (id: string): void => {
171 | dispatch({ type: actionTypes.toggleNode, payload: { id: id } });
172 | };
173 | const selectAll = (): void => {
174 | dispatch({ type: actionTypes.checkAll });
175 | };
176 | const selectNone = (): void => {
177 | dispatch({ type: actionTypes.checkNone });
178 | };
179 | const setNodes = (nodes: NodeLike[]): void => {
180 | dispatch({ type: actionTypes.setNodes, payload: { nodes } });
181 | };
182 | const toggleExpanded = (id: string): void => {
183 | dispatch({ type: actionTypes.toggleExpanded, payload: { id } });
184 | };
185 | const getCheckboxProps = (
186 | id: string
187 | ): {
188 | checked: boolean;
189 | onChange: () => void;
190 | type: string;
191 | } => {
192 | return {
193 | checked: state.checked[id],
194 | onChange: (): void => toggleChecked(id),
195 | type: 'checkbox'
196 | };
197 | };
198 | const getExpandButtonProps = (
199 | id: string
200 | ): {
201 | onClick: () => void;
202 | } => {
203 | return {
204 | onClick: (): void => {
205 | toggleExpanded(id);
206 | }
207 | };
208 | };
209 | const isExpanded = (id: string): boolean => {
210 | return state.expanded[id];
211 | };
212 | const nodes = useMemo((): Node[] => {
213 | return (Object.values(state.nodeIndex) as Node[]).filter(
214 | (node: Node) => node.parent === undefined
215 | );
216 | }, [state.nodeIndex]);
217 | const simplifiedSelection = useMemo(
218 | () => flatCollectCheckedNodes(nodes, state.checked),
219 | [nodes, state.checked]
220 | );
221 |
222 | return {
223 | nodes,
224 | state,
225 | toggleChecked,
226 | selectAll,
227 | selectNone,
228 | setNodes,
229 | getCheckboxProps,
230 | getExpandButtonProps,
231 | isExpanded,
232 | simplifiedSelection
233 | };
234 | }
235 |
--------------------------------------------------------------------------------