diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 358d714..d919f2b 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -1379,6 +1379,16 @@ var PROCESSED = "_sxBound"; var VERBS = ["get", "post", "put", "delete", "patch"]; var DEFAULT_SWAP = "outerHTML"; + var _config = { globalViewTransitions: false }; + + /** Wrap a function in View Transition API if supported and enabled. */ + function _withTransition(enabled, fn) { + if (enabled && document.startViewTransition) { + document.startViewTransition(fn); + } else { + fn(); + } + } function dispatch(el, name, detail) { @@ -1386,6 +1396,26 @@ return el.dispatchEvent(evt); } + /** Parse and dispatch SX-Trigger header events. + * Value can be: "myEvent" (plain string), '{"myEvent": {"key": "val"}}' (JSON). */ + function _dispatchTriggerEvents(el, headerVal) { + if (!headerVal) return; + try { + var parsed = JSON.parse(headerVal); + if (typeof parsed === "object" && parsed !== null) { + for (var evtName in parsed) dispatch(el, evtName, parsed[evtName]); + } else { + dispatch(el, String(parsed), {}); + } + } catch (e) { + // Plain string — may be comma-separated event names + headerVal.split(",").forEach(function (name) { + var n = name.trim(); + if (n) dispatch(el, n, {}); + }); + } + } + function csrfToken() { var m = document.querySelector('meta[name="csrf-token"]'); return m ? m.getAttribute("content") : null; @@ -1458,6 +1488,15 @@ if (!window.confirm(confirmMsg)) return Promise.resolve(); } + // sx-prompt: show prompt dialog, send result as SX-Prompt header + var promptMsg = el.getAttribute("sx-prompt"); + if (promptMsg) { + var promptVal = window.prompt(promptMsg); + if (promptVal === null) return Promise.resolve(); // cancelled + extraParams = extraParams || {}; + extraParams.promptValue = promptVal; + } + return _doFetch(el, method, url, extraParams); } @@ -1496,6 +1535,11 @@ } catch (e) { /* ignore */ } } + // SX-Prompt header from sx-prompt dialog result + if (extraParams && extraParams.promptValue !== undefined) { + headers["SX-Prompt"] = extraParams.promptValue; + } + // CSRF for same-origin mutating requests if (method !== "GET" && sameOrigin(url)) { var csrf = csrfToken(); @@ -1529,6 +1573,24 @@ } } + // sx-params: filter form parameters + var paramsSpec = el.getAttribute("sx-params"); + if (paramsSpec && body instanceof URLSearchParams) { + if (paramsSpec === "none") { + body = new URLSearchParams(); + } else if (paramsSpec.indexOf("not ") === 0) { + var excluded = paramsSpec.substring(4).split(",").map(function (s) { return s.trim(); }); + excluded.forEach(function (k) { body.delete(k); }); + } else if (paramsSpec !== "*") { + var allowed = paramsSpec.split(",").map(function (s) { return s.trim(); }); + var filtered = new URLSearchParams(); + allowed.forEach(function (k) { + body.getAll(k).forEach(function (v) { filtered.append(k, v); }); + }); + body = filtered; + } + } + // Include extra inputs var includeSel = el.getAttribute("sx-include"); if (includeSel && method !== "GET") { @@ -1587,6 +1649,11 @@ indicatorEl.style.display = ""; } + // sx-disabled-elt: disable elements during request + var disabledEltSel = el.getAttribute("sx-disabled-elt"); + var disabledElts = disabledEltSel ? Array.prototype.slice.call(document.querySelectorAll(disabledEltSel)) : []; + disabledElts.forEach(function (e) { e.disabled = true; }); + var fetchOpts = { method: method, headers: headers, signal: ctrl.signal }; // Cross-origin credentials for known subdomains try { @@ -1608,6 +1675,7 @@ el.classList.remove("sx-request"); el.removeAttribute("aria-busy"); if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; } + disabledElts.forEach(function (e) { e.disabled = false; }); if (!resp.ok) { dispatch(el, "sx:responseError", { response: resp, status: resp.status }); @@ -1617,11 +1685,42 @@ return resp.text().then(function (text) { dispatch(el, "sx:afterRequest", { response: resp }); + // --- Response header processing --- + + // SX-Redirect: navigate away (skip swap entirely) + var hdrRedirect = resp.headers.get("SX-Redirect"); + if (hdrRedirect) { location.assign(hdrRedirect); return; } + + // SX-Refresh: reload page (skip swap entirely) + var hdrRefresh = resp.headers.get("SX-Refresh"); + if (hdrRefresh === "true") { location.reload(); return; } + + // SX-Trigger: dispatch custom events on target + var hdrTrigger = resp.headers.get("SX-Trigger"); + if (hdrTrigger) _dispatchTriggerEvents(el, hdrTrigger); + // Process the response - var swapStyle = el.getAttribute("sx-swap") || DEFAULT_SWAP; + var rawSwap = el.getAttribute("sx-swap") || DEFAULT_SWAP; var target = resolveTarget(el, null); var selectSel = el.getAttribute("sx-select"); + // SX-Retarget: server overrides target + var hdrRetarget = resp.headers.get("SX-Retarget"); + if (hdrRetarget) target = document.querySelector(hdrRetarget) || target; + + // SX-Reswap: server overrides swap strategy + var hdrReswap = resp.headers.get("SX-Reswap"); + if (hdrReswap) rawSwap = hdrReswap; + + // Parse swap style and modifiers (e.g. "innerHTML transition:true") + var swapParts = rawSwap.split(/\s+/); + var swapStyle = swapParts[0]; + var useTransition = _config.globalViewTransitions; + for (var sp = 1; sp < swapParts.length; sp++) { + if (swapParts[sp] === "transition:true") useTransition = true; + else if (swapParts[sp] === "transition:false") useTransition = false; + } + // Check for text/sx content type — use direct DOM rendering path var ct = resp.headers.get("Content-Type") || ""; if (ct.indexOf("text/sx") >= 0) { @@ -1663,8 +1762,10 @@ // Main swap using DOM morph if (swapStyle !== "none" && target) { - _swapDOM(target, selectedDOM, swapStyle); - _hoistHeadElements(target); + _withTransition(useTransition, function () { + _swapDOM(target, selectedDOM, swapStyle); + _hoistHeadElements(target); + }); } } catch (err) { console.error("sx.js render error [v2]:", err, "\nsxSource first 500:", sxSource ? sxSource.substring(0, 500) : "(empty)"); @@ -1697,34 +1798,67 @@ // Main swap if (swapStyle !== "none" && target) { - _swapContent(target, content, swapStyle); - _hoistHeadElements(target); + _withTransition(useTransition, function () { + _swapContent(target, content, swapStyle); + _hoistHeadElements(target); + }); } } - // History + // SX-Location: server-driven client-side navigation + var hdrLocation = resp.headers.get("SX-Location"); + if (hdrLocation) { + var locUrl = hdrLocation; + try { var locObj = JSON.parse(hdrLocation); locUrl = locObj.path || locObj; } catch (e) {} + fetch(locUrl, { headers: { "SX-Request": "true" } }).then(function (r) { + return r.text().then(function (t) { + var main = document.getElementById("main-panel"); + if (main) { _swapContent(main, t, "innerHTML"); _postSwap(main); } + try { history.pushState({ sxUrl: locUrl }, "", locUrl); } catch (e) {} + }); + }); + return; + } + + // History: sx-push-url (pushState) and sx-replace-url (replaceState) var pushUrl = el.getAttribute("sx-push-url"); - if (pushUrl === "true" || (pushUrl && pushUrl !== "false")) { + var replaceUrl = el.getAttribute("sx-replace-url"); + // SX-Replace-Url response header overrides client-side attribute + var hdrReplaceUrl = resp.headers.get("SX-Replace-Url"); + if (hdrReplaceUrl) { + try { history.replaceState({ sxUrl: hdrReplaceUrl, scrollY: window.scrollY }, "", hdrReplaceUrl); } catch (e) {} + } else if (pushUrl === "true" || (pushUrl && pushUrl !== "false")) { var pushTarget = pushUrl === "true" ? url : pushUrl; try { history.pushState({ sxUrl: pushTarget, scrollY: window.scrollY }, "", pushTarget); } catch (e) { - // Cross-origin pushState not allowed — full navigation location.assign(pushTarget); return; } + } else if (replaceUrl === "true" || (replaceUrl && replaceUrl !== "false")) { + var replTarget = replaceUrl === "true" ? url : replaceUrl; + try { + history.replaceState({ sxUrl: replTarget, scrollY: window.scrollY }, "", replTarget); + } catch (e) { /* ignore */ } } dispatch(el, "sx:afterSwap", { target: target }); + // SX-Trigger-After-Swap + var hdrTriggerSwap = resp.headers.get("SX-Trigger-After-Swap"); + if (hdrTriggerSwap) _dispatchTriggerEvents(el, hdrTriggerSwap); // Settle tick requestAnimationFrame(function () { dispatch(el, "sx:afterSettle", { target: target }); + // SX-Trigger-After-Settle + var hdrTriggerSettle = resp.headers.get("SX-Trigger-After-Settle"); + if (hdrTriggerSettle) _dispatchTriggerEvents(el, hdrTriggerSettle); }); }); }).catch(function (err) { el.classList.remove("sx-request"); el.removeAttribute("aria-busy"); if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; } + disabledElts.forEach(function (e) { e.disabled = false; }); if (err.name === "AbortError") return; dispatch(el, "sx:sendError", { error: err }); return _handleRetry(el, verbInfo, extraParams); @@ -2012,6 +2146,14 @@ // ---- Trigger system --------------------------------------------------- + function _parseTime(s) { + // Parse time string: "2s" → 2000, "500ms" → 500, "1.5s" → 1500 + if (!s) return 0; + if (s.indexOf("ms") >= 0) return parseInt(s, 10); + if (s.indexOf("s") >= 0) return parseFloat(s) * 1000; + return parseInt(s, 10); + } + function parseTrigger(spec) { if (!spec) return null; var triggers = []; @@ -2020,12 +2162,17 @@ var p = parts[i].trim(); if (!p) continue; var tokens = p.split(/\s+/); + // Handle "every