├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .nvmrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── cypress.config.ts ├── cypress ├── fixtures │ └── example.json ├── plugins │ └── index.js └── support │ ├── commands.js │ ├── commands.ts │ ├── component-index.html │ ├── component.ts │ └── index.js ├── doc └── zh.md ├── docs ├── .vitepress │ ├── components │ │ ├── DragHandle.vue │ │ ├── FruitExample.vue │ │ ├── GroupExample.vue │ │ ├── KanbanExample.vue │ │ ├── LongListExample.vue │ │ ├── PageListExample.vue │ │ ├── ShorthandExample.vue │ │ ├── SimpleGroupExample.vue │ │ ├── SortableItem.vue │ │ ├── SortableList.vue │ │ └── utils.js │ ├── config.ts │ ├── style.styl │ └── theme │ │ └── index.js ├── basics.md ├── components │ ├── draghandle.md │ ├── slickitem.md │ └── slicklist.md ├── drag-and-drop.md ├── getting-started.md ├── index.md ├── introduction.md ├── kanban.md ├── migrating-1x.md ├── public │ ├── android-chrome-192x192.png │ ├── android-chrome-256x256.png │ ├── apple-touch-icon.png │ ├── confetti.min.js │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── favicon.ico │ ├── logo.png │ ├── logo.svg │ └── logomark.png ├── troubleshooting.md └── window-scroll.md ├── example ├── Example.vue ├── components │ ├── GroupExample.vue │ ├── InnerList.vue │ ├── SortableItem.vue │ └── SortableList.vue ├── index.html ├── index.js ├── util │ └── index.js └── vite.config.js ├── index.d.ts ├── logo ├── android-chrome-192x192.png ├── android-chrome-256x256.png ├── apple-touch-icon.png ├── brand.png ├── demo.gif ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── logo.png └── logomark.png ├── package.json ├── rollup.config.js ├── src ├── ContainerMixin.ts ├── ElementMixin.ts ├── HandleDirective.ts ├── Manager.ts ├── Sample.cy.jsx ├── SlicksortHub.ts ├── components │ ├── DragHandle.ts │ ├── SlickItem.ts │ └── SlickList.ts ├── index.ts ├── plugin.ts └── utils.ts ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": { 4 | "presets": ["@babel/preset-env"] 5 | }, 6 | "es6": { 7 | "presets": ["@babel/preset-env"], 8 | "plugins": ["transform-runtime"] 9 | }, 10 | "rollup": { 11 | "presets": [["@babel/preset-env", { "modules": false }]] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "plugin:vue/recommended", 7 | "plugin:cypress/recommended" 8 | ], 9 | "env": { 10 | "browser": true, 11 | "node": true, 12 | "mocha": true 13 | }, 14 | "parser": "@typescript-eslint/parser", 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "parserOptions": { 19 | "sourceType": "module", 20 | "ecmaVersion": 2017, 21 | "ecmaFeatures": { 22 | "experimentalObjectRestSpread": true 23 | } 24 | }, 25 | "rules": { 26 | "prefer-const": ["error"], 27 | "comma-dangle": ["error", "always-multiline"], 28 | "indent": ["error", 2, { "SwitchCase": 1 }], 29 | "semi": ["error", "always"], 30 | "no-mixed-operators": [ 31 | "warn", 32 | { 33 | "groups": [ 34 | ["&", "|", "^", "~", "<<", ">>", ">>>"], 35 | ["==", "!=", "===", "!==", ">", ">=", "<", "<="], 36 | ["in", "instanceof"] 37 | ], 38 | "allowSamePrecedence": true 39 | } 40 | ], 41 | "quotes": ["error", "single"], 42 | "eol-last": 2 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | node_modules 3 | dist 4 | styles.min.css 5 | styles.min.css.map 6 | coverage 7 | npm-debug.log 8 | .out 9 | yarn-error.log 10 | .cache 11 | .temp 12 | 13 | # Cypress 14 | cypress/videos 15 | cypress/results 16 | cypress/screenshots 17 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | .babelrc 3 | coverage 4 | src 5 | test 6 | .* 7 | *.md 8 | server.js 9 | index.html 10 | /index.js 11 | karma.conf.js 12 | webpack.config.js 13 | rollup.config.js 14 | babel.preprocess.sass.js 15 | codecov.yml 16 | .travis.yml 17 | .out 18 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16 -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 5 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [2.0.5](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.4...v2.0.5) (2023-02-12) 2 | 3 | 4 | 5 | ## [2.0.4](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.3...v2.0.4) (2023-02-12) 6 | 7 | 8 | 9 | ## [2.0.3](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.2...v2.0.3) (2022-11-25) 10 | 11 | 12 | 13 | ## [2.0.2](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.1...v2.0.2) (2022-11-19) 14 | 15 | 16 | 17 | ## [2.0.1](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.0...v2.0.1) (2022-11-19) 18 | 19 | 20 | 21 | # [2.0.0](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.0-alpha.5...v2.0.0) (2022-11-19) 22 | 23 | 24 | ### Features 25 | 26 | * track game wins and moves ([ef7d36e](https://github.com/Jexordexan/vue-slicksort/commit/ef7d36e8235df9005f10c8699a4f791bc1ad96d9)) 27 | 28 | 29 | 30 | # [2.0.0-alpha.5](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.0-alpha.4...v2.0.0-alpha.5) (2021-05-04) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * Missing hub warning ([7396699](https://github.com/Jexordexan/vue-slicksort/commit/73966998f6b2bfb01f432672efde7d23f155be03)) 36 | 37 | 38 | ### Features 39 | 40 | * Cancel drag on escape keypress ([c92833e](https://github.com/Jexordexan/vue-slicksort/commit/c92833e40bac858f2fc7b6330e854541751b21d7)) 41 | 42 | 43 | 44 | # [2.0.0-alpha.4](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.0-alpha.3...v2.0.0-alpha.4) (2021-02-24) 45 | 46 | 47 | ### Bug Fixes 48 | 49 | * animate nodes on drag out ([0a3c2be](https://github.com/Jexordexan/vue-slicksort/commit/0a3c2be16a5541ef8cf47eb4430faaba6ee52771)) 50 | * remove helper class before transition to allow animation ([ab5aafd](https://github.com/Jexordexan/vue-slicksort/commit/ab5aafdbee69cf8a5135701f745b29633a8ff8f1)) 51 | * useWindowAsScrollContainer ([0b65c19](https://github.com/Jexordexan/vue-slicksort/commit/0b65c1918b3c44c48d39f1e4f4d760fb7cdea65c)), closes [#88](https://github.com/Jexordexan/vue-slicksort/issues/88) 52 | 53 | 54 | 55 | # [2.0.0-alpha.3](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.0-alpha.2...v2.0.0-alpha.3) (2021-02-19) 56 | 57 | 58 | ### Bug Fixes 59 | 60 | * Better guessing for "closest" destination ([b462c57](https://github.com/Jexordexan/vue-slicksort/commit/b462c579978b10d26ef3ba7d5ace6e94f95b093d)) 61 | * drag settling animation ([cba65c3](https://github.com/Jexordexan/vue-slicksort/commit/cba65c38273034f64709fe157a681d6b77fd8f3e)) 62 | * Prevent memory leaks by holding on to old helper refs ([2ef5d66](https://github.com/Jexordexan/vue-slicksort/commit/2ef5d66b05e87c4b14a9ac9663d298e701026157)) 63 | * remove ghost reference on drag out ([d8e9adf](https://github.com/Jexordexan/vue-slicksort/commit/d8e9adfa285fa4e321c7bce9dd6e4a472b1f7925)) 64 | * revert setup() to data() ([c0528e5](https://github.com/Jexordexan/vue-slicksort/commit/c0528e50016ea3940bc650e2d91aa937c75c747a)) 65 | * timeout ssr compat ([06e9450](https://github.com/Jexordexan/vue-slicksort/commit/06e94507d61bf543f9b1955570a7ba2f85ed66ac)) 66 | * XY sorting bug when n=1 ([819bd4b](https://github.com/Jexordexan/vue-slicksort/commit/819bd4bf758771cbfe03a252e64a3977df309d8e)) 67 | 68 | 69 | ### Features 70 | 71 | * Add #item slot support for SlickList ([33ac30d](https://github.com/Jexordexan/vue-slicksort/commit/33ac30d264d71abb5d976874d127a5b53493452f)) 72 | * Animate nodes when dragging out ([962a706](https://github.com/Jexordexan/vue-slicksort/commit/962a706da08994234b27c16ba986f976737b061d)) 73 | * DragHandle component ([ab88102](https://github.com/Jexordexan/vue-slicksort/commit/ab881027c7dee6938a153961ec876d1217af220f)) 74 | 75 | 76 | 77 | # [2.0.0-alpha.2](https://github.com/Jexordexan/vue-slicksort/compare/v1.2.0...v2.0.0-alpha.2) (2021-02-16) 78 | 79 | 80 | ### Bug Fixes 81 | 82 | * Account for margin in drag and drop ([0c571bb](https://github.com/Jexordexan/vue-slicksort/commit/0c571bbce89e559c956b8809e41bd99aa22a251e)) 83 | * Account for scroll change when transitioning helper ([9a4c0c7](https://github.com/Jexordexan/vue-slicksort/commit/9a4c0c7eb9bf6b50ff55b720324a4dff967b6591)) 84 | * change setup() to data() ([6a5617d](https://github.com/Jexordexan/vue-slicksort/commit/6a5617de2b60513bac46f5dd7efd5dc0d407d898)) 85 | * components rendering with imported h ([daf9d09](https://github.com/Jexordexan/vue-slicksort/commit/daf9d0993517034e40cc1a3ea2d950bdc732c54e)) 86 | * drag-n-drop for vue 3 ([9f603d3](https://github.com/Jexordexan/vue-slicksort/commit/9f603d346b9e725ccf64d91970b5fa083ce625ce)) 87 | * Dragging between containers ([849aa8c](https://github.com/Jexordexan/vue-slicksort/commit/849aa8cd3cc48f4baf77e6cd94060f8ebf764986)) 88 | * nodes is undefined ([7e2409d](https://github.com/Jexordexan/vue-slicksort/commit/7e2409d7f167a220dbfc6533009267e79bdd24db)) 89 | * references to this.getOffset no longer valid ([94816c0](https://github.com/Jexordexan/vue-slicksort/commit/94816c0ca0df0a9d2547b02a369fc5f1a84b6a32)) 90 | * scroll compensation and node offset ([69fa066](https://github.com/Jexordexan/vue-slicksort/commit/69fa0666ab5c7b82135fe7cda189913d5d62d526)) 91 | * scrolling on mobile broken because of passive events ([37630b2](https://github.com/Jexordexan/vue-slicksort/commit/37630b2adec11dcaba40515ecf8519db8a68b8b9)) 92 | 93 | 94 | ### Features 95 | 96 | * add drag handle to plugin ([2b088a1](https://github.com/Jexordexan/vue-slicksort/commit/2b088a11272851fd81cd636fc22ed9179c86ac65)) 97 | * add support for allow and block props ([e032282](https://github.com/Jexordexan/vue-slicksort/commit/e0322824a721e0a7b37785142986e7545480d074)) 98 | * Allow :accept="true" as a prop ([d794cd7](https://github.com/Jexordexan/vue-slicksort/commit/d794cd7cbdf54af20e0e8320a096d2dbdcb70136)) 99 | * change modelValue to list prop ([d2c2488](https://github.com/Jexordexan/vue-slicksort/commit/d2c24886288b594e9447003baae08cda58569021)) 100 | * working drag between groups ([aa11244](https://github.com/Jexordexan/vue-slicksort/commit/aa1124454ceaba818d6b15ebf3cbd3777ed85a1b)) 101 | 102 | 103 | 104 | # [2.0.0-alpha.1](https://github.com/Jexordexan/vue-slicksort/compare/v2.0.0-alpha.0...v2.0.0-alpha.1) (2021-01-22) 105 | 106 | 107 | ### Bug Fixes 108 | 109 | * Handle Directive hook ([86d07d3](https://github.com/Jexordexan/vue-slicksort/commit/86d07d3107325e6afa59badc15c5876d372daeaa)) 110 | 111 | 112 | 113 | # [2.0.0-alpha.0](https://github.com/Jexordexan/vue-slicksort/compare/v1.1.3...v2.0.0-alpha.0) (2020-12-01) 114 | 115 | 116 | ### Features 117 | 118 | * Upgrade to Vue 3 ([e837d39](https://github.com/Jexordexan/vue-slicksort/commit/e837d3958e56b0571d48b7e2ddcd9c881e81e23b)) 119 | 120 | 121 | ### BREAKING CHANGES 122 | 123 | * No longer works in Vue 2 124 | 125 | 126 | 127 | ## Changelog 128 | 129 | ## 1.2.0 130 | 131 | - feat: `tag` props on SlickList and SlickItem components 132 | - fix: Dragging broken on Android when distance property is set 133 | - fix: node helper not clean 134 | - fix: add event listeners in passive mode 135 | - fix: stopPropogation on click events 136 | - fix: .d.ts types 137 | - docs: Chinese documentation 138 | - chore: lots of dependencies 139 | 140 | ## 1.1.0 141 | 142 | - ADDED appendTo prop - appends the helper to the selected DOM node. 143 | 144 | ## 1.0.0 145 | 146 | - ADDED Typescript support 147 | - ADDED Settling animation, the drag helper will now settle back into its new location after releasing the drag. 148 | - BREAKING: sortStart, sortMove, and sortEnd events are now kebab-case (sort-start, sort-move, sort-end) as per standard practice. 149 | - Any previous event listeners will need to be updated 150 | 151 | ### 0.1.5 152 | 153 | - Fixed a bug in firefox where the text got selected upon dragging 154 | 155 | ### 0.1.1 156 | 157 | - Fixed bug where the node index wasn't being updated on the manager 158 | 159 | ### 0.1.0 160 | 161 | - Initial push: Convert library from react to vue. 162 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018, Jordan Simonds 4 | 5 | Copyright for portions of this project are held by Claudéric Demers, 2016, as part of project react-sortable-hoc. 6 | All other copyright for this project are held by Jordan Simonds, 2018. 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vue Slicksort 🖖 2 | 3 | ![Slicksort logo](/logo/logomark.png) 4 | 5 | > A set of component mixins to turn any list into an animated, touch-friendly, sortable list. 6 | > Based on [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc) by [@clauderic] 7 | 8 | [![npm version](https://img.shields.io/npm/v/vue-slicksort.svg)](https://www.npmjs.com/package/vue-slicksort) 9 | [![npm downloads](https://img.shields.io/npm/dm/vue-slicksort.svg)](https://www.npmjs.com/package/vue-slicksort) 10 | [![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000)](https://github.com/Jexordexan/vue-slicksort/blob/master/LICENSE) 11 | ![gzip size](http://img.badgesize.io/https://npmcdn.com/vue-slicksort?compression=gzip) 12 | 13 |

14 | 15 | 16 | 17 |

18 | 19 | ### Examples available here: [vue-slicksort.netlify.app/](https://vue-slicksort.netlify.app/) 20 | 21 | ### [中文文档](./doc/zh.md) 22 | 23 | ## Features 24 | 25 | - **`v-model` Compatible** – Make any array editable with the `v-model` standard 26 | - **Mixin Components** – Integrates with your existing components 27 | - **Standalone Components** – Easy to use components for slick lists 28 | - **Drag handle, auto-scrolling, locked axis, events, and more!** 29 | - **Suuuper smooth animations** – Chasing the 60FPS dream 🌈 30 | - **Horizontal lists, vertical lists, or a grid** ↔ ↕ ⤡ 31 | - **Touch support** 👌 32 | - **Oh yeah, and it's DEPENDENCY FREE!** 👌 33 | 34 | ## Installation 35 | 36 | Using [npm](https://www.npmjs.com/package/vue-slicksort): 37 | 38 | ``` 39 | $ npm install vue-slicksort --save 40 | ``` 41 | 42 | Using yarn: 43 | 44 | ``` 45 | $ yarn add vue-slicksort 46 | ``` 47 | 48 | Using a CDN: 49 | 50 | ```html 51 | 52 | ``` 53 | 54 | Then, using a module bundler that supports either CommonJS or ES2015 modules, such as [webpack](https://github.com/webpack/webpack): 55 | 56 | ```js 57 | // Using an ES6 transpiler like Babel 58 | import { ContainerMixin, ElementMixin } from 'vue-slicksort'; 59 | 60 | // Not using an ES6 transpiler 61 | var slicksort = require('vue-slicksort'); 62 | var ContainerMixin = slicksort.ContainerMixin; 63 | var ElementMixin = slicksort.ElementMixin; 64 | ``` 65 | 66 | If you are loading the package via ` 72 | ``` 73 | 74 | ## Usage 75 | 76 | Check out the docs: [vue-slicksort.netlify.app](https://vue-slicksort.netlify.app/) 77 | 78 | 161 | 162 | 163 | 164 | ## Why should I use this? 165 | 166 | There are already a number of great Drag & Drop libraries out there (for instance, [vuedraggable](https://github.com/SortableJS/Vue.Draggable) is fantastic). If those libraries fit your needs, you should definitely give them a try first. However, most of those libraries rely on the HTML5 Drag & Drop API, which has some severe limitations. For instance, things rapidly become tricky if you need to support touch devices, if you need to lock dragging to an axis, or want to animate the nodes as they're being sorted. Vue Slicksort aims to provide a simple set of component mixins to fill those gaps. If you're looking for a dead-simple, mobile-friendly way to add sortable functionality to your lists, then you're in the right place. 167 | 404 | 405 | # FAQ 406 | 407 | 415 | 416 | 417 | ### Upgrade from v1.x 418 | 419 | There are a few changes in v2, mainly support for Vue 3 and dragging between groups. Read more about migrating here: 420 | [vue-slicksort.netlify.app/migrating-1x](https://vue-slicksort.netlify.app/migrating-1x) 421 | 422 | ### Upgrade from v0.x 423 | 424 | The event names have all changed from camelCase to dash-case to accommodate for inline HTML templates. 425 | 426 | ### Grid support? 427 | 428 | Need to sort items in a grid? We've got you covered! Just set the `axis` prop to `xy`. Grid support is currently limited to a setup where all the cells in the grid have the same width and height, though we're working hard to get variable width support in the near future. 429 | 430 | ### Item disappearing when sorting / CSS issues 431 | 432 | Upon sorting, `vue-slicksort` creates a clone of the element you are sorting (the _sortable-helper_) and appends it to the end of the `appendTo` tag. The original element will still be in-place to preserve its position in the DOM until the end of the drag (with inline-styling to make it invisible). If the _sortable-helper_ gets messed up from a CSS standpoint, consider that maybe your selectors to the draggable item are dependent on a parent element which isn't present anymore (again, since the _sortable-helper_ is at the end of the `appendTo` prop). This can also be a `z-index` issue, for example, when using `vue-slicksort` within a Bootstrap modal, you'll need to increase the `z-index` of the SortableHelper so it is displayed on top of the modal. 433 | 434 | ### Click events being swallowed 435 | 436 | By default, `vue-slicksort` is triggered immediately on `mousedown`. If you'd like to prevent this behaviour, there are a number of strategies readily available. You can use the `distance` prop to set a minimum distance (in pixels) to be dragged before sorting is enabled. You can also use the `pressDelay` prop to add a delay before sorting is enabled. Alternatively, you can also use the [HandleDirective](https://github.com/Jexordexan/vue-slicksort/blob/master/src/HandleDirective.js). 437 | 438 | ### Scoped styles 439 | 440 | If you are using scoped styles on the sortable list, you can use `appendTo` prop. 441 | 442 | ## Dependencies 443 | 444 | Slicksort has no dependencies. 445 | `vue` is the only peerDependency 446 | 447 | ## Reporting Issues 448 | 449 | If believe you've found an issue, please [report it](https://github.com/Jexordexan/vue-slicksort/issues) along with any relevant details to reproduce it. The easiest way to do so is to fork this [jsfiddle](https://jsfiddle.net/Jexordexan/1puv2L6c/). 450 | 451 | ## Asking for help 452 | 453 | Please file an issue for personal support requests. Tag them with `question`. 454 | 455 | ## Contributions 456 | 457 | Yes please! Feature requests / pull requests are welcome. 458 | 459 | ## Thanks 460 | 461 | This library is heavily based on [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc) by Claudéric Demers (@clauderic). A very simple and low overhead implementation of drag and drop that looks and performs great! 462 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "cypress"; 2 | 3 | export default defineConfig({ 4 | component: { 5 | setupNodeEvents(on, config) {}, 6 | specPattern: "src/**/*cy.*", 7 | }, 8 | 9 | component: { 10 | devServer: { 11 | framework: "vue", 12 | bundler: "vite", 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const { startDevServer } = require('@cypress/vite-dev-server'); 3 | const vue = require('@vitejs/plugin-vue'); 4 | 5 | module.exports = (on, config) => { 6 | const viteConfig = { 7 | plugins: [vue()], 8 | }; 9 | 10 | viteConfig.esbuild = viteConfig.esbuild || {}; 11 | viteConfig.esbuild.jsxFactory = 'h'; 12 | viteConfig.esbuild.jsxFragment = 'Fragment'; 13 | viteConfig.logLevel = 'error'; 14 | viteConfig.resolve = { 15 | alias: { 16 | vue: 'vue/dist/vue.esm-bundler.js', 17 | }, 18 | }; 19 | 20 | on('dev-server:start', (options) => { 21 | return startDevServer({ options, viteConfig }); 22 | }); 23 | return config; 24 | }; 25 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | Cypress.Commands.add('drag', { prevSubject: 'element' }, (subject, options) => { 18 | if (options.x && options.y) { 19 | cy.get(subject).trigger('mousedown', { which: 1 }); 20 | cy.document() 21 | .trigger('mousemove', { 22 | pageX: options.x, 23 | pageY: options.y, 24 | }) 25 | .trigger('mouseup', { force: true }); 26 | } 27 | }); 28 | // 29 | // 30 | // -- This is a dual command -- 31 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 32 | // 33 | // 34 | // -- This will overwrite an existing command -- 35 | Cypress.Commands.overwrite('get', (originalFn, selector, options) => { 36 | if (typeof selector === 'string') { 37 | return originalFn(selector.replace(/cy:(\w+)/g, '[data-cy="$1"]'), options); 38 | } else { 39 | return originalFn(selector, options); 40 | } 41 | }); 42 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************** 3 | // This example commands.ts shows you how to 4 | // create various custom commands and overwrite 5 | // existing commands. 6 | // 7 | // For more comprehensive examples of custom 8 | // commands please read more here: 9 | // https://on.cypress.io/custom-commands 10 | // *********************************************** 11 | // 12 | // 13 | // -- This is a parent command -- 14 | // Cypress.Commands.add('login', (email, password) => { ... }) 15 | // 16 | // 17 | // -- This is a child command -- 18 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 19 | // 20 | // 21 | // -- This is a dual command -- 22 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 23 | // 24 | // 25 | // -- This will overwrite an existing command -- 26 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 27 | // 28 | // declare global { 29 | // namespace Cypress { 30 | // interface Chainable { 31 | // login(email: string, password: string): Chainable 32 | // drag(subject: string, options?: Partial): Chainable 33 | // dismiss(subject: string, options?: Partial): Chainable 34 | // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable 35 | // } 36 | // } 37 | // } -------------------------------------------------------------------------------- /cypress/support/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Components App 8 | 9 | 10 |
11 | 12 | -------------------------------------------------------------------------------- /cypress/support/component.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/component.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | 22 | import { mount } from 'cypress/vue' 23 | 24 | // Augment the Cypress namespace to include type definitions for 25 | // your custom command. 26 | // Alternatively, can be defined in cypress/support/component.d.ts 27 | // with a at the top of your spec. 28 | declare global { 29 | namespace Cypress { 30 | interface Chainable { 31 | mount: typeof mount 32 | } 33 | } 34 | } 35 | 36 | Cypress.Commands.add('mount', mount) 37 | 38 | // Example use: 39 | // cy.mount(MyComponent) -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | // Alternatively you can use CommonJS syntax: 19 | // require('./commands') 20 | 21 | beforeEach(() => { 22 | cy.viewport(600, 600, { log: false }); 23 | }); 24 | -------------------------------------------------------------------------------- /doc/zh.md: -------------------------------------------------------------------------------- 1 | ## 功能 2 | 3 | - **`v-model` Compatible** – 依照 `v-model` 标准让所有数组可编辑 4 | - **Mixin Components** – 使用 `Mixin` 与你现有的组件集成 5 | - **Standalone Components** – 对于简单的列表直接使用现成组件 6 | - **Drag handle, auto-scrolling, locked axis, events, and more!** - 拖拽 handle,自动滚动,坐标轴锁定,事件等等 7 | - **Suuuper smooth animations** – 追逐 60FPS 的梦想 🌈 8 | - **Horizontal lists, vertical lists, or a grid** ↔ ↕ ⤡ - 支持水平列表,垂直列表和网格 9 | - **Touch support** 👌- 支持触控操作 10 | - **Oh yeah, and it's DEPENDENCY FREE!** 👌- 没有依赖其他库 11 | 12 | ## 安装 13 | 14 | 使用 [npm](https://www.npmjs.com/package/vue-slicksort): 15 | 16 | ``` 17 | $ npm install vue-slicksort --save 18 | ``` 19 | 20 | 使用 yarn: 21 | 22 | ``` 23 | $ yarn add vue-slicksort 24 | ``` 25 | 26 | 使用 CDN: 27 | 28 | ```html 29 | 30 | ``` 31 | 32 | 然后,使用支持 CommonJS 或者 ES2015 modules 的模块打包工具,例如 [webpack](https://github.com/webpack/webpack): 33 | 34 | ```js 35 | // 使用类似 Babel 这样的 ES6 编译工具 36 | import { ContainerMixin, ElementMixin } from 'vue-slicksort'; 37 | 38 | // 不使用 ES6 的编译工具 39 | var slicksort = require('vue-slicksort'); 40 | var ContainerMixin = slicksort.ContainerMixin; 41 | var ElementMixin = slicksort.ElementMixin; 42 | ``` 43 | 44 | 如果你通过 ` 50 | ``` 51 | 52 | ## 用法 53 | 54 | ### 基础示例 55 | 56 | ```js 57 | import Vue from 'vue'; 58 | import { ContainerMixin, ElementMixin } from 'vue-slicksort'; 59 | 60 | const SortableList = { 61 | mixins: [ContainerMixin], 62 | template: ` 63 |
    64 | 65 |
66 | `, 67 | }; 68 | 69 | const SortableItem = { 70 | mixins: [ElementMixin], 71 | props: ['item'], 72 | template: ` 73 |
  • {{item}}
  • 74 | `, 75 | }; 76 | 77 | const ExampleVue = { 78 | name: 'Example', 79 | template: ` 80 |
    81 | 82 | 83 | 84 |
    85 | `, 86 | components: { 87 | SortableItem, 88 | SortableList, 89 | }, 90 | data() { 91 | return { 92 | items: ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', 'Item 6', 'Item 7', 'Item 8'], 93 | }; 94 | }, 95 | }; 96 | 97 | const app = new Vue({ 98 | el: '#root', 99 | render: (h) => h(ExampleVue), 100 | }); 101 | ``` 102 | 103 | 就这样!默认情况下,Vue Slickort 不附带任何样式,因为它的目的是在现有组件上做扩展。 104 | 105 | ## Slicksort components 106 | 107 | 为了实现两个 mixins 预先设置了两个组件,使用方式如下: 108 | 109 | ```javascript 110 | import { SlickList, SlickItem } from 'vue-slicksort'; 111 | 112 | const ExampleVue = { 113 | name: 'Example', 114 | template: ` 115 |
    116 | 117 | 118 | {{ item }} 119 | 120 | 121 |
    122 | `, 123 | components: { 124 | SlickItem, 125 | SlickList, 126 | }, 127 | data() { 128 | return { 129 | items: ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5', 'Item 6', 'Item 7', 'Item 8'], 130 | }; 131 | }, 132 | }; 133 | ``` 134 | 135 | ## 为什么我要用这个? 136 | 137 | 现在已经有一些很好的(Drag & Drop)拖放库了(例如, [vuedraggable](https://github.com/SortableJS/Vue.Draggable) 就很棒),如果这些库能满足你的需求,你应该先试一下他们。然而,大部分的库依赖 HTML5 的(Drag & Drop)拖放 API,会有一些严重的兼容或限制问题。例如,如果你需要支持触控设备,再如果你需要锁定只在一个坐标方向上拖动,或者想要在节点进行排序时有动画,这些都会变得很麻烦。Vue Slicksort 提供了一系列简单的组件混入旨在解决之前提到的这些问题。如果你正在寻找一种非常简单、移动友好的方式将可排序功能添加到你的列表中,那么你算是来对地方了。 138 | 139 | ## 定制化和属性集 140 | 141 | 你可以在任何使用了 `ContainerMixin` 的组件上应用单一的 `props` 选项,在执行排序期间,该组件还会发出几个事件。下面是一个自定义组件的示例 142 | 143 | You apply options as individual `props` on whatever component is using the `ContainerMixin`. The component also emits several events during a sorting operation. Here's an example of a customized component: 144 | 145 | ```html 146 | 153 | 154 | 155 | ``` 156 | 157 | ## `ContainerMixin` 外层容器的 mixin 158 | 159 | ### Props 160 | 161 | #### `value` _(required)_ 162 | 163 | type: _Array_ 164 | 165 | `value` 可以从`v-model`继承,但必须设置为 `Container` 里面 `v-for` 遍历的列表相同的列表 166 | 167 | #### `axis` 168 | 169 | type: _String_ 170 | 171 | default: `y` 172 | 173 | 条目能按照水平,垂直,或者网格进行拖动,可选值有: `x`, `y` or `xy` 174 | 175 | #### `lockAxis` 176 | 177 | type: _String_ 178 | 179 | 当在排序时,你可以按照你的意愿锁定一个坐标轴方向进行移动,这是 HTML5 的 Drag & Drop 做不到的。可选值有: `x`, `y` or `xy` 180 | 181 | #### `helperClass` 182 | 183 | type: _String_ 184 | 185 | 如果你想给排序中的元素添加一些样式(注意:这个 class 是加给正在排序的那个拖起来的元素的),可以用这个属性。 186 | 187 | #### `appendTo` 188 | 189 | type: _String_ 190 | 191 | default: `body` 192 | 193 | 如果你想给正在排序的元素指定一个父元素并添加进去,可以使用这个属性,提供一个字符串的 querySelector 即可,默认是添加到 body 中的。 194 | 195 | #### `transitionDuration` 196 | 197 | type: _Number_ 198 | 199 | default: `300` 200 | 201 | 被拖动的元素移动时,其他元素的过渡持续时间(即 transition 属性的 duration),如果你想把过渡动画取消,可以设置成 0 。 202 | 203 | #### `draggedSettlingDuration` 204 | 205 | type: _Number_ 206 | 207 | default: `null` 208 | 209 | 覆写拖动的元素放开然后到安放位置的动画间隔时间(即放开拖拽后到元素进入安放位置的过渡时间),如果不设置,会按照 `transitionDuration` 的设置。 210 | 211 | #### `pressDelay` 212 | 213 | type: _Number_ 214 | 215 | default: `0` 216 | 217 | 如果你希望元素仅在按下一定时间后才可排序,更改此属性即可。 移动端合理的值是 `200`,此属性不能与 `distance` 一起使用。(这个属性可以用来解决拖动元素上有点击事件,但是点击事件不生效的情况) 218 | 219 | #### `pressThreshold` 220 | 221 | type: _Number_ 222 | 223 | default: `5` 224 | 225 | 忽略 press 事件之前可容忍的运动像素数 226 | 227 | #### `distance` 228 | 229 | type: _Number_ 230 | 231 | default: `0` 232 | 233 | 如果你希望元素仅在拖动一定的像素(即拖动一定距离)后才可排序,可以使用这个属性,此属性不能与 `pressDelay` 一起使用。(这个属性可以用来解决拖动元素上有点击事件,但是点击事件不生效的情况,个人感觉比 pressDelay 更合适) 234 | 235 | #### `useDragHandle` 236 | 237 | type: _Boolean_ 238 | 239 | default: `false` 240 | 241 | 如果你使用的是 `HandleDirective` ,将其设置为 `true` 242 | 243 | #### `useWindowAsScrollContainer` 244 | 245 | type: _Boolean_ 246 | 247 | default: `false` 248 | 249 | 如果需要,可以将 `window` 设置为滚动容器。(只有 `Container` 出现滚动条时才能看出效果) 250 | 251 | #### `hideSortableGhost` 252 | 253 | type: _Boolean_ 254 | 255 | default: `true` 256 | 257 | 控制是否自动隐藏 ghost 元素。默认情况下,为方便起见,Vue Slicksort 列表将自动隐藏当前正在排序的元素。 如果你想应用自己的样式,请将其设置为 false 。 258 | 259 | #### `lockToContainerEdges` 260 | 261 | type: _Boolean_ 262 | 263 | default: `false` 264 | 265 | 你可以将可排序元素的移动锁定到其父容器 `Container`。 266 | 267 | #### `lockOffset` 268 | 269 | type: _`OffsetValue` or [ `OffsetValue`, `OffsetValue` ]_\* 270 | 271 | default: `"50%"` 272 | 273 | 当 `lockToContainerEdges` 设置为 `true`,这个属性可以控制可排序 helper(即拖起来的那个元素)与其父容器 `Container` 的顶部/底部边缘之间的偏移距离。百分比的值是相对于当前正在排序的项目的高度,如果希望为锁定容器的顶部和底部指定不同的行为,还可以传入一个 `array`(例如:`["0%","100%"]`)。 274 | 275 | \* `OffsetValue` 可以是有限 `Number` ,也可以是由数字和单位(`px` or `%`)组成的 `String` 。 例如: `10` (与 `"10px"`相同), `"50%"` 276 | 277 | #### `shouldCancelStart` 278 | 279 | type: _Function_ 280 | 281 | default: [Function](https://github.com/Jexordexan/vue-slicksort/blob/main/src/ContainerMixin.js#L41) 282 | 283 | 该函数在排序开始之前被调用,可以用于编程方式在排序开始之前取消排序。 默认情况下,如果事件目标`input`, `textarea`, `select` 或 `option`,它将取消排序。 284 | 285 | #### `getHelperDimensions` 286 | 287 | type: _Function_ 288 | 289 | default: [Function](https://github.com/Jexordexan/vue-slicksort/blob/main/src/ContainerMixin.js#L49) 290 | 291 | 可选的 `function({node, index, collection})` 返回 SortableHelper 的计算尺寸,有关更多详细信息,请参见[默认实现](https://github.com/Jexordexan/vue-slicksort/blob/main/src/ContainerMixin.js#L49)。 292 | 293 | ### Events 294 | 295 | 事件是从 Container 元素发出的,可以使用 `v-bind` 或 `@ `指令绑定。 296 | 297 | #### `@sort-start` 298 | 299 | emits: `{ event: MouseEvent, node: HTMLElement, index: number, collection: string }` 300 | 301 | 排序开始时触发。 302 | 303 | #### `@sort-move` 304 | 305 | emits: `{ event }` 306 | 307 | 在排序期间移动鼠标时触发。 308 | 309 | #### `@sort-end` 310 | 311 | emits: `{ event, newIndex, oldIndex, collection }` 312 | 313 | 排序结束时触发。 314 | 315 | #### `@input` 316 | 317 | emits: `Array` 318 | 319 | 在排序结束然后新排序的列表生成后触发。 320 | 321 | --- 322 | 323 | ## `ElementMixin` 324 | 325 | ### Props 326 | 327 | #### `index` _(required)_ 328 | 329 | type: _Number_ 330 | 331 | 这是元素在列表中的排序索引,是必填属性。 332 | 333 | #### `collection` 334 | 335 | type: _Number or String_ 336 | 337 | default: `0` 338 | 339 | 元素所属的集合。如果你在同一 `Container` 中有多组可排序元素,这将很有用。[例子](http://Jexordexan.github.io/vue-slicksort/#/basic-configuration/multiple-lists) 340 | 341 | #### `disabled` 342 | 343 | type: _Boolean_ 344 | 345 | default: `false` 346 | 347 | 元素是否可排序 348 | 349 | ## `HandleDirective` 350 | 351 | `v-handle` 指令在可拖动元素内部使用。(即用了这个指令,可以让拖动只在元素的某个位置生效) 352 | 353 | `Container` 必须由 `:useDragHandle` 属性,且设置为 `true` 时才能正常工作。 354 | 355 | 这里有关于此的一个简单元素的例子 356 | 357 | ```html 358 | 365 | 366 | 374 | ``` 375 | 376 | # FAQ 377 | 378 | 386 | 387 | ### Upgrade from v0.x.x 388 | 389 | 事件名已经全部从驼峰命名更改为中划线分隔(dash-case),以适应内联 HTML 模板。 390 | 391 | ### Grid support? 392 | 393 | 需要排序一个网格中的元素?我们已经实现了,将 `axis` 属性设置成 `xy`即可,当前仅限于支持所有格子设置相同宽度和高度的网格。我们会继续努力在不久的未来支持可变宽度的支持 394 | 395 | ### Item disappearing when sorting / CSS issues (拖动元素消失) 396 | 397 | 在进行排序时,`vue-slicksort` 创建一个你正在排序的元素的克隆元素(即上面一直提到的 _sortable-helper_ ),然后添加到 `appendTo` 属性设置的标签的末尾。原始的元素会一直在 DOM 中保持在原来的位置直到拖动结束(使用了内联样式使其不可见)。如果从 CSS 角度来看, _sortable-helper_ 出了问题,可能是因为你的 draggable 元素的选择器依赖了一个不存在的父元素(还是刚才说的,因为 _sortable-helper_ 位于 `appendTo` 属性设置的标签的末尾)。也可能是 `z-index` 的问题,例如,在一个 Bootstrap modal(弹框)中使用 `vue-slicksort` ,如果想让 _sortable-helper_ 在弹框中展示出来,你就得增加它的 `z-index` ,来提高层级。 398 | 399 | ### Click events being swallowed(Click 事件无效) 400 | 401 | 默认情况,`vue-slicksort` 在 `mousedown` 时立即出发。如果你想要阻止这种行为,有一些现成的方法。你可以在启用排序之前使用 `distance` 属性设置一个触发拖动的最小距离(单位是像素)。也可以在启用排序之前使用 `pressDelay` 增加一个触发拖动的延迟。或者你也可以使用[HandleDirective](https://github.com/Jexordexan/vue-slicksort/blob/main/src/HandleDirective.js) 402 | 403 | ### Scoped styles 404 | 405 | 如果你在可排序列表中使用 scoped styles ,你可以使用 `appendTo` 属性。 406 | 407 | ## Dependencies 408 | 409 | Slicksort 无依赖。`vue` 是仅有的前置依赖。 410 | 411 | ## Example 412 | 413 | 这里有一个 `vue-slicksort` 的 [demo 项目](https://github.com/qiqihaobenben/vue-slicksort-demo),可以 down 到本地跑起来,把上面介绍的属性和事件实际用一下。 414 | 415 | ## Reporting Issues 416 | 417 | 如果你觉得发现了问题,请 [报告该问题](https://github.com/Jexordexan/vue-slicksort/issues) 以及所有相关详细信息以重现该问题。 提 issue 最简单的方法是 fork 此 [jsfiddle](https://jsfiddle.net/Jexordexan/1puv2L6c/) 。 418 | 419 | ## Asking for help 420 | 421 | 如果是个性化的支持请求,请用 `question` 标签标记 issue。 422 | 423 | ## Contributions 424 | 425 | 欢迎提交 Feature requests / pull requests 。 426 | 427 | ## Thanks 428 | 429 | 这个库很大程度上是基于 Claudéric Demers (@clauderic) 的 [react-sortable-hoc](https://github.com/clauderic/react-sortable-hoc) ,它是一个表现非常好的简单且高效的拖拽库。 430 | -------------------------------------------------------------------------------- /docs/.vitepress/components/DragHandle.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | -------------------------------------------------------------------------------- /docs/.vitepress/components/FruitExample.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 49 | -------------------------------------------------------------------------------- /docs/.vitepress/components/GroupExample.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 154 | 155 | 179 | -------------------------------------------------------------------------------- /docs/.vitepress/components/KanbanExample.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 93 | 94 | 140 | 141 | 163 | -------------------------------------------------------------------------------- /docs/.vitepress/components/LongListExample.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | 33 | 39 | -------------------------------------------------------------------------------- /docs/.vitepress/components/PageListExample.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | 33 | 39 | -------------------------------------------------------------------------------- /docs/.vitepress/components/ShorthandExample.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 29 | -------------------------------------------------------------------------------- /docs/.vitepress/components/SimpleGroupExample.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 88 | 89 | 99 | -------------------------------------------------------------------------------- /docs/.vitepress/components/SortableItem.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 30 | 31 | 36 | -------------------------------------------------------------------------------- /docs/.vitepress/components/SortableList.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /docs/.vitepress/components/utils.js: -------------------------------------------------------------------------------- 1 | export const random = (a = 1, b = 0) => { 2 | const lower = Math.min(a, b); 3 | const upper = Math.max(a, b) + 1; 4 | return lower + Math.floor(Math.random() * (upper - lower)); 5 | }; 6 | 7 | export const range = (length) => Array.from({ length }, (_, i) => i); 8 | 9 | export const uid = () => { 10 | // Math.random should be unique because of its seeding algorithm. 11 | // Convert it to base 36 (numbers + letters), and grab the first 9 characters 12 | // after the decimal. 13 | return '_' + Math.random().toString(36).substr(2, 9); 14 | }; 15 | 16 | export const stringsToItems = (arr) => { 17 | return arr.map((item) => { 18 | return { 19 | id: uid(), 20 | value: item, 21 | }; 22 | }); 23 | }; 24 | 25 | export const track = (...args) => { 26 | if (typeof gtag !== 'undefined') { 27 | gtag(...args); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitepress' 2 | 3 | const GOOGLE_APP_ID = 'G-6JF11BVDSJ' 4 | 5 | /* 6 | 7 | 14 | */ 15 | 16 | export default defineConfig({ 17 | title: 'Vue Slicksort', 18 | cleanUrls: true, 19 | head: [ 20 | ['link', { rel: 'icon', sizes: '32x32', href: '/favicon-32x32.png' }], 21 | ['link', { rel: 'icon', sizes: '16x16', href: '/favicon-16x16.png' }], 22 | ['script', { src: '/confetti.min.js' }], 23 | ['script', { src: `https://www.googletagmanager.com/gtag/js?id=${GOOGLE_APP_ID}` }], 24 | process.env.NODE_ENV === 'production' 25 | ? [ 26 | 'script', 27 | {}, 28 | ` 29 | window.dataLayer = window.dataLayer || []; 30 | function gtag() {dataLayer.push(arguments);} 31 | gtag('js', new Date()); 32 | gtag('config', '${GOOGLE_APP_ID}'); 33 | gtag('create', '${GOOGLE_APP_ID}', 'auto'); 34 | gtag('set', 'anonymizeIp', true); 35 | `, 36 | ] 37 | : ['meta', {}], 38 | ['meta', { property: 'og:title', content: 'Vue Slicksort' }], 39 | ['meta', { property: 'og:image', content: '/logo.png' }], 40 | ['meta', { property: 'og:image:width', content: '375' }], 41 | ['meta', { property: 'og:image:height', content: '375' }], 42 | ['meta', { property: 'og:image', content: '/logomark.png' }], 43 | ['meta', { property: 'og:image:width', content: '1219' }], 44 | ['meta', { property: 'og:image:height', content: '301' }], 45 | [ 46 | 'meta', 47 | { property: 'og:description', content: 'Beautiful, touch-friendly sorting for Vue 3' }, 48 | ], 49 | ['meta', { property: 'og:url', content: 'https://vue-slicksort.netlify.app' }], 50 | ['meta', { property: 'og:locale', content: 'en_US' }], 51 | ['meta', { property: 'twitter:image', content: '/logo.png' }], 52 | ['meta', { property: 'twitter:title', content: 'Vue Slicksort' }], 53 | ], 54 | themeConfig: { 55 | logo: '/logo.svg', 56 | socialLinks: [{ icon: 'github', link: 'https://github.com/Jexordexan/vue-slicksort' }], 57 | nav: [ 58 | { text: 'Guide', link: '/introduction' }, 59 | { text: 'Migration from 1.x', link: '/migrating-1x' }, 60 | ], 61 | 62 | sidebar: [ 63 | { 64 | text: 'Guide', 65 | items: [ 66 | { text: 'Introduction', link: '/introduction' }, 67 | { text: 'Getting started', link: '/getting-started' }, 68 | { text: 'Basic use', link: '/basics' }, 69 | { text: 'Drag and drop', link: '/drag-and-drop' }, 70 | { text: 'Troubleshooting', link: '/troubleshooting' }, 71 | { text: 'Migrating from 1.x', link: '/migrating-1x' }, 72 | ], 73 | }, 74 | { 75 | text: 'Components', 76 | items: [ 77 | { text: 'SlickList', link: '/components/slicklist' }, 78 | { text: 'SlickItem', link: '/components/slickitem' }, 79 | { text: 'DragHandle', link: '/components/draghandle' }, 80 | ], 81 | }, 82 | { 83 | text: 'Demos', 84 | items: [ 85 | { text: 'Kanban', link: '/kanban' }, 86 | { text: 'Window scroll', link: '/window-scroll' }, 87 | ], 88 | }, 89 | ], 90 | }, 91 | }) 92 | -------------------------------------------------------------------------------- /docs/.vitepress/style.styl: -------------------------------------------------------------------------------- 1 | $rubyred = #eb5757 2 | $purp = #9b51e0 3 | $bluetopaz = #58cbf2 4 | 5 | :root 6 | --vp-c-brand-lighter: lighten($purp, 30%); 7 | --vp-c-brand-light: lighten($purp, 20%); 8 | --vp-c-brand: $purp; 9 | --vp-c-brand-dark: darken($purp, 0%); 10 | --vp-c-brand-darker: darken($purp, 10%); 11 | 12 | .nav-bar-title .logo 13 | height: 2rem; 14 | 15 | ul.example-list 16 | margin 0 17 | padding 0 18 | overflow auto 19 | &.horizontal 20 | display flex 21 | 22 | .example-list-item 23 | list-style-type none 24 | display flex 25 | align-items center 26 | padding 10px 20px 27 | margin 10px 28 | border-radius 10px 29 | font-weight bold 30 | background white 31 | box-shadow inset 0 0 0 3px rgba(0, 0, 0, 0.1), 1px 2px 5px rgba(0, 0, 0, 0.15) 32 | background $purp 33 | color white 34 | line-height 1.4 35 | user-select none 36 | 37 | // Drag handle 38 | .example-drag-handle 39 | display inline-block 40 | width 25px 41 | height 25px 42 | background-image url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 50 50'%3E%3Cpath d='M 0 7.5 L 0 12.5 L 50 12.5 L 50 7.5 L 0 7.5 z M 0 22.5 L 0 27.5 L 50 27.5 L 50 22.5 L 0 22.5 z M 0 37.5 L 0 42.5 L 50 42.5 L 50 37.5 L 0 37.5 z' color='white'%3E%3C/path%3E%3C/svg%3E") 43 | background-size contain 44 | background-repeat no-repeat 45 | opacity 0.5 46 | margin-right 10px 47 | cursor: grab 48 | 49 | h4 50 | margin-top 1em 51 | 52 | 53 | .doc-button 54 | padding: 5px 15px; 55 | background: white; 56 | color: var(--vp-c-brand); 57 | border: 2px solid #ccc; 58 | border-radius: 5px; 59 | cursor: pointer; 60 | font-size: inherit; 61 | 62 | &:hover 63 | background: #eee; 64 | 65 | &:active 66 | background: #ccc; 67 | 68 | &.space-start 69 | margin-inline-start: auto; 70 | 71 | .kanban-page 72 | max-width: 0 73 | 74 | .inline-badge 75 | > p 76 | display flex 77 | gap 1rem -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | import { watchEffect } from 'vue'; 2 | import DefaultTheme from 'vitepress/theme'; 3 | import GroupExample from '../components/GroupExample.vue'; 4 | import FruitExample from '../components/FruitExample.vue'; 5 | import LongListExample from '../components/LongListExample.vue'; 6 | import PageListExample from '../components/PageListExample.vue'; 7 | import ShorthandExample from '../components/ShorthandExample.vue'; 8 | import SimpleGroupExample from '../components/SimpleGroupExample.vue'; 9 | import KanbanExample from '../components/KanbanExample.vue'; 10 | import { track } from '../components/utils'; 11 | import { plugin } from '../../../src'; 12 | import '../style.styl'; 13 | 14 | export default { 15 | ...DefaultTheme, 16 | enhanceApp({ app, router }) { 17 | watchEffect(() => { 18 | track('set', 'page', router.route.path); 19 | track('send', 'pageview'); 20 | }); 21 | 22 | app.use(plugin); 23 | app.component('GroupExample', GroupExample); 24 | app.component('FruitExample', FruitExample); 25 | app.component('ShorthandExample', ShorthandExample); 26 | app.component('LongListExample', LongListExample); 27 | app.component('PageListExample', PageListExample); 28 | app.component('SimpleGroupExample', SimpleGroupExample); 29 | app.component('KanbanExample', KanbanExample); 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /docs/basics.md: -------------------------------------------------------------------------------- 1 | # Basic Use 2 | 3 | This is a basic example of a sortable list. `v-model:list` allows data-binding between the list of fruits. 4 | 5 | ```vue 6 | 13 | 14 | 29 | ``` 30 | 31 | **Try it out!** 32 | 33 | 34 | 35 | 36 | ::: tip 37 | `:key` should refer to unique data, like an ID attribute, that can be used to track the item as it moves. Using the index (`i`) as the key is not advised and may cause unwanted rendering effects. 38 | ::: 39 | 40 | ## Slot Shorthand 41 | 42 | If you want build sortable lists quickly, use the `item` scoped slot in the `SlickList` component. 43 | This slot will repeat for each item in the `list` prop. This has to be used with `v-model:list` or the `:list` prop on the container. 44 | 45 | ```vue 46 | 54 | ``` 55 | 56 | 57 | 58 | 59 | 60 | ## Horizontal List 61 | 62 | If your lists are layed out along the X axis, pass in the `axis="x"` prop and it will just work! 63 | You will have to handle the actual CSS of making them horizontal, `display: flex` should do the trick. 64 | 65 | ```html 66 | 67 | 68 | 69 | ``` 70 | 71 | 72 | 73 | 74 | 75 | ## Using a drag handle 76 | 77 | By default the entire list item is the handle for the drag events. This might be a problem if you have inputs or buttons or other interactive elements within the items. You can use the drag handle mixin to make any element a handle. 78 | 79 | ```html 80 | 81 | 82 | 83 | 84 | 85 | {{ fruit }} 86 | 87 | 88 | ``` 89 | 90 | 91 | 92 | 93 | 94 | ## Autoscroll 95 | 96 | Slicksort will automatically autoscroll within the container if it overflows. 97 | 98 | ```vue 99 | 108 | 109 | 125 | 126 | 132 | ``` 133 | 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /docs/components/draghandle.md: -------------------------------------------------------------------------------- 1 | 2 | # `DragHandle` Component 3 | 4 | This component becomes a dedicated handle for the SlickItem. The SlickList must have the `useDragHandle` prop. 5 | 6 | ## Props 7 | 8 | ### `tag` 9 | 10 | type: `string` 11 | 12 | default: `div` 13 | 14 | The HTML tag that will render in the DOM. 15 | 16 | ## Slots 17 | 18 | ### `default` 19 | 20 | The item content 21 | 22 | -------------------------------------------------------------------------------- /docs/components/slickitem.md: -------------------------------------------------------------------------------- 1 | 2 | # `SlickItem` Component 3 | 4 | ## Props 5 | 6 | ### `index` _(required)_ 7 | 8 | type: `number` 9 | 10 | **Required** 11 | 12 | This is the element's sortableIndex within it's collection. This prop is required. 13 | 14 | ### `collection` 15 | 16 | **REMOVED IN v2.0.0** 17 | Use `group` and multiple scroll containers instead. 18 | 19 | See [Migration docs](/migrating-1x.html#collection-removed) 20 | 21 | ### `disabled` 22 | 23 | type: `boolean` 24 | 25 | default: `false` 26 | 27 | Whether the element should be sortable or not 28 | 29 | ### `tag` 30 | 31 | type: `string` 32 | 33 | default: `div` 34 | 35 | The HTML tag that will render in the DOM. 36 | 37 | ## Slots 38 | 39 | ### `default` 40 | 41 | The item content 42 | -------------------------------------------------------------------------------- /docs/components/slicklist.md: -------------------------------------------------------------------------------- 1 | 2 | # `SlickList` Component 3 | 4 | The `SlickList` contains several [`SlickItem`](slickitem) 5 | 6 | ## Props 7 | 8 | ### `tag` 9 | 10 | type: `string` 11 | 12 | default: `div` 13 | 14 | The HTML tag that will render in the DOM. 15 | 16 | ### `list` 17 | 18 | type: `any[]` 19 | 20 | **Required** 21 | 22 | The `list` can be inherited from `v-model:list` but has to be set to the same list that is rendered with `v-for` inside the `SlickList`. 23 | 24 | ### `group` 25 | 26 | type: `string` 27 | 28 | The group that this list belongs to. By default, the group is not set and items cannot be dragged in or out of the list. When the group is set, all lists in that group can drag between each other. 29 | 30 | ::: warning 31 | Setting the `group` prop requires the `Slicksort` vue plugin to be installed in your app. 32 | ::: 33 | 34 | ### `accept` 35 | 36 | type: `true | string[] | ({ source, dest, payload }) => boolean` 37 | 38 | default: `null` 39 | 40 | The groups that can be dragged into this container. If `true`, the list will accept items from all other lists. If `string[]`, the list will accept groups listed. If a function is passed, it should return a boolean, where `true` means the item is acceptable. The function will be called with a context object with the following properties: 41 | 42 | - `source`: The drag source component, with properties like `group`, `list`, and others 43 | - `dest`: The drag destination component, with the same properties as the `source` 44 | - `payload`: the data value of the item that is being dragged 45 | 46 | ### `block` 47 | 48 | type: `string[]` 49 | 50 | default: `[]` 51 | 52 | Allows you to block specific groups. This will override `accept: true`, to allow all but a few groups. 53 | 54 | ### `axis` 55 | 56 | type: `string` 57 | 58 | default: `y` 59 | 60 | Items can be sorted horizontally, vertically or in a grid. Possible values: `x`, `y` or `xy` 61 | 62 | ### `lockAxis` 63 | 64 | type: `string` 65 | 66 | If you'd like, you can lock movement to an axis while sorting. This is not something that is possible with HTML5 Drag & Drop 67 | 68 | ### `helperClass` 69 | 70 | type: `string` 71 | 72 | You can provide a class you'd like to add to the sortable helper to add some styles to it 73 | 74 | ### `appendTo` 75 | 76 | type: `string` (Query selector) 77 | 78 | default: `body` 79 | 80 | This is the element that the sortable helper is added to when sorting begins. You would change this if you would like to encapsulate the drag helper within a positioned or scrolled container. 81 | 82 | ### `transitionDuration` 83 | 84 | type: `number` 85 | 86 | default: `300` 87 | 88 | The duration of the transition when elements shift positions. Set this to `0` if you'd like to disable transitions 89 | 90 | ### `draggedSettlingDuration` 91 | 92 | type: `number` 93 | 94 | default: `transitionDuration` 95 | 96 | Override the settling duration for the drag helper. If not set, `transitionDuration` will be used. 97 | 98 | ### `pressDelay` 99 | 100 | type: `number` 101 | 102 | default: `0` 103 | 104 | If you'd like elements to only become sortable after being pressed for a certain time, change this property. A good sensible default value for mobile is `200`. Cannot be used in conjunction with the `distance` prop. 105 | 106 | ### `pressThreshold` 107 | 108 | type: `number` 109 | 110 | default: `5` 111 | 112 | Number of pixels of movement to tolerate before ignoring a press event. 113 | 114 | ### `distance` 115 | 116 | type: `number` 117 | 118 | default: `0` 119 | 120 | If you'd like elements to only become sortable after being dragged a certain number of pixels. Cannot be used in conjunction with the `pressDelay` prop. 121 | 122 | ### `cancelKey` 123 | 124 | type: `string` 125 | 126 | default: `"Escape"` 127 | 128 | The key that will stop the current drag and return the element to its original position. Fires `@sort-cancel` when complete. This can be any [KeyboardEvent.key value](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values). 129 | 130 | ### `useDragHandle` 131 | 132 | type: `boolean` 133 | 134 | default: `false` 135 | 136 | If you're using the `HandleDirective`, set this to `true` 137 | 138 | ### `useWindowAsScrollContainer` 139 | 140 | type: `boolean` 141 | 142 | default: `false` 143 | 144 | If you want, you can set the `window` as the scrolling container 145 | 146 | ### `hideSortableGhost` 147 | 148 | type: `boolean` 149 | 150 | default: `true` 151 | 152 | Whether to auto-hide the ghost element. By default, as a convenience, Vue Slicksort List will automatically hide the element that is currently being sorted. Set this to false if you would like to apply your own styling. 153 | 154 | ### `lockToContainerEdges` 155 | 156 | type: `boolean` 157 | 158 | default: `false` 159 | 160 | You can lock movement of the sortable element to it's parent `Container` 161 | 162 | ### `lockOffset` 163 | 164 | type: _`OffsetValue` or [ `OffsetValue`, `OffsetValue` ]_\* 165 | 166 | default: `"50%"` 167 | 168 | When `lockToContainerEdges` is set to `true`, this controls the offset distance between the sortable helper and the top/bottom edges of it's parent `Container`. Percentage values are relative to the height of the item currently being sorted. If you wish to specify different behaviours for locking to the _top_ of the container vs the _bottom_, you may also pass in an `array` (For example: `["0%", "100%"]`). 169 | 170 | \* `OffsetValue` can either be a finite `Number` or a `String` made up of a number and a unit (`px` or `%`). 171 | Examples: `10` (which is the same as `"10px"`), `"50%"` 172 | 173 | ### `shouldCancelStart` 174 | 175 | type: _Function_ 176 | 177 | default: [Function](https://github.com/Jexordexan/vue-slicksort/blob/master/src/ContainerMixin.js#L41) 178 | 179 | This function is invoked before sorting begins, and can be used to programatically cancel sorting before it begins. By default, it will cancel sorting if the event target is either an `input`, `textarea`, `select` or `option`. 180 | 181 | ### `getHelperDimensions` 182 | 183 | type: _Function_ 184 | 185 | default: [Function](https://github.com/Jexordexan/vue-slicksort/blob/master/src/ContainerMixin.js#L49) 186 | 187 | Optional `function({node, index})` that should return the computed dimensions of the SortableHelper. See [default implementation](https://github.com/Jexordexan/vue-slicksort/blob/master/src/ContainerMixin.js#L49) for more details 188 | 189 | ## Events 190 | 191 | Events are emitted from the Container element, and can be bound to using `v-bind` or `@` directives 192 | 193 | ### `@sort-start` 194 | 195 | emits: `{ event: MouseEvent, node: HTMLElement, index: number }` 196 | 197 | Fired when sorting begins. 198 | 199 | ### `@sort-move` 200 | 201 | emits: `{ event }` 202 | 203 | Fired when the mouse is moved during sorting. 204 | 205 | ### `@sort-end` 206 | 207 | emits: `{ event, newIndex, oldIndex }` 208 | 209 | Fired when sorting has ended. 210 | 211 | ### `@sort-cancel` 212 | 213 | emits: `{ event, newIndex, oldIndex }` 214 | 215 | Fired when sorting has been canceled. YOu can set which key cancels a drag with the `cancelKey` prop. 216 | 217 | ### `@sort-insert` 218 | 219 | emits: `{ newIndex, value }` 220 | 221 | Fired when an item is dragged from another list into this one 222 | 223 | ### `@sort-remove` 224 | 225 | emits: `{ oldIndex }` 226 | 227 | Fired when an item is dragged from this list and dropped in another 228 | 229 | ### `@update:list` 230 | 231 | emits: `Array` 232 | 233 | Fired after sorting has ended with the newly sorted list. This is compatible with `v-model:list`. 234 | 235 | ## Slots 236 | 237 | ### `default` 238 | 239 | scope: `none` 240 | 241 | The Your list of `SlickItems` goes here 242 | 243 | ### `item` (scoped) 244 | 245 | scope: `{ item }` 246 | 247 | This is a scoped slot that will repeat for every item in `list`. This should be used in place of the `default` slot for making easier sortable list. 248 | 249 | -------------------------------------------------------------------------------- /docs/drag-and-drop.md: -------------------------------------------------------------------------------- 1 | 2 | # Drag and drop 3 | 4 | New in V2, you can now drag items between lists! 5 | 6 | In order to enabled drag-and-drop, you need to install the Slicksort plugin. Instructions are on the [Getting started](/getting-started.html#using-the-plugin) page. 7 | 8 | Every list that you want to drag between then needs the `group` prop to be set. If all lists have the same "group", they will allow dragging between all lists. If using different groups, you can use the `accept` prop to allow a set of other groups that can be dragged into the list. 9 | 10 | ```html 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | ``` 23 | 24 | ## Between the same group 25 | 26 | 27 | 28 | 29 | 30 | ## Between different groups 31 | 32 | In the example below, three lists have different groups and `accept` props. 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting started 2 | 3 | ## Installing 4 | 5 | Install the package to your project 6 | 7 | ``` 8 | npm install vue-slicksort@next 9 | ``` 10 | 11 | ``` 12 | yarn add vue-slicksort@next 13 | ``` 14 | 15 | ## Using the plugin 16 | 17 | If you want the ability to drag items between lists, you must install the plugin to your Vue app. 18 | 19 | ```js 20 | // main.js 21 | 22 | import { plugin as Slicksort } from 'vue-slicksort'; 23 | 24 | const app = createApp(/* App */); 25 | 26 | // Enables groups and drag and drop functionality 27 | app.use(Slicksort); 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: Vue Slicksort 6 | # text: Super slick 7 | tagline: Super smooth, touch-friendly, sorting for Vue 3 8 | image: 9 | src: /Logo.svg 10 | alt: Slicksort 11 | actions: 12 | - theme: brand 13 | text: To the Docs! 14 | link: /introduction 15 | - theme: alt 16 | text: View on GitHub 17 | link: https://github.com/jexordexan/vue-slicksort 18 | features: 19 | - title: 🥰 Simple 20 | details: Easy sorting with `v-model` components. Drag and drop between lists is also supported! 21 | - title: ⚡️ Fast 22 | details: Silky smooth animations at 60 fps 🔥. Users will be delighted by the experience of sorting 23 | - title: 🪶 Light 24 | details: Only 7kb (gzipped) and no dependencies! 25 | footer: MIT Licensed | Copyright © 2019-present Jordan Simonds 26 | --- 27 | 28 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Slicksort exports two components for quickly creating sortable lists. These have to be used together, or with the slot shorthand. 4 | 5 | - `SlickList` For wrapping a list of items ([API](components/slicklist)) 6 | - `SlickItem` For wrapping a single item and its content ([API](components/slickitem)) 7 | 8 | The functionality of these components are also available as mixins, by using the `ContainerMixin` and `ElementMixin`, you can make your own sortable components. 9 | 10 |
    11 | 12 | [![npm version](https://img.shields.io/npm/v/vue-slicksort?style=for-the-badge)](https://www.npmjs.com/package/vue-slicksort) 13 | [![npm downloads](https://img.shields.io/npm/dm/vue-slicksort?style=for-the-badge)](https://www.npmjs.com/package/vue-slicksort) 14 | [![npm bundle size (version)](https://img.shields.io/bundlephobia/minzip/vue-slicksort/next?label=gzip&style=for-the-badge)](https://bundlephobia.com/result?p=vue-slicksort@next) 15 | [![license](https://img.shields.io/github/license/mashape/apistatus.svg?maxAge=2592000&style=for-the-badge)](https://github.com/Jexordexan/vue-slicksort/blob/dev/LICENSE) 16 | 17 |
    18 | 19 | ## Try it out! 20 | 21 | 22 |
    23 | 24 |
    25 |
    26 | -------------------------------------------------------------------------------- /docs/kanban.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Kanban example' 3 | aside: false 4 | --- 5 | 6 | ## Kanban example 7 | 8 | This example uses many advanced features at once. You can see the source code [here](https://github.com/Jexordexan/vue-slicksort/blob/docs/docs/.vitepress/components/KanbanExample.vue). 9 | - Nested lists 10 | - Drag and drop 11 | - Grid sort 12 | - Lock axis 13 | - Drag handle 14 | - Helper styles 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/migrating-1x.md: -------------------------------------------------------------------------------- 1 | # Migrating from 1.x 2 | 3 | Slicksort Version 2 is an overhaul of Version 1 with many awaited features, including sorting between lists! It also comes with some breaking changes. 4 | 5 | ## Breaking changes 6 | 7 | ### Vue 3 only 8 | 9 | Vue 2 support has been removed for now. We may revisit this later with the [vue-demi](https://github.com/vueuse/vue-demi) library. In order to use v2 of `vue-slicksort` you must upgrade to Vue 3. Apologies for the inconvenience! 10 | 11 | ### V-model changes 12 | 13 | With the transition to Vue 3, we chose to use named v-model. Anywhere you used `v-model` in V1, must become `v-model:list`. 14 | 15 | Before: 16 | 17 | ``` 18 | ... 19 | ``` 20 | 21 | After: 22 | 23 | ``` 24 | ... 25 | ``` 26 | 27 | This was done to make the emitted events more explicit, so if you were using the expanded v-model syntax, the props/events have changed from `:value` and `@input` to `:list` and `@update:list`. 28 | 29 | Before: 30 | 31 | ``` 32 | ... 33 | ``` 34 | 35 | After: 36 | 37 | ``` 38 | ... 39 | ``` 40 | 41 | ### `collection` Removed 42 | 43 | The `collection` prop has been removed from the `Element`. This props purpose has been replaced by the `group` prop on the `Container`, which can allow/prevent dragging between lists. This means that any list with elements of different `collections` should be refactored to be a set of lists with different groups. This is a simple example of the transition. 44 | 45 | **Before** 46 | 47 | ```html 48 | 55 | ``` 56 | 57 | **After** 58 | 59 | ```html 60 | 71 | ``` 72 | 73 | 105 | -------------------------------------------------------------------------------- /docs/public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/docs/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /docs/public/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/docs/public/android-chrome-256x256.png -------------------------------------------------------------------------------- /docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/public/confetti.min.js: -------------------------------------------------------------------------------- 1 | var confetti={maxCount:150,speed:2,frameInterval:15,alpha:1,gradient:!1,start:null,stop:null,toggle:null,pause:null,resume:null,togglePause:null,remove:null,isPaused:null,isRunning:null};!function(){confetti.start=s,confetti.stop=w,confetti.toggle=function(){e?w():s()},confetti.pause=u,confetti.resume=m,confetti.togglePause=function(){i?m():u()},confetti.isPaused=function(){return i},confetti.remove=function(){stop(),i=!1,a=[]},confetti.isRunning=function(){return e};var t=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.oRequestAnimationFrame||window.msRequestAnimationFrame,n=["rgba(30,144,255,","rgba(107,142,35,","rgba(255,215,0,","rgba(255,192,203,","rgba(106,90,205,","rgba(173,216,230,","rgba(238,130,238,","rgba(152,251,152,","rgba(70,130,180,","rgba(244,164,96,","rgba(210,105,30,","rgba(220,20,60,"],e=!1,i=!1,o=Date.now(),a=[],r=0,l=null;function d(t,e,i){return t.color=n[Math.random()*n.length|0]+(confetti.alpha+")"),t.color2=n[Math.random()*n.length|0]+(confetti.alpha+")"),t.x=Math.random()*e,t.y=Math.random()*i-i,t.diameter=10*Math.random()+5,t.tilt=10*Math.random()-10,t.tiltAngleIncrement=.07*Math.random()+.05,t.tiltAngle=Math.random()*Math.PI,t}function u(){i=!0}function m(){i=!1,c()}function c(){if(!i)if(0===a.length)l.clearRect(0,0,window.innerWidth,window.innerHeight),null;else{var n=Date.now(),u=n-o;(!t||u>confetti.frameInterval)&&(l.clearRect(0,0,window.innerWidth,window.innerHeight),function(){var t,n=window.innerWidth,i=window.innerHeight;r+=.01;for(var o=0;on+20||t.x<-20||t.y>i)&&(e&&a.length<=confetti.maxCount?d(t,n,i):(a.splice(o,1),o--))}(),function(t){for(var n,e,i,o,r=0;ro){var f=n;n=o,o=f}s=a.length+(Math.random()*(o-n)+n|0)}else s=a.length+n;else o&&(s=a.length+o);for(;a.length 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/public/logomark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/docs/public/logomark.png -------------------------------------------------------------------------------- /docs/troubleshooting.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | ## FAQ 4 | 5 | ### Items re-render after sort 6 | 7 | You need to use the `:key` attribute on every list item, and it needs to be unique to the object you are displaying, like an `id`. Using `index` can lead to re-rendered lists because the index changes when sorting 8 | 9 | ✅ **GOOD** 10 | 11 | ```html 12 | 13 | ``` 14 | 15 | ❌ **BAD** 16 | 17 | ```html 18 | 19 | ``` 20 | 21 | ### Use with `transition-group` 22 | 23 | Slicksort is not compatible with `transition-group` because they both try to animate the positions of the nodes. Once the drag is done and the `list` is updated, `transition-group` then tries to animate everything again. 24 | 25 | ### Drag helper is unstyled 26 | 27 | Since the drag helper is appended to the `body`, any scoped styles will not apply. This could be the situation if your css relies on: 28 | 29 | - ` 52 | -------------------------------------------------------------------------------- /example/components/GroupExample.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /example/components/InnerList.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /example/components/SortableItem.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /example/components/SortableList.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Vue Sortable 5 | 6 | 51 | 52 | 53 |
    Loading demo
    54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue'; 2 | import { plugin as Slicksort } from '../src'; 3 | import Example from './Example.vue'; 4 | 5 | const app = createApp(Example); 6 | 7 | app.use(Slicksort); 8 | 9 | app.mount('#root'); 10 | -------------------------------------------------------------------------------- /example/util/index.js: -------------------------------------------------------------------------------- 1 | export function random(min, max) { 2 | return Math.floor(Math.random() * (max - min)) + min; 3 | } 4 | 5 | export function range(length) { 6 | return Array(length) 7 | .fill() 8 | .map((_, i) => i); 9 | } 10 | -------------------------------------------------------------------------------- /example/vite.config.js: -------------------------------------------------------------------------------- 1 | import vue from '@vitejs/plugin-vue'; 2 | 3 | export default { 4 | plugins: [vue()], 5 | }; 6 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'vue-slicksort' { 2 | import { DirectiveOptions, Component, Plugin } from 'vue'; 3 | 4 | export const ContainerMixin: Component; 5 | export const ElementMixin: Component; 6 | 7 | export const SlickList: Component; 8 | export const SlickItem: Component; 9 | export const DragHandle: Component; 10 | 11 | export const HandleDirective: DirectiveOptions; 12 | 13 | export function arrayMove(arr: Array, prevIndex: number, newIndex: number): Array; 14 | 15 | export const plugin: Plugin; 16 | } 17 | -------------------------------------------------------------------------------- /logo/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/android-chrome-192x192.png -------------------------------------------------------------------------------- /logo/android-chrome-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/android-chrome-256x256.png -------------------------------------------------------------------------------- /logo/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/apple-touch-icon.png -------------------------------------------------------------------------------- /logo/brand.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/brand.png -------------------------------------------------------------------------------- /logo/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/demo.gif -------------------------------------------------------------------------------- /logo/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/favicon-16x16.png -------------------------------------------------------------------------------- /logo/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/favicon-32x32.png -------------------------------------------------------------------------------- /logo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/favicon.ico -------------------------------------------------------------------------------- /logo/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/logo.png -------------------------------------------------------------------------------- /logo/logomark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jexordexan/vue-slicksort/7a8fee6bc8aa8d3e3c4b33468c3429621b16046a/logo/logomark.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-slicksort", 3 | "version": "2.0.5", 4 | "description": "Set of mixins to turn any list into a sortable, touch-friendly, animated list", 5 | "author": { 6 | "name": "Jordan Simonds", 7 | "email": "simonds.jordan@gmail.com" 8 | }, 9 | "user": "jsimonds", 10 | "homepage": "https://vue-slicksort.netlify.app", 11 | "main": "dist/vue-slicksort.cjs.js", 12 | "module": "dist/vue-slicksort.esm.js", 13 | "browser": "dist/vue-slicksort.umd.js", 14 | "unpkg": "dist/vue-slicksort.min.js", 15 | "license": "MIT", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/Jexordexan/vue-slicksort.git" 19 | }, 20 | "bugs": { 21 | "url": "https://github.com/Jexordexan/vue-slicksort/issues" 22 | }, 23 | "keywords": [ 24 | "vue", 25 | "vuejs", 26 | "vue-component", 27 | "vue-mixin", 28 | "sortable", 29 | "sortable-list", 30 | "list", 31 | "sortable list", 32 | "smooth", 33 | "animated", 34 | "mixin", 35 | "component", 36 | "react-sortable-hoc", 37 | "vue-slicksort", 38 | "vue-sort", 39 | "vue-sortable", 40 | "drag and drop", 41 | "vue-slicksort" 42 | ], 43 | "scripts": { 44 | "start": "vite example", 45 | "changelog": "standard-changelog", 46 | "version": "npm run changelog && git add CHANGELOG.md", 47 | "build": "npm run clean && npm run rollup", 48 | "prepublishOnly": "npm run build", 49 | "rollup": "cross-env BABEL_ENV=rollup rollup -c", 50 | "clean": "rimraf dist/", 51 | "test": "eslint src/** --ext .ts && npm run test:cy", 52 | "cy": "cypress open --component", 53 | "test:cy": "cypress run --component", 54 | "deploy:alpha": "npm test && npm version prerelease --preid=alpha && npm run deploy:final", 55 | "deploy:patch": "npm test && npm version patch && npm run deploy:final", 56 | "deploy:final": "git push; git push --tags; npm publish", 57 | "docs:dev": "vitepress dev docs", 58 | "docs:build": "vitepress build docs", 59 | "docs:serve": "vitepress serve docs" 60 | }, 61 | "peerDependencies": { 62 | "vue": ">=3.0.0" 63 | }, 64 | "devDependencies": { 65 | "@babel/core": "^7.13.14", 66 | "@babel/plugin-external-helpers": "^7.12.13", 67 | "@babel/plugin-transform-regenerator": "^7.13.15", 68 | "@babel/plugin-transform-runtime": "^7.13.15", 69 | "@babel/preset-env": "^7.12.16", 70 | "@cypress/vite-dev-server": "^4.0.1", 71 | "@cypress/vue": "^5.0.1", 72 | "@rollup/plugin-typescript": "^8.2.0", 73 | "@typescript-eslint/eslint-plugin": "^5.43.0", 74 | "@typescript-eslint/parser": "^5.43.0", 75 | "@vitejs/plugin-vue": "^3.2.0", 76 | "@vitejs/plugin-vue-jsx": "^2.1.1", 77 | "@vue/compiler-sfc": "^3.2.45", 78 | "@vue/test-utils": "^2.0.0-rc.6", 79 | "@vueuse/core": "^9.5.0", 80 | "autoprefixer": "^6.3.6", 81 | "cross-env": "^1.0.7", 82 | "cssnano": "^3.10.0", 83 | "cypress": "^11.1.0", 84 | "esbuild": "^0.8.44", 85 | "eslint": "^7.20.0", 86 | "eslint-plugin-cypress": "^2.11.2", 87 | "eslint-plugin-import": "^2.0.1", 88 | "eslint-plugin-vue": "^7.6.0", 89 | "postcss": "^8.2.6", 90 | "rimraf": "^2.5.2", 91 | "rollup": "^2.38.5", 92 | "rollup-plugin-babel": "^4.4.0", 93 | "rollup-plugin-bundle-size": "^1.0.3", 94 | "rollup-plugin-esbuild": "^2.6.1", 95 | "sass": "^1.32.6", 96 | "standard-changelog": "^2.0.27", 97 | "typescript": "^4.2.3", 98 | "vite": "^3.2.4", 99 | "vitepress": "^1.0.0-alpha.46", 100 | "vue": "^3.2.45", 101 | "vuepress": "^2.0.0-alpha.23" 102 | }, 103 | "browserslist": [ 104 | "> 1%", 105 | "last 4 versions" 106 | ], 107 | "files": [ 108 | "index.d.ts", 109 | "dist/*" 110 | ], 111 | "dependencies": {} 112 | } 113 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import esbuild from 'rollup-plugin-esbuild'; 3 | import bundleSize from 'rollup-plugin-bundle-size'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import pkg from './package.json'; 6 | 7 | const input = './src/index.ts'; 8 | const moduleName = 'VueSlicksort'; 9 | 10 | export default [ 11 | { 12 | input, 13 | output: [ 14 | { file: pkg.main, format: 'cjs' }, 15 | { file: pkg.module, format: 'es' }, 16 | { file: pkg.browser, format: 'umd', name: moduleName }, 17 | ], 18 | plugins: [typescript(), babel()], 19 | }, 20 | { 21 | input, 22 | output: { 23 | file: pkg.unpkg, 24 | name: moduleName, 25 | format: 'umd', 26 | sourcemap: true, 27 | }, 28 | plugins: [ 29 | esbuild({ 30 | minify: true, 31 | define: { 32 | 'process.env.NODE_ENV': '"production"', 33 | }, 34 | }), 35 | babel(), 36 | bundleSize(), 37 | ], 38 | }, 39 | ]; 40 | -------------------------------------------------------------------------------- /src/ContainerMixin.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | /* eslint-disable @typescript-eslint/no-non-null-assertion */ 3 | import { defineComponent, PropType } from 'vue'; 4 | import Manager, { ItemRef, SortableNode } from './Manager'; 5 | import SlicksortHub, { AcceptProp, ContainerRef } from './SlicksortHub'; 6 | import { 7 | arrayMove, 8 | arrayRemove, 9 | arrayInsert, 10 | cloneNode, 11 | closest, 12 | commonOffsetParent, 13 | events, 14 | getEdgeOffset, 15 | getElementMargin, 16 | getLockPixelOffsets, 17 | getPointerOffset, 18 | limit, 19 | resetTransform, 20 | XY, 21 | TopLeft, 22 | WidthHeight, 23 | PointEvent, 24 | BottomRight, 25 | isTouch, 26 | setTransform, 27 | hasOwnProperty, 28 | } from './utils'; 29 | 30 | type PointEventListener = (e: PointEvent) => unknown; 31 | // eslint-disable-next-line @typescript-eslint/ban-types 32 | const timeout: (cb: Function, dur?: number) => number = setTimeout; 33 | type Timer = number | null; 34 | 35 | interface ComponentProps { 36 | list: unknown[]; 37 | axis: 'x' | 'y' | 'xy' | 'yx'; // 'x', 'y', 'xy' 38 | distance: number; 39 | pressDelay: number; 40 | pressThreshold: number; 41 | useDragHandle: boolean; 42 | useWindowAsScrollContainer: boolean; 43 | hideSortableGhost: boolean; 44 | lockToContainerEdges: boolean; 45 | lockOffset: string | number | string[]; 46 | transitionDuration: number; 47 | appendTo: string; 48 | draggedSettlingDuration: number; 49 | group: string; 50 | accept: boolean | string[] | ((ctx: { source: ContainerRef; dest: ContainerRef; payload: unknown }) => boolean); 51 | cancelKey: string; 52 | block: string[]; 53 | lockAxis: string; 54 | helperClass: string; 55 | contentWindow: Window; 56 | shouldCancelStart: (e: PointEvent) => boolean; 57 | getHelperDimensions: (ref: ItemRef) => { width: number; height: number }; 58 | } 59 | 60 | interface ComponentData extends ComponentProps { 61 | id: string; 62 | 63 | // usually thi.$el 64 | container: HTMLElement; 65 | 66 | // ref to document 67 | document: Document; 68 | 69 | // Provided for sortable elements to register 70 | manager: Manager; 71 | 72 | // ref to window 73 | _window: Window; 74 | 75 | // window or container 76 | // TODO make this a prop 77 | scrollContainer: { scrollTop: number; scrollLeft: number }; 78 | 79 | // Injected and used for drag and drop between lists 80 | hub?: SlicksortHub; 81 | 82 | // Normalized events for mouse and touch devices 83 | events: Record; 84 | 85 | // MOusedown or touchstart occurred 86 | _touched: boolean; 87 | 88 | // initial point of contact 89 | _pos: XY; 90 | 91 | // distance from _pos to current pointer position 92 | // Used for drag threshold, aka `distance` 93 | _delta: XY; 94 | 95 | // Data structure for `axis` prop 96 | _axis: { x: boolean; y: boolean }; 97 | 98 | // timer for press threshold 99 | pressTimer: Timer; 100 | 101 | // used to wait until next tick to cancel 102 | cancelTimer: Timer; 103 | 104 | // dragout transition timer 105 | dragendTimer: Timer; 106 | 107 | // Used for repeating autoscroll 108 | autoscrollInterval: Timer; 109 | 110 | // The translation applied to the helper 111 | translate: XY; 112 | 113 | // The minmax values of translate before starting autoscroll 114 | minTranslate: Partial; 115 | maxTranslate: Partial; 116 | 117 | // Is the user currently sorting 118 | sorting: boolean; 119 | 120 | // The user has initiated a cancel action 121 | canceling: boolean; 122 | 123 | // The active node that was originally clicked 124 | node: SortableNode; 125 | 126 | // The measurements of the node that have to be calculated separately 127 | margin: TopLeft & BottomRight; 128 | width: number; 129 | height: number; 130 | 131 | // space added around the active node 132 | marginOffset: XY; 133 | 134 | // Initial offset of the cursor 135 | initialOffset: XY; 136 | 137 | // Initial scroll position of the container and window 138 | initialScroll: TopLeft; 139 | initialWindowScroll: TopLeft; 140 | 141 | // The top-left offset of the node from the offsetParent of the *container* 142 | // Its important to use the container as a source for the offsetParent so all nodes have the same reference point 143 | offsetEdge: TopLeft; 144 | 145 | // client rect for the node and container 146 | boundingClientRect: ClientRect; 147 | containerBoundingRect: ClientRect; 148 | 149 | // Starting index (use last index for drag in operations) 150 | index: number | null; 151 | 152 | // Target index for dropping 153 | newIndex: number | null; 154 | 155 | // The node that follows the mouse 156 | helper: HTMLElement | null; 157 | 158 | // The node that stays in the list and is hidden 159 | sortableGhost: HTMLElement | null; 160 | 161 | // The element that we listen to for events 162 | listenerNode: GlobalEventHandlers; 163 | } 164 | 165 | // Export Sortable Container Component Mixin 166 | export const ContainerMixin = defineComponent({ 167 | inject: { 168 | SlicksortHub: { 169 | from: 'SlicksortHub', 170 | default: null, 171 | }, 172 | }, 173 | 174 | provide() { 175 | return { 176 | manager: this.manager, 177 | }; 178 | }, 179 | 180 | props: { 181 | list: { type: Array as PropType, required: true }, 182 | axis: { type: String, default: 'y' }, // 'x', 'y', 'xy' 183 | distance: { type: Number, default: 0 }, 184 | pressDelay: { type: Number, default: 0 }, 185 | pressThreshold: { type: Number, default: 5 }, 186 | useDragHandle: { type: Boolean, default: false }, 187 | useWindowAsScrollContainer: { type: Boolean, default: false }, 188 | hideSortableGhost: { type: Boolean, default: true }, 189 | lockToContainerEdges: { type: Boolean, default: false }, 190 | lockOffset: { type: [String, Number, Array] as PropType, default: '50%' }, 191 | transitionDuration: { type: Number, default: 300 }, 192 | appendTo: { type: String, default: 'body' }, 193 | draggedSettlingDuration: { type: Number, default: null }, 194 | group: { type: String, default: '' }, 195 | accept: { type: [Boolean, Array, Function] as PropType, default: null }, 196 | cancelKey: { type: String, default: 'Escape' }, 197 | block: { type: Array as PropType, default: () => [] }, 198 | lockAxis: { type: String, default: '' }, 199 | helperClass: { type: String, default: '' }, 200 | contentWindow: { type: Object as PropType, default: null }, 201 | shouldCancelStart: { 202 | type: Function as PropType, 203 | default: (e: PointEvent) => { 204 | // Cancel sorting if the event target is an `input`, `textarea`, `select` or `option` 205 | const disabledElements = ['input', 'textarea', 'select', 'option', 'button']; 206 | return disabledElements.indexOf((e.target as HTMLElement).tagName.toLowerCase()) !== -1; 207 | }, 208 | }, 209 | getHelperDimensions: { 210 | type: Function as PropType<(arg: ItemRef) => WidthHeight>, 211 | default: ({ node }: ItemRef) => ({ 212 | width: node.offsetWidth, 213 | height: node.offsetHeight, 214 | }), 215 | }, 216 | }, 217 | 218 | emits: ['sort-start', 'sort-move', 'sort-end', 'sort-cancel', 'sort-insert', 'sort-remove', 'update:list'], 219 | 220 | data() { 221 | let useHub = false; 222 | if (this.group) { 223 | // If the group option is set, it is assumed the user intends 224 | // to drag between containers and the required plugin has been installed 225 | if (this.SlicksortHub) { 226 | useHub = true; 227 | } else if (process.env.NODE_ENV !== 'production') { 228 | throw new Error('Slicksort plugin required to use "group" prop'); 229 | } 230 | } 231 | 232 | return ({ 233 | sorting: false, 234 | hub: useHub ? this.SlicksortHub : null, 235 | manager: new Manager(), 236 | } as unknown) as ComponentData; 237 | }, 238 | 239 | mounted() { 240 | if (this.hub) { 241 | this.id = this.hub.getId(); 242 | } 243 | this.container = this.$el; 244 | this.document = this.container.ownerDocument || document; 245 | this._window = this.contentWindow || window; 246 | this.scrollContainer = this.useWindowAsScrollContainer ? { scrollLeft: 0, scrollTop: 0 } : this.container; 247 | this.events = { 248 | start: this.handleStart, 249 | move: this.handleMove, 250 | end: this.handleEnd, 251 | }; 252 | 253 | for (const key in this.events) { 254 | if (hasOwnProperty(this.events, key)) { 255 | // @ts-ignore 256 | events[key].forEach((eventName) => this.container.addEventListener(eventName, this.events[key])); 257 | } 258 | } 259 | 260 | if (this.hub) { 261 | this.hub.addContainer(this as ContainerRef); 262 | } 263 | }, 264 | 265 | beforeUnmount() { 266 | for (const key in this.events) { 267 | if (hasOwnProperty(this.events, key)) { 268 | // @ts-ignore 269 | events[key].forEach((eventName) => this.container.removeEventListener(eventName, this.events[key])); 270 | } 271 | } 272 | 273 | if (this.hub) { 274 | this.hub.removeContainer(this as ContainerRef); 275 | } 276 | 277 | if (this.dragendTimer) clearTimeout(this.dragendTimer); 278 | if (this.cancelTimer) clearTimeout(this.cancelTimer); 279 | if (this.pressTimer) clearTimeout(this.pressTimer); 280 | if (this.autoscrollInterval) clearInterval(this.autoscrollInterval); 281 | }, 282 | 283 | methods: { 284 | handleStart(e: PointEvent) { 285 | const { distance, shouldCancelStart } = this.$props; 286 | 287 | if ((!isTouch(e) && e.button === 2) || shouldCancelStart(e)) { 288 | return false; 289 | } 290 | 291 | this._touched = true; 292 | this._pos = getPointerOffset(e); 293 | const target = e.target as HTMLElement; 294 | 295 | const node = closest(target, (el) => (el as SortableNode).sortableInfo != null) as SortableNode; 296 | 297 | if (node && node.sortableInfo && this.nodeIsChild(node) && !this.sorting) { 298 | const { useDragHandle } = this.$props; 299 | const { index } = node.sortableInfo; 300 | 301 | if (useDragHandle && !closest(target, (el) => (el as SortableNode).sortableHandle != null)) return; 302 | 303 | this.manager.active = { index }; 304 | 305 | /* 306 | * Fixes a bug in Firefox where the :active state of anchor tags 307 | * prevent subsequent 'mousemove' events from being fired 308 | * (see https://github.com/clauderic/react-sortable-hoc/issues/118) 309 | */ 310 | if (target.tagName.toLowerCase() === 'a') { 311 | e.preventDefault(); 312 | } 313 | 314 | if (!distance) { 315 | if (this.pressDelay === 0) { 316 | this.handlePress(e); 317 | } else { 318 | this.pressTimer = timeout(() => this.handlePress(e), this.pressDelay) as Timer; 319 | } 320 | } 321 | } 322 | }, 323 | 324 | nodeIsChild(node: SortableNode) { 325 | return node.sortableInfo.manager === this.manager; 326 | }, 327 | 328 | handleMove(e: PointEvent) { 329 | const { distance, pressThreshold } = this.$props; 330 | 331 | if (!this.sorting && this._touched) { 332 | const offset = getPointerOffset(e); 333 | this._delta = { 334 | x: this._pos.x - offset.x, 335 | y: this._pos.y - offset.y, 336 | }; 337 | const delta = Math.abs(this._delta.x) + Math.abs(this._delta.y); 338 | 339 | if (!distance && (!pressThreshold || (pressThreshold && delta >= pressThreshold))) { 340 | if (this.cancelTimer) clearTimeout(this.cancelTimer); 341 | this.cancelTimer = timeout(this.cancel, 0); 342 | } else if (distance && delta >= distance && this.manager.isActive()) { 343 | this.handlePress(e); 344 | } 345 | } 346 | }, 347 | 348 | handleEnd() { 349 | if (!this._touched) return; 350 | 351 | const { distance } = this.$props; 352 | 353 | this._touched = false; 354 | 355 | if (!distance) { 356 | this.cancel(); 357 | } 358 | }, 359 | 360 | cancel() { 361 | if (!this.sorting) { 362 | if (this.pressTimer) clearTimeout(this.pressTimer); 363 | this.manager.active = null; 364 | if (this.hub) this.hub.cancel(); 365 | } 366 | }, 367 | 368 | handleSortCancel(e: KeyboardEvent | TouchEvent) { 369 | if (isTouch(e) || e.key === this.cancelKey) { 370 | this.newIndex = this.index; 371 | this.canceling = true; 372 | this.translate = { x: 0, y: 0 }; 373 | this.animateNodes(); 374 | this.handleSortEnd(e); 375 | } 376 | }, 377 | 378 | handlePress(e: PointEvent) { 379 | e.stopPropagation(); 380 | const active = this.manager.getActive(); 381 | 382 | if (active) { 383 | const { getHelperDimensions, helperClass, hideSortableGhost, appendTo } = this.$props; 384 | const { node } = active; 385 | const { index } = node.sortableInfo; 386 | const margin = getElementMargin(node); 387 | 388 | const containerBoundingRect = this.container.getBoundingClientRect(); 389 | const dimensions = getHelperDimensions({ index, node }); 390 | 391 | this.node = node; 392 | this.margin = margin; 393 | this.width = dimensions.width; 394 | this.height = dimensions.height; 395 | this.marginOffset = { 396 | x: this.margin.left + this.margin.right, 397 | y: Math.max(this.margin.top, this.margin.bottom), 398 | }; 399 | this.boundingClientRect = node.getBoundingClientRect(); 400 | this.containerBoundingRect = containerBoundingRect; 401 | this.index = index; 402 | this.newIndex = index; 403 | 404 | const clonedNode = cloneNode(node); 405 | 406 | this.helper = this.document.querySelector(appendTo)!.appendChild(clonedNode); 407 | 408 | this.helper.style.position = 'fixed'; 409 | this.helper.style.top = `${this.boundingClientRect.top - margin.top}px`; 410 | this.helper.style.left = `${this.boundingClientRect.left - margin.left}px`; 411 | this.helper.style.width = `${this.width}px`; 412 | this.helper.style.height = `${this.height}px`; 413 | this.helper.style.boxSizing = 'border-box'; 414 | this.helper.style.pointerEvents = 'none'; 415 | 416 | if (hideSortableGhost) { 417 | this.sortableGhost = node; 418 | node.style.visibility = 'hidden'; 419 | node.style.opacity = '0'; 420 | } 421 | 422 | if (this.hub) { 423 | this.hub.sortStart(this as ContainerRef); 424 | this.hub.helper = this.helper; 425 | this.hub.ghost = this.sortableGhost; 426 | } 427 | 428 | this.intializeOffsets(e, this.boundingClientRect); 429 | this.offsetEdge = getEdgeOffset(node, this.container); 430 | 431 | if (helperClass) { 432 | this.helper.classList.add(...helperClass.split(' ')); 433 | } 434 | 435 | this.listenerNode = isTouch(e) ? node : this._window; 436 | // @ts-ignore 437 | events.move.forEach((eventName) => this.listenerNode.addEventListener(eventName, this.handleSortMove)); 438 | // @ts-ignore 439 | events.end.forEach((eventName) => this.listenerNode.addEventListener(eventName, this.handleSortEnd)); 440 | // @ts-ignore 441 | events.cancel.forEach((eventName) => this.listenerNode.addEventListener(eventName, this.handleSortCancel)); 442 | 443 | this.sorting = true; 444 | 445 | this.$emit('sort-start', { event: e, node, index }); 446 | } 447 | }, 448 | 449 | handleSortMove(e: PointEvent) { 450 | e.preventDefault(); // Prevent scrolling on mobile 451 | 452 | this.updatePosition(e); 453 | 454 | if (this.hub) { 455 | const payload = this.list[this.index!]; 456 | this.hub.handleSortMove(e, payload); 457 | } 458 | 459 | if (!this.hub || this.hub.isDest(this as ContainerRef)) { 460 | this.animateNodes(); 461 | this.autoscroll(); 462 | } 463 | 464 | this.$emit('sort-move', { event: e }); 465 | }, 466 | 467 | handleDropOut() { 468 | const removed = this.list[this.index!]; 469 | const newValue = arrayRemove(this.list, this.index!); 470 | this.$emit('sort-remove', { 471 | oldIndex: this.index, 472 | }); 473 | this.$emit('update:list', newValue); 474 | return removed; 475 | }, 476 | 477 | handleDropIn(payload: unknown) { 478 | const newValue = arrayInsert(this.list, this.newIndex!, payload); 479 | this.$emit('sort-insert', { 480 | newIndex: this.newIndex, 481 | value: payload, 482 | }); 483 | this.$emit('update:list', newValue); 484 | this.handleDragEnd(); 485 | }, 486 | 487 | handleDragOut() { 488 | if (this.autoscrollInterval) { 489 | clearInterval(this.autoscrollInterval); 490 | this.autoscrollInterval = null; 491 | } 492 | if (this.hub!.isSource(this as ContainerRef)) { 493 | // Trick to animate all nodes up 494 | this.translate = { 495 | x: 10000, 496 | y: 10000, 497 | }; 498 | this.animateNodes(); 499 | } else { 500 | this.manager.getRefs().forEach((ref) => { 501 | ref.node.style['transform'] = ''; 502 | }); 503 | this.dragendTimer = timeout(this.handleDragEnd, this.transitionDuration || 0); 504 | } 505 | }, 506 | 507 | handleDragEnd() { 508 | if (this.autoscrollInterval) { 509 | clearInterval(this.autoscrollInterval); 510 | this.autoscrollInterval = null; 511 | } 512 | 513 | resetTransform(this.manager.getRefs()); 514 | if (this.sortableGhost) { 515 | this.sortableGhost.remove(); 516 | this.sortableGhost = null; 517 | } 518 | 519 | if (this.dragendTimer) { 520 | clearTimeout(this.dragendTimer); 521 | this.dragendTimer = null; 522 | } 523 | this.manager.active = null; 524 | this._touched = false; 525 | this.sorting = false; 526 | }, 527 | 528 | intializeOffsets(e: PointEvent, clientRect: ClientRect) { 529 | const { useWindowAsScrollContainer, containerBoundingRect, _window } = this; 530 | 531 | this.marginOffset = { 532 | x: this.margin.left + this.margin.right, 533 | y: Math.max(this.margin.top, this.margin.bottom), 534 | }; 535 | 536 | this._axis = { 537 | x: this.axis.indexOf('x') >= 0, 538 | y: this.axis.indexOf('y') >= 0, 539 | }; 540 | 541 | this.initialOffset = getPointerOffset(e); 542 | 543 | // initialScroll; 544 | this.initialScroll = { 545 | top: this.scrollContainer.scrollTop, 546 | left: this.scrollContainer.scrollLeft, 547 | }; 548 | 549 | // initialWindowScroll; 550 | this.initialWindowScroll = { 551 | top: window.pageYOffset, 552 | left: window.pageXOffset, 553 | }; 554 | 555 | this.translate = { x: 0, y: 0 }; 556 | this.minTranslate = {}; 557 | this.maxTranslate = {}; 558 | 559 | if (this._axis.x) { 560 | this.minTranslate.x = 561 | (useWindowAsScrollContainer ? 0 : containerBoundingRect.left) - clientRect.left - this.width / 2; 562 | this.maxTranslate.x = 563 | (useWindowAsScrollContainer ? _window.innerWidth : containerBoundingRect.left + containerBoundingRect.width) - 564 | clientRect.left - 565 | this.width / 2; 566 | } 567 | if (this._axis.y) { 568 | this.minTranslate.y = 569 | (useWindowAsScrollContainer ? 0 : containerBoundingRect.top) - clientRect.top - this.height / 2; 570 | this.maxTranslate.y = 571 | (useWindowAsScrollContainer 572 | ? _window.innerHeight 573 | : containerBoundingRect.top + containerBoundingRect.height) - 574 | clientRect.top - 575 | this.height / 2; 576 | } 577 | }, 578 | 579 | handleDragIn(e: PointEvent, sortableGhost: SortableNode, helper: SortableNode) { 580 | if (this.hub!.isSource(this as ContainerRef)) { 581 | return; 582 | } 583 | 584 | if (this.dragendTimer) { 585 | this.handleDragEnd(); 586 | clearTimeout(this.dragendTimer); 587 | this.dragendTimer = null; 588 | } 589 | 590 | const nodes = this.manager.getRefs(); 591 | this.index = nodes.length; 592 | this.manager.active = { index: this.index }; 593 | 594 | const containerBoundingRect = this.container.getBoundingClientRect(); 595 | const helperBoundingRect = helper.getBoundingClientRect(); 596 | this.containerBoundingRect = containerBoundingRect; 597 | 598 | this.sortableGhost = cloneNode(sortableGhost); 599 | this.container.appendChild(this.sortableGhost); 600 | const ghostRect = this.sortableGhost.getBoundingClientRect(); 601 | this.boundingClientRect = ghostRect; 602 | this.margin = getElementMargin(this.sortableGhost); 603 | this.width = ghostRect.width; 604 | this.height = ghostRect.height; 605 | 606 | // XY coords of the inserted node, relative to the top-left corner of the container 607 | this.offsetEdge = getEdgeOffset(this.sortableGhost, this.container); 608 | 609 | this.intializeOffsets(e, ghostRect); 610 | 611 | // Move the initialOffset back to the insertion point of the 612 | // sortableGhost (end of the list), as if we had started the drag there. 613 | this.initialOffset.x += ghostRect.x - helperBoundingRect.x; 614 | this.initialOffset.y += ghostRect.y - helperBoundingRect.y; 615 | 616 | // Turn on dragging 617 | this.sorting = true; 618 | }, 619 | 620 | handleSortEnd(e: PointEvent | KeyboardEvent) { 621 | // Remove the event listeners if the node is still in the DOM 622 | if (this.listenerNode) { 623 | events.move.forEach((eventName) => 624 | // @ts-ignore 625 | this.listenerNode.removeEventListener(eventName, this.handleSortMove), 626 | ); 627 | events.end.forEach((eventName) => 628 | // @ts-ignore 629 | this.listenerNode.removeEventListener(eventName, this.handleSortEnd), 630 | ); 631 | events.cancel.forEach((eventName) => 632 | // @ts-ignore 633 | this.listenerNode.removeEventListener(eventName, this.handleSortCancel), 634 | ); 635 | } 636 | 637 | const nodes = this.manager.getRefs(); 638 | 639 | // Remove the helper class(es) early to give it a chance to transition back 640 | if (this.helper && this.helperClass) { 641 | this.helper.classList.remove(...this.helperClass.split(' ')); 642 | } 643 | 644 | // Stop autoscroll 645 | if (this.autoscrollInterval) clearInterval(this.autoscrollInterval); 646 | this.autoscrollInterval = null; 647 | 648 | const onEnd = () => { 649 | // Remove the helper from the DOM 650 | if (this.helper) { 651 | this.helper.remove(); 652 | this.helper = null; 653 | } 654 | 655 | if (this.hideSortableGhost && this.sortableGhost) { 656 | this.sortableGhost.style.visibility = ''; 657 | this.sortableGhost.style.opacity = ''; 658 | } 659 | 660 | resetTransform(nodes); 661 | 662 | // Update state 663 | if (this.hub && !this.hub.isDest(this as ContainerRef)) { 664 | this.canceling ? this.hub.cancel() : this.hub.handleSortEnd(); 665 | } else if (this.canceling) { 666 | this.$emit('sort-cancel', { event: e }); 667 | } else { 668 | this.$emit('sort-end', { 669 | event: e, 670 | oldIndex: this.index, 671 | newIndex: this.newIndex, 672 | }); 673 | this.$emit('update:list', arrayMove(this.list, this.index!, this.newIndex!)); 674 | } 675 | 676 | this.manager.active = null; 677 | this._touched = false; 678 | this.canceling = false; 679 | this.sorting = false; 680 | }; 681 | 682 | if (this.transitionDuration || this.draggedSettlingDuration) { 683 | this.transitionHelperIntoPlace(nodes, onEnd); 684 | } else { 685 | onEnd(); 686 | } 687 | }, 688 | 689 | transitionHelperIntoPlace(nodes: ItemRef[], cb: () => void) { 690 | if (this.draggedSettlingDuration === 0 || nodes.length === 0 || !this.helper) { 691 | return Promise.resolve(); 692 | } 693 | 694 | const indexNode = nodes[this.index!].node; 695 | let targetX = 0; 696 | let targetY = 0; 697 | 698 | const scrollDifference = { 699 | top: window.pageYOffset - this.initialWindowScroll.top, 700 | left: window.pageXOffset - this.initialWindowScroll.left, 701 | }; 702 | 703 | if (this.hub && !this.hub.isDest(this as ContainerRef) && !this.canceling) { 704 | const dest = this.hub.getDest(); 705 | if (!dest) return; 706 | const destIndex = dest.newIndex; 707 | const destRefs = dest.manager.getOrderedRefs(); 708 | const destNode = destIndex < destRefs.length ? destRefs[destIndex].node : dest.sortableGhost!; 709 | const ancestor = commonOffsetParent(indexNode, destNode)!; 710 | 711 | const sourceOffset = getEdgeOffset(indexNode, ancestor); 712 | const targetOffset = getEdgeOffset(destNode, ancestor); 713 | 714 | targetX = targetOffset.left - sourceOffset.left - scrollDifference.left; 715 | targetY = targetOffset.top - sourceOffset.top - scrollDifference.top; 716 | } else { 717 | const newIndexNode = nodes[this.newIndex!].node; 718 | const deltaScroll = { 719 | left: this.scrollContainer.scrollLeft - this.initialScroll.left + scrollDifference.left, 720 | top: this.scrollContainer.scrollTop - this.initialScroll.top + scrollDifference.top, 721 | }; 722 | targetX = -deltaScroll.left; 723 | if (this.translate && this.translate.x > 0) { 724 | // Diff against right edge when moving to the right 725 | targetX += 726 | newIndexNode.offsetLeft + newIndexNode.offsetWidth - (indexNode.offsetLeft + indexNode.offsetWidth); 727 | } else { 728 | targetX += newIndexNode.offsetLeft - indexNode.offsetLeft; 729 | } 730 | 731 | targetY = -deltaScroll.top; 732 | if (this.translate && this.translate.y > 0) { 733 | // Diff against the bottom edge when moving down 734 | targetY += 735 | newIndexNode.offsetTop + newIndexNode.offsetHeight - (indexNode.offsetTop + indexNode.offsetHeight); 736 | } else { 737 | targetY += newIndexNode.offsetTop - indexNode.offsetTop; 738 | } 739 | } 740 | 741 | const duration = this.draggedSettlingDuration !== null ? this.draggedSettlingDuration : this.transitionDuration; 742 | 743 | setTransform(this.helper, `translate3d(${targetX}px,${targetY}px, 0)`, `${duration}ms`); 744 | 745 | // Register an event handler to clean up styles when the transition 746 | // finishes. 747 | const cleanup = (event: TransitionEvent) => { 748 | if (!event || event.propertyName === 'transform') { 749 | clearTimeout(cleanupTimer); 750 | setTransform(this.helper); 751 | cb(); 752 | } 753 | }; 754 | // Force cleanup in case 'transitionend' never fires 755 | const cleanupTimer = setTimeout(cleanup, duration + 10); 756 | this.helper.addEventListener('transitionend', cleanup); 757 | }, 758 | 759 | updatePosition(e: PointEvent) { 760 | const { lockAxis, lockToContainerEdges } = this.$props; 761 | 762 | const offset = getPointerOffset(e); 763 | const translate = { 764 | x: offset.x - this.initialOffset.x, 765 | y: offset.y - this.initialOffset.y, 766 | }; 767 | // Adjust for window scroll 768 | translate.y -= window.pageYOffset - this.initialWindowScroll.top; 769 | translate.x -= window.pageXOffset - this.initialWindowScroll.left; 770 | 771 | this.translate = translate; 772 | 773 | if (lockToContainerEdges) { 774 | const [minLockOffset, maxLockOffset] = getLockPixelOffsets(this.lockOffset, this.height, this.width); 775 | const minOffset = { 776 | x: this.width / 2 - minLockOffset.x, 777 | y: this.height / 2 - minLockOffset.y, 778 | }; 779 | const maxOffset = { 780 | x: this.width / 2 - maxLockOffset.x, 781 | y: this.height / 2 - maxLockOffset.y, 782 | }; 783 | 784 | if (this.minTranslate.x && this.maxTranslate.x) 785 | translate.x = limit(this.minTranslate.x + minOffset.x, this.maxTranslate.x - maxOffset.x, translate.x); 786 | if (this.minTranslate.y && this.maxTranslate.y) 787 | translate.y = limit(this.minTranslate.y + minOffset.y, this.maxTranslate.y - maxOffset.y, translate.y); 788 | } 789 | 790 | if (lockAxis === 'x') { 791 | translate.y = 0; 792 | } else if (lockAxis === 'y') { 793 | translate.x = 0; 794 | } 795 | 796 | if (this.helper) { 797 | this.helper.style['transform'] = `translate3d(${translate.x}px,${translate.y}px, 0)`; 798 | } 799 | }, 800 | 801 | animateNodes() { 802 | const { transitionDuration, hideSortableGhost } = this.$props; 803 | const nodes = this.manager.getOrderedRefs(); 804 | const deltaScroll = { 805 | left: this.scrollContainer.scrollLeft - this.initialScroll.left, 806 | top: this.scrollContainer.scrollTop - this.initialScroll.top, 807 | }; 808 | const sortingOffset = { 809 | left: this.offsetEdge.left + this.translate.x + deltaScroll.left, 810 | top: this.offsetEdge.top + this.translate.y + deltaScroll.top, 811 | }; 812 | const scrollDifference = { 813 | top: window.pageYOffset - this.initialWindowScroll.top, 814 | left: window.pageXOffset - this.initialWindowScroll.left, 815 | }; 816 | this.newIndex = null; 817 | 818 | for (let i = 0, len = nodes.length; i < len; i++) { 819 | const { node } = nodes[i]; 820 | const index = node.sortableInfo.index; 821 | const width = node.offsetWidth; 822 | const height = node.offsetHeight; 823 | const offset = { 824 | width: this.width > width ? width / 2 : this.width / 2, 825 | height: this.height > height ? height / 2 : this.height / 2, 826 | }; 827 | 828 | const translate = { 829 | x: 0, 830 | y: 0, 831 | }; 832 | let { edgeOffset } = nodes[i]; 833 | 834 | // If we haven't cached the node's offsetTop / offsetLeft value 835 | if (!edgeOffset) { 836 | nodes[i].edgeOffset = edgeOffset = getEdgeOffset(node, this.container); 837 | } 838 | 839 | // Get a reference to the next and previous node 840 | const nextNode = i < nodes.length - 1 && nodes[i + 1]; 841 | const prevNode = i > 0 && nodes[i - 1]; 842 | 843 | // Also cache the next node's edge offset if needed. 844 | // We need this for calculating the animation in a grid setup 845 | if (nextNode && !nextNode.edgeOffset) { 846 | nextNode.edgeOffset = getEdgeOffset(nextNode.node, this.container); 847 | } 848 | 849 | // If the node is the one we're currently animating, skip it 850 | if (index === this.index) { 851 | /* 852 | * With windowing libraries such as `react-virtualized`, the sortableGhost 853 | * node may change while scrolling down and then back up (or vice-versa), 854 | * so we need to update the reference to the new node just to be safe. 855 | */ 856 | if (hideSortableGhost) { 857 | this.sortableGhost = node; 858 | node.style.visibility = 'hidden'; 859 | node.style.opacity = '0'; 860 | } 861 | 862 | continue; 863 | } 864 | 865 | if (transitionDuration) { 866 | node.style['transitionDuration'] = `${transitionDuration}ms`; 867 | } 868 | 869 | if (this._axis.x) { 870 | if (this._axis.y) { 871 | // Calculations for a grid setup 872 | if ( 873 | index < this.index! && 874 | ((sortingOffset.left + scrollDifference.left - offset.width <= edgeOffset.left && 875 | sortingOffset.top + scrollDifference.top <= edgeOffset.top + offset.height) || 876 | sortingOffset.top + scrollDifference.top + offset.height <= edgeOffset.top) 877 | ) { 878 | // If the current node is to the left on the same row, or above the node that's being dragged 879 | // then move it to the right 880 | translate.x = this.width + this.marginOffset.x; 881 | if (edgeOffset.left + translate.x > this.containerBoundingRect.width - offset.width && nextNode) { 882 | // If it moves passed the right bounds, then animate it to the first position of the next row. 883 | // We just use the offset of the next node to calculate where to move, because that node's original position 884 | // is exactly where we want to go 885 | translate.x = nextNode.edgeOffset!.left - edgeOffset.left; 886 | translate.y = nextNode.edgeOffset!.top - edgeOffset.top; 887 | } 888 | if (this.newIndex === null) { 889 | this.newIndex = index; 890 | } 891 | } else if ( 892 | index > this.index! && 893 | ((sortingOffset.left + scrollDifference.left + offset.width >= edgeOffset.left && 894 | sortingOffset.top + scrollDifference.top + offset.height >= edgeOffset.top) || 895 | sortingOffset.top + scrollDifference.top + offset.height >= edgeOffset.top + height) 896 | ) { 897 | // If the current node is to the right on the same row, or below the node that's being dragged 898 | // then move it to the left 899 | translate.x = -(this.width + this.marginOffset.x); 900 | if (edgeOffset.left + translate.x < this.containerBoundingRect.left + offset.width && prevNode) { 901 | // If it moves passed the left bounds, then animate it to the last position of the previous row. 902 | // We just use the offset of the previous node to calculate where to move, because that node's original position 903 | // is exactly where we want to go 904 | translate.x = prevNode.edgeOffset!.left - edgeOffset.left; 905 | translate.y = prevNode.edgeOffset!.top - edgeOffset.top; 906 | } 907 | this.newIndex = index; 908 | } 909 | } else { 910 | if (index > this.index! && sortingOffset.left + scrollDifference.left + offset.width >= edgeOffset.left) { 911 | translate.x = -(this.width + this.marginOffset.x); 912 | this.newIndex = index; 913 | } else if ( 914 | index < this.index! && 915 | sortingOffset.left + scrollDifference.left <= edgeOffset.left + offset.width 916 | ) { 917 | translate.x = this.width + this.marginOffset.x; 918 | if (this.newIndex == null) { 919 | this.newIndex = index; 920 | } 921 | } 922 | } 923 | } else if (this._axis.y) { 924 | if (index > this.index! && sortingOffset.top + scrollDifference.top + offset.height >= edgeOffset.top) { 925 | translate.y = -(this.height + this.marginOffset.y); 926 | this.newIndex = index; 927 | } else if ( 928 | index < this.index! && 929 | sortingOffset.top + scrollDifference.top <= edgeOffset.top + offset.height 930 | ) { 931 | translate.y = this.height + this.marginOffset.y; 932 | if (this.newIndex == null) { 933 | this.newIndex = index; 934 | } 935 | } 936 | } 937 | node.style['transform'] = `translate3d(${translate.x}px,${translate.y}px,0)`; 938 | } 939 | 940 | if (this.newIndex == null) { 941 | this.newIndex = this.index; 942 | } 943 | }, 944 | 945 | autoscroll() { 946 | const translate = this.translate; 947 | const direction = { 948 | x: 0, 949 | y: 0, 950 | }; 951 | const speed = { 952 | x: 1, 953 | y: 1, 954 | }; 955 | const acceleration = { 956 | x: 10, 957 | y: 10, 958 | }; 959 | 960 | if (translate.y >= this.maxTranslate.y! - this.height / 2) { 961 | direction.y = 1; // Scroll Down 962 | speed.y = acceleration.y * Math.abs((this.maxTranslate.y! - this.height / 2 - translate.y) / this.height); 963 | } else if (translate.x >= this.maxTranslate.x! - this.width / 2) { 964 | direction.x = 1; // Scroll Right 965 | speed.x = acceleration.x * Math.abs((this.maxTranslate.x! - this.width / 2 - translate.x) / this.width); 966 | } else if (translate.y <= this.minTranslate.y! + this.height / 2) { 967 | direction.y = -1; // Scroll Up 968 | speed.y = acceleration.y * Math.abs((translate.y - this.height / 2 - this.minTranslate.y!) / this.height); 969 | } else if (translate.x <= this.minTranslate.x! + this.width / 2) { 970 | direction.x = -1; // Scroll Left 971 | speed.x = acceleration.x * Math.abs((translate.x - this.width / 2 - this.minTranslate.x!) / this.width); 972 | } 973 | 974 | if (this.autoscrollInterval) { 975 | clearInterval(this.autoscrollInterval); 976 | this.autoscrollInterval = null; 977 | } 978 | 979 | if (direction.x !== 0 || direction.y !== 0) { 980 | this.autoscrollInterval = window.setInterval(() => { 981 | const offset = { 982 | left: 1 * speed.x * direction.x, 983 | top: 1 * speed.y * direction.y, 984 | }; 985 | 986 | if (this.useWindowAsScrollContainer) { 987 | this._window.scrollBy(offset.left, offset.top); 988 | } else { 989 | this.scrollContainer.scrollTop += offset.top; 990 | this.scrollContainer.scrollLeft += offset.left; 991 | } 992 | 993 | this.translate.x += offset.left; 994 | this.translate.y += offset.top; 995 | this.animateNodes(); 996 | }, 5); 997 | } 998 | }, 999 | }, 1000 | }); 1001 | -------------------------------------------------------------------------------- /src/ElementMixin.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from 'vue'; 2 | import Manager, { ItemRef } from './Manager'; 3 | 4 | interface ComponentData { 5 | manager: Manager; 6 | ref: ItemRef; 7 | } 8 | 9 | // Export Sortable Element Component Mixin 10 | export const ElementMixin = defineComponent({ 11 | inject: ['manager'], 12 | props: { 13 | index: { 14 | type: Number, 15 | required: true, 16 | }, 17 | disabled: { 18 | type: Boolean, 19 | default: false, 20 | }, 21 | }, 22 | 23 | data() { 24 | return ({} as unknown) as ComponentData; 25 | }, 26 | 27 | watch: { 28 | index(newIndex) { 29 | if (this.$el && this.$el.sortableInfo) { 30 | this.$el.sortableInfo.index = newIndex; 31 | } 32 | }, 33 | disabled(isDisabled) { 34 | if (isDisabled) { 35 | this.removeDraggable(); 36 | } else { 37 | this.setDraggable(this.index); 38 | } 39 | }, 40 | }, 41 | 42 | mounted() { 43 | const { disabled, index } = this.$props; 44 | 45 | if (!disabled) { 46 | this.setDraggable(index); 47 | } 48 | }, 49 | 50 | beforeUnmount() { 51 | if (!this.disabled) this.removeDraggable(); 52 | }, 53 | 54 | methods: { 55 | setDraggable(index: number) { 56 | const node = this.$el; 57 | 58 | node.sortableInfo = { 59 | index, 60 | manager: this.manager, 61 | }; 62 | 63 | this.ref = { node }; 64 | this.manager.add(this.ref); 65 | }, 66 | 67 | removeDraggable() { 68 | this.manager.remove(this.ref); 69 | }, 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /src/HandleDirective.ts: -------------------------------------------------------------------------------- 1 | import { Directive } from 'vue'; 2 | 3 | // Export Sortable Element Handle Directive 4 | export const HandleDirective: Directive = { 5 | beforeMount(el) { 6 | el.sortableHandle = true; 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/Manager.ts: -------------------------------------------------------------------------------- 1 | import { TopLeft } from './utils'; 2 | 3 | export interface SortableNode extends HTMLElement { 4 | sortableInfo: { 5 | index: number; 6 | manager: Manager; 7 | }; 8 | sortableHandle?: boolean; 9 | } 10 | 11 | export interface ItemRef { 12 | node: SortableNode; 13 | index?: number; 14 | edgeOffset?: TopLeft | null; 15 | } 16 | 17 | export default class Manager { 18 | private refs: ItemRef[] = []; 19 | public active: { index: number } | null = null; 20 | 21 | add(ref: ItemRef): void { 22 | if (!this.refs) { 23 | this.refs = []; 24 | } 25 | 26 | this.refs.push(ref); 27 | } 28 | 29 | remove(ref: ItemRef): void { 30 | const index = this.getIndex(ref); 31 | 32 | if (index !== -1) { 33 | this.refs.splice(index, 1); 34 | } 35 | } 36 | 37 | isActive(): boolean { 38 | return !!this.active; 39 | } 40 | 41 | getActive(): ItemRef | null { 42 | return this.refs.find(({ node }) => node?.sortableInfo?.index == this?.active?.index) || null; 43 | } 44 | 45 | getIndex(ref: ItemRef): number { 46 | return this.refs.indexOf(ref); 47 | } 48 | 49 | getRefs(): ItemRef[] { 50 | return this.refs; 51 | } 52 | 53 | getOrderedRefs(): ItemRef[] { 54 | return this.refs.sort((a, b) => { 55 | return a.node.sortableInfo.index - b.node.sortableInfo.index; 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Sample.cy.jsx: -------------------------------------------------------------------------------- 1 | import { mount } from '@cypress/vue'; 2 | import { SlickList, SlickItem } from './index'; 3 | import { ref } from 'vue'; 4 | 5 | const components = { 6 | setup() { 7 | const list = ref([1,2,3]); 8 | return () => 9 | {list.value.map((item, i) => 10 | Item {item} 11 | )} 12 | ; 13 | }, 14 | }; 15 | 16 | describe('it', () => { 17 | it('works', () => { 18 | mount(components); 19 | cy.get('cy:list cy:item').first().should('contain', 'Item').drag({ x: 80, y: 50 }); 20 | cy.get('cy:list').should('have.text', 'Item 2Item 3Item 1'); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/SlicksortHub.ts: -------------------------------------------------------------------------------- 1 | import Manager from './Manager'; 2 | import { getRectCenter, getDistance, getPointerOffset, isPointWithinRect, PointEvent } from './utils'; 3 | 4 | let containerIDCounter = 1; 5 | 6 | export interface ContainerRef { 7 | id: string; 8 | group: string; 9 | accept: AcceptProp | null; 10 | block: string[]; 11 | container: HTMLElement; 12 | newIndex: number; 13 | manager: Manager; 14 | sortableGhost: HTMLElement | null; 15 | 16 | handleDragIn(e: PointEvent, ghost: HTMLElement | null, helper: HTMLElement | null): void; 17 | handleDragOut(): void; 18 | handleDragEnd(): void; 19 | handleSortEnd(e: PointEvent): void; 20 | handleDropIn(payload: unknown): void; 21 | handleDropOut(): unknown; 22 | 23 | updatePosition(e: PointEvent): void; 24 | animateNodes(): void; 25 | autoscroll(): void; 26 | } 27 | 28 | type AcceptPropArgs = { source: ContainerRef; dest: ContainerRef; payload: unknown }; 29 | export type AcceptProp = boolean | string[] | ((args: AcceptPropArgs) => boolean); 30 | 31 | /** 32 | * Always allow when dest === source 33 | * Defer to 'dest.accept()' if it is a function 34 | * Allow any group in the accept lists 35 | * Deny any group in the block list 36 | * Allow the same group by default, this can be overridden with the block prop 37 | */ 38 | function canAcceptElement(dest: ContainerRef, source: ContainerRef, payload: unknown): boolean { 39 | if (source.id === dest.id) return true; 40 | if (dest.block && dest.block.includes(source.group)) return false; 41 | if (typeof dest.accept === 'function') { 42 | return dest.accept({ dest, source, payload }); 43 | } 44 | if (typeof dest.accept === 'boolean') { 45 | return dest.accept; 46 | } 47 | if (dest.accept && dest.accept.includes(source.group)) return true; 48 | if (dest.group === source.group) return true; 49 | return false; 50 | } 51 | 52 | function findClosestDest( 53 | { x, y }: { x: number; y: number }, 54 | refs: ContainerRef[], 55 | currentDest: ContainerRef, 56 | ): ContainerRef | null { 57 | // Quickly check if we are within the bounds of the current destination 58 | if (isPointWithinRect({ x, y }, currentDest.container.getBoundingClientRect())) { 59 | return currentDest; 60 | } 61 | 62 | let closest = null; 63 | let minDistance = Infinity; 64 | for (let i = 0; i < refs.length; i++) { 65 | const ref = refs[i]; 66 | const rect = ref.container.getBoundingClientRect(); 67 | const isWithin = isPointWithinRect({ x, y }, rect); 68 | 69 | if (isWithin) { 70 | // If we are within another destination, stop here 71 | return ref; 72 | } 73 | 74 | const center = getRectCenter(rect); 75 | const distance = getDistance(x, y, center.x, center.y); 76 | if (distance < minDistance) { 77 | closest = ref; 78 | minDistance = distance; 79 | } 80 | } 81 | 82 | // Try to guess the closest destination 83 | return closest; 84 | } 85 | 86 | export default class SlicksortHub { 87 | public helper: HTMLElement | null = null; 88 | public ghost: HTMLElement | null = null; 89 | 90 | private refs: ContainerRef[] = []; 91 | private source: ContainerRef | null = null; 92 | private dest: ContainerRef | null = null; 93 | 94 | getId(): string { 95 | return '' + containerIDCounter++; 96 | } 97 | 98 | isSource({ id }: ContainerRef): boolean { 99 | return this.source?.id === id; 100 | } 101 | 102 | getSource(): ContainerRef | null { 103 | return this.source; 104 | } 105 | 106 | isDest({ id }: ContainerRef): boolean { 107 | return this.dest?.id === id; 108 | } 109 | 110 | getDest(): ContainerRef | null { 111 | return this.dest; 112 | } 113 | 114 | addContainer(ref: ContainerRef): void { 115 | this.refs.push(ref); 116 | } 117 | 118 | removeContainer(ref: ContainerRef): void { 119 | this.refs = this.refs.filter((c) => c.id !== ref.id); 120 | } 121 | 122 | sortStart(ref: ContainerRef): void { 123 | this.source = ref; 124 | this.dest = ref; 125 | } 126 | 127 | handleSortMove(e: PointEvent, payload: unknown): void { 128 | const dest = this.dest; 129 | const source = this.source; 130 | 131 | if (!dest || !source) return; 132 | 133 | const refs = this.refs; 134 | const pointer = getPointerOffset(e, 'client'); 135 | const newDest = findClosestDest(pointer, refs, dest) || dest; 136 | 137 | if (dest.id !== newDest.id && canAcceptElement(newDest, source, payload)) { 138 | this.dest = newDest; 139 | dest.handleDragOut(); 140 | newDest.handleDragIn(e, this.ghost, this.helper); 141 | } 142 | if (dest.id !== this.source?.id) { 143 | this.dest?.updatePosition(e); 144 | this.dest?.animateNodes(); 145 | this.dest?.autoscroll(); 146 | } 147 | } 148 | 149 | handleSortEnd(): void { 150 | if (this.source?.id === this.dest?.id) return; 151 | const payload = this.source?.handleDropOut(); 152 | this.dest?.handleDropIn(payload); 153 | this.reset(); 154 | } 155 | 156 | reset(): void { 157 | this.source = null; 158 | this.dest = null; 159 | this.helper = null; 160 | this.ghost = null; 161 | } 162 | 163 | cancel(): void { 164 | this.dest?.handleDragEnd(); 165 | this.reset(); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/components/DragHandle.ts: -------------------------------------------------------------------------------- 1 | import { h, defineComponent } from 'vue'; 2 | 3 | export const DragHandle = defineComponent({ 4 | props: { 5 | tag: { 6 | type: String, 7 | default: 'span', 8 | }, 9 | }, 10 | mounted() { 11 | this.$el.sortableHandle = true; 12 | }, 13 | render() { 14 | return h(this.tag, this.$slots.default?.()); 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/SlickItem.ts: -------------------------------------------------------------------------------- 1 | import { h, defineComponent } from 'vue'; 2 | import { ElementMixin } from '../ElementMixin'; 3 | 4 | export const SlickItem = defineComponent({ 5 | name: 'SlickItem', 6 | mixins: [ElementMixin], 7 | props: { 8 | tag: { 9 | type: String, 10 | default: 'div', 11 | }, 12 | }, 13 | render() { 14 | return h(this.tag, this.$slots.default?.()); 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/SlickList.ts: -------------------------------------------------------------------------------- 1 | import { h, defineComponent, PropType } from 'vue'; 2 | import { ContainerMixin } from '../ContainerMixin'; 3 | import { hasOwnProperty } from '../utils'; 4 | import { SlickItem } from './SlickItem'; 5 | 6 | export const SlickList = defineComponent({ 7 | name: 'SlickList', 8 | mixins: [ContainerMixin], 9 | props: { 10 | tag: { 11 | type: String, 12 | default: 'div', 13 | }, 14 | itemKey: { 15 | type: [String, Function] as PropType<((item: unknown) => string) | string>, 16 | default: 'id', 17 | }, 18 | }, 19 | render() { 20 | if (this.$slots.item) { 21 | return h( 22 | this.tag, 23 | this.list.map((item, index) => { 24 | let key: string; 25 | if (item == null) { 26 | return; 27 | } else if (typeof this.itemKey === 'function') { 28 | key = this.itemKey(item); 29 | } else if ( 30 | typeof item === 'object' && 31 | hasOwnProperty(item, this.itemKey) && 32 | typeof item[this.itemKey] == 'string' 33 | ) { 34 | key = item[this.itemKey] as string; 35 | } else if (typeof item === 'string') { 36 | key = item; 37 | } else { 38 | throw new Error('Cannot find key for item, use the item-key prop and pass a function or string'); 39 | } 40 | return h( 41 | SlickItem, 42 | { 43 | key, 44 | index, 45 | }, 46 | { 47 | default: () => this.$slots.item?.({ item, index }), 48 | }, 49 | ); 50 | }), 51 | ); 52 | } 53 | return h(this.tag, this.$slots.default?.()); 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { ElementMixin } from './ElementMixin'; 2 | export { ContainerMixin } from './ContainerMixin'; 3 | export { HandleDirective } from './HandleDirective'; 4 | export { SlickList } from './components/SlickList'; 5 | export { SlickItem } from './components/SlickItem'; 6 | export { DragHandle } from './components/DragHandle'; 7 | 8 | export { arrayMove } from './utils'; 9 | export { default as plugin } from './plugin'; 10 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import SlicksortHub from './SlicksortHub'; 2 | import { HandleDirective } from './HandleDirective'; 3 | import { Plugin } from 'vue'; 4 | 5 | const plugin: Plugin = { 6 | install(app) { 7 | app.directive('drag-handle', HandleDirective); 8 | app.provide('SlicksortHub', new SlicksortHub()); 9 | }, 10 | }; 11 | 12 | export default plugin; 13 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { ItemRef, SortableNode } from './Manager'; 2 | 3 | export type XY = { x: number; y: number }; 4 | 5 | export type TopLeft = { top: number; left: number }; 6 | export type BottomRight = { bottom: number; right: number }; 7 | export type WidthHeight = { width: number; height: number }; 8 | export type Timer = ReturnType; 9 | export type PointEventName = 10 | | 'mousedown' 11 | | 'mousemove' 12 | | 'mouseup' 13 | | 'touchstart' 14 | | 'touchmove' 15 | | 'touchend' 16 | | 'touchcancel'; 17 | export type PointEvent = MouseEvent | TouchEvent; 18 | 19 | export const isTouch = (e: Event): e is TouchEvent => { 20 | return (e as TouchEvent).touches != null; 21 | }; 22 | 23 | // eslint-disable-next-line @typescript-eslint/ban-types 24 | export function hasOwnProperty( 25 | obj: X | null, 26 | prop: Y, 27 | ): obj is X & Record { 28 | return !!obj && Object.prototype.hasOwnProperty.call(obj, prop); 29 | } 30 | 31 | export function arrayMove(arr: T[], previousIndex: number, newIndex: number): (T | undefined)[] { 32 | const array: (T | undefined)[] = arr.slice(0); 33 | if (newIndex >= array.length) { 34 | let k = newIndex - array.length; 35 | while (k-- + 1) { 36 | array.push(undefined); 37 | } 38 | } 39 | array.splice(newIndex, 0, array.splice(previousIndex, 1)[0]); 40 | return array; 41 | } 42 | 43 | export function arrayRemove(arr: T[], previousIndex: number): T[] { 44 | const array = arr.slice(0); 45 | if (previousIndex >= array.length) return array; 46 | array.splice(previousIndex, 1); 47 | return array; 48 | } 49 | 50 | export function arrayInsert(arr: T[], newIndex: number, value: T): T[] { 51 | const array = arr.slice(0); 52 | if (newIndex === array.length) { 53 | array.push(value); 54 | } else { 55 | array.splice(newIndex, 0, value); 56 | } 57 | return array; 58 | } 59 | 60 | export const events: Record = { 61 | start: ['touchstart', 'mousedown'], 62 | move: ['touchmove', 'mousemove'], 63 | end: ['touchend', 'mouseup'], 64 | cancel: ['touchcancel', 'keyup'], 65 | }; 66 | 67 | export function closest( 68 | el: SortableNode | Node | null, 69 | fn: (el: SortableNode | Node) => boolean, 70 | ): SortableNode | Node | undefined { 71 | while (el) { 72 | if (fn(el)) return el; 73 | el = el.parentNode; 74 | } 75 | } 76 | 77 | export function limit(min: number, max: number, value: number): number { 78 | if (value < min) { 79 | return min; 80 | } 81 | if (value > max) { 82 | return max; 83 | } 84 | return value; 85 | } 86 | 87 | function getCSSPixelValue(stringValue: string): number { 88 | if (stringValue.substr(-2) === 'px') { 89 | return parseFloat(stringValue); 90 | } 91 | return 0; 92 | } 93 | 94 | export function getElementMargin(element: HTMLElement): TopLeft & BottomRight { 95 | const style = window.getComputedStyle(element); 96 | 97 | return { 98 | top: getCSSPixelValue(style.marginTop), 99 | right: getCSSPixelValue(style.marginRight), 100 | bottom: getCSSPixelValue(style.marginBottom), 101 | left: getCSSPixelValue(style.marginLeft), 102 | }; 103 | } 104 | 105 | export function getPointerOffset(e: PointEvent, reference: 'client' | 'page' = 'page'): XY { 106 | const x = `${reference}X` as 'clientX' | 'pageX'; 107 | const y = `${reference}Y` as 'clientY' | 'pageY'; 108 | 109 | return { 110 | x: isTouch(e) ? e.touches[0][x] : e[x], 111 | y: isTouch(e) ? e.touches[0][y] : e[y], 112 | }; 113 | } 114 | 115 | function offsetParents(node: HTMLElement) { 116 | const nodes = [node]; 117 | for (; node; node = node.offsetParent as HTMLElement) { 118 | nodes.unshift(node); 119 | } 120 | return nodes; 121 | } 122 | 123 | export function commonOffsetParent(node1: HTMLElement, node2: HTMLElement): HTMLElement | undefined { 124 | const parents1 = offsetParents(node1); 125 | const parents2 = offsetParents(node2); 126 | 127 | if (parents1[0] != parents2[0]) throw 'No common ancestor!'; 128 | 129 | for (let i = 0; i < parents1.length; i++) { 130 | if (parents1[i] != parents2[i]) return parents1[i - 1]; 131 | } 132 | } 133 | 134 | export function getEdgeOffset( 135 | node: HTMLElement, 136 | container: HTMLElement, 137 | offset = { top: 0, left: 0 }, 138 | ): { top: number; left: number } { 139 | // Get the actual offsetTop / offsetLeft value, no matter how deep the node is nested 140 | if (node) { 141 | const nodeOffset = { 142 | top: offset.top + node.offsetTop, 143 | left: offset.left + node.offsetLeft, 144 | }; 145 | if (node.offsetParent !== container.offsetParent) { 146 | return getEdgeOffset(node.offsetParent as HTMLElement, container, nodeOffset); 147 | } else { 148 | return nodeOffset; 149 | } 150 | } 151 | return { top: 0, left: 0 }; 152 | } 153 | 154 | export function cloneNode(node: HTMLElement): HTMLElement { 155 | const fields = node.querySelectorAll('input, textarea, select') as NodeListOf; 156 | const clonedNode = node.cloneNode(true) as HTMLElement; 157 | const clonedFields = [...clonedNode.querySelectorAll('input, textarea, select')] as HTMLInputElement[]; // Convert NodeList to Array 158 | 159 | clonedFields.forEach((field, index) => { 160 | if (field.type !== 'file' && fields[index]) { 161 | field.value = fields[index].value; 162 | } 163 | }); 164 | 165 | return clonedNode; 166 | } 167 | 168 | export function getLockPixelOffsets(lockOffset: string | number | number[], width: number, height: number): XY[] { 169 | if (typeof lockOffset == 'string') { 170 | lockOffset = +lockOffset; 171 | } 172 | 173 | if (!Array.isArray(lockOffset)) { 174 | lockOffset = [lockOffset, lockOffset]; 175 | } 176 | 177 | if (lockOffset.length !== 2) { 178 | throw new Error( 179 | `lockOffset prop of SortableContainer should be a single value or an array of exactly two values. Given ${lockOffset}`, 180 | ); 181 | } 182 | 183 | const [minLockOffset, maxLockOffset] = lockOffset; 184 | 185 | return [getLockPixelOffset(minLockOffset, width, height), getLockPixelOffset(maxLockOffset, width, height)]; 186 | } 187 | 188 | export function getLockPixelOffset(lockOffset: number, width: number, height: number): XY { 189 | let offsetX = lockOffset; 190 | let offsetY = lockOffset; 191 | let unit = 'px'; 192 | 193 | if (typeof lockOffset === 'string') { 194 | const match = /^[+-]?\d*(?:\.\d*)?(px|%)$/.exec(lockOffset); 195 | 196 | if (match === null) { 197 | throw new Error( 198 | `lockOffset value should be a number or a string of a number followed by "px" or "%". Given ${lockOffset}`, 199 | ); 200 | } 201 | 202 | offsetX = offsetY = parseFloat(lockOffset); 203 | unit = match[1]; 204 | } 205 | 206 | if (!isFinite(offsetX) || !isFinite(offsetY)) { 207 | throw new Error(`lockOffset value should be a finite. Given ${lockOffset}`); 208 | } 209 | 210 | if (unit === '%') { 211 | offsetX = (offsetX * width) / 100; 212 | offsetY = (offsetY * height) / 100; 213 | } 214 | 215 | return { 216 | x: offsetX, 217 | y: offsetY, 218 | }; 219 | } 220 | 221 | export function getDistance(x1: number, y1: number, x2: number, y2: number): number { 222 | const x = x1 - x2; 223 | const y = y1 - y2; 224 | return Math.sqrt(x * x + y * y); 225 | } 226 | 227 | export function getRectCenter(clientRect: ClientRect): XY { 228 | return { 229 | x: clientRect.left + clientRect.width / 2, 230 | y: clientRect.top + clientRect.height / 2, 231 | }; 232 | } 233 | 234 | export function resetTransform(nodes: ItemRef[] = []): void { 235 | for (let i = 0, len = nodes.length; i < len; i++) { 236 | const node = nodes[i]; 237 | const el = node.node; 238 | 239 | if (!el) return; 240 | 241 | // Clear the cached offsetTop / offsetLeft value 242 | node.edgeOffset = null; 243 | 244 | // Remove the transforms / transitions 245 | setTransform(el); 246 | } 247 | } 248 | 249 | export function setTransform(el: HTMLElement | null, transform = '', duration = ''): void { 250 | if (!el) return; 251 | el.style['transform'] = transform; 252 | el.style['transitionDuration'] = duration; 253 | } 254 | 255 | function withinBounds(pos: number, top: number, bottom: number) { 256 | const upper = Math.max(top, bottom); 257 | const lower = Math.min(top, bottom); 258 | return lower <= pos && pos <= upper; 259 | } 260 | 261 | export function isPointWithinRect({ x, y }: XY, { top, left, width, height }: ClientRect): boolean { 262 | const withinX = withinBounds(x, left, left + width); 263 | const withinY = withinBounds(y, top, top + height); 264 | return withinX && withinY; 265 | } 266 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "es2017", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "esnext", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "./", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "./src", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue-jsx' 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | }) 7 | --------------------------------------------------------------------------------