Преглед изворни кода

Add "sort" plugin (#4137)

* add implementation and tests

* wip

* add docs

* fix test

* move dependancy

* wip

* wip

* try to fix cypress test

* fix tests

* add to releases

* skip flaky test
Caleb Porzio пре 1 година
родитељ
комит
8ee5f9c018

+ 35 - 17
package-lock.json

@@ -7,17 +7,16 @@
             "workspaces": [
                 "packages/*"
             ],
-            "dependencies": {
-                "@floating-ui/dom": "^1.5.3"
-            },
             "devDependencies": {
+                "@floating-ui/dom": "^1.5.3",
                 "axios": "^0.21.1",
                 "chalk": "^4.1.1",
                 "cypress": "^7.0.0",
                 "cypress-plugin-tab": "^1.0.5",
                 "dot-json": "^1.2.2",
                 "esbuild": "~0.16.17",
-                "jest": "^26.6.3"
+                "jest": "^26.6.3",
+                "sortablejs": "^1.15.2"
             }
         },
         "node_modules/@alpinejs/anchor": {
@@ -64,6 +63,10 @@
             "resolved": "packages/persist",
             "link": true
         },
+        "node_modules/@alpinejs/sort": {
+            "resolved": "packages/sort",
+            "link": true
+        },
         "node_modules/@alpinejs/ui": {
             "resolved": "packages/ui",
             "link": true
@@ -1025,6 +1028,7 @@
             "version": "1.5.0",
             "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.0.tgz",
             "integrity": "sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==",
+            "dev": true,
             "dependencies": {
                 "@floating-ui/utils": "^0.1.3"
             }
@@ -1033,6 +1037,7 @@
             "version": "1.5.3",
             "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz",
             "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==",
+            "dev": true,
             "dependencies": {
                 "@floating-ui/core": "^1.4.2",
                 "@floating-ui/utils": "^0.1.3"
@@ -1041,7 +1046,8 @@
         "node_modules/@floating-ui/utils": {
             "version": "0.1.6",
             "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
-            "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A=="
+            "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==",
+            "dev": true
         },
         "node_modules/@istanbuljs/load-nyc-config": {
             "version": "1.1.0",
@@ -6833,6 +6839,12 @@
                 "node": ">=0.10.0"
             }
         },
+        "node_modules/sortablejs": {
+            "version": "1.15.2",
+            "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.2.tgz",
+            "integrity": "sha512-FJF5jgdfvoKn1MAKSdGs33bIqLi3LmsgVTliuX6iITj834F+JRQZN90Z93yql8h0K2t0RwDPBmxwlbZfDcxNZA==",
+            "dev": true
+        },
         "node_modules/source-map": {
             "version": "0.6.1",
             "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -7854,37 +7866,38 @@
             }
         },
         "packages/alpinejs": {
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT",
             "dependencies": {
                 "@vue/reactivity": "~3.1.1"
             }
         },
         "packages/anchor": {
-            "version": "3.13.2",
+            "name": "@alpinejs/anchor",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/collapse": {
             "name": "@alpinejs/collapse",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/csp": {
             "name": "@alpinejs/csp",
-            "version": "3.0.0-alpha.0",
+            "version": "3.13.8",
             "license": "MIT",
             "dependencies": {
-                "@vue/reactivity": "^3.0.2"
+                "@vue/reactivity": "~3.1.1"
             }
         },
         "packages/docs": {
             "name": "@alpinejs/docs",
-            "version": "3.13.2-revision.1",
+            "version": "3.13.8-revision.1",
             "license": "MIT"
         },
         "packages/focus": {
             "name": "@alpinejs/focus",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT",
             "dependencies": {
                 "focus-trap": "^6.9.4",
@@ -7901,17 +7914,17 @@
         },
         "packages/intersect": {
             "name": "@alpinejs/intersect",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/mask": {
             "name": "@alpinejs/mask",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/morph": {
             "name": "@alpinejs/morph",
-            "version": "3.13.2",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/navigate": {
@@ -7924,12 +7937,17 @@
         },
         "packages/persist": {
             "name": "@alpinejs/persist",
-            "version": "3.13.2",
+            "version": "3.13.8",
+            "license": "MIT"
+        },
+        "packages/sort": {
+            "name": "@alpinejs/sort",
+            "version": "3.13.8",
             "license": "MIT"
         },
         "packages/ui": {
             "name": "@alpinejs/ui",
-            "version": "3.13.1-beta.0",
+            "version": "3.13.8-beta.0",
             "license": "MIT",
             "devDependencies": {}
         }

+ 3 - 2
package.json

@@ -4,14 +4,15 @@
         "packages/*"
     ],
     "devDependencies": {
+        "@floating-ui/dom": "^1.5.3",
         "axios": "^0.21.1",
         "chalk": "^4.1.1",
         "cypress": "^7.0.0",
         "cypress-plugin-tab": "^1.0.5",
-        "@floating-ui/dom": "^1.5.3",
         "dot-json": "^1.2.2",
         "esbuild": "~0.16.17",
-        "jest": "^26.6.3"
+        "jest": "^26.6.3",
+        "sortablejs": "^1.15.2"
     },
     "scripts": {
         "build": "node ./scripts/build.js",

+ 2 - 2
packages/docs/src/en/plugins/anchor.md

@@ -1,13 +1,13 @@
 ---
 order: 5
 title: Anchor
-description: Anchor an element's positioning to another element on the pageg
+description: Anchor an element's positioning to another element on the page
 graph_image: https://alpinejs.dev/social_anchor.jpg
 ---
 
 # Anchor Plugin
 
-Alpine's Anchor plugin allows you easily anchor an element's positioning to another element on the page.
+Alpine's Anchor plugin allows you to easily anchor an element's positioning to another element on the page.
 
 This functionality is useful when creating dropdown menus, popovers, dialogs, and tooltips with Alpine.
 

+ 249 - 0
packages/docs/src/en/plugins/sort.md

@@ -0,0 +1,249 @@
+---
+order: 6
+title: Sort
+description: Easily re-order elements by dragging them with your mouse
+graph_image: https://alpinejs.dev/social_sort.jpg
+---
+
+# Sort Plugin
+
+Alpine's Sort plugin allows you to easily re-order elements by dragging them with your mouse.
+
+This functionality is useful for things like Kanban boards, to-do lists, sortable table columns, etc.
+
+The drag functionality used in this plugin is provided by the [SortableJS](https://github.com/SortableJS/Sortable) project.
+
+<a name="installation"></a>
+## Installation
+
+You can use this plugin by either including it from a `<script>` tag or installing it via NPM:
+
+### Via CDN
+
+You can include the CDN build of this plugin as a `<script>` tag; just make sure to include it BEFORE Alpine's core JS file.
+
+```alpine
+<!-- Alpine Plugins -->
+<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/sort@3.x.x/dist/cdn.min.js"></script>
+
+<!-- Alpine Core -->
+<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
+```
+
+### Via NPM
+
+You can install Anchor from NPM for use inside your bundle like so:
+
+```shell
+npm install @alpinejs/sort
+```
+
+Then initialize it from your bundle:
+
+```js
+import Alpine from 'alpinejs'
+import sort from '@alpinejs/sort'
+
+Alpine.plugin(sort)
+
+...
+```
+
+<a name="basic-usage"></a>
+## Basic usage
+
+The primary API for using this plugin is the `x-sort` directive. By adding `x-sort` to an element, its children become sortable—meaning you can drag them around with your mouse, and they will change positions.
+
+```alpine
+<ul x-sort>
+    <li>foo</li>
+    <li>bar</li>
+    <li>baz</li>
+</ul>
+```
+
+<!-- START_VERBATIM -->
+<div x-data>
+    <ul x-sort>
+        <li>foo</li>
+        <li>bar</li>
+        <li>baz</li>
+    </ul>
+</div>
+<!-- END_VERBATIM -->
+
+<a name="sort-handlers"></a>
+## Sort handlers
+
+You can react to sorting changes by passing a handler function to `x-sort` and adding keys to each item using `x-sort:key`. Here is an example of a simple handler function that shows an alert dialog with the changed item's key and its new position:
+
+```alpine
+<div x-data="{ handle(key, position) { alert(key + ' - ' + position)} }">
+    <ul x-sort="handle">
+        <li x-sort:key="1">foo</li>
+        <li x-sort:key="2">bar</li>
+        <li x-sort:key="3">baz</li>
+    </ul>
+</div>
+```
+
+<!-- START_VERBATIM -->
+<div x-data="{ handle(key, position) { alert(key + ' - ' + position)} }">
+    <ul x-sort="handle">
+        <li x-sort:key="1">foo</li>
+        <li x-sort:key="2">bar</li>
+        <li x-sort:key="3">baz</li>
+    </ul>
+</div>
+<!-- END_VERBATIM -->
+
+As you can see, the `key` and `position` parameters are passed into the handler function on every sorting change. The `key` parameter is the value provided to `x-sort:key`, and the `position` parameter is its new position in the list of children (starting at index `0`).
+
+Handler functions are often used to persist the new order of items in the database so that the sorting order of a list is preserved between page refreshes.
+
+<a name="sorting-groups"></a>
+## Sorting groups
+
+This plugin allows you to drag items from one `x-sort` sortable list into another one by adding a matching `.group` modifier to both lists:
+
+```alpine
+<div>
+    <ul x-sort.group.todos>
+        <li x-sort:key="1">foo</li>
+        <li x-sort:key="2">bar</li>
+        <li x-sort:key="3">baz</li>
+    </ul>
+
+    <ol x-sort.group.todos>
+        <li x-sort:key="1">foo</li>
+        <li x-sort:key="2">bar</li>
+        <li x-sort:key="3">baz</li>
+    </ol>
+</div>
+```
+
+Because both sortable lists above use the same group name (`todos`), you can drag items from one list onto another.
+
+> When using sort handlers like `x-sort="handle"` and dragging an item from one group to another, only the destination lists handler will be called with the key and new position.
+
+<a name="drag-handles"></a>
+## Drag handles
+
+By default, each child element of `x-sort` is draggable by clicking and dragging anywhere within it. However, you may want to designate a smaller, more specific element as the "drag handle" so that the rest of the element can be interacted with like normal, and only the handle will respond to mouse dragging:
+
+```alpine
+<ul x-sort>
+    <li>
+        <span x-sort:handle> - </span>foo
+    </li>
+
+    <li>
+        <span x-sort:handle> - </span>bar
+    </li>
+
+    <li>
+        <span x-sort:handle> - </span>baz
+    </li>
+</ul>
+```
+
+<!-- START_VERBATIM -->
+<div x-data>
+    <ul x-sort>
+        <li>
+            <span x-sort:handle> - </span>foo
+        </li>
+        <li>
+            <span x-sort:handle> - </span>bar
+        </li>
+        <li>
+            <span x-sort:handle> - </span>baz
+        </li>
+    </ul>
+</div>
+<!-- END_VERBATIM -->
+
+As you can see in the above example, the hyphen "-" is draggable, but the item text ("foo") is not.
+
+<a name="ghost-elements"></a>
+## Ghost elements
+
+When a user drags an item, the element will follow their mouse to appear as though they are physically dragging the element.
+
+By default, a "hole" (empty space) will be left in the original element's place during the drag.
+
+If you would like to show a "ghost" of the original element in its place instead of an empty space, you can add the `.ghost` modifier to `x-sort`:
+
+```alpine
+<ul x-sort.ghost>
+    <li>foo</li>
+    <li>bar</li>
+    <li>baz</li>
+</ul>
+```
+
+<!-- START_VERBATIM -->
+<div x-data>
+    <ul x-sort.ghost>
+        <li>foo</li>
+        <li>bar</li>
+        <li>baz</li>
+    </ul>
+</div>
+<!-- END_VERBATIM -->
+
+<a name="ghost-styling"></a>
+### Styling the ghost element
+
+By default, the "ghost" element has a `.sortable-ghost` CSS class attached to it while the original element is being dragged.
+
+This makes it easy to add any custom styling you would like:
+
+```alpine
+<style>
+.sortable-ghost {
+    opacity: .5 !important;
+}
+</style>
+
+<ul x-sort.ghost>
+    <li>foo</li>
+    <li>bar</li>
+    <li>baz</li>
+</ul>
+```
+
+<!-- START_VERBATIM -->
+<div x-data>
+    <ul x-sort.ghost x-sort:config="{ ghostClass: 'opacity-50' }">
+        <li>foo</li>
+        <li>bar</li>
+        <li>baz</li>
+    </ul>
+</div>
+<!-- END_VERBATIM -->
+
+<a name="custom-configuration"></a>
+## Custom configuration
+
+Alpine chooses sensible defaults for configuring [SortableJS](https://github.com/SortableJS/Sortable?tab=readme-ov-file#options) under the hood. However, you can add or override any of these options yourself using `x-sort:config`:
+
+```alpine
+<ul x-sort x-sort:config="{ filter: '.no-drag' }">
+    <li>foo</li>
+    <li class="no-drag">bar (not dragable)</li>
+    <li>baz</li>
+</ul>
+```
+
+<!-- START_VERBATIM -->
+<div x-data>
+    <ul x-sort x-sort:config="{ filter: '.no-drag' }">
+        <li>foo</li>
+        <li class="no-drag">bar (not dragable)</li>
+        <li>baz</li>
+    </ul>
+</div>
+<!-- END_VERBATIM -->
+
+[View the full list of SortableJS configuration options here →](https://github.com/SortableJS/Sortable?tab=readme-ov-file#options)

+ 5 - 0
packages/sort/builds/cdn.js

@@ -0,0 +1,5 @@
+import sort from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+    window.Alpine.plugin(sort)
+})

+ 5 - 0
packages/sort/builds/module.js

@@ -0,0 +1,5 @@
+import sort  from '../src/index.js'
+
+export default sort
+
+export { sort }

+ 15 - 0
packages/sort/package.json

@@ -0,0 +1,15 @@
+{
+    "name": "@alpinejs/sort",
+    "version": "3.13.8",
+    "description": "An Alpine plugin for drag sorting items on the page",
+    "homepage": "https://alpinejs.dev/plugins/sort",
+    "repository": {
+        "type": "git",
+        "url": "https://github.com/alpinejs/alpine.git",
+        "directory": "packages/sort"
+    },
+    "author": "Caleb Porzio",
+    "license": "MIT",
+    "main": "dist/module.cjs.js",
+    "module": "dist/module.esm.js"
+}

+ 127 - 0
packages/sort/src/index.js

@@ -0,0 +1,127 @@
+import Sortable from 'sortablejs'
+
+export default function (Alpine) {
+    Alpine.directive('sort', (el, { value, modifiers, expression }, { effect, evaluate, evaluateLater, cleanup }) => {
+        if (value === 'config') {
+            return // This will get handled by the main directive...
+        }
+
+        if (value === 'handle') {
+            return // This will get handled by the main directive...
+        }
+
+        if (value === 'key') {
+            if ([undefined, null, ''].includes(expression)) return
+
+            el._x_sort_key = evaluate(expression)
+
+            return
+        }
+
+        let preferences = {
+            hideGhost: ! modifiers.includes('ghost'),
+            useHandles: !! el.querySelector('[x-sort\\:handle]'),
+            group: modifiers.indexOf('group') !== -1 ? modifiers[modifiers.indexOf('group') + 1] : null,
+        }
+
+        let handleSort = generateSortHandler(expression, evaluateLater)
+
+        let config = getConfigurationOverrides(el, modifiers, evaluate)
+
+        let sortable = initSortable(el, config, preferences, (key, position) => {
+            handleSort(key, position)
+        })
+
+        cleanup(() => sortable.destroy())
+    })
+}
+
+function generateSortHandler(expression, evaluateLater) {
+    // No handler was passed to x-sort...
+    if ([undefined, null, ''].includes(expression)) return () => {}
+
+    let handle = evaluateLater(expression)
+
+    return (key, position) => {
+        // In the case of `x-sort="handleSort"`, let us call it manually...
+        Alpine.dontAutoEvaluateFunctions(() => {
+            handle(
+                // If a function is returned, call it with the key/position params...
+                received => {
+                    if (typeof received === 'function') received(key, position)
+                },
+                // Provide $key and $position to the scope in case they want to call their own function...
+                { scope: {
+                    $key: key,
+                    $position: position,
+                } },
+            )
+        })
+    }
+}
+
+function getConfigurationOverrides(el, modifiers, evaluate)
+{
+    return el.hasAttribute('x-sort:config')
+        ? evaluate(el.getAttribute('x-sort:config'))
+        : {}
+}
+
+function initSortable(el, config, preferences, handle) {
+    let ghostRef
+
+    let options = {
+        animation: 150,
+
+        handle: preferences.useHandles ? '[x-sort\\:handle]' : null,
+
+        group: preferences.group,
+
+        onSort(e) {
+            // If item has been dragged between groups...
+            if (e.from !== e.to) {
+                // And this is the group it was dragged FROM...
+                if (e.to !== e.target) {
+                    return // Don't do anything, because the other group will call the handler...
+                }
+            }
+
+            let key = e.item._x_sort_key
+            let position = e.newIndex
+
+            if (key !== undefined || key !== null) {
+                handle(key, position)
+            }
+        },
+
+        onStart() {
+            ghostRef = document.querySelector('.sortable-ghost')
+
+            if (preferences.hideGhost && ghostRef) ghostRef.style.opacity = '0'
+        },
+
+
+        onEnd() {
+            if (preferences.hideGhost && ghostRef) ghostRef.style.opacity = '1'
+
+            ghostRef = undefined
+
+            keepElementsWithinMorphMarkers(el)
+        }
+    }
+
+    return new Sortable(el, { ...options, ...config })
+}
+
+function keepElementsWithinMorphMarkers(el) {
+    let cursor = el.firstChild
+
+    while (cursor.nextSibling) {
+        if (cursor.textContent.trim() === '[if ENDBLOCK]><![endif]') {
+            el.append(cursor)
+            break
+        }
+
+        cursor = cursor.nextSibling
+    }
+}

+ 1 - 0
scripts/build.js

@@ -14,6 +14,7 @@ let zlib = require('zlib');
     'anchor',
     'morph',
     'focus',
+    'sort',
     'mask',
     'ui',
 ]).forEach(package => {

+ 6 - 0
scripts/release.js

@@ -62,6 +62,9 @@ function writeNewAlpineVersion() {
 
     writeToPackageDotJson('mask', 'version', version)
     console.log('Bumping @alpinejs/mask package.json: '+version)
+
+    writeToPackageDotJson('sort', 'version', version)
+    console.log('Bumping @alpinejs/sort package.json: '+version)
 }
 
 function writeNewDocsVersion() {
@@ -107,6 +110,9 @@ function publish() {
     console.log('Publishing @alpinejs/mask on NPM...');
     runFromPackage('mask', 'npm publish --access public')
 
+    console.log('Publishing @alpinejs/sort on NPM...');
+    runFromPackage('sort', 'npm publish --access public')
+
     log('\n\nFinished!')
 }
 

+ 177 - 0
tests/cypress/integration/plugins/sort.spec.js

@@ -0,0 +1,177 @@
+import { haveText, html, test } from '../../utils'
+
+test('basic drag sorting works',
+    [html`
+        <div x-data>
+            <ul x-sort>
+                <li id="1">foo</li>
+                <li id="2">bar</li>
+                <li id="3">baz</li>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul li').eq(0).should(haveText('foo'))
+        get('ul li').eq(1).should(haveText('bar'))
+        get('ul li').eq(2).should(haveText('baz'))
+
+        // Unfortunately, github actions doesn't like "async/await" here
+        // so we need to use .then() throughout this entire test...
+        get('#1').drag('#3').then(() => {
+            get('ul li').eq(0).should(haveText('bar'))
+            get('ul li').eq(1).should(haveText('baz'))
+            get('ul li').eq(2).should(haveText('foo'))
+
+            get('#3').drag('#1').then(() => {
+                get('ul li').eq(0).should(haveText('bar'))
+                get('ul li').eq(1).should(haveText('foo'))
+                get('ul li').eq(2).should(haveText('baz'))
+            })
+        })
+    },
+)
+
+test('can use a custom handle',
+    [html`
+        <div x-data>
+            <ul x-sort>
+                <li id="1"><span x-sort:handle>handle</span> - foo</li>
+                <li id="2"><span x-sort:handle>handle</span> - bar</li>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul li').eq(0).should(haveText('handle - foo'))
+        get('ul li').eq(1).should(haveText('handle - bar'))
+
+        get('#1 span').drag('#2').then(() => {
+            get('ul li').eq(0).should(haveText('handle - bar'))
+            get('ul li').eq(1).should(haveText('handle - foo'))
+        })
+    },
+)
+
+// Skipping this because it passes locally but not in CI...
+test.skip('can move items between groups',
+    [html`
+        <div x-data>
+            <ul x-sort.group.one>
+                <li id="1">foo</li>
+                <li id="2">bar</li>
+            </ul>
+
+            <ol x-sort.group.one>
+                <li id="3">oof</li>
+                <li id="4">rab</li>
+            </ol>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul li').eq(0).should(haveText('foo'))
+        get('ul li').eq(1).should(haveText('bar'))
+        get('ol li').eq(0).should(haveText('oof'))
+        get('ol li').eq(1).should(haveText('rab'))
+
+        get('#1').drag('#4').then(() => {
+            get('ul li').eq(0).should(haveText('bar'))
+            get('ol li').eq(0).should(haveText('oof'))
+            get('ol li').eq(1).should(haveText('foo'))
+            get('ol li').eq(2).should(haveText('rab'))
+        })
+    },
+)
+
+test('sort handle method',
+    [html`
+        <div x-data="{ handle(key, position) { $refs.outlet.textContent = key+'-'+position } }">
+            <ul x-sort="handle">
+                <li x-sort:key="1" id="1">foo</li>
+                <li x-sort:key="2" id="2">bar</li>
+                <li x-sort:key="3" id="3">baz</li>
+            </ul>
+
+            <h1 x-ref="outlet"></h1>
+        </div>
+    `],
+    ({ get }) => {
+        get('#1').drag('#3').then(() => {
+            get('h1').should(haveText('1-2'))
+
+            get('#3').drag('#1').then(() => {
+                get('h1').should(haveText('3-2'))
+            })
+        })
+    },
+)
+
+test('can access key and position in handler',
+    [html`
+        <div x-data="{ handle(key, position) { $refs.outlet.textContent = key+'-'+position } }">
+            <ul x-sort="handle($position, $key)">
+                <li x-sort:key="1" id="1">foo</li>
+                <li x-sort:key="2" id="2">bar</li>
+                <li x-sort:key="3" id="3">baz</li>
+            </ul>
+
+            <h1 x-ref="outlet"></h1>
+        </div>
+    `],
+    ({ get }) => {
+        get('#1').drag('#3').then(() => {
+            get('h1').should(haveText('2-1'))
+
+            get('#3').drag('#1').then(() => {
+                get('h1').should(haveText('2-3'))
+            })
+        })
+    },
+)
+
+test('can use custom sortablejs configuration',
+    [html`
+        <div x-data>
+            <ul x-sort x-sort:config="{ filter: '[data-ignore]' }">
+                <li id="1" data-ignore>foo</li>
+                <li id="2">bar</li>
+                <li id="3">baz</li>
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('ul li').eq(0).should(haveText('foo'))
+        get('ul li').eq(1).should(haveText('bar'))
+        get('ul li').eq(2).should(haveText('baz'))
+
+        get('#1').drag('#3').then(() => {
+            get('ul li').eq(0).should(haveText('foo'))
+            get('ul li').eq(1).should(haveText('bar'))
+            get('ul li').eq(2).should(haveText('baz'))
+
+            get('#3').drag('#1').then(() => {
+                get('ul li').eq(0).should(haveText('baz'))
+                get('ul li').eq(1).should(haveText('foo'))
+                get('ul li').eq(2).should(haveText('bar'))
+            })
+        })
+    },
+)
+
+test('works with Livewire morphing',
+    [html`
+        <div x-data>
+            <ul x-sort>
+                <!-- [if BLOCK]><![endif] -->
+                <li id="1">foo</li>
+                <li id="2">bar</li>
+                <li id="3">baz</li>
+                <!-- [if ENDBLOCK]><![endif] -->
+            </ul>
+        </div>
+    `],
+    ({ get }) => {
+        get('#1').drag('#3').then(() => {
+            // This is the easiest way I can think of to assert the order of HTML comments doesn't change...
+            get('ul').should('have.html', `\n                <!-- [if BLOCK]><![endif] -->\n                \n                <li id="2" style="">bar</li>\n                <li id="3" style="">baz</li>\n                \n            <li id="1" draggable="false" class="" style="opacity: 1;">foo</li><!-- [if ENDBLOCK]><![endif] -->`)
+        })
+    },
+)

+ 1 - 0
tests/cypress/spec.html

@@ -13,6 +13,7 @@
     <script src="/../../packages/collapse/dist/cdn.js"></script>
     <script src="/../../packages/anchor/dist/cdn.js"></script>
     <script src="/../../packages/mask/dist/cdn.js"></script>
+    <script src="/../../packages/sort/dist/cdn.js"></script>
     <script src="/../../packages/ui/dist/cdn.js"></script>
     <script>
         let root = document.querySelector('#root')

+ 160 - 0
tests/cypress/support/drag.js

@@ -0,0 +1,160 @@
+/**
+ * This entire file was coppied from the following github repo: https://github.com/4teamwork/cypress-drag-drop/blob/master/index.js
+ *
+ * Full credit to the "cypress-drag-drop" package...
+ */
+
+const dataTransfer = new DataTransfer()
+
+function omit(object = {}, keys = []) {
+    return Object.entries(object).reduce((accum, [key, value]) => (key in keys ? accum : { ...accum, [key]: value }), {})
+}
+
+function isAttached(element) {
+    return !!element.closest('html')
+}
+
+const DragSimulator = {
+    MAX_TRIES: 5,
+    DELAY_INTERVAL_MS: 10,
+    counter: 0,
+    targetElement: null,
+    rectsEqual(r1, r2) {
+        return r1.top === r2.top && r1.right === r2.right && r1.bottom === r2.bottom && r1.left === r2.left
+    },
+    createDefaultOptions(options) {
+        const commonOptions = omit(options, ['source', 'target'])
+        const source = { ...commonOptions, ...options.source }
+        const target = { ...commonOptions, ...options.target }
+        return { source, target }
+    },
+    get dropped() {
+        const currentSourcePosition = this.source.getBoundingClientRect()
+        return !this.rectsEqual(this.initialSourcePosition, currentSourcePosition)
+    },
+    get hasTriesLeft() {
+        return this.counter < this.MAX_TRIES
+    },
+    set target(target) {
+        this.targetElement = target
+    },
+    get target() {
+        return cy.wrap(this.targetElement)
+    },
+    dragstart(clientPosition = {}) {
+        return cy
+            .wrap(this.source)
+            .trigger('pointerdown', {
+                which: 1,
+                button: 0,
+                ...clientPosition,
+                eventConstructor: 'PointerEvent',
+                ...this.options.source,
+            })
+            .trigger('mousedown', {
+                which: 1,
+                button: 0,
+                ...clientPosition,
+                eventConstructor: 'MouseEvent',
+                ...this.options.source,
+            })
+            .trigger('dragstart', { dataTransfer, eventConstructor: 'DragEvent', ...this.options.source })
+    },
+    drop(clientPosition = {}) {
+        return this.target
+            .trigger('drop', {
+                dataTransfer,
+                eventConstructor: 'DragEvent',
+                ...this.options.target,
+            })
+            .then(() => {
+                if (isAttached(this.targetElement)) {
+                    this.target
+                        .trigger('mouseup', {
+                            which: 1,
+                            button: 0,
+                            ...clientPosition,
+                            eventConstructor: 'MouseEvent',
+                            ...this.options.target,
+                        })
+                        .then(() => {
+                            if (isAttached(this.targetElement)) {
+                                this.target.trigger('pointerup', {
+                                    which: 1,
+                                    button: 0,
+                                    ...clientPosition,
+                                    eventConstructor: 'PointerEvent',
+                                    ...this.options.target,
+                                })
+                            }
+                        })
+                }
+            })
+    },
+    dragover(clientPosition = {}) {
+        if (!this.counter || (!this.dropped && this.hasTriesLeft)) {
+            this.counter += 1
+            return this.target
+                .trigger('dragover', {
+                    dataTransfer,
+                    eventConstructor: 'DragEvent',
+                    ...this.options.target,
+                })
+                .trigger('mousemove', {
+                    ...this.options.target,
+                    ...clientPosition,
+                    eventConstructor: 'MouseEvent',
+                })
+                .trigger('pointermove', {
+                    ...this.options.target,
+                    ...clientPosition,
+                    eventConstructor: 'PointerEvent',
+                })
+                .wait(this.DELAY_INTERVAL_MS)
+                .then(() => this.dragover(clientPosition))
+        }
+        if (!this.dropped) {
+            console.error(`Exceeded maximum tries of: ${this.MAX_TRIES}, aborting`)
+            return false
+        } else {
+            return true
+        }
+    },
+    init(source, target, options = {}) {
+        this.options = this.createDefaultOptions(options)
+        this.counter = 0
+        this.source = source.get(0)
+        this.initialSourcePosition = this.source.getBoundingClientRect()
+        return cy.get(target).then((targetWrapper) => {
+            this.target = targetWrapper.get(0)
+        })
+    },
+    drag(sourceWrapper, targetSelector, options) {
+        this.init(sourceWrapper, targetSelector, options)
+            .then(() => this.dragstart())
+            .then(() => this.dragover())
+            .then((success) => {
+                if (success) {
+                    return this.drop().then(() => true)
+                } else {
+                    return false
+                }
+            })
+    },
+    move(sourceWrapper, options) {
+        const { deltaX, deltaY } = options
+        const { top, left } = sourceWrapper.offset()
+        const finalCoords = { clientX: left + deltaX, clientY: top + deltaY }
+        this.init(sourceWrapper, sourceWrapper, options)
+            .then(() => this.dragstart({ clientX: left, clientY: top }))
+            .then(() => this.dragover(finalCoords))
+            .then(() => this.drop(finalCoords))
+    },
+}
+
+function addChildCommand(name, command) {
+    Cypress.Commands.add(name, { prevSubject: 'element' }, (...args) => command(...args))
+}
+
+addChildCommand('drag', DragSimulator.drag.bind(DragSimulator))
+addChildCommand('move', DragSimulator.move.bind(DragSimulator))

+ 3 - 0
tests/cypress/support/index.js

@@ -16,6 +16,9 @@
 // Import commands.js using ES2015 syntax:
 import './commands'
 
+// Import drag and drop helpers (https://github.com/4teamwork/cypress-drag-drop/blob/master/index.js)
+import './drag'
+
 // Alternatively you can use CommonJS syntax:
 // require('./commands')
 require('cypress-plugin-tab')