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