1import { Controller } from "@hotwired/stimulus"; 2import Sortable from "sortablejs"; 3 4// Drag-and-drop ordering (mouse + touch) for nested form rows. Position is 5// derived from the row order and stays hidden. With several lists on one 6// controller (e.g. partner tiers), rows can be dragged between them and the 7// list's data-tier / data-kind is written to the row's hidden tier / kind field. 8// When a list has aria-labelledby, each row's select is labelled by that heading 9// plus the row's position label, so the name follows the row across lists. 10export default class extends Controller { 11 static targets = ["list"]; 12 13 initialize() { 14 this.sortables = new Map(); 15 } 16 17 connect() { 18 this.renumber(); 19 } 20 21 disconnect() { 22 for (const sortable of this.sortables.values()) sortable.destroy(); 23 this.sortables.clear(); 24 } 25 26 listTargetConnected(list) { 27 // Lists within one controller instance share a group so rows can move 28 // between them; instances stay isolated from each other. Target callbacks 29 // run before connect(), so initialize the group lazily here. 30 this.group ||= crypto.randomUUID(); 31 32 const sortable = Sortable.create(list, { 33 group: this.group, 34 handle: "[data-drag-handle]", 35 draggable: "[data-nested-form-row]", 36 ghostClass: "opacity-50", 37 dragClass: "opacity-50", 38 animation: 150, 39 onEnd: ({ item, from, to }) => { 40 this.dispatch("moved", { detail: { item, from, to } }); 41 this.renumber(); 42 }, 43 }); 44 this.sortables.set(list, sortable); 45 } 46 47 listTargetDisconnected(list) { 48 this.sortables.get(list)?.destroy(); 49 this.sortables.delete(list); 50 } 51 52 renumber() { 53 for (const list of this.listTargets) { 54 const labelledby = list.getAttribute("aria-labelledby"); 55 let position = 0; 56 57 for (const row of list.querySelectorAll(":scope > [data-nested-form-row]")) { 58 if (row.classList.contains("hidden")) continue; // marked for destruction 59 60 position += 1; 61 for (const field of ["tier", "kind"]) { 62 if (list.dataset[field]) row.querySelector(`input[name*='[${field}]']`).value = list.dataset[field]; 63 } 64 row.querySelector("input[name*='[position]']").value = position; 65 const positionLabel = row.querySelector("[data-position-label]"); 66 positionLabel.textContent = `#${position}`; 67 if (labelledby) { 68 row.querySelector("select")?.setAttribute("aria-labelledby", `${labelledby} ${positionLabel.id}`); 69 } 70 } 71 72 list.querySelector(":scope > [data-empty-message]")?.classList.toggle("hidden", position > 0); 73 } 74 } 75}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.