assets_js_controllers_sortable-list-controller.js
import { Controller } from "@hotwired/stimulus"
/**
* Sortable List Controller - Drag and Drop Reordering
*
* Provides drag-and-drop reordering functionality for list items.
* Emits a custom event when items are reordered so parent controllers
* can respond to changes.
*
* Usage:
* <ul data-controller="sortable-list" data-action="sortable-list:reordered->parent#handleReorder">
* <li data-sortable-list-target="item" data-item-id="1" draggable="true">
* <span data-sortable-list-target="handle">☰</span> Item 1
* </li>
* <li data-sortable-list-target="item" data-item-id="2" draggable="true">
* <span data-sortable-list-target="handle">☰</span> Item 2
* </li>
* </ul>
*
* Events:
* - sortable-list:reordered - Dispatched when order changes
* detail: { order: ['id1', 'id2', 'id3'], items: [...DOMElements] }
*/
class SortableListController extends Controller {
static targets = ["item", "handle"]
initialize() {
this.draggedElement = null;
this.draggedOverElement = null;
this.boundHandlers = {
dragstart: this.dragStart.bind(this),
dragover: this.dragOver.bind(this),
dragenter: this.dragEnter.bind(this),
dragleave: this.dragLeave.bind(this),
drop: this.drop.bind(this),
dragend: this.dragEnd.bind(this),
};
}
connect() {
// Make items draggable
this.itemTargets.forEach(item => {
item.setAttribute('draggable', 'true');
this.addDragListeners(item);
});
}
disconnect() {
this.itemTargets.forEach(item => {
this.removeDragListeners(item);
});
}
/**
* Handle drag start - store reference to dragged element
*/
dragStart(event) {
this.draggedElement = event.currentTarget;
this.draggedElement.classList.add('dragging');
event.dataTransfer.effectAllowed = 'move';
}
/**
* Handle drag over - allow drop by preventing default
*/
dragOver(event) {
if (event.preventDefault) {
event.preventDefault();
}
event.dataTransfer.dropEffect = 'move';
const targetItem = event.currentTarget;
if (targetItem !== this.draggedElement) {
this.draggedOverElement = targetItem;
targetItem.classList.add('drag-over');
}
return false;
}
/**
* Handle drag enter - visual feedback
*/
dragEnter(event) {
const targetItem = event.currentTarget;
if (targetItem !== this.draggedElement) {
targetItem.classList.add('drag-over');
}
}
/**
* Handle drag leave - remove visual feedback
*/
dragLeave(event) {
event.currentTarget.classList.remove('drag-over');
}
/**
* Handle drop - reorder items
*/
drop(event) {
if (event.stopPropagation) {
event.stopPropagation();
}
const targetItem = event.currentTarget;
if (this.draggedElement !== targetItem) {
// Determine if we're dropping above or below
const rect = targetItem.getBoundingClientRect();
const midpoint = rect.top + (rect.height / 2);
const dropAbove = event.clientY < midpoint;
// Reorder in DOM
if (dropAbove) {
targetItem.parentNode.insertBefore(this.draggedElement, targetItem);
} else {
targetItem.parentNode.insertBefore(this.draggedElement, targetItem.nextSibling);
}
// Emit reordered event
this.emitReorderedEvent();
}
return false;
}
/**
* Handle drag end - cleanup
*/
dragEnd(event) {
// Remove all drag-related classes
this.itemTargets.forEach(item => {
item.classList.remove('dragging', 'drag-over');
});
this.draggedElement = null;
this.draggedOverElement = null;
}
/**
* Emit custom event with new order
*/
emitReorderedEvent() {
const order = this.itemTargets.map(item => {
return item.dataset.itemId || item.dataset.columnKey || item.id;
});
const event = new CustomEvent('sortable-list:reordered', {
detail: {
order: order,
items: this.itemTargets
},
bubbles: true,
cancelable: true
});
this.element.dispatchEvent(event);
}
/**
* Get current order of items
*/
getOrder() {
return this.itemTargets.map(item => {
return item.dataset.itemId || item.dataset.columnKey || item.id;
});
}
addDragListeners(item) {
item.addEventListener('dragstart', this.boundHandlers.dragstart);
item.addEventListener('dragover', this.boundHandlers.dragover);
item.addEventListener('dragenter', this.boundHandlers.dragenter);
item.addEventListener('dragleave', this.boundHandlers.dragleave);
item.addEventListener('drop', this.boundHandlers.drop);
item.addEventListener('dragend', this.boundHandlers.dragend);
}
removeDragListeners(item) {
item.removeEventListener('dragstart', this.boundHandlers.dragstart);
item.removeEventListener('dragover', this.boundHandlers.dragover);
item.removeEventListener('dragenter', this.boundHandlers.dragenter);
item.removeEventListener('dragleave', this.boundHandlers.dragleave);
item.removeEventListener('drop', this.boundHandlers.drop);
item.removeEventListener('dragend', this.boundHandlers.dragend);
}
}
// Register controller
if (!window.Controllers) {
window.Controllers = {};
}
window.Controllers["sortable-list"] = SortableListController;