├── .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 | ![image](https://github.com/nuttrtools/nested-sortable/assets/37809353/48df3748-a22e-4a5a-bd59-a07693edd433) 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 | ![image](https://github.com/nuttrtools/nested-sortable/assets/37809353/8f9d0710-946c-4068-92a4-b846f7632e14) 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 |