├── .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 |
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 |
--------------------------------------------------------------------------------