├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── public └── index.html ├── src ├── Sample.vue ├── components │ ├── DraggableContainer.vue │ └── DraggableItem.vue ├── composables │ └── draggable.ts ├── index.js ├── main.ts ├── shims-vue.d.ts ├── types │ └── draggable-item.interface.ts └── utils │ ├── change-order.ts │ ├── id-generator.ts │ ├── throttle.ts │ └── to-draggable-items.ts ├── tsconfig.json └── vue.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Kyle Kim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue3-draggable 2 | 3 | list-based drag&drop component for vue 3.x, with no dependencies 4 | 5 | ![vue3-drag2](https://user-images.githubusercontent.com/59331444/104086030-774ce700-5297-11eb-9f5a-211bd4b7c01f.gif) 6 | 7 | # Features 8 | 9 | - support v-model 10 | - support transition 11 | - customizable draggable component 12 | 13 | Nested useage is currently not supported 14 | 15 | # Installation 16 | 17 | ``` 18 | npm i vue3-draggable 19 | ``` 20 | 21 | # Try Sample 22 | 23 | ```bash 24 | git clone https://github.com/shkilo/vue3-draggable.git 25 | 26 | npm i 27 | npm run serve 28 | ``` 29 | 30 | # Usage 31 | 32 | import component: 33 | 34 | ```javascript 35 | import Draggable from "vue3-draggable"; 36 | 37 | export default { 38 | components: { 39 | Draggable, 40 | }, 41 | }; 42 | ``` 43 | 44 | template: 45 | 46 | ```vue 47 | 48 | 55 | 56 | ``` 57 | 58 | This componet is implemented based on [v-slot](https://v3.vuejs.org/guide/component-slots.html#slots) 59 | 60 | ### Props 61 | 62 | | Name | Required | Type | Description | 63 | | :--------- | :------- | :----- | :------------------------------- | 64 | | modelValue | REQUIRED | ARRAY | v-model value, items to be bound | 65 | | transition | OPTIONAL | STRING | transition delay in ms | 66 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"] 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-draggable", 3 | "version": "2.0.8", 4 | "private": false, 5 | "description": "simple drag&drop component for vue 3.x", 6 | "author": { 7 | "name": "Kyle Kim", 8 | "email": "kyle.hwan.k@gmail.com" 9 | }, 10 | "scripts": { 11 | "serve": "vue-cli-service serve", 12 | "build": "vue-cli-service build", 13 | "lint": "vue-cli-service lint", 14 | "build-library": "vue-cli-service build --target lib --name vue3-draggable ./src/index.js" 15 | }, 16 | "main": "dist/vue3-draggable.umd.min.js", 17 | "files": [ 18 | "dist/*.umd.min.*" 19 | ], 20 | "devDependencies": { 21 | "@typescript-eslint/eslint-plugin": "^2.33.0", 22 | "@typescript-eslint/parser": "^2.33.0", 23 | "@vue/cli-plugin-babel": "~4.5.0", 24 | "@vue/cli-plugin-eslint": "~4.5.0", 25 | "@vue/cli-plugin-typescript": "^4.5.13", 26 | "@vue/cli-service": "~4.5.0", 27 | "@vue/compiler-sfc": "^3.0.0", 28 | "@vue/eslint-config-prettier": "^6.0.0", 29 | "@vue/eslint-config-typescript": "^5.0.2", 30 | "babel-eslint": "^10.1.0", 31 | "core-js": "^3.6.5", 32 | "eslint": "^6.7.2", 33 | "eslint-plugin-prettier": "^3.1.3", 34 | "eslint-plugin-vue": "^7.0.0-0", 35 | "prettier": "^1.19.1", 36 | "typescript": "~4.1.5", 37 | "vue": "^3.0.0" 38 | }, 39 | "peerDependencies": { 40 | "vue": "^3.0.0" 41 | }, 42 | "eslintConfig": { 43 | "root": true, 44 | "env": { 45 | "node": true 46 | }, 47 | "extends": [ 48 | "plugin:vue/vue3-essential", 49 | "eslint:recommended", 50 | "@vue/typescript" 51 | ], 52 | "parserOptions": { 53 | "parser": "@typescript-eslint/parser" 54 | }, 55 | "rules": {} 56 | }, 57 | "browserslist": [ 58 | "> 1%", 59 | "last 2 versions", 60 | "not dead" 61 | ], 62 | "bugs": "https://github.com/shkilo/vue3-draggable/issues", 63 | "keywords": [ 64 | "draggable", 65 | "vue3", 66 | "vue", 67 | "drag", 68 | "drop", 69 | "drag and drop" 70 | ], 71 | "license": "MIT", 72 | "repository": "https://github.com/shkilo/vue3-draggable" 73 | } 74 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 | -------------------------------------------------------------------------------- /src/Sample.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 54 | 55 | 91 | -------------------------------------------------------------------------------- /src/components/DraggableContainer.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 54 | 55 | 60 | -------------------------------------------------------------------------------- /src/components/DraggableItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 52 | 53 | 58 | -------------------------------------------------------------------------------- /src/composables/draggable.ts: -------------------------------------------------------------------------------- 1 | import { ref, onMounted, onUpdated, watch, SetupContext, Ref } from "vue"; 2 | import { DraggableItem } from "../types/draggable-item.interface"; 3 | import { changeArrayOrder } from "../utils/change-order"; 4 | import { getIdGenerator } from "../utils/id-generator"; 5 | import { throttle } from "../utils/throttle"; 6 | import { toOriginalArray, toDraggableItems } from "../utils/to-draggable-items"; 7 | 8 | let itemCurrentlyDragging = ref(null); 9 | let containerIdCurrentlyDraggedOver = ref(null); 10 | let transitioning = false; 11 | const containerIdGenerator = getIdGenerator(); 12 | 13 | const useDraggableContainer = ( 14 | originalItems: Ref>, 15 | context: SetupContext 16 | ) => { 17 | const id = containerIdGenerator(); 18 | const items = ref>( 19 | toDraggableItems(originalItems.value) 20 | ); 21 | 22 | // update v-model when dropped 23 | watch(itemCurrentlyDragging, () => { 24 | if (itemCurrentlyDragging.value) { 25 | return; 26 | } 27 | context.emit("update:modelValue", toOriginalArray(items.value)); 28 | }); 29 | 30 | // case when an item is being dragged to another container 31 | watch(containerIdCurrentlyDraggedOver, () => { 32 | if (containerIdCurrentlyDraggedOver.value === id) { 33 | return; 34 | } 35 | items.value = items.value.filter( 36 | (item) => item.id !== itemCurrentlyDragging.value.id 37 | ); 38 | }); 39 | 40 | // when an item is moved to an empty container 41 | const onDragOver = () => { 42 | if ( 43 | transitioning || 44 | !itemCurrentlyDragging.value || 45 | containerIdCurrentlyDraggedOver.value === id 46 | ) { 47 | return; 48 | } 49 | 50 | if (items.value.length > 0) { 51 | return; 52 | } 53 | 54 | containerIdCurrentlyDraggedOver.value = id; 55 | items.value = [itemCurrentlyDragging.value]; 56 | }; 57 | 58 | // handle event emitted from draggableItem 59 | const onItemDragOver = ({ position }: { position: number }) => { 60 | if (transitioning || !itemCurrentlyDragging.value) { 61 | return; 62 | } 63 | items.value = changeArrayOrder( 64 | items.value, 65 | itemCurrentlyDragging.value, 66 | position 67 | ); 68 | }; 69 | 70 | return { 71 | id, 72 | items, 73 | onDragOver, 74 | onItemDragOver, 75 | }; 76 | }; 77 | 78 | const useDraggableItem = ( 79 | item: Ref, 80 | position: Ref, 81 | containerId: Ref, 82 | context: SetupContext 83 | ) => { 84 | const draggableItemEl = ref(null); 85 | const isDragging = ref( 86 | item.value.id === itemCurrentlyDragging.value?.id ? true : false 87 | ); 88 | const middleY = ref(null); 89 | 90 | onMounted(async () => { 91 | const box = draggableItemEl.value.getBoundingClientRect(); 92 | middleY.value = box.top + box.height / 2; 93 | }); 94 | 95 | onUpdated(() => { 96 | const box = draggableItemEl.value.getBoundingClientRect(); 97 | middleY.value = box.top + box.height / 2; 98 | }); 99 | 100 | const onDragStart = () => { 101 | itemCurrentlyDragging.value = item.value; 102 | containerIdCurrentlyDraggedOver.value = containerId.value; 103 | isDragging.value = true; 104 | }; 105 | 106 | const onDragEnd = () => { 107 | itemCurrentlyDragging.value = null; 108 | }; 109 | 110 | const onDragOver = throttle((e: DragEvent) => { 111 | if (item.value.id === itemCurrentlyDragging.value.id) { 112 | return; 113 | } 114 | 115 | if (containerIdCurrentlyDraggedOver.value !== containerId.value) { 116 | containerIdCurrentlyDraggedOver.value = containerId.value; 117 | } 118 | 119 | const offset = middleY.value - e.clientY; 120 | 121 | context.emit("itemDragOver", { 122 | position: offset > 0 ? position.value : position.value + 1, 123 | }); 124 | }, 50); 125 | 126 | const transitionStart = () => { 127 | transitioning = true; 128 | }; 129 | 130 | const transitionEnd = () => { 131 | transitioning = false; 132 | }; 133 | 134 | watch(itemCurrentlyDragging, () => { 135 | if (itemCurrentlyDragging.value) { 136 | return; 137 | } 138 | isDragging.value = false; 139 | }); 140 | 141 | return { 142 | draggableItemEl, 143 | isDragging, 144 | onDragStart, 145 | onDragOver, 146 | onDragEnd, 147 | transitionStart, 148 | transitionEnd, 149 | }; 150 | }; 151 | 152 | export { useDraggableContainer, useDraggableItem }; 153 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Draggable from "./components/DraggableContainer"; 2 | 3 | export default Draggable; 4 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import Sample from "./Sample.vue"; 3 | 4 | createApp(Sample).mount("#app"); 5 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import type { DefineComponent } from 'vue' 4 | const component: DefineComponent<{}, {}, any> 5 | export default component 6 | } 7 | -------------------------------------------------------------------------------- /src/types/draggable-item.interface.ts: -------------------------------------------------------------------------------- 1 | export interface DraggableItem { 2 | id: number; 3 | data: object; 4 | } 5 | -------------------------------------------------------------------------------- /src/utils/change-order.ts: -------------------------------------------------------------------------------- 1 | import { DraggableItem } from "../types/draggable-item.interface"; 2 | 3 | export const changeArrayOrder = ( 4 | arr: Array, 5 | target: DraggableItem, 6 | newIndexOfTarget: number 7 | ): Array => { 8 | let newArr = arr.filter((e) => e.id !== target.id); 9 | newArr.splice(newIndexOfTarget, 0, { ...target }); 10 | return newArr; 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/id-generator.ts: -------------------------------------------------------------------------------- 1 | export const getIdGenerator = () => { 2 | let num = 0; 3 | return () => { 4 | return num++; 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /src/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | export const throttle = (callback: Function, limit: number) => { 2 | let waiting = false; 3 | return (...args: any[]) => { 4 | if (waiting) { 5 | return; 6 | } 7 | callback(...args); 8 | waiting = true; 9 | setTimeout(() => { 10 | waiting = false; 11 | }, limit); 12 | return; 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/to-draggable-items.ts: -------------------------------------------------------------------------------- 1 | import { DraggableItem } from "../types/draggable-item.interface"; 2 | import { getIdGenerator } from "./id-generator"; 3 | 4 | const draggableItemIdGenrator = getIdGenerator(); 5 | 6 | export const toDraggableItems = (arr: Array): Array => { 7 | return arr.map((e) => ({ 8 | id: draggableItemIdGenrator(), 9 | data: e, 10 | })); 11 | }; 12 | 13 | export const toOriginalArray = (arr: Array): Array => { 14 | return arr.map((e) => e.data); 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "strictNullChecks": false, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "moduleResolution": "node", 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | "sourceMap": true, 15 | "baseUrl": ".", 16 | "types": [ 17 | "webpack-env" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | , "src/index.js" ], 38 | "exclude": [ 39 | "node_modules" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | css: { extract: false } 3 | }; 4 | --------------------------------------------------------------------------------