├── .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 | [](https://github.com/eliottvincent/vue3-sortablejs/actions) [](https://www.npmjs.com/package/vue3-sortablejs) [](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 |
73 | My Component
74 |
75 |
76 |
a
77 |
b
78 |
c
79 |
80 |
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 |
92 |
93 |
a
94 |
b
95 |
c
96 |
97 |
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 |
122 |
123 |
a
124 |
b
125 |
c
126 |
127 |
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 |
154 |
155 |
156 | {{ item }}
157 |
158 |
159 |
160 | Items data: {{ items }}
161 |
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 |
191 |
192 |
a
193 |
b
194 |
c
195 |
196 |
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 |
205 | Foo
206 |
207 |
208 |
209 | {{ item }}
210 |
211 |
212 |
213 | Bar
214 |
215 |
216 |
217 | {{ item }}
218 |
219 |
220 |
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 |
2 |
16 |
a
17 |
b
18 |
c
19 |
20 |
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 |
--------------------------------------------------------------------------------