A Pen by Kevin Batdorf on CodePen.
Created
March 12, 2023 17:02
-
-
Save lukas-slezevicius/276d035d6a71d19871e38c826342c1b0 to your computer and use it in GitHub Desktop.
Sorting/Drag&Drop Demo Using AlpineJS + TailwindCSS
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<div class="min-h-screen justify-center flex p-16 bg-blue-200"> | |
<div> | |
<!-- This is a revised version. See the original clunky version here: https://codepen.io/KevinBatdorf/pen/ff805cf637420bcbb2caa9d199527247?editors=1010 --> | |
<p class="mb-10 text-center">Drag & drop or press the menu icon button <br>(or use your tab key)</p> | |
<div class="pt-6 pb-4 bg-indigo-500 rounded-lg shadow-xl max-w-sm"> | |
<h1 id="agenda-title" class="text-white font-extrabold text-lg p-6 pt-0">What's the agenda for today?</h1> | |
<ul | |
aria-labelledBy="agenda-title" | |
x-title="Sorting Demo" | |
x-data="dragAndSortHandler(items)" | |
@keydown.window.tab="usedKeyboard = true" | |
@dragenter.stop.prevent="dropcheck++" | |
@dragleave="dropcheck--;dropcheck || rePositionPlaceholder()" | |
@dragover.stop.prevent | |
@dragend="revertState()" | |
@drop.stop.prevent="resetState()"> | |
<template x-for="(item, index) in items" :key="index"> | |
<li | |
:x-ref="index" | |
@dragstart="dragstart($event)" | |
@dragend="$event.target.setAttribute('draggable', false)" | |
@dragover="updateListOrder($event)" | |
draggable="false" | |
class="border-b border-transparent" | |
:class="{ | |
'opacity-25': indexBeingDragged == index, | |
}"> | |
<!-- Pointer events are disabled while dragging, otherwise drag events fire on child elements --> | |
<div class="bg-indigo-300 p-6 flex justify-between" | |
:class="{'pointer-events-none': indexBeingDragged}"> | |
<p x-text="item.name"></p> | |
<div class="relative" aria-haspopup="true"> | |
<!-- Lots of events are here as it combines click drag, click, and keyboard events --> | |
<button | |
aria-label="Sorting menu" | |
@mousedown="setParentDraggable(event)" | |
@mouseup="openContextMenu($event)" | |
@click="openContextMenu($event)" | |
@click.away.stop.prevent="closeAllContextMenus()" | |
@keydown.space="openContextMenu($event)" | |
@keyup.stop.prevent | |
@keydown.arrow-down="highlightFirstContextButton($event)" | |
@keydown.tab="closeAllContextMenus()" | |
@dragover.stop.prevent | |
:class="{'focus:outline-none': !usedKeyboard}"> | |
<svg | |
@click.stop | |
@dragover.stop.prevent | |
role="img" | |
class="block w-6 text-indigo-500" | |
viewBox="0 0 20 20" | |
fill="currentColor"> | |
<path | |
fill-rule="evenodd" | |
d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" | |
clip-rule="evenodd"/> | |
</svg> | |
</button> | |
<ul | |
role="menu" | |
:aria-expanded="(openedContextMenu == index).toString()" | |
x-show="openedContextMenu == index" | |
x-transition:enter="transition ease-in duration-100" | |
x-transition:enter-start="transform opacity-75 -translate-y-1" | |
x-transition:leave-end="transform -translate-y-1 opacity-0" | |
class="absolute w-32 bg-indigo-500 py-2 -mt-3 left-0 transform -translate-x-12 z-50 shadow-lg rounded text-sm"> | |
<li role="menuitem"> | |
<button | |
@keydown.arrow-down="highlightNextContextMenuItem($event)" | |
@keydown.tab="closeAllContextMenus()" | |
tabindex="-1" | |
@click="index && move(index, index - 1)" | |
class="text-left w-full pl-4 hover:bg-indigo-400" | |
:class="{'focus:outline-none': !usedKeyboard}" | |
> | |
Move up | |
</button> | |
</li> | |
<!-- hard coded for two options. If you need more then you need a new method --> | |
<li role="menuitem"> | |
<button | |
@keydown.arrow-up="highlightPreviousContextMenuItem($event)" | |
@keydown.tab="closeAllContextMenus()" | |
tabindex="-1" | |
@click="(index + 1 < items.length) && move(index + 1, index)" | |
class="text-left w-full pl-4 hover:bg-indigo-400" | |
:class="{'focus:outline-none': !usedKeyboard}"> | |
Move down | |
</button> | |
</li> | |
</ul> | |
</div> | |
</div> | |
</li> | |
</template> | |
</ul> | |
</div> | |
</div> | |
</div> | |
<!-- Dev tools --> | |
<div | |
id="alpine-devtools" | |
x-data="devtools()" | |
x-show="alpines.length" | |
x-init="start()"> | |
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const items = [ | |
{ | |
name: 'Learn about the draggable API', | |
}, | |
{ | |
name: 'Practice guitar', | |
}, | |
{ | |
name: 'Read a novel for 25 minutes', | |
}, | |
{ | |
name: 'Practice Thai vocabulary', | |
}, | |
{ | |
name: 'Sleep', | |
}, | |
] | |
function dragAndSortHandler(items) { | |
return { | |
// Keeps track of when we leave the dropzone | |
// Otherwise child events will trigger @dragloave | |
dropcheck: 0, | |
usedKeyboard: false, | |
originalIndexBeingDragged: null, | |
indexBeingDragged: null, | |
indexBeingDraggedOver: null, | |
openedContextMenu: null, | |
items: items, | |
preDragOrder: items, | |
dragstart(event) { | |
if (this.openedContextMenu) { | |
// Without this the drag will show the context menu | |
return this.closeContextMenu() | |
} | |
// Store a copy for when we drag out of range | |
this.preDragOrder = [...this.items] | |
// The index is continuously updated to reorder live and also keep a placeholder | |
this.indexBeingDragged = event.target.getAttribute('x-ref') | |
// The original is needed for then the drag leaves the container | |
this.originalIndexBeingDragged = event.target.getAttribute('x-ref') | |
// Not entirely sure this is needed but moz recommended it (?) | |
event.dataTransfer.dropEffect = "copy" | |
}, | |
updateListOrder(event) { | |
// This fires every time you drag over another list item | |
// It reorders the items array but maintains the placeholder | |
if (this.indexBeingDragged) { | |
this.indexBeingDraggedOver = event.target.getAttribute('x-ref') | |
let from = this.indexBeingDragged | |
let to = this.indexBeingDraggedOver | |
if (this.indexBeingDragged == to) return | |
if (from == to) return | |
this.move(from, to) | |
this.indexBeingDragged = to | |
} | |
}, | |
// These are needed for the handle effect | |
setParentDraggable(event) { | |
event.target.closest('li').setAttribute('draggable', true) | |
}, | |
setParentNotDraggable(event) { | |
event.target.closest('li').setAttribute('draggable', false) | |
}, | |
resetState() { | |
this.dropcheck = 0 | |
this.indexBeingDragged = null | |
this.preDragOrder = [...this.items] | |
this.indexBeingDraggedOver = null | |
this.originalIndexBeingDragged = null | |
}, | |
// This acts as a cancelled event, when the item is dropped outside the container | |
revertState() { | |
this.items = this.preDragOrder.length ? this.preDragOrder : this.items | |
this.resetState() | |
}, | |
// Just repositions the placeholder when we move out of range of the container | |
rePositionPlaceholder() { | |
this.items = [...this.preDragOrder] | |
this.indexBeingDragged = this.originalIndexBeingDragged | |
}, | |
move(from, to) { | |
let items = this.items | |
if (to >= items.length) { | |
let k = to - items.length + 1 | |
while (k--) { | |
items.push(undefined) | |
} | |
} | |
items.splice(to, 0, items.splice(from, 1)[0]) | |
this.items = items | |
}, | |
// THe rest are just for adding better UX to the context menu | |
openContextMenu(event) { | |
this.openedContextMenu = event.target.closest('li').__x_for_key | |
}, | |
closeAllContextMenus() { | |
this.openedContextMenu = null | |
}, | |
highlightFirstContextButton($event) { | |
event.target.nextElementSibling.querySelector('button').focus() | |
}, | |
highlightNextContextMenuItem(event) { | |
event.target.closest('li').nextElementSibling.querySelector('button').focus() | |
}, | |
highlightPreviousContextMenuItem(event) { | |
event.target.closest('li').previousElementSibling.querySelector('button').focus() | |
}, | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script src="https://codepen.io/KevinBatdorf/pen/236afb4466871b6444ce7f1ceebaf89c.js"></script> | |
<script src="https://cdn.jsdelivr.net/gh/alpinejs/[email protected]/dist/alpine.min.js"></script> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
[x-cloak] { display: none; } |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet" /> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment