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:
soky srm
2025-11-26 17:51:38 +01:00
committed by GitHub
parent 6e6fce1737
commit db8353e895
21 changed files with 1179 additions and 83 deletions

View 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,
);
}
}
}