├── .babelrc ├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .npmignore ├── .prettierrc ├── README.md ├── jest.config.json ├── package-lock.json ├── package.json ├── res └── scripts │ └── build.js ├── rollup.config.js ├── src ├── directive.js ├── index.cjs.js ├── index.js ├── index.mjs └── sortablejs.js └── test ├── Component.vue └── sortablejs.spec.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env"] 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 80 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:vue/vue3-recommended", 7 | "@vue/prettier" 8 | ], 9 | 10 | rules: { 11 | "no-console": "warn" 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | 3 | name: Test & Publish 4 | 5 | jobs: 6 | test: 7 | strategy: 8 | matrix: 9 | os: [ubuntu-latest] 10 | node-version: [14.x, 16.x, 18.x] 11 | fail-fast: false 12 | 13 | runs-on: ${{ matrix.os }} 14 | 15 | steps: 16 | - name: Checkout 17 | id: checkout 18 | uses: actions/checkout@v3 19 | 20 | - name: Install Node 21 | id: install_node 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | 26 | - name: Verify versions 27 | run: node --version && npm --version && node -p process.versions.v8 28 | 29 | - name: Install dependencies 30 | id: install_dependencies 31 | run: | 32 | npm install 33 | 34 | - name: Test 35 | id: test 36 | run: | 37 | npm run test 38 | 39 | publish: 40 | runs-on: ubuntu-latest 41 | if: startsWith(github.ref, 'refs/tags/v') 42 | needs: test 43 | steps: 44 | - name: Checkout 45 | id: checkout 46 | uses: actions/checkout@v3 47 | 48 | - name: Install Node 49 | id: install_node 50 | uses: actions/setup-node@v3 51 | with: 52 | node-version: "14" 53 | registry-url: "https://registry.npmjs.org" 54 | 55 | - name: Install dependencies 56 | id: install_dependencies 57 | run: | 58 | npm install 59 | 60 | - name: Build 61 | id: build 62 | run: | 63 | npm run build 64 | 65 | - name: Publish 66 | id: publish 67 | run: | 68 | npm publish 69 | env: 70 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | node_modules/ 3 | npm-debug.log 4 | .DS_Store 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !/dist/* 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "arrowParens": "avoid", 4 | "singleQuote": false 5 | } 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 Sortable 2 | 3 | [![Build Status](https://github.com/eliottvincent/vue3-sortablejs/actions/workflows/test.yml/badge.svg)](https://github.com/eliottvincent/vue3-sortablejs/actions) [![Version](https://img.shields.io/npm/v/vue3-sortablejs.svg)](https://www.npmjs.com/package/vue3-sortablejs) [![Downloads](https://img.shields.io/npm/dt/vue3-sortablejs.svg)](https://www.npmjs.com/package/vue3-sortablejs) 4 | 5 | > Re-orderable drag-and-drop lists, via a **Vue directive**. Based on and offering all features of [Sortable](https://github.com/SortableJS/Sortable). 6 | > 7 | > [[view demo]](https://sortablejs.github.io/Sortable/#simple-list) 8 | 9 | 10 | ### Yet another Sortable wrapper 11 | 12 | Several Vue wrappers for Sortable exist out there, yet I decided to build another one. 13 | 14 | The goal was to have a wrapper that: 15 | * supports Vue 3 16 | * is **light** and easy to maintain 17 | * works as a **directive**, for example to conditionally enable / disable the drag-and-drop feature without having to change the whole component 18 | * doesn't iterate on the data by itself 19 | * doesn't update the underlying data model (see [Order mutation](#order-mutation)) 20 | 21 | As a reference, here are other Sortable wrappers: 22 | * [`vuedraggable`](https://www.npmjs.com/package/vuedraggable) only supports Vue 2 23 | * [`vuedraggable@next`](https://www.npmjs.com/package/vuedraggable) supports Vue 3, but adds a lot of overhead on top of Sortable 24 | * [`vue-sortable`](https://www.npmjs.com/package/vue-sortable) is totally outdated (last update is from 2016) 25 | * [`sortablejs-vue3`](https://www.npmjs.com/package/sortablejs-vue3) is the best wrapper I found, but only works as a component 26 | 27 | 28 | ## Usage 29 | 30 | Get Vue 3 Sortable from [jsDelivr](https://cdn.jsdelivr.net/npm/vue3-sortablejs/dist/vue3-sortablejs.global.js) or [UNPKG](https://unpkg.com/vue3-sortablejs/dist/vue3-sortablejs.global.js) and use it like this: 31 | 32 | ```html 33 | 34 | 35 | 36 | 37 |
38 |
39 |
a
40 |
b
41 |
c
42 |
43 |
44 | 45 | 54 | ``` 55 | 56 | Vue 3 Sortable is also available through npm as the [`vue3-sortablejs`](https://www.npmjs.com/package/vue3-sortablejs) package. 57 | 58 | Install the package: 59 | ```sh 60 | npm install --save vue3-sortablejs 61 | ``` 62 | 63 | Register the plugin in `App.vue`: 64 | ```js 65 | import VueSortable from "vue3-sortablejs"; 66 | 67 | app.use(VueSortable); 68 | ``` 69 | 70 | And then use it like this in `MyComponent.vue`: 71 | ```html 72 | 81 | ``` 82 | 83 | 84 | ## Options 85 | 86 | You can pass an object of options, in order to affect the behavior of the directive: 87 | * `disabled` whether to disable the drag-and-drop behavior 88 | * `options` an object containing any [Sortable option](https://github.com/SortableJS/Sortable#options) 89 | 90 | ```html 91 | 98 | ``` 99 | 100 | 101 | ## Events 102 | 103 | A custom `ready` event will be triggered as soon as Sortable is registered on the component. You can use it to access the underlying Sortable instance. 104 | As well, you can listen to any native Sortable event. 105 | 106 | * `@ready`: Sortable is ready and attached to the component 107 | * `@choose`: element is chosen 108 | * `@unchoose`: element is unchosen 109 | * `@start`: element dragging started 110 | * `@end`: element dragging ended 111 | * `@add`: element is dropped into the list from another list 112 | * `@update`: changed sorting within list 113 | * `@sort`: called by any change to the list (add / update / remove) 114 | * `@remove`: element is removed from the list into another list 115 | * `@filter`: attempt to drag a filtered element 116 | * `@move`: event when you move an item in the list or between lists 117 | * `@clone`: called when creating a clone of element 118 | * `@change`: called when dragging element changes position 119 | 120 | ```html 121 | 128 | 129 | 143 | ``` 144 | 145 | 146 | ## Order mutation 147 | 148 | This wrapper only impacts the actual DOM order, **it does not mutate the data order**. 149 | This avoids a lot of overhead in the code, and gives you the full control on your data. 150 | 151 | It is really simple to change the order in your data after an item is dropped: 152 | ```html 153 | 162 | 163 | 182 | ``` 183 | 184 | 185 | ## Notes 186 | 187 | It is highly recommended to set a **key on the children items**, to help Sortable track the DOM: 188 | 189 | ```html 190 | 197 | ``` 198 | 199 | In the same way, if you use the `group` option, it is highly recommended to set a **key on the parent** itself. Otherwise the DOM managed by Sortable can become out-of-sync with the actual data state. I have noticed this helps a lot when using Sortable with complex components. 200 | 201 | The key must be based on the number of items the parent contains. This will force a re-render when an item is added / removed, and make Sortable re-initialize and start from a clean state every time. This may seem a bit hacky, but it's the only way to keep a consistant behavior. 202 | 203 | ```html 204 | 221 | 222 | 231 | ``` 232 | 233 | 234 | ## License 235 | 236 | vue3-sortablejs is released under the MIT License. See the bundled LICENSE file for details. 237 | -------------------------------------------------------------------------------- /jest.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "testEnvironment": "jsdom", 3 | "transform": { 4 | "^.+\\.vue$": "@vue/vue3-jest", 5 | "^.+\\js$": "babel-jest" 6 | }, 7 | "moduleFileExtensions": ["vue", "js"] 8 | } 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue3-sortablejs", 3 | "version": "1.0.7", 4 | "description": "A directive for Sortable on Vue 3", 5 | "author": "Eliott Vincent ", 6 | "homepage": "https://github.com/eliottvincent/vue3-sortablejs", 7 | "repository": { 8 | "type": "git", 9 | "url": "git://github.com/eliottvincent/vue3-sortablejs" 10 | }, 11 | "bugs": { 12 | "url": "https://github.com/eliottvincent/vue3-sortablejs/issues" 13 | }, 14 | "main": "dist/vue3-sortablejs.cjs.js", 15 | "module": "dist/vue3-sortablejs.esm-bundler.js", 16 | "browser": "dist/vue3-sortablejs.esm-browser.js", 17 | "exports": { 18 | ".": { 19 | "module": "./dist/vue3-sortablejs.esm-bundler.js", 20 | "require": "./dist/vue3-sortablejs.cjs.js", 21 | "import": "./dist/vue3-sortablejs.mjs" 22 | } 23 | }, 24 | "sideEffects": false, 25 | "files": [ 26 | "dist" 27 | ], 28 | "scripts": { 29 | "build": "node res/scripts/build.js", 30 | "lint": "eslint src", 31 | "test": "npm run lint && npm run test:unit", 32 | "test:unit": "jest ./test" 33 | }, 34 | "dependencies": { 35 | "sortablejs": "1.15.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/preset-env": "7.20.2", 39 | "@rollup/plugin-commonjs": "13.0.2", 40 | "@rollup/plugin-node-resolve": "15.0.1", 41 | "@rollup/plugin-replace": "5.0.02", 42 | "@rollup/plugin-terser": "0.2.1", 43 | "@vue/eslint-config-prettier": "6.0.0", 44 | "@vue/test-utils": "2.3.0", 45 | "@vue/vue3-jest": "29.2.2", 46 | "babel-jest": "29.4.3", 47 | "eslint": "7.32.0", 48 | "eslint-plugin-prettier": "4.2.1", 49 | "eslint-plugin-vue": "8.5.0", 50 | "jest": "27.5.1", 51 | "prettier": "1.19.1", 52 | "rollup": "2.79.1", 53 | "vue": "3.2.47" 54 | }, 55 | "keywords": [ 56 | "vue", 57 | "vue3", 58 | "vuejs", 59 | "sortable", 60 | "sortablejs", 61 | "draggable", 62 | "directive", 63 | "drag-and-drop", 64 | "drag", 65 | "drop" 66 | ], 67 | "license": "MIT" 68 | } 69 | -------------------------------------------------------------------------------- /res/scripts/build.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const spawn = require("child_process").spawn; 3 | 4 | async function run() { 5 | await Promise.all([build(), copy()]); 6 | } 7 | 8 | async function build() { 9 | await spawn("rollup", ["-c", "rollup.config.js"], { stdio: "inherit" }); 10 | } 11 | 12 | async function copy() { 13 | await fs.promises.copyFile("src/index.mjs", "dist/vue3-sortablejs.mjs"); 14 | } 15 | 16 | run(); 17 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import resolve from "@rollup/plugin-node-resolve"; 2 | import commonjs from "@rollup/plugin-commonjs"; 3 | import terser from "@rollup/plugin-terser"; 4 | import pkg from "./package.json"; 5 | 6 | const banner = `/*! 7 | * vue3-sortablejs v${pkg.version} 8 | * (c) ${new Date().getFullYear()} Eliott Vincent 9 | * @license MIT 10 | */`; 11 | 12 | const configs = [ 13 | { 14 | input: "src/index.js", 15 | file: "dist/vue3-sortablejs.esm-browser.js", 16 | format: "es" 17 | }, 18 | { 19 | input: "src/index.js", 20 | file: "dist/vue3-sortablejs.esm-bundler.js", 21 | format: "es" 22 | }, 23 | { 24 | input: "src/index.cjs.js", 25 | file: "dist/vue3-sortablejs.global.js", 26 | format: "iife", 27 | minify: true 28 | }, 29 | { 30 | input: "src/index.cjs.js", 31 | file: "dist/vue3-sortablejs.cjs.js", 32 | format: "cjs", 33 | env: "development" 34 | } 35 | ]; 36 | 37 | function createEntries() { 38 | return configs.map(config => { 39 | return createEntry(config); 40 | }); 41 | } 42 | 43 | function createEntry(config) { 44 | const isGlobalBuild = config.format === "iife"; 45 | 46 | const c = { 47 | external: ["sortablejs"], 48 | input: config.input, 49 | plugins: [ 50 | resolve(), 51 | commonjs() 52 | ], 53 | output: { 54 | banner, 55 | file: config.file, 56 | format: config.format, 57 | exports: "auto", 58 | globals: { 59 | sortablejs: "Sortable" 60 | } 61 | }, 62 | onwarn: (msg, warn) => { 63 | if (!/Circular/.test(msg)) { 64 | warn(msg); 65 | } 66 | } 67 | }; 68 | 69 | if (isGlobalBuild) { 70 | c.output.name = "sortablejs"; 71 | } 72 | 73 | if (config.minify) { 74 | c.plugins.push(terser()); 75 | } 76 | 77 | return c; 78 | } 79 | 80 | export default createEntries(); 81 | -------------------------------------------------------------------------------- /src/directive.js: -------------------------------------------------------------------------------- 1 | import Sortable from "sortablejs"; 2 | 3 | /** 4 | * Triggers on bind 5 | * @public 6 | * @param {object} element 7 | * @param {object} binding 8 | * @return {undefined} 9 | */ 10 | var bind = function(element, binding) { 11 | if ((binding.value || {}).disabled === true) { 12 | __reset(element); 13 | 14 | return; 15 | } 16 | 17 | // Initialize private state ($sortablejs) 18 | element.$s = {}; 19 | element.$s.sortable = null; 20 | element.$s.options = (binding.value || {}).options || null; 21 | 22 | // Initialize Sortable 23 | element.$s.sortable = new Sortable(element, { 24 | ...element.$s.options 25 | }); 26 | 27 | // Forward Sortable instance 28 | __emitEvent(element, "ready", { 29 | sortable: element.$s.sortable 30 | }); 31 | }; 32 | 33 | /** 34 | * Triggers on update 35 | * @public 36 | * @param {object} element 37 | * @param {object} binding 38 | * @return {undefined} 39 | */ 40 | var update = function(element, binding) { 41 | if ( 42 | JSON.stringify(binding.value || {}) !== 43 | JSON.stringify(binding.oldValue || {}) 44 | ) { 45 | bind(element, binding); 46 | } 47 | }; 48 | 49 | /** 50 | * Triggers on unbind 51 | * @public 52 | * @param {object} element 53 | * @return {undefined} 54 | */ 55 | var unbind = function(element) { 56 | __reset(element); 57 | }; 58 | 59 | /** 60 | * Emits event 61 | * @private 62 | * @param {object} element 63 | * @param {string} type 64 | * @param {object} data 65 | * @return {undefined} 66 | */ 67 | var __emitEvent = function(element, type, data) { 68 | const event = new CustomEvent(type); 69 | 70 | for (let key in data) { 71 | event[key] = data[key]; 72 | } 73 | 74 | element.dispatchEvent(event); 75 | }; 76 | 77 | /** 78 | * Resets 79 | * @private 80 | * @param {object} element 81 | * @return {undefined} 82 | */ 83 | var __reset = function(element) { 84 | // Reset Sortable 85 | if ((element.$s || {}).sortable) { 86 | element.$s.sortable.destroy(); 87 | element.$s.sortable = null; 88 | } 89 | 90 | // Reset private state 91 | element.$s = {}; 92 | }; 93 | 94 | export { bind, update, unbind }; 95 | -------------------------------------------------------------------------------- /src/index.cjs.js: -------------------------------------------------------------------------------- 1 | import { sortablejs } from "./sortablejs"; 2 | 3 | export default sortablejs; 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { sortablejs } from "./sortablejs"; 2 | 3 | export default sortablejs; 4 | 5 | export { sortablejs }; 6 | -------------------------------------------------------------------------------- /src/index.mjs: -------------------------------------------------------------------------------- 1 | import sortablejs from "../dist/vue3-sortablejs.cjs.js"; 2 | 3 | export { sortablejs as default }; 4 | -------------------------------------------------------------------------------- /src/sortablejs.js: -------------------------------------------------------------------------------- 1 | import { bind, update, unbind } from "./directive"; 2 | 3 | var sortablejsDirective = { 4 | beforeMount(element, binding) { 5 | return bind(element, binding); 6 | }, 7 | 8 | updated(element, binding) { 9 | return update(element, binding); 10 | }, 11 | 12 | beforeUnmount(element) { 13 | return unbind(element); 14 | } 15 | }; 16 | 17 | var sortablejs = { 18 | install: function(Vue) { 19 | Vue.directive("sortable", sortablejsDirective); 20 | }, 21 | 22 | directive: sortablejsDirective 23 | }; 24 | 25 | export default sortablejs; 26 | 27 | export { sortablejs }; 28 | -------------------------------------------------------------------------------- /test/Component.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 41 | -------------------------------------------------------------------------------- /test/sortablejs.spec.js: -------------------------------------------------------------------------------- 1 | import { nextTick } from "vue"; 2 | import { mount, createLocalVue } from "@vue/test-utils"; 3 | 4 | import Sortablejs from "./../src/"; 5 | import Component from "./Component.vue"; 6 | 7 | const global = { 8 | directives: { 9 | sortable: Sortablejs.directive 10 | } 11 | }; 12 | 13 | describe("initialization", () => { 14 | it("should be created with no options", () => { 15 | const wrapper = mount(Component, { 16 | global: global 17 | }); 18 | 19 | const element = wrapper.get(".element"); 20 | 21 | expect(element.exists()).toBe(true); 22 | 23 | // '$s' private store should be attached to element 24 | expect(element.wrapperElement.$s).toBeDefined(); 25 | expect(element.wrapperElement.$s.options).toBe(null); 26 | expect(element.wrapperElement.$s.sortable).toBeDefined(); 27 | 28 | // Sortable instance should be returned via 'ready' event 29 | expect(wrapper.emitted()).toHaveProperty("ready"); 30 | expect(wrapper.emitted().ready).toHaveLength(1); 31 | expect(wrapper.emitted().ready[0][0]).toMatchObject({ 32 | sortable: element.wrapperElement.$s.sortable 33 | }); 34 | 35 | // Order should be correct 36 | expect(element.wrapperElement.$s.sortable.toArray()).toMatchObject([ 37 | "1", "2", "3" 38 | ]); 39 | }); 40 | 41 | it("should be created with options", () => { 42 | const options = { 43 | animation: 200, 44 | easing: "cubic-bezier(0.26, 0, 0, 1.05)", 45 | ghostClass: "element__item--ghost", 46 | handle: ".element__item-handle" 47 | }; 48 | 49 | const wrapper = mount(Component, { 50 | props: { 51 | sortableOptions: { 52 | options: options 53 | } 54 | }, 55 | 56 | global: global 57 | }); 58 | 59 | const element = wrapper.get(".element"); 60 | const storeOptions = element.wrapperElement.$s.options; 61 | const instanceOptions = element.wrapperElement.$s.sortable.options; 62 | 63 | // Store should have correct options 64 | expect(storeOptions).toStrictEqual(options); 65 | 66 | // Sortable instance should have correct options 67 | expect(instanceOptions.animation).toStrictEqual(options.animation); 68 | expect(instanceOptions.easing).toStrictEqual(options.easing); 69 | expect(instanceOptions.ghostClass).toStrictEqual(options.ghostClass); 70 | expect(instanceOptions.handle).toStrictEqual(options.handle); 71 | }); 72 | 73 | it("should be created then disabled", async () => { 74 | const wrapper = mount(Component, { 75 | props: { 76 | sortableOptions: { 77 | disabled: false 78 | } 79 | }, 80 | 81 | global: global 82 | }); 83 | 84 | const element = wrapper.get(".element"); 85 | expect(element.wrapperElement.$s.sortable).toBeDefined(); 86 | 87 | await wrapper.setProps({ 88 | sortableOptions: { 89 | disabled: true 90 | } 91 | }); 92 | 93 | expect(element.wrapperElement.$s.sortable).toBeUndefined(); 94 | }); 95 | }); 96 | 97 | describe("basic behavior", () => { 98 | const chosenClass = "item-chosen"; 99 | 100 | const wrapper = mount(Component, { 101 | props: { 102 | sortableOptions: { 103 | options: { 104 | chosenClass: chosenClass 105 | } 106 | } 107 | }, 108 | 109 | global: global 110 | }); 111 | 112 | const itemA = wrapper.get("[data-id=\"1\"]"); 113 | 114 | it("should set correct 'chosen' class", async () => { 115 | const element = wrapper.get(".element"); 116 | 117 | await itemA.trigger("mousedown"); 118 | 119 | expect(wrapper.emitted()).toHaveProperty("choose"); 120 | expect(itemA.classes()).toContain(chosenClass); 121 | }); 122 | 123 | it("should remove 'chosen' class", async () => { 124 | document.dispatchEvent(new Event("mouseup")) 125 | 126 | expect(wrapper.emitted()).toHaveProperty("unchoose"); 127 | expect(itemA.classes()).not.toContain(chosenClass); 128 | }); 129 | }); 130 | --------------------------------------------------------------------------------