├── 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 | [![NPM](https://img.shields.io/npm/v/react-tree-select-hook.svg)](https://www.npmjs.com/package/react-tree-select-box) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](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 | --------------------------------------------------------------------------------