diff --git a/app/javascript/controllers/dashboard_sortable_controller.js b/app/javascript/controllers/dashboard_sortable_controller.js index d44d47c92..bb100ab7b 100644 --- a/app/javascript/controllers/dashboard_sortable_controller.js +++ b/app/javascript/controllers/dashboard_sortable_controller.js @@ -1,7 +1,12 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["section"]; + static targets = ["section", "handle"]; + + // Short delay to prevent accidental touches on the grip handle + static values = { + holdDelay: { type: Number, default: 150 }, + }; connect() { this.draggedElement = null; @@ -10,6 +15,8 @@ export default class extends Controller { this.currentTouchY = 0; this.isTouching = false; this.keyboardGrabbedElement = null; + this.holdTimer = null; + this.holdActivated = false; } // ===== Mouse Drag Events ===== @@ -60,16 +67,44 @@ export default class extends Controller { } // ===== Touch Events ===== + // Touch events are bound to the drag handle only, with a short hold delay + // to prevent accidental touches. + touchStart(event) { - this.draggedElement = event.currentTarget; + // Find the parent section element from the handle + const section = event.currentTarget.closest( + "[data-dashboard-sortable-target='section']", + ); + if (!section) return; + + this.pendingSection = section; this.touchStartY = event.touches[0].clientY; + this.currentTouchY = this.touchStartY; + this.holdActivated = false; + + // Start hold timer + this.holdTimer = setTimeout(() => { + this.activateDrag(); + }, this.holdDelayValue); + } + + activateDrag() { + if (!this.pendingSection) return; + + this.holdActivated = true; this.isTouching = true; - this.draggedElement.classList.add("opacity-50", "scale-105"); + this.draggedElement = this.pendingSection; + this.draggedElement.classList.add("opacity-50", "scale-[1.02]"); this.draggedElement.setAttribute("aria-grabbed", "true"); + + // Haptic feedback if available + if (navigator.vibrate) { + navigator.vibrate(30); + } } touchMove(event) { - if (!this.isTouching || !this.draggedElement) return; + if (!this.holdActivated || !this.isTouching || !this.draggedElement) return; event.preventDefault(); this.currentTouchY = event.touches[0].clientY; @@ -84,8 +119,13 @@ export default class extends Controller { } } - touchEnd(event) { - if (!this.isTouching || !this.draggedElement) return; + touchEnd() { + this.cancelHold(); + + if (!this.holdActivated || !this.isTouching || !this.draggedElement) { + this.resetTouchState(); + return; + } const afterElement = this.getDragAfterElement(this.currentTouchY); const container = this.element; @@ -96,13 +136,26 @@ export default class extends Controller { container.insertBefore(this.draggedElement, afterElement); } - this.draggedElement.classList.remove("opacity-50", "scale-105"); + this.draggedElement.classList.remove("opacity-50", "scale-[1.02]"); this.draggedElement.setAttribute("aria-grabbed", "false"); this.clearPlaceholders(); this.saveOrder(); + this.resetTouchState(); + } + + cancelHold() { + if (this.holdTimer) { + clearTimeout(this.holdTimer); + this.holdTimer = null; + } + } + + resetTouchState() { this.isTouching = false; this.draggedElement = null; + this.pendingSection = null; + this.holdActivated = false; } // ===== Keyboard Navigation ===== diff --git a/app/javascript/controllers/reports_sortable_controller.js b/app/javascript/controllers/reports_sortable_controller.js index 82ddcc2f1..0229df1c4 100644 --- a/app/javascript/controllers/reports_sortable_controller.js +++ b/app/javascript/controllers/reports_sortable_controller.js @@ -1,7 +1,12 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["section"]; + static targets = ["section", "handle"]; + + // Short delay to prevent accidental touches on the grip handle + static values = { + holdDelay: { type: Number, default: 150 }, + }; connect() { this.draggedElement = null; @@ -10,6 +15,8 @@ export default class extends Controller { this.currentTouchY = 0; this.isTouching = false; this.keyboardGrabbedElement = null; + this.holdTimer = null; + this.holdActivated = false; } // ===== Mouse Drag Events ===== @@ -60,16 +67,44 @@ export default class extends Controller { } // ===== Touch Events ===== + // Touch events are bound to the drag handle only, with a short hold delay + // to prevent accidental touches. + touchStart(event) { - this.draggedElement = event.currentTarget; + // Find the parent section element from the handle + const section = event.currentTarget.closest( + "[data-reports-sortable-target='section']", + ); + if (!section) return; + + this.pendingSection = section; this.touchStartY = event.touches[0].clientY; + this.currentTouchY = this.touchStartY; + this.holdActivated = false; + + // Start hold timer + this.holdTimer = setTimeout(() => { + this.activateDrag(); + }, this.holdDelayValue); + } + + activateDrag() { + if (!this.pendingSection) return; + + this.holdActivated = true; this.isTouching = true; - this.draggedElement.classList.add("opacity-50", "scale-105"); + this.draggedElement = this.pendingSection; + this.draggedElement.classList.add("opacity-50", "scale-[1.02]"); this.draggedElement.setAttribute("aria-grabbed", "true"); + + // Haptic feedback if available + if (navigator.vibrate) { + navigator.vibrate(30); + } } touchMove(event) { - if (!this.isTouching || !this.draggedElement) return; + if (!this.holdActivated || !this.isTouching || !this.draggedElement) return; event.preventDefault(); this.currentTouchY = event.touches[0].clientY; @@ -84,8 +119,13 @@ export default class extends Controller { } } - touchEnd(event) { - if (!this.isTouching || !this.draggedElement) return; + touchEnd() { + this.cancelHold(); + + if (!this.holdActivated || !this.isTouching || !this.draggedElement) { + this.resetTouchState(); + return; + } const afterElement = this.getDragAfterElement(this.currentTouchY); const container = this.element; @@ -96,13 +136,26 @@ export default class extends Controller { container.insertBefore(this.draggedElement, afterElement); } - this.draggedElement.classList.remove("opacity-50", "scale-105"); + this.draggedElement.classList.remove("opacity-50", "scale-[1.02]"); this.draggedElement.setAttribute("aria-grabbed", "false"); this.clearPlaceholders(); this.saveOrder(); + this.resetTouchState(); + } + + cancelHold() { + if (this.holdTimer) { + clearTimeout(this.holdTimer); + this.holdTimer = null; + } + } + + resetTouchState() { this.isTouching = false; this.draggedElement = null; + this.pendingSection = null; + this.holdActivated = false; } // ===== Keyboard Navigation ===== diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index daf6454b4..aaf94f683 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -46,9 +46,6 @@ data-action=" dragstart->dashboard-sortable#dragStart dragend->dashboard-sortable#dragEnd - touchstart->dashboard-sortable#touchStart - touchmove->dashboard-sortable#touchMove - touchend->dashboard-sortable#touchEnd keydown->dashboard-sortable#handleKeyDown">