Bläddra i källkod

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 år sedan
förälder
incheckning
8ee5f9c018

+ 35 - 17
package-lock.json

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

+ 3 - 2
package.json

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

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

@@ -1,13 +1,13 @@
 ---
 ---
 order: 5
 order: 5
 title: Anchor
 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
 graph_image: https://alpinejs.dev/social_anchor.jpg
 ---
 ---
 
 
 # Anchor Plugin
 # 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.
 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',
     'anchor',
     'morph',
     'morph',
     'focus',
     'focus',
+    'sort',
     'mask',
     'mask',
     'ui',
     'ui',
 ]).forEach(package => {
 ]).forEach(package => {

+ 6 - 0
scripts/release.js

@@ -62,6 +62,9 @@ function writeNewAlpineVersion() {
 
 
     writeToPackageDotJson('mask', 'version', version)
     writeToPackageDotJson('mask', 'version', version)
     console.log('Bumping @alpinejs/mask package.json: '+version)
     console.log('Bumping @alpinejs/mask package.json: '+version)
+
+    writeToPackageDotJson('sort', 'version', version)
+    console.log('Bumping @alpinejs/sort package.json: '+version)
 }
 }
 
 
 function writeNewDocsVersion() {
 function writeNewDocsVersion() {
@@ -107,6 +110,9 @@ function publish() {
     console.log('Publishing @alpinejs/mask on NPM...');
     console.log('Publishing @alpinejs/mask on NPM...');
     runFromPackage('mask', 'npm publish --access public')
     runFromPackage('mask', 'npm publish --access public')
 
 
+    console.log('Publishing @alpinejs/sort on NPM...');
+    runFromPackage('sort', 'npm publish --access public')
+
     log('\n\nFinished!')
     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/collapse/dist/cdn.js"></script>
     <script src="/../../packages/anchor/dist/cdn.js"></script>
     <script src="/../../packages/anchor/dist/cdn.js"></script>
     <script src="/../../packages/mask/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 src="/../../packages/ui/dist/cdn.js"></script>
     <script>
     <script>
         let root = document.querySelector('#root')
         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.js using ES2015 syntax:
 import './commands'
 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:
 // Alternatively you can use CommonJS syntax:
 // require('./commands')
 // require('./commands')
 require('cypress-plugin-tab')
 require('cypress-plugin-tab')