Files
wordpress-theme-aksrt/assets/js/core/modal-runtime.js
T
2026-08-05 02:04:55 +08:00

429 lines
15 KiB
JavaScript

/**
* Canonical frontend modal runtime.
*
* Owns the modal shell while adding cancellable loaders,
* short in-memory caching, request deduplication, retryable errors, focus
* restoration, and modal-scoped rehydration.
*
* @package AKSRT
*/
(function (window, document) {
"use strict";
var ROOT_ID = "aksrt-modal-runtime-root";
var active = null;
var sequence = 0;
var loaders = Object.create(null);
function isMobile() {
return window.matchMedia && window.matchMedia("(max-width: 768px)").matches;
}
function ensureRoot() {
var root = document.getElementById(ROOT_ID);
if (root) return root;
root = document.createElement("div");
root.id = ROOT_ID;
root.className = "aksrt-modal-runtime-host";
document.body.appendChild(root);
return root;
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function lockScroll(lock) {
document.documentElement.classList.toggle("aksrt-modal-runtime-open", !!lock);
document.body.classList.toggle("aksrt-modal-runtime-open", !!lock);
}
function skeleton(options) {
return (options && options.skeleton) ||
'<div class="aksrt-modal-runtime__skeleton" aria-live="polite" aria-busy="true">' +
'<span class="aksrt-modal-runtime__skeleton-line is-wide"></span>' +
'<span class="aksrt-modal-runtime__skeleton-line"></span>' +
'<span class="aksrt-modal-runtime__skeleton-line is-short"></span>' +
'<span class="aksrt-modal-runtime__skeleton-block"></span>' +
'</div>';
}
function telemetryStart(kind) {
if (kind === false) return "";
return window.AksrtModalTelemetry.start(kind || "generic");
}
function telemetryMark(session, event) {
if (!session || !session.telemetryId) return;
window.AksrtModalTelemetry.mark(session.telemetryId, event);
}
function setState(session, status, error) {
session.state.status = status;
session.state.error = error || null;
session.state.updatedAt = Date.now();
if (session.shell) session.shell.setAttribute("data-aksrt-modal-state", status);
if (typeof session.options.onState === "function") {
session.options.onState(status, session);
}
}
function focusable(shell) {
return shell.querySelectorAll("button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])");
}
function closeSelector() {
return "[data-aksrt-modal-close], .aksrt-modal-runtime__close";
}
function bindChrome(session) {
var shell = session.shell;
shell.addEventListener("click", function (event) {
var closeButton = event.target.closest(closeSelector());
if (closeButton) {
event.preventDefault();
close("control");
return;
}
if (event.target === shell || event.target.classList.contains("aksrt-modal-runtime__overlay")) {
close("overlay");
return;
}
var retry = event.target.closest("[data-aksrt-modal-retry]");
if (retry) {
event.preventDefault();
refresh();
}
});
shell.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
event.preventDefault();
close("escape");
return;
}
if (event.key !== "Tab") return;
var nodes = focusable(shell);
if (!nodes.length) {
event.preventDefault();
shell.focus();
return;
}
var first = nodes[0];
var last = nodes[nodes.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
});
}
function createSession(options) {
options = options || {};
var root = ensureRoot();
var shell = document.createElement("div");
var bottom = options.bottom === true || (options.mobileBottom !== false && isMobile());
var heightStyle = options.height ? "min-height:" + parseInt(options.height, 10) + "px;" : "";
var initialHtml = options.html !== undefined ? options.html : skeleton(options);
var titleId = "aksrt-modal-title-" + String(sequence + 1);
var titleHtml = options.title
? '<header class="aksrt-modal-runtime__header"><h3 class="aksrt-modal-runtime__title" id="' + titleId + '">' + escapeHtml(options.title) + '</h3><button type="button" class="aksrt-modal-runtime__close" data-aksrt-modal-close aria-label="关闭">&times;</button></header>'
: "";
shell.className =
"aksrt-modal-runtime" +
(bottom ? " aksrt-modal-runtime--bottom" : "") +
(options.className ? " " + options.className : "");
shell.setAttribute("role", "dialog");
shell.setAttribute("aria-modal", "true");
if (options.title) shell.setAttribute("aria-labelledby", titleId);
shell.setAttribute("tabindex", "-1");
shell.innerHTML =
'<div class="aksrt-modal-runtime__overlay" data-aksrt-modal-close></div>' +
'<div class="aksrt-modal-runtime__dialog" style="' + heightStyle + '">' +
titleHtml +
'<div class="aksrt-modal-runtime__content">' + initialHtml + "</div></div>";
var session = {
id: String(++sequence),
shell: shell,
content: shell.querySelector(".aksrt-modal-runtime__content"),
options: options,
state: window.AksrtModalState.create(options.kind || "generic"),
focusReturn: document.activeElement,
controller: null,
flight: null,
closed: false,
telemetryId: telemetryStart(options.telemetry === false ? false : options.kind),
};
root.appendChild(shell);
bindChrome(session);
active = session;
lockScroll(true);
void shell.offsetWidth;
shell.classList.add("is-open");
setState(session, options.html !== undefined ? "ready" : "loading");
telemetryMark(session, "shell");
var nodes = focusable(shell);
try {
(nodes.length ? nodes[0] : shell).focus();
} catch (error) {}
document.dispatchEvent(new CustomEvent("aksrt:modal:open", {
detail: { shell: shell, options: options, session: session },
}));
if (options.html !== undefined) {
rehydrate(session);
telemetryMark(session, "first-content");
telemetryMark(session, "ready");
if (typeof options.onContent === "function") options.onContent(options.html, session);
}
return session;
}
function rehydrate(session) {
if (!session || !session.content) return;
window.AksrtFrontend.rehydrate(session.content, "modal");
}
function abortSessionRequest(session) {
if (!session) return;
if (session.flight && typeof session.flight.release === "function") session.flight.release();
session.flight = null;
if (session.controller) {
try { session.controller.abort(); } catch (error) {}
}
session.controller = null;
}
function close(reason, preserveRequest) {
if (!active) return;
var session = active;
active = null;
session.closed = true;
if (!preserveRequest) abortSessionRequest(session);
session.shell.classList.remove("is-open");
if (!preserveRequest) lockScroll(false);
document.dispatchEvent(new CustomEvent("aksrt:modal:close", {
detail: { shell: session.shell, reason: reason || "close", session: session },
}));
var node = session.shell;
window.setTimeout(function () {
if (node && node.parentNode) node.parentNode.removeChild(node);
var target = session.focusReturn;
if (!preserveRequest && !active && target && target !== node && document.contains(target) && typeof target.focus === "function") {
try { target.focus(); } catch (error) {}
}
}, 220);
}
function responseData(result) {
if (!result) return null;
if (result.data !== undefined && result.data !== null) return result.data;
if (result.json && result.json.data !== undefined) return result.json.data;
return result.json;
}
function responseHtml(result) {
var data = responseData(result);
if (data && typeof data.html === "string") return data.html;
if (result && result.json && typeof result.json.html === "string") return result.json.html;
return result && typeof result.text === "string" ? result.text : "";
}
function errorMessage(error) {
if (error && error.code === "unauthorized") return "请先登录";
return error && error.message ? String(error.message) : "加载失败,请重试";
}
function renderError(session, error) {
if (!session || session.closed || active !== session) return;
var unauthorized = error && error.code === "unauthorized";
setState(session, unauthorized ? "unauthorized" : "error", error);
telemetryMark(session, unauthorized ? "unauthorized" : "error");
session.content.innerHTML =
'<div class="aksrt-modal-runtime__error touch border-title"><b>提示</b></div>' +
'<div class="aksrt-modal-runtime__error-body">' + escapeHtml(errorMessage(error)) +
'</div><div class="but-average modal-buts">' +
'<button type="button" class="but" data-aksrt-modal-retry>重试</button>' +
'<button type="button" class="but" data-aksrt-modal-close>关闭</button></div>';
rehydrate(session);
if (typeof session.options.onError === "function") session.options.onError(error, session);
}
function renderResult(session, result) {
if (!session || session.closed || active !== session) return;
if (session.options.responseType === "json" || typeof session.options.renderData === "function") {
var data = responseData(result);
if (data === null || data === undefined) {
setState(session, "empty");
telemetryMark(session, "first-content");
telemetryMark(session, "ready");
session.content.innerHTML = '<div class="aksrt-modal-runtime__error-body">暂无内容</div>';
return;
}
if (typeof session.options.renderData === "function") {
session.options.renderData(data, session, result);
}
setState(session, "ready");
telemetryMark(session, "first-content");
rehydrate(session);
telemetryMark(session, "ready");
if (typeof session.options.onData === "function") session.options.onData(data, session, result);
return;
}
var html = responseHtml(result);
if (!html) {
setState(session, "empty");
telemetryMark(session, "first-content");
telemetryMark(session, "ready");
session.content.innerHTML = '<div class="aksrt-modal-runtime__error-body">暂无内容</div>';
return;
}
session.content.innerHTML = html;
setState(session, "ready");
telemetryMark(session, "first-content");
rehydrate(session);
telemetryMark(session, "ready");
if (typeof session.options.onContent === "function") session.options.onContent(html, session);
}
function load(session, loadOptions) {
loadOptions = loadOptions || {};
if (!session || session.closed || !session.options.url) return Promise.resolve(session && session.shell);
abortSessionRequest(session);
setState(session, "loading");
session.content.innerHTML = skeleton(session.options);
session.controller = window.AbortController ? new window.AbortController() : null;
var requestOptions = {
method: session.options.method || "GET",
headers: session.options.headers || {},
body: session.options.body,
query: session.options.query,
signal: session.controller ? session.controller.signal : undefined,
};
if (requestOptions.body === undefined) delete requestOptions.body;
if (!requestOptions.signal) delete requestOptions.signal;
var request = function () {
return window.AksrtRequest.request(session.options.url, requestOptions);
};
var method = String(session.options.method || "GET").toUpperCase();
var cacheable = method === "GET" && session.options.cache !== false;
var cacheKey = session.options.cacheKey ||
method + ":" + window.AksrtRequest.url(session.options.url, session.options.query);
var cache = window.AksrtModalCache;
var acquireOptions = {
ttl: Number(session.options.cacheTtl || 15000),
tags: session.options.cacheTags || session.options.tags || [],
skipCache: !!loadOptions.force || !cacheable,
store: cacheable,
cancel: function () {
if (session.controller) {
try { session.controller.abort(); } catch (error) {}
}
},
};
session.flight = cacheable
? cache.acquire(cacheKey, request, acquireOptions)
: { promise: request(), release: function () {} };
return session.flight.promise.then(function (result) {
if (!session.closed && active === session) renderResult(session, result);
return session.shell;
}).catch(function (error) {
var aborted = error && (error.name === "AbortError" || error.code === "ABORT_ERR");
if (!aborted && !session.closed && active === session) renderError(session, error);
return session.shell;
}).then(function (shell) {
if (session.flight && typeof session.flight.release === "function") session.flight.release();
session.flight = null;
return shell;
});
}
function open(options) {
options = options || {};
close("replace");
var session = createSession(options);
return session.shell;
}
function sessionKey(options) {
if (!options || !options.url) return "";
var method = String(options.method || "GET").toUpperCase();
var requestUrl = window.AksrtRequest.url(options.url, options.query);
return method + ":" + requestUrl;
}
function openRemote(options) {
options = Object.assign({}, options || {});
if (!options.url) return Promise.reject(new Error("missing url"));
var sameRequest = active && active.flight && sessionKey(active.options) === sessionKey(options);
close("replace", !!sameRequest);
var session = createSession(options);
return load(session).then(function () { return session.shell; });
}
function refresh(options) {
if (!active || !active.options.url) return Promise.resolve(null);
if (options && typeof options === "object") active.options = Object.assign({}, active.options, options);
return load(active, { force: true });
}
function registerLoader(kind, loader) {
if (!kind || typeof loader !== "function") return;
loaders[String(kind)] = loader;
}
function openLoader(kind, context) {
var loader = loaders[String(kind)];
if (!loader) return Promise.reject(new Error("missing modal loader: " + kind));
return openRemote(loader(context || {}));
}
function invalidate(tags) {
return window.AksrtModalCache.invalidate(tags);
}
function rolloutEnabled(kind) {
var config = window.aksrtData || window.aksrt || {};
var rollout = config.frontendModalRollout;
if (!rollout || typeof rollout[kind] === "undefined") return true;
return rollout[kind] === true;
}
window.AksrtModalRuntime = {
open: open,
openRemote: openRemote,
close: close,
refresh: refresh,
registerLoader: registerLoader,
openLoader: openLoader,
invalidate: invalidate,
isRolloutEnabled: rolloutEnabled,
getActive: function () { return active ? active.shell : null; },
getState: function () { return active ? active.state : null; },
};
document.dispatchEvent(new CustomEvent("aksrt:modal-runtime:ready"));
})(window, document);