import { Controller } from "@hotwired/stimulus"; export default class extends Controller { static targets = ["section"]; connect() { this.draggedElement = null; this.placeholder = null; this.touchStartY = 0; this.currentTouchY = 0; this.isTouching = false; this.keyboardGrabbedElement = null; } // ===== Mouse Drag Events ===== dragStart(event) { this.draggedElement = event.currentTarget; this.draggedElement.classList.add("opacity-50"); this.draggedElement.setAttribute("aria-grabbed", "true"); event.dataTransfer.effectAllowed = "move"; } dragEnd(event) { event.currentTarget.classList.remove("opacity-50"); event.currentTarget.setAttribute("aria-grabbed", "false"); this.clearPlaceholders(); } dragOver(event) { event.preventDefault(); event.dataTransfer.dropEffect = "move"; const afterElement = this.getDragAfterElement(event.clientY); const container = this.element; this.clearPlaceholders(); if (afterElement == null) { this.showPlaceholder(container.lastElementChild, "after"); } else { this.showPlaceholder(afterElement, "before"); } } drop(event) { event.preventDefault(); event.stopPropagation(); const afterElement = this.getDragAfterElement(event.clientY); const container = this.element; if (afterElement == null) { container.appendChild(this.draggedElement); } else { container.insertBefore(this.draggedElement, afterElement); } this.clearPlaceholders(); this.saveOrder(); } // ===== Touch Events ===== touchStart(event) { this.draggedElement = event.currentTarget; this.touchStartY = event.touches[0].clientY; this.isTouching = true; this.draggedElement.classList.add("opacity-50", "scale-105"); this.draggedElement.setAttribute("aria-grabbed", "true"); } touchMove(event) { if (!this.isTouching || !this.draggedElement) return; event.preventDefault(); this.currentTouchY = event.touches[0].clientY; const afterElement = this.getDragAfterElement(this.currentTouchY); this.clearPlaceholders(); if (afterElement == null) { this.showPlaceholder(this.element.lastElementChild, "after"); } else { this.showPlaceholder(afterElement, "before"); } } touchEnd(event) { if (!this.isTouching || !this.draggedElement) return; const afterElement = this.getDragAfterElement(this.currentTouchY); const container = this.element; if (afterElement == null) { container.appendChild(this.draggedElement); } else { container.insertBefore(this.draggedElement, afterElement); } this.draggedElement.classList.remove("opacity-50", "scale-105"); this.draggedElement.setAttribute("aria-grabbed", "false"); this.clearPlaceholders(); this.saveOrder(); this.isTouching = false; this.draggedElement = null; } // ===== Keyboard Navigation ===== handleKeyDown(event) { const currentSection = event.currentTarget; switch (event.key) { case "ArrowUp": event.preventDefault(); if (this.keyboardGrabbedElement === currentSection) { this.moveUp(currentSection); } break; case "ArrowDown": event.preventDefault(); if (this.keyboardGrabbedElement === currentSection) { this.moveDown(currentSection); } break; case "Enter": case " ": event.preventDefault(); this.toggleGrabMode(currentSection); break; case "Escape": if (this.keyboardGrabbedElement) { event.preventDefault(); this.releaseKeyboardGrab(); } break; } } toggleGrabMode(section) { if (this.keyboardGrabbedElement === section) { this.releaseKeyboardGrab(); } else { this.grabWithKeyboard(section); } } grabWithKeyboard(section) { // Release any previously grabbed element if (this.keyboardGrabbedElement) { this.releaseKeyboardGrab(); } this.keyboardGrabbedElement = section; section.setAttribute("aria-grabbed", "true"); section.classList.add("ring-2", "ring-primary", "ring-offset-2"); } releaseKeyboardGrab() { if (this.keyboardGrabbedElement) { this.keyboardGrabbedElement.setAttribute("aria-grabbed", "false"); this.keyboardGrabbedElement.classList.remove( "ring-2", "ring-primary", "ring-offset-2", ); this.keyboardGrabbedElement = null; this.saveOrder(); } } moveUp(section) { const previousSibling = section.previousElementSibling; if (previousSibling?.hasAttribute("data-section-key")) { this.element.insertBefore(section, previousSibling); section.focus(); } } moveDown(section) { const nextSibling = section.nextElementSibling; if (nextSibling?.hasAttribute("data-section-key")) { this.element.insertBefore(nextSibling, section); section.focus(); } } getDragAfterElement(y) { const draggableElements = [ ...this.sectionTargets.filter((section) => section !== this.draggedElement), ]; return draggableElements.reduce( (closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } return closest; }, { offset: Number.NEGATIVE_INFINITY }, ).element; } showPlaceholder(element, position) { if (!element) return; if (position === "before") { element.classList.add("border-t-4", "border-primary"); } else { element.classList.add("border-b-4", "border-primary"); } } clearPlaceholders() { this.sectionTargets.forEach((section) => { section.classList.remove( "border-t-4", "border-b-4", "border-primary", "border-t-2", "border-b-2", ); }); } async saveOrder() { const order = this.sectionTargets.map( (section) => section.dataset.sectionKey, ); // Safely obtain CSRF token const csrfToken = document.querySelector('meta[name="csrf-token"]'); if (!csrfToken) { console.error( "[Dashboard Sortable] CSRF token not found. Cannot save section order.", ); return; } try { const response = await fetch("/dashboard/preferences", { method: "PATCH", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken.content, }, body: JSON.stringify({ preferences: { section_order: order } }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); console.error( "[Dashboard Sortable] Failed to save section order:", response.status, errorData, ); } } catch (error) { console.error( "[Dashboard Sortable] Network error saving section order:", error, ); } } }