mirror of
https://github.com/we-promise/sure.git
synced 2026-04-08 23:04:49 +00:00
Initial implementation of collapsible sections and re-order feature (#355)
* Initial implementation * Add support for reports section too * UI Improvement now it looks a lot nicer :) * Remove duplicate section titles * FIX malformed DIV * Add accessibility and touch support WCAG 2.1 Level AA Compliant - Keyboard operable (Success Criterion 2.1.1) - Focus visible (Success Criterion 2.4.7) - Name, Role, Value (Success Criterion 4.1.2) Screen Reader Support - Clear instructions in aria-label - Proper semantic roles - State changes announced via aria-grabbed * Add proper UI for tab highlight * Add keyboard support to collapse also * FIX js errors * Fix rabbit * FIX we don't need the html * FIX CSRF and error handling * Simplify into one single DB migration --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
267
app/javascript/controllers/dashboard_sortable_controller.js
Normal file
267
app/javascript/controllers/dashboard_sortable_controller.js
Normal file
@@ -0,0 +1,267 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user