├── .gitignore ├── jsconfig.json ├── package.json ├── public └── index.html ├── readme.md └── src ├── App.js ├── Tree ├── File │ ├── TreeFile.js │ └── TreeFile.style.js ├── FileIcons.js ├── Folder │ ├── TreeFolder.js │ └── TreeFolder.style.js ├── Tree.js ├── Tree.style.js ├── TreePlaceholderInput.js └── state │ ├── TreeContext.js │ ├── constants.js │ ├── index.js │ └── reducer.js ├── index.js ├── styles.css └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/react 3 | # Edit at https://www.gitignore.io/?templates=react 4 | 5 | package-lock.json 6 | 7 | ### react ### 8 | .DS_* 9 | *.log 10 | logs 11 | **/*.backup.* 12 | **/*.back.* 13 | 14 | node_modules 15 | bower_components 16 | 17 | *.sublime* 18 | 19 | psd 20 | thumb 21 | sketch 22 | 23 | # End of https://www.gitignore.io/api/react -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "src" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-folder-tree", 3 | "version": "1.0.0", 4 | "description": "", 5 | "keywords": [], 6 | "main": "src/index.js", 7 | "dependencies": { 8 | "lodash.clonedeep": "4.5.0", 9 | "react": "16.12.0", 10 | "react-dom": "16.12.0", 11 | "react-icons": "3.9.0", 12 | "react-scripts": "3.0.1", 13 | "styled-components": "5.1.0", 14 | "uuid": "7.0.3" 15 | }, 16 | "devDependencies": { 17 | "typescript": "3.3.3" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test --env=jsdom", 23 | "eject": "react-scripts eject" 24 | }, 25 | "browserslist": [ 26 | ">0.2%", 27 | "not dead", 28 | "not ie <= 11", 29 | "not op_mini all" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 16 | 25 | 29 | React App 30 | 31 | 32 | 33 | 36 |
37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # React Folder Tree Component 2 | 3 | Simple yet flexible folder tree component with Imperative and Declarative API with build-in folder editing capabilities. 4 | 5 | [Live Demo](https://ck6c8.csb.app/) 6 | 7 | ## Imperative API 8 | 9 | Imperative API can be editable. :D 10 | 11 | ```jsx 12 | 33 | ``` 34 | 35 | ## Declarative API 36 | 37 | ```jsx 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | ``` 50 | 51 | Made with <3 and React; 52 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useLayoutEffect } from "react"; 2 | import "./styles.css"; 3 | 4 | import Tree from "./Tree/Tree"; 5 | 6 | const structure = [ 7 | { 8 | type: "folder", 9 | name: "client", 10 | files: [ 11 | { 12 | type: "folder", 13 | name: "ui", 14 | files: [ 15 | { type: "file", name: "Toggle.js" }, 16 | { type: "file", name: "Button.js" }, 17 | { type: "file", name: "Button.style.js" }, 18 | ], 19 | }, 20 | { 21 | type: "folder", 22 | name: "components", 23 | files: [ 24 | { type: "file", name: "Tree.js" }, 25 | { type: "file", name: "Tree.style.js" }, 26 | ], 27 | }, 28 | { type: "file", name: "setup.js" }, 29 | { type: "file", name: "setupTests.js" }, 30 | ], 31 | }, 32 | { 33 | type: "folder", 34 | name: "packages", 35 | files: [ 36 | { 37 | type: "file", 38 | name: "main.js", 39 | }, 40 | ], 41 | }, 42 | { type: "file", name: "index.js" }, 43 | ]; 44 | 45 | export default function App() { 46 | let [data, setData] = useState(structure); 47 | 48 | const handleClick = (node) => { 49 | console.log(node); 50 | }; 51 | const handleUpdate = (state) => { 52 | localStorage.setItem( 53 | "tree", 54 | JSON.stringify(state, function (key, value) { 55 | if (key === "parentNode" || key === "id") { 56 | return null; 57 | } 58 | return value; 59 | }) 60 | ); 61 | }; 62 | 63 | useLayoutEffect(() => { 64 | try { 65 | let savedStructure = JSON.parse(localStorage.getItem("tree")); 66 | if (savedStructure) { 67 | setData(savedStructure); 68 | } 69 | } catch (err) { 70 | console.log(err); 71 | } 72 | }, []); 73 | 74 | return ( 75 |
76 |

Imparative API (editable)

77 | 78 | 79 | 80 |

Declarative API

81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/Tree/File/TreeFile.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from "react"; 2 | import { AiOutlineFile, AiOutlineDelete, AiOutlineEdit } from "react-icons/ai"; 3 | 4 | import { StyledFile } from "Tree/File/TreeFile.style"; 5 | import { useTreeContext } from "Tree/state/TreeContext"; 6 | import { ActionsWrapper, StyledName } from "Tree/Tree.style.js"; 7 | import { PlaceholderInput } from "Tree/TreePlaceholderInput"; 8 | 9 | import { FILE } from "Tree/state/constants"; 10 | import FILE_ICONS from "Tree/FileIcons"; 11 | 12 | const File = ({ name, id, node }) => { 13 | const { dispatch, isImparative, onNodeClick } = useTreeContext(); 14 | const [isEditing, setEditing] = useState(false); 15 | const ext = useRef(""); 16 | 17 | let splitted = name?.split("."); 18 | ext.current = splitted[splitted.length - 1]; 19 | 20 | const toggleEditing = () => setEditing(!isEditing); 21 | const commitEditing = (name) => { 22 | dispatch({ type: FILE.EDIT, payload: { id, name } }); 23 | setEditing(false); 24 | }; 25 | const commitDelete = () => { 26 | dispatch({ type: FILE.DELETE, payload: { id } }); 27 | }; 28 | const handleNodeClick = React.useCallback( 29 | (e) => { 30 | e.stopPropagation(); 31 | onNodeClick({ node }); 32 | }, 33 | [node] 34 | ); 35 | const handleCancel = () => { 36 | setEditing(false); 37 | }; 38 | 39 | return ( 40 | 41 | {isEditing ? ( 42 | 49 | ) : ( 50 | 51 | 52 | {FILE_ICONS[ext.current] ? ( 53 | FILE_ICONS[ext.current] 54 | ) : ( 55 | 56 | )} 57 |   {name} 58 | 59 | {isImparative && ( 60 |
61 | 62 | 63 |
64 | )} 65 |
66 | )} 67 |
68 | ); 69 | }; 70 | 71 | export { File }; 72 | -------------------------------------------------------------------------------- /src/Tree/File/TreeFile.style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components/macro"; 2 | 3 | export const StyledFile = styled.div` 4 | flex-wrap: nowrap; 5 | display: flex; 6 | align-items: center; 7 | font-weight: normal; 8 | padding-left: ${(p) => p.theme.indent}px; 9 | `; 10 | -------------------------------------------------------------------------------- /src/Tree/FileIcons.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { DiJavascript1, DiCss3Full, DiHtml5, DiReact } from "react-icons/di"; 3 | 4 | const FILE_ICONS = { 5 | js: , 6 | css: , 7 | html: , 8 | jsx: 9 | }; 10 | 11 | export default FILE_ICONS; 12 | -------------------------------------------------------------------------------- /src/Tree/Folder/TreeFolder.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { 3 | AiOutlineFolderAdd, 4 | AiOutlineFileAdd, 5 | AiOutlineFolder, 6 | AiOutlineFolderOpen, 7 | AiOutlineDelete, 8 | AiOutlineEdit, 9 | } from "react-icons/ai"; 10 | 11 | import { 12 | ActionsWrapper, 13 | Collapse, 14 | StyledName, 15 | VerticalLine, 16 | } from "Tree/Tree.style"; 17 | import { StyledFolder } from "./TreeFolder.style"; 18 | 19 | import { FILE, FOLDER } from "Tree/state/constants"; 20 | import { useTreeContext } from "Tree/state/TreeContext"; 21 | import { PlaceholderInput } from "Tree/TreePlaceholderInput"; 22 | 23 | const FolderName = ({ isOpen, name, handleClick }) => ( 24 | 25 | {isOpen ? : } 26 |   {name} 27 | 28 | ); 29 | 30 | const Folder = ({ id, name, children, node }) => { 31 | const { dispatch, isImparative, onNodeClick } = useTreeContext(); 32 | const [isEditing, setEditing] = useState(false); 33 | const [isOpen, setIsOpen] = useState(false); 34 | const [childs, setChilds] = useState([]); 35 | 36 | useEffect(() => { 37 | setChilds([children]); 38 | }, [children]); 39 | 40 | const commitFolderCreation = (name) => { 41 | dispatch({ type: FOLDER.CREATE, payload: { id, name } }); 42 | }; 43 | const commitFileCreation = (name) => { 44 | dispatch({ type: FILE.CREATE, payload: { id, name } }); 45 | }; 46 | const commitDeleteFolder = () => { 47 | dispatch({ type: FOLDER.DELETE, payload: { id } }); 48 | }; 49 | const commitFolderEdit = (name) => { 50 | dispatch({ type: FOLDER.EDIT, payload: { id, name } }); 51 | setEditing(false); 52 | }; 53 | 54 | const handleCancel = () => { 55 | setEditing(false); 56 | setChilds([children]); 57 | }; 58 | 59 | const handleNodeClick = React.useCallback( 60 | (event) => { 61 | event.stopPropagation(); 62 | onNodeClick({ node }); 63 | }, 64 | [node] 65 | ); 66 | 67 | const handleFileCreation = (event) => { 68 | event.stopPropagation(); 69 | setIsOpen(true); 70 | setChilds([ 71 | ...childs, 72 | , 77 | ]); 78 | }; 79 | 80 | const handleFolderCreation = (event) => { 81 | event.stopPropagation(); 82 | setIsOpen(true); 83 | setChilds([ 84 | ...childs, 85 | , 90 | ]); 91 | }; 92 | 93 | const handleFolderRename = () => { 94 | setIsOpen(true); 95 | setEditing(true); 96 | }; 97 | 98 | return ( 99 | 100 | 101 | 102 | {isEditing ? ( 103 | 110 | ) : ( 111 | setIsOpen(!isOpen)} 115 | /> 116 | )} 117 | 118 | {isImparative && ( 119 |
120 | 121 | 122 | 123 | 124 |
125 | )} 126 |
127 | 128 | {childs} 129 | 130 |
131 |
132 | ); 133 | }; 134 | 135 | export { Folder, FolderName }; 136 | -------------------------------------------------------------------------------- /src/Tree/Folder/TreeFolder.style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components/macro"; 2 | 3 | export const StyledFolder = styled.section` 4 | font-weight: bold; 5 | padding-left: ${(p) => p.theme.indent}px; 6 | .tree__file { 7 | padding-left: ${(p) => p.theme.indent}px; 8 | } 9 | `; 10 | -------------------------------------------------------------------------------- /src/Tree/Tree.js: -------------------------------------------------------------------------------- 1 | import React, { useReducer, useLayoutEffect } from "react"; 2 | import { v4 } from "uuid"; 3 | import { ThemeProvider } from "styled-components"; 4 | 5 | import { useDidMountEffect } from "utils"; 6 | import { TreeContext, reducer } from "./state"; 7 | 8 | import { StyledTree } from "Tree/Tree.style"; 9 | import { Folder } from "Tree/Folder/TreeFolder"; 10 | import { File } from "Tree/File/TreeFile"; 11 | 12 | const Tree = ({ children, data, onNodeClick, onUpdate }) => { 13 | const [state, dispatch] = useReducer(reducer, data); 14 | 15 | useLayoutEffect(() => { 16 | dispatch({ type: "SET_DATA", payload: data }); 17 | }, [data]); 18 | 19 | useDidMountEffect(() => { 20 | onUpdate && onUpdate(state); 21 | }, [state]); 22 | 23 | const isImparative = data && !children; 24 | 25 | return ( 26 | 27 | { 33 | onNodeClick && onNodeClick(node); 34 | }, 35 | }} 36 | > 37 | 38 | {isImparative ? ( 39 | 40 | ) : ( 41 | children 42 | )} 43 | 44 | 45 | 46 | ); 47 | }; 48 | 49 | const TreeRecusive = ({ data, parentNode }) => { 50 | return data.map((item) => { 51 | item.parentNode = parentNode; 52 | if (!parentNode) { 53 | item.parentNode = data; 54 | } 55 | if (!item.id) item.id = v4(); 56 | 57 | if (item.type === "file") { 58 | return ; 59 | } 60 | if (item.type === "folder") { 61 | return ( 62 | 63 | 64 | 65 | ); 66 | } 67 | }); 68 | }; 69 | 70 | Tree.File = File; 71 | Tree.Folder = Folder; 72 | 73 | export default Tree; 74 | -------------------------------------------------------------------------------- /src/Tree/Tree.style.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components/macro"; 2 | 3 | export const StyledTree = styled.div` 4 | line-height: 1.75; 5 | z-index: 1; 6 | 7 | .tree__input { 8 | width: auto; 9 | } 10 | `; 11 | 12 | export const ActionsWrapper = styled.div` 13 | width: 100%; 14 | 15 | display: flex; 16 | align-items: center; 17 | flex-wrap: nowrap; 18 | justify-content: space-between; 19 | 20 | .actions { 21 | display: flex; 22 | align-items: center; 23 | flex-wrap: nowrap; 24 | justify-content: space-between; 25 | opacity: 0; 26 | pointer-events: none; 27 | transition: 0.2s; 28 | 29 | > svg { 30 | cursor: pointer; 31 | margin-left: 10px; 32 | transform: scale(1); 33 | transition: 0.2s; 34 | 35 | :hover { 36 | transform: scale(1.1); 37 | } 38 | } 39 | } 40 | 41 | &:hover .actions { 42 | opacity: 1; 43 | pointer-events: all; 44 | transition: 0.2s; 45 | } 46 | `; 47 | 48 | export const StyledName = styled.div` 49 | background-color: white; 50 | display: flex; 51 | align-items: center; 52 | cursor: pointer; 53 | `; 54 | 55 | export const Collapse = styled.div` 56 | height: max-content; 57 | max-height: ${p => (p.isOpen ? "800px" : "0px")}; 58 | overflow: hidden; 59 | transition: 0.3s ease-in-out; 60 | `; 61 | 62 | export const VerticalLine = styled.section` 63 | position: relative; 64 | :before { 65 | content: ""; 66 | display: block; 67 | position: absolute; 68 | top: -2px; /* just to hide 1px peek */ 69 | left: 1px; 70 | width: 0; 71 | height: 100%; 72 | border: 1px solid #dbdbdd; 73 | z-index: -1; 74 | } 75 | `; 76 | -------------------------------------------------------------------------------- /src/Tree/TreePlaceholderInput.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { v4 } from "uuid"; 3 | import { AiOutlineFile } from "react-icons/ai"; 4 | 5 | import FILE_ICONS from "./FileIcons"; 6 | import { StyledFile } from "Tree/File/TreeFile.style"; 7 | import { FolderName } from "Tree/Folder/TreeFolder"; 8 | import { StyledFolder } from "Tree/Folder/TreeFolder.style"; 9 | 10 | const FileEdit = ({ ext, inputRef, updateExt, defaultValue, style }) => { 11 | const extension = FILE_ICONS[ext] ? FILE_ICONS[ext] : ; 12 | 13 | return ( 14 | 15 | {extension} 16 |    17 | 23 | 24 | ); 25 | }; 26 | 27 | const FolderEdit = ({ name, inputRef, defaultValue, style }) => { 28 | return ( 29 | 30 | {}} 33 | name={ 34 | 39 | } 40 | /> 41 | 42 | ); 43 | }; 44 | 45 | const PlaceholderInput = ({ 46 | type, 47 | name, 48 | onSubmit, 49 | onCancel, 50 | defaultValue, 51 | style, 52 | }) => { 53 | const [ext, setExt] = useState(""); 54 | const inputRef = useRef(); 55 | 56 | const updateExt = (e) => { 57 | let splitted = e.target.value.split("."); 58 | let ext = splitted && splitted[splitted.length - 1]; 59 | setExt(ext); 60 | }; 61 | 62 | useEffect(() => { 63 | if (!inputRef.current) return; 64 | inputRef.current.focus(); 65 | inputRef.current.addEventListener("keyup", (e) => { 66 | if (e.key === "Enter") onSubmit(e.target.value); 67 | if (e.key === "Escape") { 68 | onCancel && onCancel(); 69 | } 70 | }); 71 | }, [inputRef]); 72 | 73 | return type === "file" ? ( 74 | 81 | ) : ( 82 | 88 | ); 89 | }; 90 | 91 | export { PlaceholderInput }; 92 | -------------------------------------------------------------------------------- /src/Tree/state/TreeContext.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const defaultValue = { 4 | dispatch: null, 5 | state: null, 6 | isImparative: null, 7 | onNodeClick: () => {} 8 | }; 9 | const TreeContext = React.createContext(defaultValue); 10 | 11 | const useTreeContext = () => React.useContext(TreeContext); 12 | 13 | export { TreeContext, useTreeContext }; 14 | -------------------------------------------------------------------------------- /src/Tree/state/constants.js: -------------------------------------------------------------------------------- 1 | const createActionTypes = (name) => { 2 | return { 3 | CREATE: `${name}_CREATE`, 4 | EDIT: `${name}_EDIT`, 5 | DELETE: `${name}_DELETE`, 6 | }; 7 | }; 8 | 9 | const FILE = createActionTypes("FILE"); 10 | const FOLDER = createActionTypes("FOLDER"); 11 | 12 | export { FILE, FOLDER }; 13 | -------------------------------------------------------------------------------- /src/Tree/state/index.js: -------------------------------------------------------------------------------- 1 | export { TreeContext, useTreeContext } from './TreeContext' 2 | export { reducer } from './reducer' -------------------------------------------------------------------------------- /src/Tree/state/reducer.js: -------------------------------------------------------------------------------- 1 | import _cloneDeep from "lodash.clonedeep"; 2 | import { searchDFS, createFile, createFolder } from "utils"; 3 | import { FILE, FOLDER } from "./constants"; 4 | 5 | const reducer = (state, action) => { 6 | let newState = _cloneDeep(state); 7 | let node = null; 8 | let parent = null; 9 | if (action.payload && action.payload.id) { 10 | let foundNode = searchDFS({ 11 | data: newState, 12 | cond: (item) => { 13 | return item.id === action.payload.id; 14 | }, 15 | }); 16 | node = foundNode.item; 17 | parent = node.parentNode; 18 | } 19 | 20 | switch (action.type) { 21 | case "SET_DATA": 22 | return action.payload; 23 | 24 | case FILE.CREATE: 25 | node.files.push(createFile({ name: action.payload.name })); 26 | return newState; 27 | 28 | case FOLDER.CREATE: 29 | node.files.push(createFolder({ name: action.payload.name })); 30 | return newState; 31 | 32 | case FOLDER.EDIT: 33 | case FILE.EDIT: 34 | node.name = action.payload.name; 35 | return newState; 36 | 37 | case FOLDER.DELETE: 38 | case FILE.DELETE: 39 | if (!parent || Array.isArray(parent)) { 40 | newState = newState.filter((file) => file.id !== action.payload.id); 41 | return newState; 42 | } else { 43 | parent.files = parent.files.filter( 44 | (file) => file.id !== action.payload.id 45 | ); 46 | } 47 | return newState; 48 | 49 | default: 50 | return state; 51 | } 52 | }; 53 | 54 | export { reducer }; 55 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import App from "./App"; 5 | 6 | const rootElement = document.getElementById("root"); 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | rootElement 12 | ); 13 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | .App { 2 | padding: 10px; 3 | font-family: "PT Sans"; 4 | color: #353535; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react"; 2 | 3 | // @deprecated 4 | export const findNodeById = (nodes, id) => { 5 | let final; 6 | 7 | function findNode(nodes, id) { 8 | nodes.forEach((n) => { 9 | if (n.id === id) { 10 | final = n; 11 | return; 12 | } 13 | if (n.files) findNode(n.files, id); 14 | }); 15 | } 16 | 17 | findNode(nodes, id); 18 | 19 | return final; 20 | }; 21 | 22 | export const searchDFS = ({ data, cond, childPathKey = "files" }) => { 23 | let final = null; 24 | let parentPath = []; 25 | let parent = null; 26 | let next = null; 27 | let prev = null; 28 | 29 | const recursiveFind = (tree) => { 30 | tree.forEach((item, index) => { 31 | if (cond(item, index)) { 32 | final = item; 33 | 34 | if (parentPath) { 35 | parentPath.forEach((p) => { 36 | // check if parent has the `current item` 37 | if (p && p[childPathKey].includes(item)) { 38 | parent = p; 39 | // set next & previous indexes 40 | next = p[childPathKey][index + 1]; 41 | prev = p[childPathKey][index - 1]; 42 | } else { 43 | parent = tree; 44 | // if parent is null then check the root of the tree 45 | next = tree[index + 1]; 46 | prev = tree[index - 1]; 47 | } 48 | }); 49 | } 50 | return; 51 | } 52 | if (item[childPathKey]) { 53 | // push parent stack 54 | parentPath.push(item); 55 | recursiveFind(item[childPathKey]); 56 | } 57 | }); 58 | }; 59 | 60 | recursiveFind(data); 61 | return { 62 | parent, 63 | item: final, 64 | nextSibling: next, 65 | previousSibling: prev, 66 | }; 67 | }; 68 | 69 | export const useDidMountEffect = (func, deps) => { 70 | const didMount = useRef(false); 71 | 72 | useEffect(() => { 73 | if (didMount.current) func(); 74 | else didMount.current = true; 75 | }, deps); 76 | }; 77 | 78 | export const createFile = ({ name }) => ({ name, type: "file" }); 79 | export const createFolder = ({ name }) => ({ name, type: "folder", files: [] }); 80 | --------------------------------------------------------------------------------