Files
pocketbase/ui/src/base/sortable.js
2026-04-18 16:50:39 +03:00

189 lines
5.1 KiB
JavaScript

window.app = window.app || {};
window.app.components = window.app.components || {};
/**
* Creates a reactive single level sortable container (nested sortables are not supported).
*
* @example
* ```js
* app.components.sortable({
* data: () => data.list,
* dataItem: (item) => t.strong(null, "ID:", () => item.id),
* })
* ```
*
* @param {Object>} [propsArg]
* @return {Element}
*/
window.app.components.sortable = function(propsArg = {}) {
const props = store({
rid: undefined,
id: undefined,
hidden: undefined,
inert: undefined,
className: "",
data: [],
dataItem: function(item, i, parent) {
return t.span(null, "Item " + i);
},
onchange: function(sortedList, fromIndex, toIndex) {},
handle: "", // specific handle selector (if not set attached to the entire list item)
before: undefined,
after: undefined,
});
const watchers = app.utils.extendStore(props, propsArg);
function initSortEvents(listEl) {
function clearDragData() {
listEl.querySelectorAll(":scope > [data-dragstart=\"true\"]")?.forEach((item) => {
item.dataset.dragstart = false;
});
listEl.querySelectorAll(":scope > [data-dragover=\"true\"]")?.forEach((item) => {
item.dataset.dragover = false;
});
}
// drag
// ---
listEl.addEventListener("dragstart", (e) => {
if (props.handle && !e.target.closest(props.handle)) {
e.preventDefault();
return;
}
const child = closestChild(listEl, e.target);
if (child) {
child.dataset.dragstart = true;
}
});
listEl.addEventListener("dragenter", (e) => {
for (let child of listEl.children) {
if (child.dataset.dragover) {
child.dataset.dragover = false;
}
}
const to = closestChild(listEl, e.target);
if (to) {
to.dataset.dragover = true;
}
});
listEl.addEventListener("dragend", (e) => {
clearDragData();
});
// drop
// ---
// prevent default to allow drop
// (https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/drop_event)
listEl.addEventListener("dragover", (e) => {
e.preventDefault();
});
listEl.addEventListener("drop", (e) => {
if (!props.onchange) {
clearDragData();
return;
}
const from = listEl.querySelector(":scope > [data-dragstart=\"true\"]");
const to = closestChild(listEl, e.target);
clearDragData();
if (!from || !to || to == from) {
return;
}
const fromIndex = childIndex(from);
const toIndex = childIndex(to);
const clone = props.data.slice();
const deleted = clone.splice(fromIndex, 1);
clone.splice(toIndex, 0, deleted[0]);
props.onchange(clone, fromIndex, toIndex);
});
}
function childIndex(node) {
if (!node?.parentNode) {
return -1;
}
for (let i = 0; i < node.parentNode.children.length; i++) {
if (node.parentNode.children[i] == node) {
return i;
}
}
return -1;
}
function closestChild(parent, node) {
if (!node || !node.parentNode) {
return null;
}
if (node.parentNode == parent) {
return node;
}
return closestChild(parent, node.parentNode);
}
return t.div(
{
rid: props.rid,
id: () => props.id,
hidden: () => props.hidden,
inert: () => props.inert,
className: () => props.className,
onmount: (listEl) => {
initSortEvents(listEl);
},
onunmount: (listEl) => {
watchers.forEach((w) => w?.unwatch());
},
},
(el) => {
if (typeof props.before == "function") {
return props.before(el);
}
return props.before;
},
(el) => {
const children = [];
for (let i = 0; i < props.data.length; i++) {
let child = props.dataItem(props.data[i], i, el);
if (!child) {
continue;
}
if (props.handle) {
const handle = child.querySelector(props.handle);
if (handle) {
handle.draggable = true;
}
} else {
child.draggable = true;
}
children.push(child);
}
return children;
},
(el) => {
if (typeof props.after == "function") {
return props.after(el);
}
return props.after;
},
);
};