├── .nvmrc ├── .npmrc ├── .storybook ├── preview-head.html ├── preview.cjs └── main.cjs ├── src ├── TreeViewItem.tsx ├── react-draggable-tree.tsx ├── utils.ts ├── TreeViewItemRow.ts ├── TreeViewProps.ts ├── stories │ ├── Node.ts │ └── TreeView.stories.tsx ├── TreeView.tsx └── TreeViewState.ts ├── tsconfig.node.json ├── README.md ├── vite.config.ts ├── tsconfig.json ├── LICENSE ├── package.json └── .gitignore /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.17.1 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/TreeViewItem.tsx: -------------------------------------------------------------------------------- 1 | export interface TreeViewItem { 2 | key: string; 3 | parent: this | undefined; 4 | children: this[]; 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/preview.cjs: -------------------------------------------------------------------------------- 1 | export const parameters = { 2 | actions: { argTypesRegex: "^on[A-Z].*" }, 3 | controls: { 4 | matchers: { 5 | color: /(background|color)$/i, 6 | date: /Date$/, 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /src/react-draggable-tree.tsx: -------------------------------------------------------------------------------- 1 | export type { TreeViewItem } from "./TreeViewItem"; 2 | export type { TreeViewItemRow } from "./TreeViewItemRow"; 3 | export type { TreeViewProps } from "./TreeViewProps"; 4 | export { TreeView } from "./TreeView"; 5 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function assertNonNull(value: T | null | undefined): T { 2 | if (value == null) { 3 | throw new Error("Unexpected null value"); 4 | } 5 | return value; 6 | } 7 | 8 | export function first(array: readonly T[]): T | undefined { 9 | return array[0]; 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-draggable-tree 2 | 3 | [![NPM](https://nodei.co/npm/react-draggable-tree.png)](https://nodei.co/npm/react-draggable-tree/) 4 | 5 | > See [this branch](https://github.com/seanchas116/react-draggable-tree/tree/legacy) for older version (<= 0.4). 6 | 7 | Unstyled draggable tree view component for React 8 | 9 | * [Storybook](https://react-draggable-tree-2ku5.vercel.app/) 10 | 11 | WIP: more docs 12 | -------------------------------------------------------------------------------- /.storybook/main.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "stories": ["../src/**/*.stories.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"], 3 | "addons": ["@storybook/addon-links", "@storybook/addon-essentials", "@storybook/addon-interactions"], 4 | "framework": { 5 | name: "@storybook/react-vite", 6 | options: {} 7 | }, 8 | "features": { 9 | "storyStoreV7": true 10 | }, 11 | docs: { 12 | autodocs: true 13 | } 14 | }; -------------------------------------------------------------------------------- /src/TreeViewItemRow.ts: -------------------------------------------------------------------------------- 1 | import { TreeViewItem } from "./TreeViewItem"; 2 | 3 | export interface TreeViewItemRow { 4 | item: T; 5 | depth: number; 6 | } 7 | 8 | export function getItemRows( 9 | item: T, 10 | depth: number 11 | ): TreeViewItemRow[] { 12 | return [ 13 | { item, depth }, 14 | ...item.children.flatMap((child) => getItemRows(child, depth + 1)), 15 | ]; 16 | } 17 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react"; 4 | import dts from "vite-plugin-dts"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | build: { 9 | lib: { 10 | entry: path.resolve(__dirname, "src/react-draggable-tree.tsx"), 11 | name: "ReactDraggableTree", 12 | fileName: "react-draggable-tree", 13 | }, 14 | rollupOptions: { 15 | external: ["react", "react-dom"], 16 | }, 17 | }, 18 | plugins: [dts(), react()], 19 | }); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"], 20 | "references": [{ "path": "./tsconfig.node.json" }] 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ryohei Ikegami 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/TreeViewProps.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { TreeViewItem } from "./TreeViewItem"; 3 | import { TreeViewItemRow } from "./TreeViewItemRow"; 4 | 5 | export interface TreeViewProps { 6 | rootItem: T; 7 | 8 | header?: React.ReactNode; 9 | footer?: React.ReactNode; 10 | indentation?: number; 11 | dropIndicatorOffset?: number; 12 | nonReorderable?: boolean; 13 | dropBetweenIndicator: (params: { top: number; left: number }) => JSX.Element; 14 | dropOverIndicator: (params: { top: number; height: number }) => JSX.Element; 15 | className?: string; 16 | hidden?: boolean; 17 | style?: React.CSSProperties; 18 | 19 | background?: React.ReactNode; 20 | 21 | renderRow: (params: { 22 | rows: readonly TreeViewItemRow[]; 23 | index: number; 24 | item: T; 25 | depth: number; 26 | indentation: number; 27 | }) => JSX.Element; 28 | 29 | handleDragStart: (params: { item: T; event: React.DragEvent }) => boolean; 30 | handleDragEnd?: (params: { item: T }) => void; 31 | canDrop?: (params: { 32 | item: T; 33 | event: React.DragEvent; 34 | draggedItem: T | undefined; // undefined if the drag is not initiated from a tree view 35 | }) => boolean; 36 | handleDrop?: (params: { 37 | item: T; 38 | event: React.DragEvent; 39 | draggedItem: T | undefined; // undefined if the drag is not initiated from a tree view 40 | before: T | undefined; 41 | }) => void; 42 | } 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-draggable-tree", 3 | "version": "0.7.0", 4 | "description": "Draggable tree view for React", 5 | "homepage": "https://github.com/seanchas116/react-draggable-tree#readme", 6 | "bugs": { 7 | "url": "https://github.com/seanchas116/react-draggable-tree/issues" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/seanchas116/react-draggable-tree.git" 12 | }, 13 | "files": [ 14 | "dist" 15 | ], 16 | "license": "MIT", 17 | "author": "Ryohei Ikegami", 18 | "type": "module", 19 | "main": "dist/react-draggable-tree.js", 20 | "typings": "dist/react-draggable-tree.d.ts", 21 | "scripts": { 22 | "build": "tsc && vite build", 23 | "build-storybook": "storybook build", 24 | "storybook": "storybook dev -p 6006" 25 | }, 26 | "devDependencies": { 27 | "@babel/core": "^7.22.9", 28 | "@storybook/addon-actions": "^7.1.0", 29 | "@storybook/addon-essentials": "^7.1.0", 30 | "@storybook/addon-interactions": "^7.1.0", 31 | "@storybook/addon-links": "^7.1.0", 32 | "@storybook/react": "^7.1.0", 33 | "@storybook/react-vite": "^7.1.0", 34 | "@storybook/testing-library": "^0.2.0", 35 | "@types/node": "^20.4.4", 36 | "@types/react": "^18.2.15", 37 | "@types/react-dom": "^18.2.7", 38 | "@vitejs/plugin-react": "^4.0.3", 39 | "babel-loader": "^9.1.3", 40 | "lorem-ipsum": "^2.0.8", 41 | "react": "^18.2.0", 42 | "react-dom": "^18.2.0", 43 | "storybook": "^7.1.0", 44 | "tiny-typed-emitter": "^2.1.0", 45 | "typescript": "^5.1.6", 46 | "vite": "^4.4.6", 47 | "vite-plugin-dts": "^3.3.1" 48 | }, 49 | "dependencies": { 50 | "events": "^3.3.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/node 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 3 | 4 | ### Node ### 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | .pnpm-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | *.lcov 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # Snowpack dependency directory (https://snowpack.dev/) 50 | web_modules/ 51 | 52 | # TypeScript cache 53 | *.tsbuildinfo 54 | 55 | # Optional npm cache directory 56 | .npm 57 | 58 | # Optional eslint cache 59 | .eslintcache 60 | 61 | # Optional stylelint cache 62 | .stylelintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variable files 80 | .env 81 | .env.development.local 82 | .env.test.local 83 | .env.production.local 84 | .env.local 85 | 86 | # parcel-bundler cache (https://parceljs.org/) 87 | .cache 88 | .parcel-cache 89 | 90 | # Next.js build output 91 | .next 92 | out 93 | 94 | # Nuxt.js build / generate output 95 | .nuxt 96 | dist 97 | 98 | # Gatsby files 99 | .cache/ 100 | # Comment in the public line in if your project uses Gatsby and not Next.js 101 | # https://nextjs.org/blog/next-9-1#public-directory-support 102 | # public 103 | 104 | # vuepress build output 105 | .vuepress/dist 106 | 107 | # vuepress v2.x temp and cache directory 108 | .temp 109 | 110 | # Docusaurus cache and generated files 111 | .docusaurus 112 | 113 | # Serverless directories 114 | .serverless/ 115 | 116 | # FuseBox cache 117 | .fusebox/ 118 | 119 | # DynamoDB Local files 120 | .dynamodb/ 121 | 122 | # TernJS port file 123 | .tern-port 124 | 125 | # Stores VSCode versions used for testing VSCode extensions 126 | .vscode-test 127 | 128 | # yarn v2 129 | .yarn/cache 130 | .yarn/unplugged 131 | .yarn/build-state.yml 132 | .yarn/install-state.gz 133 | .pnp.* 134 | 135 | ### Node Patch ### 136 | # Serverless Webpack directories 137 | .webpack/ 138 | 139 | # Optional stylelint cache 140 | 141 | # SvelteKit build / generate output 142 | .svelte-kit 143 | 144 | # End of https://www.toptal.com/developers/gitignore/api/node 145 | 146 | storybook-static -------------------------------------------------------------------------------- /src/stories/Node.ts: -------------------------------------------------------------------------------- 1 | function generateRandomID() { 2 | return Math.random().toString(36).substr(2, 9); 3 | } 4 | 5 | export class Node { 6 | readonly key = generateRandomID(); 7 | type: "leaf" | "branch" = "leaf"; 8 | name = ""; 9 | selected = false; 10 | collapsed = false; 11 | parent: Node | undefined = undefined; 12 | nextSibling: Node | undefined = undefined; 13 | previousSibling: Node | undefined = undefined; 14 | firstChild: Node | undefined = undefined; 15 | lastChild: Node | undefined = undefined; 16 | 17 | get children(): readonly Node[] { 18 | const children: Node[] = []; 19 | let node = this.firstChild; 20 | while (node) { 21 | children.push(node); 22 | node = node.nextSibling as Node | undefined; 23 | } 24 | return children; 25 | } 26 | 27 | remove(): void { 28 | const parent = this.parent; 29 | if (!parent) { 30 | return; 31 | } 32 | 33 | const prev = this.previousSibling; 34 | const next = this.nextSibling; 35 | 36 | if (prev) { 37 | prev.nextSibling = next; 38 | } else { 39 | parent.firstChild = next; 40 | } 41 | if (next) { 42 | next.previousSibling = prev; 43 | } else { 44 | parent.lastChild = prev; 45 | } 46 | this.parent = undefined; 47 | this.previousSibling = undefined; 48 | this.nextSibling = undefined; 49 | } 50 | 51 | insertBefore(child: Node, next: Node | undefined): void { 52 | if (child === next) { 53 | return; 54 | } 55 | if (child.includes(this)) { 56 | throw new Error("Cannot insert node to its descendant"); 57 | } 58 | if (next && next.parent !== this) { 59 | throw new Error("The ref node is not a child of this node"); 60 | } 61 | child.remove(); 62 | 63 | let prev = next ? next.previousSibling : this.lastChild; 64 | if (prev) { 65 | prev.nextSibling = child; 66 | } else { 67 | this.firstChild = child; 68 | } 69 | if (next) { 70 | next.previousSibling = child; 71 | } else { 72 | this.lastChild = child; 73 | } 74 | child.previousSibling = prev; 75 | child.nextSibling = next; 76 | child.parent = this; 77 | } 78 | 79 | append(...children: Node[]): void { 80 | for (const child of children) { 81 | this.insertBefore(child, undefined); 82 | } 83 | } 84 | 85 | includes(other: Node): boolean { 86 | if (this === other.parent) { 87 | return true; 88 | } 89 | if (!other.parent) { 90 | return false; 91 | } 92 | return this.includes(other.parent); 93 | } 94 | 95 | get root(): Node { 96 | return this.parent?.root ?? this; 97 | } 98 | 99 | select() { 100 | this.selected = true; 101 | for (const child of this.children) { 102 | child.deselect(); 103 | } 104 | } 105 | 106 | deselect() { 107 | this.selected = false; 108 | for (const child of this.children) { 109 | child.deselect(); 110 | } 111 | } 112 | 113 | get ancestorSelected(): boolean { 114 | return this.selected || (this.parent?.ancestorSelected ?? false); 115 | } 116 | 117 | get selectedDescendants(): Node[] { 118 | if (this.selected) { 119 | return [this]; 120 | } 121 | return this.children.flatMap((child) => child.selectedDescendants); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/TreeView.tsx: -------------------------------------------------------------------------------- 1 | import React, { createRef, useEffect, useState } from "react"; 2 | import { TreeViewItem } from "./TreeViewItem"; 3 | import { TreeViewProps } from "./TreeViewProps"; 4 | import { TreeViewState, DropLocation } from "./TreeViewState"; 5 | 6 | //// TreeRow 7 | 8 | function TreeRow({ 9 | state, 10 | index, 11 | dragImageRef, 12 | }: { 13 | state: TreeViewState; 14 | index: number; 15 | dragImageRef: React.RefObject; 16 | }) { 17 | const { item, depth } = state.rows[index]; 18 | return ( 19 |
e && state.itemToDOM.set(item, e)} 21 | //draggable={!currentFocus.isTextInput} 22 | draggable 23 | onDragStart={state.onRowDragStart.bind(state, index)} 24 | onDragEnd={state.onRowDragEnd.bind(state, index)} 25 | onDragEnter={state.onRowDragEnter.bind(state, index)} 26 | onDrop={state.onRowDrop.bind(state, index)} 27 | onDragOver={state.onRowDragOver.bind(state, index)} 28 | > 29 | {state.props.renderRow({ 30 | rows: state.rows, 31 | index: index, 32 | item, 33 | depth, 34 | indentation: state.indentation, 35 | })} 36 |
37 | ); 38 | } 39 | 40 | // Background 41 | 42 | function Background({ 43 | state, 44 | }: { 45 | state: TreeViewState; 46 | }) { 47 | return ( 48 |
59 | {state.props.background} 60 |
61 | ); 62 | } 63 | 64 | //// DropIndicator 65 | 66 | function DropIndicator({ 67 | state, 68 | }: { 69 | state: TreeViewState; 70 | }) { 71 | const [dropLocation, setDropLocation] = 72 | useState | undefined>(); 73 | 74 | useEffect(() => { 75 | state.addListener("dropLocationChange", setDropLocation); 76 | return () => { 77 | state.removeListener("dropLocationChange", setDropLocation); 78 | }; 79 | }, [state, setDropLocation]); 80 | 81 | const indicator = dropLocation?.indication; 82 | if (!indicator) { 83 | return null; 84 | } 85 | 86 | if (indicator.type === "between") { 87 | return state.props.dropBetweenIndicator({ 88 | top: indicator.top, 89 | left: indicator.depth * state.indentation + state.dropIndicatorOffset, 90 | }); 91 | } else { 92 | return state.props.dropOverIndicator({ 93 | top: indicator.top, 94 | height: indicator.height, 95 | }); 96 | } 97 | } 98 | 99 | //// TreeView 100 | 101 | export function TreeView( 102 | props: TreeViewProps 103 | ): JSX.Element | null { 104 | const dragImageRef = createRef(); 105 | 106 | const [state] = useState(() => new TreeViewState(props)); 107 | state.setProps(props); 108 | 109 | return ( 110 |