├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── README.md ├── app-demo.gif ├── assets └── index.html ├── package.json ├── src ├── consts.ts ├── draggingHandler.ts ├── index.ts ├── startDraggingHandler.ts ├── stopDraggingHandler.ts ├── types.ts └── utils │ ├── accessElementTransitionProperty.ts │ ├── fixContainerHeight.ts │ ├── getDOMNodePosition.ts │ ├── getNextListItem.ts │ ├── getPrevListItem.ts │ ├── insertDOMNodeAfter.ts │ ├── removeDOMNode.ts │ └── swapDOMNodes.ts ├── static └── styles.css ├── tsconfig.json ├── webpack.config.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "@typescript-eslint" 5 | ], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "rules": { 12 | "semi": ["error", "never"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | dist 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # native-js-dnd-list 2 | 3 | 👾 Drag'n'Drop list written in TypeScript with zero dependencies. Supports mobile devices 4 | 5 | Demo: https://loonywizard.github.io/dnd-list/ 6 | 7 | ![](app-demo.gif) 8 | 9 | ## Project setup 10 | 11 | ### Install dependencies 12 | ``` 13 | yarn 14 | ``` 15 | 16 | ### Run 17 | ``` 18 | yarn dev 19 | ``` 20 | 21 | ### Lint 22 | ``` 23 | yarn lint 24 | ``` 25 | -------------------------------------------------------------------------------- /app-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loonywizard/native-js-dnd-list/c069c54e16870580854534eab5c09f06a82362cc/app-demo.gif -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TypeScript Drag'n'Drop list 5 | 6 | 7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 | Hello 👋🏻 15 |
(id: 0)
16 |
17 |
18 |
19 | This is Drag'n'Drop list written in TypeScript with zero dependecies 20 |
(id: 1)
21 |
22 |
23 |
24 | Just drag and drop items to see how it works! 25 |
(id: 2)
26 |
27 |
28 |
29 | If you're interesting in source code, check my 30 | GitHub repo 31 |
(id: 3)
32 |
33 |
34 |
35 | Found any bugs or have a suggestion? Please, add an 36 | Issue! 37 |
(id: 4)
38 |
39 |
40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@loonywizard/native-js-dnd-list", 3 | "version": "2.0.0", 4 | "license": "MIT", 5 | "description": "TypeScript drag'n'drop list without dependencies", 6 | "main": "src/index.js", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/loonywizard/native-js-dnd-list.git" 10 | }, 11 | "author": "Vladimir Nikitin vladimirsuperwizard@gmail.com", 12 | "homepage": "https://github.com/loonywizard/native-js-dnd-list", 13 | "scripts": { 14 | "dev": "NODE_ENV=development webpack serve", 15 | "build": "NODE_ENV=production webpack", 16 | "lint": "eslint src --ext .ts" 17 | }, 18 | "devDependencies": { 19 | "@babel/core": "^7.12.9", 20 | "@babel/preset-env": "^7.12.7", 21 | "@babel/preset-typescript": "^7.12.7", 22 | "@types/copy-webpack-plugin": "^6.4.0", 23 | "@types/html-webpack-plugin": "^3.2.4", 24 | "@types/node": "^14.14.28", 25 | "@types/webpack": "^4.41.26", 26 | "@types/webpack-dev-server": "^3.11.1", 27 | "@typescript-eslint/eslint-plugin": "^4.8.2", 28 | "@typescript-eslint/parser": "^4.8.2", 29 | "babel-loader": "^8.2.1", 30 | "copy-webpack-plugin": "^7.0.0", 31 | "eslint": "^7.14.0", 32 | "eslint-plugin-import": "^2.22.1", 33 | "fork-ts-checker-webpack-plugin": "^6.1.0", 34 | "html-webpack-plugin": "5.0.0-alpha.4", 35 | "ts-loader": "^8.0.11", 36 | "ts-node": "^9.1.1", 37 | "typescript": "^4.1.2", 38 | "webpack": "^5.6.0", 39 | "webpack-cli": "^4.2.0", 40 | "webpack-dev-server": "^3.11.0" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/consts.ts: -------------------------------------------------------------------------------- 1 | import { IAppState } from './types' 2 | 3 | /* 4 | * Height of divider element in pixels 5 | */ 6 | const DIVIDER_HEIGHT = 10 7 | 8 | const INITIAL_APP_STATE: IAppState = { 9 | isMouseDown: false, 10 | isDragging: false, 11 | draggingHasStarted: false, 12 | 13 | mouseOffsetX: null, 14 | mouseOffsetY: null, 15 | 16 | savedDividerElement: null, 17 | draggingItem: null, 18 | } 19 | 20 | export { 21 | DIVIDER_HEIGHT, 22 | INITIAL_APP_STATE, 23 | } 24 | -------------------------------------------------------------------------------- /src/draggingHandler.ts: -------------------------------------------------------------------------------- 1 | import { getDOMNodePosition } from './utils/getDOMNodePosition' 2 | import { swapDOMNodes } from './utils/swapDOMNodes' 3 | import { getPrevListItem } from './utils/getPrevListItem' 4 | import { getNextListItem } from './utils/getNextListItem' 5 | 6 | import { DIVIDER_HEIGHT } from './consts' 7 | 8 | import { IAppState } from './types' 9 | 10 | 11 | function createDraggingHandler(state: IAppState) { 12 | function handleDragging(event: MouseEvent | TouchEvent) { 13 | state.isDragging = state.isMouseDown 14 | 15 | if (!state.isDragging) return 16 | 17 | event.preventDefault() 18 | 19 | const mouseEventOrTouch: MouseEvent | Touch = ( 20 | event instanceof TouchEvent ? event.touches[0] : event 21 | ) 22 | 23 | if (!state.draggingItem) return 24 | 25 | if (state.mouseOffsetX !== null && state.mouseOffsetY !== null) { 26 | /* Update dragging item position */ 27 | state.draggingItem.style.top = `${mouseEventOrTouch.pageY - state.mouseOffsetY}px` 28 | state.draggingItem.style.left = `${mouseEventOrTouch.pageX - state.mouseOffsetX}px` 29 | } 30 | 31 | const draggingItemCoordinates = getDOMNodePosition(state.draggingItem) 32 | 33 | const prevItem = getPrevListItem(state.draggingItem) 34 | const nextItem = getNextListItem(state.draggingItem) 35 | 36 | /* 37 | * Swap dragging item with previous item when: 38 | * 39 | * 1. Previous item exists 40 | * 2. Y center coordinate of dragging item is less than Y center coordinate of previous item 41 | */ 42 | if (prevItem) { 43 | const prevItemCoordinates = getDOMNodePosition(prevItem) 44 | const shouldSwaplistItems = ( 45 | draggingItemCoordinates.top + state.draggingItem.offsetHeight / 2 < 46 | prevItemCoordinates.top + prevItem.offsetHeight / 2 47 | ) 48 | 49 | if (shouldSwaplistItems) { 50 | const dividerAboveDraggingItem = state.draggingItem.previousElementSibling 51 | const dividerAbovePrevItem = prevItem.previousElementSibling 52 | 53 | dividerAboveDraggingItem.style.height = `${DIVIDER_HEIGHT}px` 54 | 55 | swapDOMNodes(state.draggingItem, dividerAboveDraggingItem) 56 | swapDOMNodes(state.draggingItem, prevItem) 57 | 58 | dividerAbovePrevItem.style.height = `${state.draggingItem.offsetHeight + 2 * DIVIDER_HEIGHT}px` 59 | 60 | return 61 | } 62 | } 63 | 64 | /* 65 | * Swap dragging item with next item when: 66 | * 67 | * 1. Previous item exists 68 | * 2. Y center coordinate of dragging item is more than Y center coordinate of next item 69 | */ 70 | if (nextItem) { 71 | const nextItemCoodridanes = getDOMNodePosition(nextItem) 72 | const shouldSwaplistItems = ( 73 | draggingItemCoordinates.top + state.draggingItem.offsetHeight / 2 > 74 | nextItemCoodridanes.top + nextItem.offsetHeight / 2 75 | ) 76 | 77 | if (shouldSwaplistItems) { 78 | const dividerAboveDraggingItem = state.draggingItem.previousElementSibling 79 | const dividerUnderNextItem = nextItem.nextElementSibling 80 | 81 | dividerAboveDraggingItem.style.height = `${DIVIDER_HEIGHT}px` 82 | 83 | swapDOMNodes(state.draggingItem, nextItem) 84 | swapDOMNodes(state.draggingItem, dividerUnderNextItem) 85 | 86 | dividerUnderNextItem.style.height = `${state.draggingItem.offsetHeight + 2 * DIVIDER_HEIGHT}px` 87 | } 88 | } 89 | } 90 | 91 | return handleDragging 92 | } 93 | 94 | export { createDraggingHandler } 95 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createStartDraggingHandler } from './startDraggingHandler' 2 | import { createStopDraggingHandler } from './stopDraggingHandler' 3 | import { createDraggingHandler } from './draggingHandler' 4 | 5 | import { INITIAL_APP_STATE } from './consts' 6 | 7 | import { fixContainerHeight } from './utils/fixContainerHeight' 8 | 9 | import { IAppState } from './types' 10 | 11 | function init() { 12 | const state: IAppState = INITIAL_APP_STATE 13 | const listItems = Array.from(document.getElementsByClassName('item')) 14 | 15 | fixContainerHeight(document.getElementById('list')) 16 | 17 | const stopDraggingHandler = createStopDraggingHandler(state) 18 | const draggingHandler = createDraggingHandler(state) 19 | 20 | listItems.forEach((listItem) => { 21 | const startDraggingHandler = createStartDraggingHandler(state, listItem) 22 | 23 | listItem.addEventListener('mousedown', startDraggingHandler) 24 | listItem.addEventListener('touchstart', startDraggingHandler) 25 | 26 | listItem.ondragstart = () => false 27 | }) 28 | 29 | document.addEventListener('mouseup', stopDraggingHandler) 30 | document.addEventListener('touchend', stopDraggingHandler) 31 | 32 | document.addEventListener('mousemove', draggingHandler) 33 | document.addEventListener('touchmove', draggingHandler, { passive: false }) 34 | } 35 | 36 | window.onload = init 37 | 38 | -------------------------------------------------------------------------------- /src/startDraggingHandler.ts: -------------------------------------------------------------------------------- 1 | import { getDOMNodePosition } from './utils/getDOMNodePosition' 2 | import { removeDOMNode } from './utils/removeDOMNode' 3 | import { accessElementTransitionProperty } from './utils/accessElementTransitionProperty' 4 | 5 | import { DIVIDER_HEIGHT } from './consts' 6 | 7 | import { IAppState } from './types' 8 | 9 | 10 | function createStartDraggingHandler(state: IAppState, listItem: HTMLElement) { 11 | function startDraggingHandler(event: MouseEvent | TouchEvent) { 12 | const mouseEventOrTouch: MouseEvent | Touch = ( 13 | event instanceof TouchEvent ? event.touches[0] : event 14 | ) 15 | 16 | state.draggingItem = listItem 17 | state.isMouseDown = true 18 | state.draggingHasStarted = true 19 | 20 | state.mouseOffsetX = mouseEventOrTouch.pageX - getDOMNodePosition(state.draggingItem).left 21 | state.mouseOffsetY = mouseEventOrTouch.pageY - getDOMNodePosition(state.draggingItem).top 22 | 23 | state.draggingItem.classList.add('draggable') 24 | 25 | state.draggingItem.style.top = `${mouseEventOrTouch.pageY - state.mouseOffsetY}px` 26 | state.draggingItem.style.left = `${mouseEventOrTouch.pageX - state.mouseOffsetX}px` 27 | 28 | const dividerAbove = state.draggingItem.previousElementSibling 29 | 30 | dividerAbove.classList.add('not-animated') 31 | dividerAbove.style.height = `${2 * DIVIDER_HEIGHT + state.draggingItem.offsetHeight}px` 32 | 33 | accessElementTransitionProperty(dividerAbove) 34 | 35 | dividerAbove.classList.remove('not-animated') 36 | 37 | state.savedDividerElement = state.draggingItem.nextElementSibling 38 | removeDOMNode(state.savedDividerElement) 39 | } 40 | 41 | return startDraggingHandler 42 | } 43 | 44 | export { createStartDraggingHandler } -------------------------------------------------------------------------------- /src/stopDraggingHandler.ts: -------------------------------------------------------------------------------- 1 | import { getDOMNodePosition } from './utils/getDOMNodePosition' 2 | import { insertDOMNodeAfter } from './utils/insertDOMNodeAfter' 3 | import { accessElementTransitionProperty } from './utils/accessElementTransitionProperty' 4 | 5 | import { DIVIDER_HEIGHT } from './consts' 6 | 7 | import { IAppState } from './types' 8 | 9 | 10 | function createStopDraggingHandler(state: IAppState) { 11 | function stopDraggingHandler() { 12 | if (state.draggingItem) { 13 | const dividerAbove = state.draggingItem.previousElementSibling 14 | const dividerAbovePosition = getDOMNodePosition(dividerAbove) 15 | 16 | state.draggingItem.style.top = `${dividerAbovePosition.top + DIVIDER_HEIGHT}px` 17 | state.draggingItem.style.left = `${dividerAbovePosition.left}px` 18 | 19 | state.draggingItem.classList.remove('draggable') 20 | 21 | dividerAbove.classList.add('not-animated') 22 | dividerAbove.style.height = `${DIVIDER_HEIGHT}px` 23 | 24 | accessElementTransitionProperty(dividerAbove) 25 | 26 | dividerAbove.classList.remove('not-animated') 27 | 28 | if (state.savedDividerElement) { 29 | insertDOMNodeAfter(state.savedDividerElement, state.draggingItem) 30 | } 31 | 32 | state.draggingItem = null 33 | } 34 | 35 | state.isMouseDown = false 36 | state.isDragging = false 37 | state.draggingHasStarted = false 38 | state.mouseOffsetX = null 39 | state.mouseOffsetY = null 40 | } 41 | 42 | return stopDraggingHandler 43 | } 44 | 45 | export { createStopDraggingHandler } 46 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | interface IDOMNodePosition { 2 | left: number, 3 | top: number, 4 | } 5 | 6 | interface IAppState { 7 | isMouseDown: boolean, 8 | isDragging: boolean, 9 | draggingHasStarted: boolean, 10 | 11 | /* 12 | * When starting element dragging remember X and Y offset: 13 | * distance between top-left corner of element and mouse 14 | */ 15 | mouseOffsetX: number | null, 16 | mouseOffsetY: number | null, 17 | 18 | /* 19 | * When starting dragging item, we remove it's bottom divider, 20 | * And when we stop dragging, we put that divider after item 21 | */ 22 | savedDividerElement: HTMLElement | null, 23 | draggingItem: HTMLElement | null, 24 | } 25 | 26 | export { IDOMNodePosition, IAppState } 27 | -------------------------------------------------------------------------------- /src/utils/accessElementTransitionProperty.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * The browser doesn't update transition property, changed by adding and then removing class, 3 | * because both changes are happening in a single javascript round, so browser takes its chance 4 | * to optimize the process and doesn't update transition property 5 | * 6 | * The solution is to try to access property value, it triggers browser to update 7 | * property we're trying to access 8 | * 9 | * See stackoverflow answer: https://stackoverflow.com/a/24195559 10 | */ 11 | function accessElementTransitionProperty(element: HTMLElement): void { 12 | window.getComputedStyle(element).getPropertyValue('transition') 13 | } 14 | 15 | export { accessElementTransitionProperty } 16 | -------------------------------------------------------------------------------- /src/utils/fixContainerHeight.ts: -------------------------------------------------------------------------------- 1 | function fixContainerHeight(listContainer: HTMLElement): void { 2 | listContainer.style.height = getComputedStyle(listContainer).getPropertyValue('height') 3 | } 4 | 5 | export { fixContainerHeight } 6 | -------------------------------------------------------------------------------- /src/utils/getDOMNodePosition.ts: -------------------------------------------------------------------------------- 1 | import { IDOMNodePosition } from '../types' 2 | 3 | /* 4 | * Returns position of given DOM node 5 | */ 6 | function getDOMNodePosition(node: HTMLElement): IDOMNodePosition { 7 | const { top, left } = node.getBoundingClientRect() 8 | 9 | return { top: top + document.documentElement.scrollTop, left } 10 | } 11 | 12 | export { getDOMNodePosition } 13 | -------------------------------------------------------------------------------- /src/utils/getNextListItem.ts: -------------------------------------------------------------------------------- 1 | /* 2 | *
...
| ** Dragging item ** 3 | *
...
| Next item after dragging item 4 | */ 5 | function getNextListItem(listItem: HTMLElement): HTMLElement | null { 6 | return listItem.nextElementSibling 7 | } 8 | 9 | export { getNextListItem } 10 | -------------------------------------------------------------------------------- /src/utils/getPrevListItem.ts: -------------------------------------------------------------------------------- 1 | /* 2 | *
...
| Previous item before dragging item 3 | *
| Divider between previous and dragging listItems 4 | *
...
| ** Dragging item ** 5 | */ 6 | function getPrevListItem(listItem: HTMLElement): HTMLElement | null { 7 | const divider = listItem.previousElementSibling 8 | 9 | if (!divider) return null 10 | 11 | return divider.previousElementSibling 12 | } 13 | 14 | export { getPrevListItem } 15 | -------------------------------------------------------------------------------- /src/utils/insertDOMNodeAfter.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Inserts given DOM node after given ref node 3 | */ 4 | function insertDOMNodeAfter(nodeToInsert: HTMLElement, refNode: HTMLElement) { 5 | const nextNode = refNode.nextElementSibling 6 | 7 | if (!refNode.parentNode) return 8 | 9 | if (nextNode) { 10 | refNode.parentNode.insertBefore(nodeToInsert, nextNode) 11 | } else { 12 | refNode.parentNode.appendChild(nodeToInsert) 13 | } 14 | } 15 | 16 | export { insertDOMNodeAfter } 17 | -------------------------------------------------------------------------------- /src/utils/removeDOMNode.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Removes given DOM node 3 | */ 4 | function removeDOMNode(node: HTMLElement) { 5 | if (node.parentNode) node.parentNode.removeChild(node) 6 | } 7 | 8 | export { removeDOMNode } 9 | -------------------------------------------------------------------------------- /src/utils/swapDOMNodes.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * Swaps to given DOM nodes, if they are neighbours 3 | */ 4 | function swapDOMNodes(firstNode: HTMLElement, secondNode: HTMLElement) { 5 | if (firstNode.nextElementSibling === secondNode) { 6 | if (firstNode.parentNode) { 7 | firstNode.parentNode.insertBefore(secondNode, firstNode) 8 | } 9 | } else if (secondNode.nextElementSibling === firstNode) { 10 | if (firstNode.parentNode) { 11 | firstNode.parentNode.insertBefore(firstNode, secondNode) 12 | } 13 | } 14 | } 15 | 16 | export { swapDOMNodes } 17 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | margin: 0; 4 | padding: 0; 5 | font-family: sans-serif; 6 | } 7 | 8 | * { 9 | box-sizing: border-box; 10 | } 11 | 12 | .container { 13 | min-height: 100vh; 14 | background-color: #e2f7ff; 15 | padding-top: 100px; 16 | padding-bottom: 30px; 17 | } 18 | 19 | @media screen and (max-width: 600px) { 20 | .container { 21 | padding-top: 10px; 22 | } 23 | } 24 | 25 | .list { 26 | margin-left: auto; 27 | margin-right: auto; 28 | width: 300px; 29 | background: #5271bc69; 30 | padding: 0 10px; 31 | border-radius: 5px; 32 | font-size: 1.2rem; 33 | } 34 | 35 | .item { 36 | width: 280px; 37 | background: #ffffff; 38 | border: 1px solid #2b2b44; 39 | border-radius: 2px; 40 | padding: 5px 10px; 41 | user-select: none; 42 | cursor: pointer; 43 | } 44 | 45 | .item:hover { 46 | background: #e4e9ff; 47 | } 48 | 49 | .item-id { 50 | color: #585858; 51 | font-size: .9rem; 52 | padding: 5px 0; 53 | } 54 | 55 | .divider { 56 | height: 10px; 57 | transition: height 0.1s ease; 58 | } 59 | 60 | .item.draggable { 61 | position: absolute; 62 | background: #d0d9fd; 63 | cursor: move; 64 | } 65 | 66 | .not-animated { 67 | transition: none; 68 | } 69 | 70 | a { 71 | color: #ff22bb; 72 | text-decoration: none; 73 | } 74 | 75 | a:visited { 76 | color: #ca16dd; 77 | } 78 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "target": "es6", 5 | "jsx": "react", 6 | "allowJs": true, 7 | "moduleResolution": "node", 8 | "strict": true, 9 | "noEmit": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | 3 | import { Configuration } from 'webpack' 4 | import path from 'path' 5 | import HtmlWebpackPlugin from 'html-webpack-plugin' 6 | import CopyWebpackPlugin from 'copy-webpack-plugin' 7 | import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin' 8 | 9 | const isProd = process.env.NODE_ENV === 'production' 10 | 11 | const config: Configuration = { 12 | mode: isProd ? 'production' : 'development', 13 | entry: './src/index.ts', 14 | output: { 15 | filename: '[name][contenthash].js', 16 | path: path.resolve(__dirname, 'dist'), 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts$/, 22 | use: 'babel-loader', 23 | exclude: /node_modules/, 24 | }, 25 | ], 26 | }, 27 | resolve: { 28 | extensions: ['.ts', '.js'], 29 | }, 30 | plugins: [ 31 | new HtmlWebpackPlugin({ template: './assets/index.html' }), 32 | new ForkTsCheckerWebpackPlugin(), 33 | new CopyWebpackPlugin({ 34 | patterns: [{ from: 'static' }], 35 | }) 36 | ], 37 | } 38 | 39 | if (!isProd) { 40 | config.devServer = { 41 | contentBase: path.join(__dirname, 'dist'), 42 | compress: true, 43 | port: 9000, 44 | } 45 | } 46 | 47 | module.exports = config --------------------------------------------------------------------------------