910 lines
26 KiB
JavaScript
910 lines
26 KiB
JavaScript
|
|
/* ============================================================
|
|||
|
|
VOR Bootstrap / Globals
|
|||
|
|
============================================================ */
|
|||
|
|
window.VOR = window.VOR || {};
|
|||
|
|
VOR.locale = VOR.locale ?? 'en-US';
|
|||
|
|
VOR.currency = VOR.currency ?? 'EUR';
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
Network / API
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.post = async function (data = {}, target, method, opts = {}) {
|
|||
|
|
const {
|
|||
|
|
url = "/rss/php/handler.php",
|
|||
|
|
timeoutMs = 15000,
|
|||
|
|
credentials = "same-origin",
|
|||
|
|
} = opts;
|
|||
|
|
|
|||
|
|
if (!target || !method) {
|
|||
|
|
return { ok: false, error: "Missing target/method" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fd = new FormData();
|
|||
|
|
|
|||
|
|
const append = (k, v) => {
|
|||
|
|
if (v === undefined || v === null) return;
|
|||
|
|
|
|||
|
|
if (v instanceof File || v instanceof Blob) {
|
|||
|
|
fd.append(k, v);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (v instanceof FileList) {
|
|||
|
|
for (const f of v) fd.append(k, f);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Array.isArray(v)) {
|
|||
|
|
for (const item of v) append(`${k}[]`, item);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof v === "object") {
|
|||
|
|
fd.append(k, JSON.stringify(v));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fd.append(k, String(v));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
for (const [k, v] of Object.entries(data || {})) {
|
|||
|
|
append(k, v);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fd.append("target", target);
|
|||
|
|
fd.append("method", method);
|
|||
|
|
|
|||
|
|
const ctrl = timeoutMs ? new AbortController() : null;
|
|||
|
|
const t = timeoutMs ? setTimeout(() => ctrl.abort(), timeoutMs) : null;
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const res = await fetch(url, {
|
|||
|
|
method: "POST",
|
|||
|
|
body: fd,
|
|||
|
|
credentials,
|
|||
|
|
signal: ctrl?.signal,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
const resp = await res.json();
|
|||
|
|
|
|||
|
|
if (!res.ok) {
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
error: resp?.message || `HTTP ${res.status}`,
|
|||
|
|
status: res.status,
|
|||
|
|
raw: resp,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (!resp || typeof resp !== "object") {
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
error: "Bas json response",
|
|||
|
|
status: res.status,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (resp.status == "success") {
|
|||
|
|
return {
|
|||
|
|
ok: true,
|
|||
|
|
data: resp.message,
|
|||
|
|
status: res.status,
|
|||
|
|
raw: resp,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
ok: false,
|
|||
|
|
error: resp.message ?? "Failed",
|
|||
|
|
status: res.status,
|
|||
|
|
raw: resp,
|
|||
|
|
};
|
|||
|
|
} catch (err) {
|
|||
|
|
return { ok: false, error: "Could not parse JSON", status: res.status };
|
|||
|
|
}
|
|||
|
|
} catch (errno) {
|
|||
|
|
const msg =
|
|||
|
|
errno?.name === "AbortError" ? "Timeout" : errno?.message || String(errno);
|
|||
|
|
return { ok: false, error: msg };
|
|||
|
|
} finally {
|
|||
|
|
if (t) clearTimeout(t);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
UI: Table
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.uiTable = function ({
|
|||
|
|
el,
|
|||
|
|
headers = [],
|
|||
|
|
data = [],
|
|||
|
|
sortable = true,
|
|||
|
|
emptyText = "No data",
|
|||
|
|
classArr = ["table"],
|
|||
|
|
editRenderer = null,
|
|||
|
|
} = {}) {
|
|||
|
|
const container = typeof el === "string" ? document.querySelector(el) : el;
|
|||
|
|
if (!container) return;
|
|||
|
|
|
|||
|
|
VOR.clearHtml(container);
|
|||
|
|
|
|||
|
|
const table = VOR.createEl("table", classArr);
|
|||
|
|
const thead = VOR.createEl("thead");
|
|||
|
|
const tbody = VOR.createEl("tbody");
|
|||
|
|
|
|||
|
|
let sortState = { key: null, dir: "asc" };
|
|||
|
|
let openEditorRow = null;
|
|||
|
|
|
|||
|
|
function closeEditor() {
|
|||
|
|
if (openEditorRow) {
|
|||
|
|
openEditorRow.remove();
|
|||
|
|
openEditorRow = null;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sortData(rows) {
|
|||
|
|
if (!sortable || !sortState.key) return rows;
|
|||
|
|
|
|||
|
|
return [...rows].sort((a, b) => {
|
|||
|
|
const v1 = a[sortState.key];
|
|||
|
|
const v2 = b[sortState.key];
|
|||
|
|
|
|||
|
|
if (v1 === v2) return 0;
|
|||
|
|
if (v1 == null) return 1;
|
|||
|
|
if (v2 == null) return -1;
|
|||
|
|
|
|||
|
|
if (typeof v1 === "number" && typeof v2 === "number") {
|
|||
|
|
return sortState.dir === "asc" ? v1 - v2 : v2 - v1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return sortState.dir === "asc"
|
|||
|
|
? String(v1).localeCompare(String(v2))
|
|||
|
|
: String(v2).localeCompare(String(v1));
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function expandRow(tr, rowData) {
|
|||
|
|
if (openEditorRow && openEditorRow.previousSibling === tr) {
|
|||
|
|
closeEditor();
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
closeEditor();
|
|||
|
|
|
|||
|
|
if (typeof editRenderer !== "function") return;
|
|||
|
|
|
|||
|
|
const editorTr = VOR.createEl("tr", ["row-editor"]);
|
|||
|
|
const td = VOR.createEl("td", null, null, { colspan: headers.length });
|
|||
|
|
|
|||
|
|
const content = editRenderer(rowData);
|
|||
|
|
if (content instanceof HTMLElement) td.append(content);
|
|||
|
|
|
|||
|
|
editorTr.append(td);
|
|||
|
|
tr.after(editorTr);
|
|||
|
|
openEditorRow = editorTr;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderBody(rows) {
|
|||
|
|
VOR.clearHtml(tbody);
|
|||
|
|
closeEditor();
|
|||
|
|
|
|||
|
|
if (!rows.length) {
|
|||
|
|
const tr = VOR.createEl("tr");
|
|||
|
|
const td = VOR.createEl("td", ["empty"], emptyText, {
|
|||
|
|
colspan: headers.length,
|
|||
|
|
});
|
|||
|
|
tr.append(td);
|
|||
|
|
tbody.append(tr);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
rows.forEach((row) => {
|
|||
|
|
const tr = VOR.createEl("tr");
|
|||
|
|
|
|||
|
|
headers.forEach((h) => {
|
|||
|
|
const td = VOR.createEl("td");
|
|||
|
|
const value = row[h.key];
|
|||
|
|
|
|||
|
|
if (h.action) {
|
|||
|
|
const btn = VOR.createEl(
|
|||
|
|
"button",
|
|||
|
|
["table-action", `is-${h.action}`],
|
|||
|
|
h.format && typeof h.format === "function"
|
|||
|
|
? h.format(value, row)
|
|||
|
|
: value ?? ""
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
btn.addEventListener("click", (e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
|
|||
|
|
switch (h.action) {
|
|||
|
|
case "visit":
|
|||
|
|
if (h.actionConfig?.url) {
|
|||
|
|
const url = h.actionConfig.url(row);
|
|||
|
|
if (url) window.location.href = url;
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case "toggle":
|
|||
|
|
if (h.actionConfig?.selector) {
|
|||
|
|
const sel = h.actionConfig.selector(row);
|
|||
|
|
if (sel) VOR.uiToggle(sel, true);
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
|
|||
|
|
case "edit-expand":
|
|||
|
|
expandRow(tr, row);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
td.append(btn);
|
|||
|
|
} else {
|
|||
|
|
const text =
|
|||
|
|
h.format && typeof h.format === "function"
|
|||
|
|
? h.format(value, row)
|
|||
|
|
: value ?? "";
|
|||
|
|
td.append(document.createTextNode(text));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tr.append(td);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
tbody.append(tr);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function renderHeader() {
|
|||
|
|
const tr = VOR.createEl("tr");
|
|||
|
|
|
|||
|
|
headers.forEach((h) => {
|
|||
|
|
const th = VOR.createEl(
|
|||
|
|
"th",
|
|||
|
|
sortable && h.sortable !== false ? ["sortable"] : null,
|
|||
|
|
h.label
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (sortable && h.sortable !== false) {
|
|||
|
|
th.addEventListener("click", () => {
|
|||
|
|
closeEditor();
|
|||
|
|
|
|||
|
|
if (sortState.key === h.key) {
|
|||
|
|
sortState.dir = sortState.dir === "asc" ? "desc" : "asc";
|
|||
|
|
} else {
|
|||
|
|
sortState.key = h.key;
|
|||
|
|
sortState.dir = "asc";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderBody(sortData(data));
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
tr.append(th);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
thead.append(tr);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
renderHeader();
|
|||
|
|
renderBody(data);
|
|||
|
|
|
|||
|
|
table.append(thead, tbody);
|
|||
|
|
container.append(table);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
State Store
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.state = (function () {
|
|||
|
|
const store = {};
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
set(key, value) {
|
|||
|
|
store[key] = value;
|
|||
|
|
},
|
|||
|
|
get(key) {
|
|||
|
|
return store[key];
|
|||
|
|
},
|
|||
|
|
update(key, fn) {
|
|||
|
|
if (typeof fn === "function") {
|
|||
|
|
store[key] = fn(store[key]);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
remove(key) {
|
|||
|
|
delete store[key];
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
})();
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
UI: Alerts / Confirm / Form / Dropdown / Pagination / Badge
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.uiAlert = function (message, type = "info", timeout = 4000) {
|
|||
|
|
let container = document.querySelector(".vor-alert-stack");
|
|||
|
|
if (!container) {
|
|||
|
|
container = VOR.createEl("div", ["vor-alert-stack"]);
|
|||
|
|
document.body.append(container);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const alert = VOR.createEl("div", ["vor-alert", `is-${type}`]);
|
|||
|
|
alert.append(document.createTextNode(message));
|
|||
|
|
|
|||
|
|
container.append(alert);
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
alert.remove();
|
|||
|
|
if (!container.children.length) container.remove();
|
|||
|
|
}, timeout);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiConfirm = function ({
|
|||
|
|
title = "Confirm",
|
|||
|
|
text = "",
|
|||
|
|
confirmLabel = "Confirm",
|
|||
|
|
cancelLabel = "Cancel",
|
|||
|
|
onConfirm = null,
|
|||
|
|
} = {}) {
|
|||
|
|
const overlay = VOR.createEl("div", ["modal-overlay", "is-active"]);
|
|||
|
|
overlay.setAttribute("aria-hidden", "false");
|
|||
|
|
|
|||
|
|
const content = VOR.createEl("div", ["modal-content", "vor-modal-sm"]);
|
|||
|
|
|
|||
|
|
const header = VOR.createEl("div", ["modal-header"]);
|
|||
|
|
const titleEl = VOR.createEl("h3", ["modal-title"], title);
|
|||
|
|
const closeBtn = VOR.createEl("button", ["btn-close"], "×", {
|
|||
|
|
type: "button",
|
|||
|
|
"aria-label": "Close"
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
closeBtn.addEventListener("click", () => {
|
|||
|
|
overlay.remove();
|
|||
|
|
document.body.classList.remove("lock-scroll");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
header.append(titleEl, closeBtn);
|
|||
|
|
|
|||
|
|
const body = VOR.createEl("div", ["modal-body"]);
|
|||
|
|
body.append(VOR.createEl("p", null, text));
|
|||
|
|
|
|||
|
|
const footer = VOR.createEl("div", ["modal-footer"]);
|
|||
|
|
|
|||
|
|
const cancelBtn = VOR.createEl("button", ["btn-gray-light"], cancelLabel, {
|
|||
|
|
type: "button"
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const confirmBtn = VOR.createEl("button", ["btn-red"], confirmLabel, {
|
|||
|
|
type: "button"
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
cancelBtn.addEventListener("click", () => {
|
|||
|
|
overlay.remove();
|
|||
|
|
document.body.classList.remove("lock-scroll");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
confirmBtn.addEventListener("click", () => {
|
|||
|
|
if (typeof onConfirm === "function") onConfirm();
|
|||
|
|
overlay.remove();
|
|||
|
|
document.body.classList.remove("lock-scroll");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
footer.append(cancelBtn, confirmBtn);
|
|||
|
|
content.append(header, body, footer);
|
|||
|
|
overlay.append(content);
|
|||
|
|
document.body.append(overlay);
|
|||
|
|
document.body.classList.add("lock-scroll");
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiForm = function ({ fields = [], actions = [] } = {}) {
|
|||
|
|
const form = VOR.createEl("form", ["vor-form"]);
|
|||
|
|
|
|||
|
|
fields.forEach((f) => {
|
|||
|
|
const group = VOR.createEl("div", ["form-group"]);
|
|||
|
|
|
|||
|
|
if (f.label) {
|
|||
|
|
group.append(VOR.createEl("label", null, f.label));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const input = VOR.createInput({
|
|||
|
|
type: f.type || "text",
|
|||
|
|
name: f.name,
|
|||
|
|
value: f.value ?? null,
|
|||
|
|
options: f.options ?? null,
|
|||
|
|
classArr: ["input"],
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
group.append(input);
|
|||
|
|
form.append(group);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (actions.length) {
|
|||
|
|
const actionWrap = VOR.createEl("div", ["form-actions"]);
|
|||
|
|
|
|||
|
|
actions.forEach((a) => {
|
|||
|
|
const btn = VOR.createEl(
|
|||
|
|
"button",
|
|||
|
|
["btn", a.variant ? `is-${a.variant}` : null].filter(Boolean),
|
|||
|
|
a.label,
|
|||
|
|
{ type: a.type || "button" }
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
if (a.onClick) btn.addEventListener("click", a.onClick);
|
|||
|
|
actionWrap.append(btn);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
form.append(actionWrap);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return form;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiDropdown = function ({ trigger, items = [] } = {}) {
|
|||
|
|
const menu = VOR.createEl("div", ["vor-dropdown"]);
|
|||
|
|
|
|||
|
|
items.forEach((i) => {
|
|||
|
|
const item = VOR.createEl("div", ["dropdown-item"], i.label);
|
|||
|
|
|
|||
|
|
item.addEventListener("click", () => {
|
|||
|
|
if (typeof i.action === "function") i.action();
|
|||
|
|
menu.remove();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
menu.append(item);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.body.append(menu);
|
|||
|
|
|
|||
|
|
const rect = trigger.getBoundingClientRect();
|
|||
|
|
menu.style.position = "absolute";
|
|||
|
|
menu.style.top = `${rect.bottom + window.scrollY}px`;
|
|||
|
|
menu.style.left = `${rect.left + window.scrollX}px`;
|
|||
|
|
|
|||
|
|
document.addEventListener("click", () => menu.remove(), { once: true });
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiPagination = function ({ page = 1, totalPages = 1, onChange = null } = {}) {
|
|||
|
|
const wrap = VOR.createEl("div", ["vor-pagination"]);
|
|||
|
|
|
|||
|
|
for (let i = 1; i <= totalPages; i++) {
|
|||
|
|
const btn = VOR.createEl(
|
|||
|
|
"button",
|
|||
|
|
["page-btn", i === page ? "is-active" : null].filter(Boolean),
|
|||
|
|
i
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
btn.addEventListener("click", () => {
|
|||
|
|
if (typeof onChange === "function") onChange(i);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
wrap.append(btn);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return wrap;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiBadge = function (text, type = "neutral") {
|
|||
|
|
return VOR.createEl("span", ["badge", `is-${type}`], text);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
UI: Loading / Field Errors / Empty State
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.uiLoader = function (el, state = true) {
|
|||
|
|
if (!el) return;
|
|||
|
|
|
|||
|
|
if (state) {
|
|||
|
|
el.dataset.originalText = el.textContent;
|
|||
|
|
el.textContent = "Loading...";
|
|||
|
|
el.disabled = true;
|
|||
|
|
} else {
|
|||
|
|
el.textContent = el.dataset.originalText || "";
|
|||
|
|
el.disabled = false;
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiFieldError = function (formEl, errors = {}) {
|
|||
|
|
if (!formEl) return;
|
|||
|
|
|
|||
|
|
formEl.querySelectorAll(".field-error").forEach((e) => e.remove());
|
|||
|
|
|
|||
|
|
Object.entries(errors).forEach(([name, msg]) => {
|
|||
|
|
const field = formEl.querySelector(`[name="${name}"]`);
|
|||
|
|
if (!field) return;
|
|||
|
|
|
|||
|
|
const err = VOR.createEl("div", ["field-error"], msg);
|
|||
|
|
field.closest(".form-group")?.append(err);
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiEmpty = function ({ title = "", text = "", action = null } = {}) {
|
|||
|
|
const wrap = VOR.createEl("div", ["vor-empty"]);
|
|||
|
|
|
|||
|
|
if (title) wrap.append(VOR.createEl("h3", null, title));
|
|||
|
|
if (text) wrap.append(VOR.createEl("p", null, text));
|
|||
|
|
|
|||
|
|
if (action) {
|
|||
|
|
const btn = VOR.createEl("button", ["btn"], action.label);
|
|||
|
|
btn.addEventListener("click", action.onClick);
|
|||
|
|
wrap.append(btn);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return wrap;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
DOM Helpers
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.createEl = function (tag, classArr = null, text = null, attrs = null) {
|
|||
|
|
const el = document.createElement(tag);
|
|||
|
|
|
|||
|
|
if (text !== null && text !== undefined) {
|
|||
|
|
el.append(document.createTextNode(String(text)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (classArr?.length) {
|
|||
|
|
el.classList.add(...classArr);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (attrs) {
|
|||
|
|
for (const [k, v] of Object.entries(attrs)) {
|
|||
|
|
if (v === null || v === undefined) continue;
|
|||
|
|
|
|||
|
|
if (k.startsWith("data-")) {
|
|||
|
|
el.setAttribute(k, String(v));
|
|||
|
|
} else if (k.startsWith("on") && typeof v === "function") {
|
|||
|
|
el.addEventListener(k.slice(2), v);
|
|||
|
|
} else if (k in el) {
|
|||
|
|
el[k] = v;
|
|||
|
|
} else {
|
|||
|
|
el.setAttribute(k, String(v));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return el;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.createInput = function ({
|
|||
|
|
type = "text",
|
|||
|
|
classArr = null,
|
|||
|
|
placeholder = "",
|
|||
|
|
options = null,
|
|||
|
|
name,
|
|||
|
|
id = null,
|
|||
|
|
dataset = null,
|
|||
|
|
value = null,
|
|||
|
|
} = {}) {
|
|||
|
|
if (!name) {
|
|||
|
|
throw new Error("Create input: Name is required");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const el =
|
|||
|
|
type === "select" ? VOR.createEl("select", classArr) : VOR.createEl("input", classArr);
|
|||
|
|
|
|||
|
|
el.name = name;
|
|||
|
|
|
|||
|
|
if (id) el.id = id;
|
|||
|
|
|
|||
|
|
if (dataset) {
|
|||
|
|
for (const [k, v] of Object.entries(dataset)) {
|
|||
|
|
if (v !== undefined && v !== null) el.dataset[k] = String(v);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (type !== "select") {
|
|||
|
|
el.type = type;
|
|||
|
|
|
|||
|
|
if (placeholder && !["checkbox", "radio", "hidden"].includes(type)) {
|
|||
|
|
el.placeholder = placeholder;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (["checkbox", "radio"].includes(type)) {
|
|||
|
|
if (value === true) {
|
|||
|
|
el.checked = true;
|
|||
|
|
} else if (value !== null && value !== undefined) {
|
|||
|
|
el.value = String(value);
|
|||
|
|
}
|
|||
|
|
} else if (value !== null && value !== undefined) {
|
|||
|
|
el.value = String(value);
|
|||
|
|
}
|
|||
|
|
} else if (options) {
|
|||
|
|
for (const [val, txt] of Object.entries(options)) {
|
|||
|
|
const opt = document.createElement("option");
|
|||
|
|
opt.value = String(val);
|
|||
|
|
opt.textContent = String(txt);
|
|||
|
|
|
|||
|
|
if (value !== null && value !== undefined && String(val) === String(value)) {
|
|||
|
|
opt.selected = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
el.appendChild(opt);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return el;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.clearHtml = function (el) {
|
|||
|
|
if (!el) return el;
|
|||
|
|
el.replaceChildren();
|
|||
|
|
return el;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.getDefaultId = function () {
|
|||
|
|
const id = window.location.pathname.replace(/\/$/, "").split("/").pop();
|
|||
|
|
const num = parseInt(id, 10);
|
|||
|
|
return Number.isFinite(num) ? num : null;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.getRandomId = function () {
|
|||
|
|
return crypto?.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.prettyNum = function (
|
|||
|
|
val,
|
|||
|
|
{ type = "decimal", locale = VOR.locale, currency = VOR.currency } = {}
|
|||
|
|
) {
|
|||
|
|
if (val === null || val === undefined || val === "") return "";
|
|||
|
|
|
|||
|
|
const opts = { style: type };
|
|||
|
|
if (type === "currency") opts.currency = currency;
|
|||
|
|
|
|||
|
|
const n = typeof val === "string" ? Number(val) : val;
|
|||
|
|
return new Intl.NumberFormat(locale, opts).format(n);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
Theme / Modals / Toggles
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.uiGetTheme = function () {
|
|||
|
|
const t = document.documentElement.getAttribute("data-theme");
|
|||
|
|
return t === "dark" || t === "light" ? t : "light";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiSetTheme = function (theme = "light") {
|
|||
|
|
const next = theme === "dark" ? "dark" : "light";
|
|||
|
|
document.documentElement.setAttribute("data-theme", next);
|
|||
|
|
localStorage.setItem("vor_theme", next);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiToggleTheme = function () {
|
|||
|
|
const next = VOR.uiGetTheme() === "dark" ? "light" : "dark";
|
|||
|
|
VOR.uiSetTheme(next);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiCloseAllModals = function () {
|
|||
|
|
document.querySelectorAll(".modal-overlay.is-active").forEach((m) => {
|
|||
|
|
m.classList.remove("is-active");
|
|||
|
|
m.setAttribute("aria-hidden", "true");
|
|||
|
|
});
|
|||
|
|
document.body.classList.remove("lock-scroll");
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiToggle = function (selector, forceState = null) {
|
|||
|
|
const el = document.querySelector(selector);
|
|||
|
|
if (!el) return;
|
|||
|
|
|
|||
|
|
const isDrawer = el.classList.contains("drawer");
|
|||
|
|
const isModal = el.classList.contains("modal-overlay");
|
|||
|
|
|
|||
|
|
const activeClass = isDrawer ? "is-open" : "is-active";
|
|||
|
|
|
|||
|
|
const nextState = forceState !== null ? !!forceState : !el.classList.contains(activeClass);
|
|||
|
|
|
|||
|
|
el.classList.toggle(activeClass, nextState);
|
|||
|
|
el.setAttribute("aria-hidden", nextState ? "false" : "true");
|
|||
|
|
|
|||
|
|
if (isModal) {
|
|||
|
|
document.body.classList.toggle("lock-scroll", nextState);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
Global Event Wiring
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.initGlobalEvents = function () {
|
|||
|
|
const app = document.body;
|
|||
|
|
|
|||
|
|
app.addEventListener("click", (e) => {
|
|||
|
|
const toggleBtn = e.target.closest("[data-toggle]");
|
|||
|
|
if (toggleBtn) {
|
|||
|
|
const sel = toggleBtn.dataset.toggle;
|
|||
|
|
if (sel) VOR.uiToggle(sel);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const themeBtn = e.target.closest("[data-theme-toggle]");
|
|||
|
|
if (themeBtn) {
|
|||
|
|
const v = (themeBtn.dataset.themeToggle || "").trim();
|
|||
|
|
if (v === "light" || v === "dark") {
|
|||
|
|
VOR.uiSetTheme(v);
|
|||
|
|
} else {
|
|||
|
|
VOR.uiToggleTheme();
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const accordionHeader = e.target.closest(".accordion-header");
|
|||
|
|
if (accordionHeader) {
|
|||
|
|
const item = accordionHeader.closest(".accordion-item");
|
|||
|
|
if (item) item.classList.toggle("is-active");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const chip = e.target.closest("[data-chip]");
|
|||
|
|
if (chip) {
|
|||
|
|
const scope = chip.parentElement || document;
|
|||
|
|
scope
|
|||
|
|
.querySelectorAll("[data-chip].is-active")
|
|||
|
|
.forEach((c) => c.classList.remove("is-active"));
|
|||
|
|
chip.classList.add("is-active");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const modalOverlay = e.target.closest(".modal-overlay");
|
|||
|
|
if (modalOverlay && modalOverlay.classList.contains("is-active")) {
|
|||
|
|
if (e.target.closest(".modal-content")) return;
|
|||
|
|
|
|||
|
|
modalOverlay.classList.remove("is-active");
|
|||
|
|
modalOverlay.setAttribute("aria-hidden", "true");
|
|||
|
|
document.body.classList.remove("lock-scroll");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const drawerModal = e.target.closest(".drawer-modal");
|
|||
|
|
if (drawerModal && drawerModal.classList.contains("is-active")) {
|
|||
|
|
if (e.target.closest(".drawer-panel")) return;
|
|||
|
|
if (drawerModal.dataset.closeBackdrop === "0") return;
|
|||
|
|
|
|||
|
|
VOR.uiDrawerModalClose(drawerModal);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
document.addEventListener("keydown", (e) => {
|
|||
|
|
if (e.key !== "Escape") return;
|
|||
|
|
|
|||
|
|
const openModal = document.querySelector(".modal-overlay.is-active");
|
|||
|
|
if (openModal) {
|
|||
|
|
openModal.classList.remove("is-active");
|
|||
|
|
openModal.setAttribute("aria-hidden", "true");
|
|||
|
|
if (!document.querySelector(".modal-overlay.is-active, .drawer-modal.is-active")) {
|
|||
|
|
document.body.classList.remove("lock-scroll");
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const openDrawerModal = document.querySelector(".drawer-modal.is-active");
|
|||
|
|
if (openDrawerModal) {
|
|||
|
|
if (openDrawerModal.dataset.closeEsc === "0") return;
|
|||
|
|
VOR.uiDrawerModalClose(openDrawerModal);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const openDrawer = document.querySelector(".drawer.is-open");
|
|||
|
|
if (openDrawer) {
|
|||
|
|
openDrawer.classList.remove("is-open");
|
|||
|
|
openDrawer.setAttribute("aria-hidden", "true");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
UI: Drawer Modal (off-canvas modal)
|
|||
|
|
============================================================ */
|
|||
|
|
VOR.uiDrawerModalOpen = function ({
|
|||
|
|
id = "vor-drawer-modal",
|
|||
|
|
title = "",
|
|||
|
|
content = null,
|
|||
|
|
side = "right",
|
|||
|
|
width = null,
|
|||
|
|
onClose = null,
|
|||
|
|
closeOnBackdrop = true,
|
|||
|
|
closeOnEsc = true,
|
|||
|
|
showHeader = true,
|
|||
|
|
showFooter = false,
|
|||
|
|
footer = null,
|
|||
|
|
} = {}) {
|
|||
|
|
|
|||
|
|
const existing = document.getElementById(id);
|
|||
|
|
if (existing) existing.remove();
|
|||
|
|
|
|||
|
|
const overlay = VOR.createEl("div", ["drawer-modal"], null, {
|
|||
|
|
id,
|
|||
|
|
"data-modal-type": "drawer",
|
|||
|
|
"data-close-backdrop": closeOnBackdrop ? "1" : "0",
|
|||
|
|
"data-close-esc": closeOnEsc ? "1" : "0",
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (side === "left") overlay.classList.add("is-left");
|
|||
|
|
|
|||
|
|
const panel = VOR.createEl("div", ["drawer-panel"]);
|
|||
|
|
if (width) panel.style.width = String(width);
|
|||
|
|
|
|||
|
|
if (showHeader) {
|
|||
|
|
const header = VOR.createEl("div", ["drawer-panel-header"]);
|
|||
|
|
|
|||
|
|
const h = VOR.createEl("div");
|
|||
|
|
if (title) h.append(VOR.createEl("div", ["h5"], title));
|
|||
|
|
|
|||
|
|
const closeBtn = VOR.createEl("button", ["btn-close"], "×", {
|
|||
|
|
type: "button",
|
|||
|
|
"aria-label": "Close",
|
|||
|
|
"data-drawer-close": "1",
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
header.append(h, closeBtn);
|
|||
|
|
panel.append(header);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const body = VOR.createEl("div", ["drawer-panel-body"]);
|
|||
|
|
|
|||
|
|
if (content instanceof HTMLElement) {
|
|||
|
|
body.append(content);
|
|||
|
|
} else if (typeof content === "string") {
|
|||
|
|
body.innerHTML = content;
|
|||
|
|
} else {
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
panel.append(body);
|
|||
|
|
|
|||
|
|
if (showFooter) {
|
|||
|
|
const foot = VOR.createEl("div", ["drawer-panel-footer"]);
|
|||
|
|
if (footer instanceof HTMLElement) foot.append(footer);
|
|||
|
|
else if (typeof footer === "string") foot.innerHTML = footer;
|
|||
|
|
panel.append(foot);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
overlay.append(panel);
|
|||
|
|
document.body.append(overlay);
|
|||
|
|
document.body.classList.add("lock-scroll");
|
|||
|
|
|
|||
|
|
requestAnimationFrame(() => {
|
|||
|
|
overlay.classList.add("is-active");
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
overlay.addEventListener("click", (e) => {
|
|||
|
|
if (e.target.closest("[data-drawer-close]")) {
|
|||
|
|
VOR.uiDrawerModalClose(id, onClose);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
overlay.__vorOnClose = typeof onClose === "function" ? onClose : null;
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
const focusable = panel.querySelector(
|
|||
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|||
|
|
);
|
|||
|
|
focusable?.focus?.();
|
|||
|
|
}, 0);
|
|||
|
|
|
|||
|
|
return { overlay, panel, body };
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
VOR.uiDrawerModalClose = function (id = "vor-drawer-modal", onClose = null) {
|
|||
|
|
const overlay = typeof id === "string" ? document.getElementById(id) : id;
|
|||
|
|
if (!overlay) return;
|
|||
|
|
|
|||
|
|
|
|||
|
|
overlay.classList.remove("is-active");
|
|||
|
|
|
|||
|
|
const cb = typeof onClose === "function" ? onClose : overlay.__vorOnClose;
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
overlay.remove();
|
|||
|
|
if (!document.querySelector(".modal-overlay.is-active, .drawer-modal.is-active")) {
|
|||
|
|
document.body.classList.remove("lock-scroll");
|
|||
|
|
}
|
|||
|
|
if (typeof cb === "function") cb();
|
|||
|
|
}, 180);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/* ============================================================
|
|||
|
|
Boot
|
|||
|
|
============================================================ */
|
|||
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|||
|
|
const savedTheme = localStorage.getItem("vor_theme");
|
|||
|
|
VOR.uiSetTheme(savedTheme === "dark" ? "dark" : "light");
|
|||
|
|
VOR.initGlobalEvents();
|
|||
|
|
});
|