├── .eslintrc.cjs
├── .gitignore
├── LICENSE
├── README.md
├── index.html
├── index.ts
├── package-lock.json
├── package.json
├── src
├── App.css
├── App.tsx
├── Tree
│ ├── NestedSortable.tsx
│ ├── components
│ │ ├── Action
│ │ │ ├── Action.module.css
│ │ │ ├── Action.tsx
│ │ │ └── index.ts
│ │ ├── Handle
│ │ │ ├── Handle.tsx
│ │ │ └── index.ts
│ │ ├── Remove
│ │ │ ├── Remove.tsx
│ │ │ └── index.ts
│ │ ├── TreeItem
│ │ │ ├── SortableTreeItem.tsx
│ │ │ ├── TreeItem.module.css
│ │ │ ├── TreeItem.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ ├── keyboardCoordinates.ts
│ ├── types.ts
│ └── utilities.ts
├── index.css
├── index.ts
├── main.tsx
└── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | '@typescript-eslint/ban-ts-comment': 'off'
18 | },
19 | }
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2024 nuttrtools
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Nested Sortable
2 | This is a small library used for creating a sortable nested list.
3 |
4 | ## NPM package
5 | [@nuttrtools/nested-sortable](https://www.npmjs.com/package/@nuttrtools/nested-sortable)
6 |
7 | ## Screenshot
8 | 
9 |
10 | ## Usage
11 | https://github.com/nuttrtools/nested-sortable/blob/f78a8416c35cdad09f38bb161b1d90721fe5f730/src/App.tsx#L31
12 |
13 | ## Props
14 | ### Item Style ([CSSProperties](https://github.com/DefinitelyTyped/DefinitelyTyped/blob/ba3c0017958023b3b6c5fee487acfeda2273fdb9/types/react/v17/index.d.ts#L1558))
15 | Used to style list items
16 | https://github.com/nuttrtools/nested-sortable/blob/f78a8416c35cdad09f38bb161b1d90721fe5f730/src/App.tsx#L7-L9
17 |
18 | ### Collapsible (Boolean)
19 | used to enable the collapsible option
20 |
21 | ### Default Items ([TreeItem](https://github.com/nuttrtools/nested-sortable/blob/f78a8416c35cdad09f38bb161b1d90721fe5f730/src/Tree/types.ts#L4-L9)[])
22 | Used to pass the items in the sortable list
23 | #### Structure:
24 | https://github.com/nuttrtools/nested-sortable/blob/f78a8416c35cdad09f38bb161b1d90721fe5f730/src/App.tsx#L11-L27
25 |
26 | ### Indicator (Boolean)
27 | Used to add a drop indicator
28 |
29 | 
30 |
31 | ### Indentation Width (Number)
32 | Specify indentation for the nested components.
33 |
34 | Default is 50
35 |
36 | ### Removable (Boolean)
37 | Specify whether the removed feature is required or not.
38 |
39 | Default false
40 |
41 | ### *onOrderChange (Function)
42 | The function triggers when the given list of items changes (either rearranged or removed)
43 |
44 | ## Start demo
45 | ```
46 | git clone https://github.com/nuttrtools/nested-sortable
47 | cd nested-sortable && npm install
48 | npm run dev
49 | ```
50 |
51 | ## Storybook
52 | https://master--5fc05e08a4a65d0021ae0bf2.chromatic.com/?path=/story/examples-tree-sortable--all-features
53 |
54 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Nested Sortable
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/index.ts:
--------------------------------------------------------------------------------
1 | export * from './src'
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@nuttrtools/nested-sortable",
3 | "version": "0.5.4",
4 | "type": "module",
5 | "main": "./dist/ns.umd.js",
6 | "module": "./dist/ns.es.js",
7 | "types": "./dist/index.d.ts",
8 | "files": ["dist"],
9 | "license": "MIT",
10 | "repository": {
11 | "type": "git",
12 | "url": "https://github.com/nuttrtools/nested-sortable"
13 | },
14 | "scripts": {
15 | "dev": "vite",
16 | "build": "tsc && vite build",
17 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
18 | "preview": "vite preview"
19 | },
20 | "dependencies": {
21 | "@dnd-kit/core": "^6.1.0",
22 | "@dnd-kit/sortable": "^8.0.0",
23 | "classnames": "^2.5.1",
24 | "react": "^18.2.0",
25 | "react-dom": "^18.2.0"
26 | },
27 | "devDependencies": {
28 | "@types/react": "^18.2.43",
29 | "@types/react-dom": "^18.2.17",
30 | "@typescript-eslint/eslint-plugin": "^6.14.0",
31 | "@typescript-eslint/parser": "^6.14.0",
32 | "@vitejs/plugin-react": "^4.2.1",
33 | "eslint": "^8.55.0",
34 | "eslint-plugin-react-hooks": "^4.6.0",
35 | "eslint-plugin-react-refresh": "^0.4.5",
36 | "typescript": "^5.2.2",
37 | "vite": "^5.0.8",
38 | "vite-plugin-dts": "^3.7.1",
39 | "vite-plugin-lib-inject-css": "^1.3.0"
40 | },
41 | "peerDependencies": {
42 | "@types/react": "^18.2.43"
43 | },
44 | "exports": {
45 | ".": {
46 | "import": "./dist/ns.es.js",
47 | "require": "./dist/ns.umd.js",
48 | "types": "./dist/index.d.ts",
49 | "default": "./dist/ns.es.js"
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nuttrtools/nested-sortable/691e9c053fbd464830239528bf6e0329e597fba3/src/App.css
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { CSSProperties } from "react"
2 | import { NestedSortable } from "./Tree/NestedSortable"
3 | import { TreeItems } from "./Tree/types";
4 |
5 | function App() {
6 |
7 | const itemStyle: CSSProperties = {
8 | padding: 0
9 | }
10 |
11 | const items: TreeItems = [
12 | {
13 | id: 'id1',
14 | name: 'Home',
15 | children: [],
16 | },
17 | {
18 | id: 'id2',
19 | name: 'Collections',
20 | children: [
21 | {id: 'id3', name: 'Spring', children: []},
22 | {id: 'id4', name: 'Summer', children: []},
23 | {id: 'id5', name: 'Fall', children: []},
24 | {id: 'id6', name: 'Winter', children: []},
25 | ],
26 | },
27 | ];
28 |
29 | return (
30 | <>
31 | console.log(newItems)} itemStyle={itemStyle}/>
32 | >
33 | )
34 | }
35 |
36 | export default App
37 |
--------------------------------------------------------------------------------
/src/Tree/NestedSortable.tsx:
--------------------------------------------------------------------------------
1 | import {CSSProperties, useEffect, useMemo, useRef, useState} from 'react';
2 | import {createPortal} from 'react-dom';
3 | import {
4 | Announcements,
5 | DndContext,
6 | closestCenter,
7 | KeyboardSensor,
8 | PointerSensor,
9 | useSensor,
10 | useSensors,
11 | DragStartEvent,
12 | DragOverlay,
13 | DragMoveEvent,
14 | DragEndEvent,
15 | DragOverEvent,
16 | MeasuringStrategy,
17 | DropAnimation,
18 | Modifier,
19 | defaultDropAnimation,
20 | UniqueIdentifier,
21 | } from '@dnd-kit/core';
22 | import {
23 | SortableContext,
24 | arrayMove,
25 | verticalListSortingStrategy,
26 | } from '@dnd-kit/sortable';
27 |
28 | import {
29 | buildTree,
30 | flattenTree,
31 | getProjection,
32 | getChildCount,
33 | removeItem,
34 | removeChildrenOf,
35 | setProperty,
36 | } from './utilities';
37 | import type {FlattenedItem, SensorContext, TreeItems} from './types';
38 | import {sortableTreeKeyboardCoordinates} from './keyboardCoordinates';
39 | import {SortableTreeItem} from './components';
40 | import {CSS} from '@dnd-kit/utilities';
41 |
42 | const initialItems: TreeItems = [
43 | {
44 | id: 'id1',
45 | name: 'Home',
46 | children: [],
47 | },
48 | {
49 | id: 'id2',
50 | name: 'Collections',
51 | children: [
52 | {id: 'id3', name: 'Spring', children: []},
53 | {id: 'id4', name: 'Summer', children: []},
54 | {id: 'id5', name: 'Fall', children: []},
55 | {id: 'id6', name: 'Winter', children: []},
56 | ],
57 | },
58 | {
59 | id: 'id7',
60 | name: 'About Us',
61 | children: [],
62 | },
63 | {
64 | id: 'id8',
65 | name: 'My Account',
66 | children: [
67 | {id: 'id9', name: 'Addresses', children: []},
68 | {id: 'id10', name: 'Order History', children: []},
69 | ],
70 | },
71 | ];
72 |
73 | const measuring = {
74 | droppable: {
75 | strategy: MeasuringStrategy.Always,
76 | },
77 | };
78 |
79 | const dropAnimationConfig: DropAnimation = {
80 | keyframes({transform}) {
81 | return [
82 | {opacity: 1, transform: CSS.Transform.toString(transform.initial)},
83 | {
84 | opacity: 0,
85 | transform: CSS.Transform.toString({
86 | ...transform.final,
87 | x: transform.final.x + 5,
88 | y: transform.final.y + 5,
89 | }),
90 | },
91 | ];
92 | },
93 | easing: 'ease-out',
94 | sideEffects({active}) {
95 | active.node.animate([{opacity: 0}, {opacity: 1}], {
96 | duration: defaultDropAnimation.duration,
97 | easing: defaultDropAnimation.easing,
98 | });
99 | },
100 | };
101 |
102 | interface Props {
103 | collapsible?: boolean;
104 | defaultItems?: TreeItems;
105 | indentationWidth?: number;
106 | indicator?: boolean;
107 | removable?: boolean;
108 | itemStyle?: CSSProperties;
109 | actionNode?: JSX.Element;
110 | onOrderChange: (items: TreeItems) => void;
111 | }
112 |
113 | export function NestedSortable({
114 | collapsible,
115 | defaultItems = initialItems,
116 | indicator = false,
117 | indentationWidth = 50,
118 | removable,
119 | onOrderChange,
120 | itemStyle = {},
121 | actionNode
122 | }: Props) {
123 | const [items, setItems] = useState(() => defaultItems);
124 | const [activeId, setActiveId] = useState(null);
125 | const [overId, setOverId] = useState(null);
126 | const [offsetLeft, setOffsetLeft] = useState(0);
127 | const [currentPosition, setCurrentPosition] = useState<{
128 | parentId: UniqueIdentifier | null;
129 | overId: UniqueIdentifier;
130 | } | null>(null);
131 |
132 | const flattenedItems = useMemo(() => {
133 | const flattenedTree = flattenTree(items);
134 | const collapsedItems = flattenedTree.reduce(
135 | (acc, {children, collapsed, id}) =>
136 | collapsed && children.length ? [...acc, id] : acc,
137 | []
138 | );
139 |
140 |
141 | return removeChildrenOf(
142 | flattenedTree,
143 | activeId ? [activeId, ...collapsedItems] : collapsedItems
144 | );
145 | }, [activeId, items]);
146 | const projected =
147 | activeId && overId
148 | ? getProjection(
149 | flattenedItems,
150 | activeId,
151 | overId,
152 | offsetLeft,
153 | indentationWidth
154 | )
155 | : null;
156 | const sensorContext: SensorContext = useRef({
157 | items: flattenedItems,
158 | offset: offsetLeft,
159 | });
160 | const [coordinateGetter] = useState(() =>
161 | sortableTreeKeyboardCoordinates(sensorContext, indicator, indentationWidth)
162 | );
163 | const sensors = useSensors(
164 | useSensor(PointerSensor),
165 | useSensor(KeyboardSensor, {
166 | coordinateGetter,
167 | })
168 | );
169 |
170 | const sortedIds = useMemo(() => flattenedItems.map(({id}) => id), [
171 | flattenedItems,
172 | ]);
173 | const activeItem = activeId
174 | ? flattenedItems.find(({id}) => id === activeId)
175 | : null;
176 |
177 | useEffect(() => {
178 | sensorContext.current = {
179 | items: flattenedItems,
180 | offset: offsetLeft,
181 | };
182 | }, [flattenedItems, offsetLeft]);
183 |
184 | const announcements: Announcements = {
185 | onDragStart({active}) {
186 | return `Picked up ${active.id}.`;
187 | },
188 | onDragMove({active, over}) {
189 | return getMovementAnnouncement('onDragMove', active.id, over?.id);
190 | },
191 | onDragOver({active, over}) {
192 | return getMovementAnnouncement('onDragOver', active.id, over?.id);
193 | },
194 | onDragEnd({active, over}) {
195 | return getMovementAnnouncement('onDragEnd', active.id, over?.id);
196 | },
197 | onDragCancel({active}) {
198 | return `Moving was cancelled. ${active.id} was dropped in its original position.`;
199 | },
200 | };
201 |
202 | return (
203 |
214 |
215 | {flattenedItems.map(({id, name, children, collapsed, depth}) => (
216 | handleCollapse(id)
227 | : undefined
228 | }
229 | onRemove={removable ? () => handleRemove(id) : undefined}
230 | itemStyle={itemStyle}
231 | actionNode={actionNode}
232 | />
233 | ))}
234 | {createPortal(
235 |
239 | {activeId && activeItem ? (
240 |
250 | ) : null}
251 | ,
252 | document.body
253 | )}
254 |
255 |
256 | );
257 |
258 | function handleDragStart({active: {id: activeId}}: DragStartEvent) {
259 | setActiveId(activeId);
260 | setOverId(activeId);
261 |
262 | const activeItem = flattenedItems.find(({id}) => id === activeId);
263 |
264 | if (activeItem) {
265 | setCurrentPosition({
266 | parentId: activeItem.parentId,
267 | overId: activeId,
268 | });
269 | }
270 |
271 | document.body.style.setProperty('cursor', 'grabbing');
272 | }
273 |
274 | function handleDragMove({delta}: DragMoveEvent) {
275 | setOffsetLeft(delta.x);
276 | }
277 |
278 | function handleDragOver({over}: DragOverEvent) {
279 | setOverId(over?.id ?? null);
280 | }
281 |
282 | function handleDragEnd({active, over}: DragEndEvent) {
283 | resetState();
284 |
285 | if (projected && over) {
286 | const {depth, parentId} = projected;
287 | const clonedItems: FlattenedItem[] = JSON.parse(
288 | JSON.stringify(flattenTree(items))
289 | );
290 | const overIndex = clonedItems.findIndex(({id}) => id === over.id);
291 | const activeIndex = clonedItems.findIndex(({id}) => id === active.id);
292 | const activeTreeItem = clonedItems[activeIndex];
293 |
294 | clonedItems[activeIndex] = {...activeTreeItem, depth, parentId};
295 |
296 | const sortedItems = arrayMove(clonedItems, activeIndex, overIndex);
297 | const newItems = buildTree(sortedItems);
298 |
299 | onOrderChange(newItems)
300 | setItems(newItems);
301 | }
302 | }
303 |
304 | function handleDragCancel() {
305 | resetState();
306 | }
307 |
308 | function resetState() {
309 | setOverId(null);
310 | setActiveId(null);
311 | setOffsetLeft(0);
312 | setCurrentPosition(null);
313 |
314 | document.body.style.setProperty('cursor', '');
315 | }
316 |
317 | function handleRemove(id: UniqueIdentifier) {
318 | setItems((items) => {
319 | const newItems = removeItem(items, id)
320 | onOrderChange(newItems)
321 | return newItems
322 | });
323 | }
324 |
325 | function handleCollapse(id: UniqueIdentifier) {
326 | setItems((items) =>
327 | setProperty(items, id, 'collapsed', (value) => {
328 | return !value;
329 | })
330 | );
331 | }
332 |
333 | function getMovementAnnouncement(
334 | eventName: string,
335 | activeId: UniqueIdentifier,
336 | overId?: UniqueIdentifier
337 | ) {
338 | if (overId && projected) {
339 | if (eventName !== 'onDragEnd') {
340 | if (
341 | currentPosition &&
342 | projected.parentId === currentPosition.parentId &&
343 | overId === currentPosition.overId
344 | ) {
345 | return;
346 | } else {
347 | setCurrentPosition({
348 | parentId: projected.parentId,
349 | overId,
350 | });
351 | }
352 | }
353 |
354 | const clonedItems: FlattenedItem[] = JSON.parse(
355 | JSON.stringify(flattenTree(items))
356 | );
357 | const overIndex = clonedItems.findIndex(({id}) => id === overId);
358 | const activeIndex = clonedItems.findIndex(({id}) => id === activeId);
359 | const sortedItems = arrayMove(clonedItems, activeIndex, overIndex);
360 |
361 | const previousItem = sortedItems[overIndex - 1];
362 |
363 | let announcement;
364 | const movedVerb = eventName === 'onDragEnd' ? 'dropped' : 'moved';
365 | const nestedVerb = eventName === 'onDragEnd' ? 'dropped' : 'nested';
366 |
367 | if (!previousItem) {
368 | const nextItem = sortedItems[overIndex + 1];
369 | announcement = `${activeId} was ${movedVerb} before ${nextItem.id}.`;
370 | } else {
371 | if (projected.depth > previousItem.depth) {
372 | announcement = `${activeId} was ${nestedVerb} under ${previousItem.id}.`;
373 | } else {
374 | let previousSibling: FlattenedItem | undefined = previousItem;
375 | while (previousSibling && projected.depth < previousSibling.depth) {
376 | const parentId: UniqueIdentifier | null = previousSibling.parentId;
377 | previousSibling = sortedItems.find(({id}) => id === parentId);
378 | }
379 |
380 | if (previousSibling) {
381 | announcement = `${activeId} was ${movedVerb} after ${previousSibling.id}.`;
382 | }
383 | }
384 | }
385 |
386 | return announcement;
387 | }
388 |
389 | return;
390 | }
391 | }
392 |
393 | const adjustTranslate: Modifier = ({transform}) => {
394 | return {
395 | ...transform,
396 | y: transform.y - 25,
397 | };
398 | };
399 |
--------------------------------------------------------------------------------
/src/Tree/components/Action/Action.module.css:
--------------------------------------------------------------------------------
1 |
2 | .Action {
3 | display: flex;
4 | width: 12px;
5 | padding: 15px;
6 | align-items: center;
7 | justify-content: center;
8 | flex: 0 0 auto;
9 | touch-action: none;
10 | cursor: var(--cursor, pointer);
11 | border-radius: 5px;
12 | border: none;
13 | outline: none;
14 | appearance: none;
15 | background-color: transparent;
16 | -webkit-tap-highlight-color: transparent;
17 |
18 | @media (hover: hover) {
19 | &:hover {
20 | background-color: var(--action-background, rgba(0, 0, 0, 0.05));
21 |
22 | svg {
23 | fill: #6f7b88;
24 | }
25 | }
26 | }
27 |
28 | svg {
29 | flex: 0 0 auto;
30 | margin: auto;
31 | height: 100%;
32 | overflow: visible;
33 | fill: #919eab;
34 | }
35 |
36 | &:active {
37 | background-color: var(--background, rgba(0, 0, 0, 0.05));
38 |
39 | svg {
40 | fill: var(--fill, #788491);
41 | }
42 | }
43 |
44 | &:focus-visible {
45 | outline: none;
46 | box-shadow: 0 0 0 2px rgba(255, 255, 255, 0),
47 | 0 0px 0px 2px $focused-outline-color;
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/src/Tree/components/Action/Action.tsx:
--------------------------------------------------------------------------------
1 | import React, {forwardRef, CSSProperties} from 'react';
2 | import classNames from 'classnames';
3 |
4 | import styles from './Action.module.css';
5 |
6 | export interface Props extends React.HTMLAttributes {
7 | active?: {
8 | fill: string;
9 | background: string;
10 | };
11 | cursor?: CSSProperties['cursor'];
12 | }
13 |
14 | export const Action = forwardRef(
15 | ({active, className, cursor, style, ...props}, ref) => {
16 | return (
17 |
31 | );
32 | }
33 | );
34 |
--------------------------------------------------------------------------------
/src/Tree/components/Action/index.ts:
--------------------------------------------------------------------------------
1 | export {Action} from './Action';
2 | export type {Props as ActionProps} from './Action';
3 |
--------------------------------------------------------------------------------
/src/Tree/components/Handle/Handle.tsx:
--------------------------------------------------------------------------------
1 | import {forwardRef} from 'react';
2 |
3 | import {Action, ActionProps} from '../Action';
4 |
5 | export const Handle = forwardRef(
6 | (props, ref) => {
7 | return (
8 |
14 |
17 |
18 | );
19 | }
20 | );
21 |
--------------------------------------------------------------------------------
/src/Tree/components/Handle/index.ts:
--------------------------------------------------------------------------------
1 | export {Handle} from './Handle';
2 |
--------------------------------------------------------------------------------
/src/Tree/components/Remove/Remove.tsx:
--------------------------------------------------------------------------------
1 | import {Action, ActionProps} from '../Action';
2 |
3 | export function Remove(props: ActionProps) {
4 | return (
5 |
12 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/src/Tree/components/Remove/index.ts:
--------------------------------------------------------------------------------
1 | export {Remove} from './Remove';
2 |
--------------------------------------------------------------------------------
/src/Tree/components/TreeItem/SortableTreeItem.tsx:
--------------------------------------------------------------------------------
1 | import {CSSProperties} from 'react';
2 | import type {UniqueIdentifier} from '@dnd-kit/core';
3 | import {AnimateLayoutChanges, useSortable} from '@dnd-kit/sortable';
4 | import {CSS} from '@dnd-kit/utilities';
5 |
6 | import {TreeItem, Props as TreeItemProps} from './TreeItem';
7 | import {iOS} from '../../utilities';
8 |
9 | interface Props extends TreeItemProps {
10 | id: UniqueIdentifier;
11 | itemStyle?: CSSProperties,
12 | actionNode?: JSX.Element
13 | }
14 |
15 | const animateLayoutChanges: AnimateLayoutChanges = ({isSorting, wasDragging}) =>
16 | isSorting || wasDragging ? false : true;
17 |
18 | export function SortableTreeItem({id, depth, itemStyle = {}, actionNode, ...props}: Props) {
19 | const {
20 | attributes,
21 | isDragging,
22 | isSorting,
23 | listeners,
24 | setDraggableNodeRef,
25 | setDroppableNodeRef,
26 | transform,
27 | transition,
28 | } = useSortable({
29 | id,
30 | animateLayoutChanges,
31 | });
32 | const style: CSSProperties = {
33 | transform: CSS.Translate.toString(transform),
34 | transition,
35 | ...itemStyle
36 | };
37 |
38 | return (
39 |
55 | );
56 | }
57 |
--------------------------------------------------------------------------------
/src/Tree/components/TreeItem/TreeItem.module.css:
--------------------------------------------------------------------------------
1 | .Wrapper {
2 | list-style: none;
3 | box-sizing: border-box;
4 | padding-left: var(--spacing);
5 | margin-bottom: -1px;
6 |
7 | &.clone {
8 | display: inline-block;
9 | pointer-events: none;
10 | padding: 0;
11 | padding-left: 10px;
12 | padding-top: 5px;
13 |
14 | .TreeItem {
15 | --vertical-padding: 5px;
16 |
17 | padding-right: 24px;
18 | border-radius: 4px;
19 | box-shadow: 0px 15px 15px 0 rgba(34, 33, 81, 0.1);
20 | }
21 | }
22 |
23 | &.ghost {
24 | &.indicator {
25 | opacity: 1;
26 | position: relative;
27 | z-index: 1;
28 | margin-bottom: -1px;
29 |
30 | .TreeItem {
31 | position: relative;
32 | padding: 0;
33 | height: 8px;
34 | border-color: #2389ff;
35 | background-color: #56a1f8;
36 |
37 | &:before {
38 | position: absolute;
39 | left: -8px;
40 | top: -4px;
41 | display: block;
42 | content: '';
43 | width: 12px;
44 | height: 12px;
45 | border-radius: 50%;
46 | border: 1px solid #2389ff;
47 | background-color: #ffffff;
48 | }
49 |
50 | > * {
51 | /* Items are hidden using height and opacity to retain focus */
52 | opacity: 0;
53 | height: 0;
54 | }
55 | }
56 | }
57 |
58 | &:not(.indicator) {
59 | opacity: 0.5;
60 | }
61 |
62 | .TreeItem > * {
63 | box-shadow: none;
64 | background-color: transparent;
65 | }
66 | }
67 | }
68 |
69 | .TreeItem {
70 | --vertical-padding: 10px;
71 |
72 | position: relative;
73 | display: flex;
74 | align-items: center;
75 | padding: var(--vertical-padding) 10px;
76 | background-color: #fff;
77 | border: 1px solid #dedede;
78 | color: #222;
79 | box-sizing: border-box;
80 | }
81 |
82 | .Text {
83 | flex-grow: 1;
84 | padding-left: 0.5rem;
85 | white-space: nowrap;
86 | text-overflow: ellipsis;
87 | overflow: hidden;
88 | }
89 |
90 | .Count {
91 | position: absolute;
92 | top: -10px;
93 | right: -10px;
94 | display: flex;
95 | align-items: center;
96 | justify-content: center;
97 | width: 24px;
98 | height: 24px;
99 | border-radius: 50%;
100 | background-color: #2389ff;
101 | font-size: 0.8rem;
102 | font-weight: 600;
103 | color: #fff;
104 | }
105 |
106 | .disableInteraction {
107 | pointer-events: none;
108 | }
109 |
110 | .disableSelection,
111 | .clone {
112 | .Text,
113 | .Count {
114 | user-select: none;
115 | -webkit-user-select: none;
116 | }
117 | }
118 |
119 | .Collapse {
120 | svg {
121 | transition: transform 250ms ease;
122 | }
123 |
124 | &.collapsed svg {
125 | transform: rotate(-90deg);
126 | }
127 | }
128 |
129 |
--------------------------------------------------------------------------------
/src/Tree/components/TreeItem/TreeItem.tsx:
--------------------------------------------------------------------------------
1 | import React, {forwardRef, HTMLAttributes, FC} from 'react';
2 | import classNames from 'classnames';
3 |
4 | import { Action } from '../Action';
5 | import { Handle } from '../Handle';
6 | import { Remove } from '../Remove';
7 | import styles from './TreeItem.module.css';
8 | import { UniqueIdentifier } from '@dnd-kit/core';
9 |
10 | export interface Props extends Omit, 'id'> {
11 | id: UniqueIdentifier;
12 | childCount?: number;
13 | clone?: boolean;
14 | collapsed?: boolean;
15 | depth: number;
16 | disableInteraction?: boolean;
17 | disableSelection?: boolean;
18 | ghost?: boolean;
19 | handleProps?: any;
20 | indicator?: boolean;
21 | indentationWidth: number;
22 | text: string;
23 | onCollapse?(): void;
24 | onRemove?(): void;
25 | wrapperRef?(node: HTMLLIElement): void;
26 | ActionNode?: JSX.Element
27 | }
28 |
29 | export const TreeItem = forwardRef(
30 | (
31 | {
32 | id,
33 | childCount,
34 | clone,
35 | depth,
36 | disableSelection,
37 | disableInteraction,
38 | ghost,
39 | handleProps,
40 | indentationWidth,
41 | indicator,
42 | collapsed,
43 | onCollapse,
44 | onRemove,
45 | style,
46 | text,
47 | wrapperRef,
48 | ActionNode,
49 | ...props
50 | },
51 | ref
52 | ) => {
53 | return (
54 |
71 |
72 |
73 | {onCollapse && (
74 |
81 | {collapseIcon}
82 |
83 | )}
84 |
{text}
85 | {
86 | ActionNode &&
87 | }
88 | {!clone && onRemove && }
89 | {clone && childCount && childCount > 1 ? (
90 | {childCount}
91 | ) : null}
92 |
93 |
94 | );
95 | }
96 | );
97 |
98 | const collapseIcon = (
99 |
102 | );
103 |
--------------------------------------------------------------------------------
/src/Tree/components/TreeItem/index.ts:
--------------------------------------------------------------------------------
1 | export {TreeItem} from './TreeItem';
2 | export {SortableTreeItem} from './SortableTreeItem';
3 |
--------------------------------------------------------------------------------
/src/Tree/components/index.ts:
--------------------------------------------------------------------------------
1 | export {TreeItem, SortableTreeItem} from './TreeItem';
2 |
--------------------------------------------------------------------------------
/src/Tree/keyboardCoordinates.ts:
--------------------------------------------------------------------------------
1 | import {
2 | closestCorners,
3 | getFirstCollision,
4 | KeyboardCode,
5 | KeyboardCoordinateGetter,
6 | DroppableContainer,
7 | } from '@dnd-kit/core';
8 |
9 | import type {SensorContext} from './types';
10 | import {getProjection} from './utilities';
11 |
12 | const directions: string[] = [
13 | KeyboardCode.Down,
14 | KeyboardCode.Right,
15 | KeyboardCode.Up,
16 | KeyboardCode.Left,
17 | ];
18 |
19 | const horizontal: string[] = [KeyboardCode.Left, KeyboardCode.Right];
20 |
21 | export const sortableTreeKeyboardCoordinates: (
22 | context: SensorContext,
23 | indicator: boolean,
24 | indentationWidth: number
25 | ) => KeyboardCoordinateGetter = (context, indicator, indentationWidth) => (
26 | event,
27 | {
28 | currentCoordinates,
29 | context: {active, over, collisionRect, droppableRects, droppableContainers},
30 | }
31 | ) => {
32 | if (directions.includes(event.code)) {
33 | if (!active || !collisionRect) {
34 | return;
35 | }
36 |
37 | event.preventDefault();
38 |
39 | const {
40 | current: {items, offset},
41 | } = context;
42 |
43 | if (horizontal.includes(event.code) && over?.id) {
44 | const {depth, maxDepth, minDepth} = getProjection(
45 | items,
46 | active.id,
47 | over.id,
48 | offset,
49 | indentationWidth
50 | );
51 |
52 | switch (event.code) {
53 | case KeyboardCode.Left:
54 | if (depth > minDepth) {
55 | return {
56 | ...currentCoordinates,
57 | x: currentCoordinates.x - indentationWidth,
58 | };
59 | }
60 | break;
61 | case KeyboardCode.Right:
62 | if (depth < maxDepth) {
63 | return {
64 | ...currentCoordinates,
65 | x: currentCoordinates.x + indentationWidth,
66 | };
67 | }
68 | break;
69 | }
70 |
71 | return undefined;
72 | }
73 |
74 | const containers: DroppableContainer[] = [];
75 |
76 | droppableContainers.forEach((container) => {
77 | if (container?.disabled || container.id === over?.id) {
78 | return;
79 | }
80 |
81 | const rect = droppableRects.get(container.id);
82 |
83 | if (!rect) {
84 | return;
85 | }
86 |
87 | switch (event.code) {
88 | case KeyboardCode.Down:
89 | if (collisionRect.top < rect.top) {
90 | containers.push(container);
91 | }
92 | break;
93 | case KeyboardCode.Up:
94 | if (collisionRect.top > rect.top) {
95 | containers.push(container);
96 | }
97 | break;
98 | }
99 | });
100 |
101 | const collisions = closestCorners({
102 | active,
103 | collisionRect,
104 | pointerCoordinates: null,
105 | droppableRects,
106 | droppableContainers: containers,
107 | });
108 | let closestId = getFirstCollision(collisions, 'id');
109 |
110 | if (closestId === over?.id && collisions.length > 1) {
111 | closestId = collisions[1].id;
112 | }
113 |
114 | if (closestId && over?.id) {
115 | const activeRect = droppableRects.get(active.id);
116 | const newRect = droppableRects.get(closestId);
117 | const newDroppable = droppableContainers.get(closestId);
118 |
119 | if (activeRect && newRect && newDroppable) {
120 | const newIndex = items.findIndex(({id}) => id === closestId);
121 | const newItem = items[newIndex];
122 | const activeIndex = items.findIndex(({id}) => id === active.id);
123 | const activeItem = items[activeIndex];
124 |
125 | if (newItem && activeItem) {
126 | const {depth} = getProjection(
127 | items,
128 | active.id,
129 | closestId,
130 | (newItem.depth - activeItem.depth) * indentationWidth,
131 | indentationWidth
132 | );
133 | const isBelow = newIndex > activeIndex;
134 | const modifier = isBelow ? 1 : -1;
135 | const offset = indicator
136 | ? (collisionRect.height - activeRect.height) / 2
137 | : 0;
138 |
139 | const newCoordinates = {
140 | x: newRect.left + depth * indentationWidth,
141 | y: newRect.top + modifier * offset,
142 | };
143 |
144 | return newCoordinates;
145 | }
146 | }
147 | }
148 | }
149 |
150 | return undefined;
151 | };
152 |
--------------------------------------------------------------------------------
/src/Tree/types.ts:
--------------------------------------------------------------------------------
1 | import type {MutableRefObject} from 'react';
2 | import type {UniqueIdentifier} from '@dnd-kit/core';
3 |
4 | export interface TreeItem {
5 | id: UniqueIdentifier;
6 | name: string;
7 | children: TreeItem[];
8 | collapsed?: boolean;
9 | }
10 |
11 | export type TreeItems = TreeItem[];
12 |
13 | export interface FlattenedItem extends TreeItem {
14 | parentId: UniqueIdentifier | null;
15 | depth: number;
16 | index: number;
17 | }
18 |
19 | export type SensorContext = MutableRefObject<{
20 | items: FlattenedItem[];
21 | offset: number;
22 | }>;
23 |
--------------------------------------------------------------------------------
/src/Tree/utilities.ts:
--------------------------------------------------------------------------------
1 | import type {UniqueIdentifier} from '@dnd-kit/core';
2 | import {arrayMove} from '@dnd-kit/sortable';
3 |
4 | import type {FlattenedItem, TreeItem, TreeItems} from './types';
5 |
6 | export const iOS = /iPad|iPhone|iPod/.test(navigator.platform);
7 |
8 | function getDragDepth(offset: number, indentationWidth: number) {
9 | return Math.round(offset / indentationWidth);
10 | }
11 |
12 | export function getProjection(
13 | items: FlattenedItem[],
14 | activeId: UniqueIdentifier,
15 | overId: UniqueIdentifier,
16 | dragOffset: number,
17 | indentationWidth: number
18 | ) {
19 | const overItemIndex = items.findIndex(({id}) => id === overId);
20 | const activeItemIndex = items.findIndex(({id}) => id === activeId);
21 | const activeItem = items[activeItemIndex];
22 | const newItems = arrayMove(items, activeItemIndex, overItemIndex);
23 | const previousItem = newItems[overItemIndex - 1];
24 | const nextItem = newItems[overItemIndex + 1];
25 | const dragDepth = getDragDepth(dragOffset, indentationWidth);
26 | const projectedDepth = activeItem.depth + dragDepth;
27 | const maxDepth = getMaxDepth({
28 | previousItem,
29 | });
30 | const minDepth = getMinDepth({nextItem});
31 | let depth = projectedDepth;
32 |
33 | if (projectedDepth >= maxDepth) {
34 | depth = maxDepth;
35 | } else if (projectedDepth < minDepth) {
36 | depth = minDepth;
37 | }
38 |
39 | return {depth, maxDepth, minDepth, parentId: getParentId()};
40 |
41 | function getParentId() {
42 | if (depth === 0 || !previousItem) {
43 | return null;
44 | }
45 |
46 | if (depth === previousItem.depth) {
47 | return previousItem.parentId;
48 | }
49 |
50 | if (depth > previousItem.depth) {
51 | return previousItem.id;
52 | }
53 |
54 | const newParent = newItems
55 | .slice(0, overItemIndex)
56 | .reverse()
57 | .find((item) => item.depth === depth)?.parentId;
58 |
59 | return newParent ?? null;
60 | }
61 | }
62 |
63 | function getMaxDepth({previousItem}: {previousItem: FlattenedItem}) {
64 | if (previousItem) {
65 | return previousItem.depth + 1;
66 | }
67 |
68 | return 0;
69 | }
70 |
71 | function getMinDepth({nextItem}: {nextItem: FlattenedItem}) {
72 | if (nextItem) {
73 | return nextItem.depth;
74 | }
75 |
76 | return 0;
77 | }
78 |
79 | function flatten(
80 | items: TreeItems,
81 | parentId: UniqueIdentifier | null = null,
82 | depth = 0
83 | ): FlattenedItem[] {
84 | return items.reduce((acc, item, index) => {
85 | return [
86 | ...acc,
87 | {...item, parentId, depth, index},
88 | ...flatten(item.children, item.id, depth + 1),
89 | ];
90 | }, []);
91 | }
92 |
93 | export function flattenTree(items: TreeItems): FlattenedItem[] {
94 | return flatten(items);
95 | }
96 |
97 | export function buildTree(flattenedItems: FlattenedItem[]): TreeItems {
98 | const root: TreeItem = {id: 'root', name: 'root', children: []};
99 | const nodes: Record = {[root.id]: root};
100 | const items = flattenedItems.map((item) => ({...item, children: []}));
101 |
102 | for (const item of items) {
103 | const {id, children, name} = item;
104 | const parentId = item.parentId ?? root.id;
105 | const parent = nodes[parentId] ?? findItem(items, parentId);
106 |
107 | nodes[id] = {id, children, name};
108 | parent.children.push(item);
109 | }
110 |
111 | return root.children;
112 | }
113 |
114 | export function findItem(items: TreeItem[], itemId: UniqueIdentifier) {
115 | return items.find(({id}) => id === itemId);
116 | }
117 |
118 | export function findItemDeep(
119 | items: TreeItems,
120 | itemId: UniqueIdentifier
121 | ): TreeItem | undefined {
122 | for (const item of items) {
123 | const {id, children} = item;
124 |
125 | if (id === itemId) {
126 | return item;
127 | }
128 |
129 | if (children.length) {
130 | const child = findItemDeep(children, itemId);
131 |
132 | if (child) {
133 | return child;
134 | }
135 | }
136 | }
137 |
138 | return undefined;
139 | }
140 |
141 | export function removeItem(items: TreeItems, id: UniqueIdentifier) {
142 | const newItems = [];
143 |
144 | for (const item of items) {
145 | if (item.id === id) {
146 | continue;
147 | }
148 |
149 | if (item.children.length) {
150 | item.children = removeItem(item.children, id);
151 | }
152 |
153 | newItems.push(item);
154 | }
155 |
156 | return newItems;
157 | }
158 |
159 | export function setProperty(
160 | items: TreeItems,
161 | id: UniqueIdentifier,
162 | property: T,
163 | setter: (value: TreeItem[T]) => TreeItem[T]
164 | ) {
165 | for (const item of items) {
166 | if (item.id === id) {
167 | item[property] = setter(item[property]);
168 | continue;
169 | }
170 |
171 | if (item.children.length) {
172 | item.children = setProperty(item.children, id, property, setter);
173 | }
174 | }
175 |
176 | return [...items];
177 | }
178 |
179 | function countChildren(items: TreeItem[], count = 0): number {
180 | return items.reduce((acc, {children}) => {
181 | if (children.length) {
182 | return countChildren(children, acc + 1);
183 | }
184 |
185 | return acc + 1;
186 | }, count);
187 | }
188 |
189 | export function getChildCount(items: TreeItems, id: UniqueIdentifier) {
190 | const item = findItemDeep(items, id);
191 |
192 | return item ? countChildren(item.children) : 0;
193 | }
194 |
195 | export function removeChildrenOf(
196 | items: FlattenedItem[],
197 | ids: UniqueIdentifier[]
198 | ) {
199 | const excludeParentIds = [...ids];
200 |
201 | return items.filter((item) => {
202 | if (item.parentId && excludeParentIds.includes(item.parentId)) {
203 | if (item.children.length) {
204 | excludeParentIds.push(item.id);
205 | }
206 | return false;
207 | }
208 |
209 | return true;
210 | });
211 | }
212 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nuttrtools/nested-sortable/691e9c053fbd464830239528bf6e0329e597fba3/src/index.css
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { NestedSortable } from './Tree/NestedSortable';
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App.tsx'
4 | import './index.css'
5 |
6 | ReactDOM.createRoot(document.getElementById('root')!).render(
7 |
8 |
9 | ,
10 | )
11 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import path from 'node:path';
4 | import { libInjectCss } from 'vite-plugin-lib-inject-css'
5 | import dts from "vite-plugin-dts"
6 |
7 | // https://vitejs.dev/config/
8 | export default defineConfig({
9 | plugins: [react(), libInjectCss(),dts({ include: "src" })],
10 | build: {
11 | lib: {
12 | entry: path.resolve(__dirname, 'src/index.ts'),
13 | name: 'nested-sortable',
14 | formats: ['es', 'umd'],
15 | fileName: (format) => `ns.${format}.js`,
16 | },
17 | rollupOptions: {
18 | external: ['react', 'react-dom', 'styled-components'],
19 | output: {
20 | globals: {
21 | react: 'React',
22 | 'react-dom': 'ReactDOM',
23 | 'styled-components': 'styled',
24 | },
25 | },
26 | },
27 | },
28 | })
29 |
--------------------------------------------------------------------------------