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();
|
||
}); |