Implement 7 missing sx attributes: boost, preload, preserve, indicator, validate, ignore, optimistic
Add sx-preserve/sx-ignore (morph skip), sx-indicator (loading element), sx-validate (form validation), sx-boost (progressive enhancement), sx-preload (hover prefetch with 30s cache), and sx-optimistic (instant UI preview with rollback). Move all from HTMX_MISSING_ATTRS to SX_UNIQUE_ATTRS with full ATTR_DETAILS docs and reference.sx demos. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1579,6 +1579,14 @@
|
||||
el.classList.add("sx-request");
|
||||
el.setAttribute("aria-busy", "true");
|
||||
|
||||
// sx-indicator: show indicator element
|
||||
var indicatorSel = el.getAttribute("sx-indicator");
|
||||
var indicatorEl = indicatorSel ? (document.querySelector(indicatorSel) || el.closest(indicatorSel)) : null;
|
||||
if (indicatorEl) {
|
||||
indicatorEl.classList.add("sx-request");
|
||||
indicatorEl.style.display = "";
|
||||
}
|
||||
|
||||
var fetchOpts = { method: method, headers: headers, signal: ctrl.signal };
|
||||
// Cross-origin credentials for known subdomains
|
||||
try {
|
||||
@@ -1590,9 +1598,16 @@
|
||||
} catch (e) {}
|
||||
if (body && method !== "GET") fetchOpts.body = body;
|
||||
|
||||
return fetch(url, fetchOpts).then(function (resp) {
|
||||
// sx-preload: use cached response if available
|
||||
var preloaded = method === "GET" ? _getPreloaded(url) : null;
|
||||
var fetchPromise = preloaded
|
||||
? Promise.resolve({ ok: true, status: 200, headers: new Headers({ "Content-Type": preloaded.contentType }), text: function () { return Promise.resolve(preloaded.text); }, _preloaded: true })
|
||||
: fetch(url, fetchOpts);
|
||||
|
||||
return fetchPromise.then(function (resp) {
|
||||
el.classList.remove("sx-request");
|
||||
el.removeAttribute("aria-busy");
|
||||
if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; }
|
||||
|
||||
if (!resp.ok) {
|
||||
dispatch(el, "sx:responseError", { response: resp, status: resp.status });
|
||||
@@ -1709,6 +1724,7 @@
|
||||
}).catch(function (err) {
|
||||
el.classList.remove("sx-request");
|
||||
el.removeAttribute("aria-busy");
|
||||
if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; }
|
||||
if (err.name === "AbortError") return;
|
||||
dispatch(el, "sx:sendError", { error: err });
|
||||
return _handleRetry(el, verbInfo, extraParams);
|
||||
@@ -1723,6 +1739,9 @@
|
||||
* keyed (id) elements.
|
||||
*/
|
||||
function _morphDOM(oldNode, newNode) {
|
||||
// sx-preserve / sx-ignore: skip morphing entirely
|
||||
if (oldNode.hasAttribute && (oldNode.hasAttribute("sx-preserve") || oldNode.hasAttribute("sx-ignore"))) return;
|
||||
|
||||
// Different node types or tag names → replace wholesale
|
||||
if (oldNode.nodeType !== newNode.nodeType ||
|
||||
oldNode.nodeName !== newNode.nodeName) {
|
||||
@@ -1805,10 +1824,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Remove leftover old children
|
||||
// Remove leftover old children (skip sx-preserve / sx-ignore)
|
||||
while (oi < oldChildren.length) {
|
||||
var leftover = oldChildren[oi];
|
||||
if (leftover.parentNode === oldParent) oldParent.removeChild(leftover);
|
||||
if (leftover.parentNode === oldParent &&
|
||||
!(leftover.hasAttribute && (leftover.hasAttribute("sx-preserve") || leftover.hasAttribute("sx-ignore")))) {
|
||||
oldParent.removeChild(leftover);
|
||||
}
|
||||
oi++;
|
||||
}
|
||||
}
|
||||
@@ -1923,7 +1945,18 @@
|
||||
function _swapContent(target, html, strategy) {
|
||||
switch (strategy) {
|
||||
case "innerHTML":
|
||||
// Detach sx-preserve elements, swap, then re-attach
|
||||
var preserved = [];
|
||||
target.querySelectorAll("[sx-preserve][id]").forEach(function (el) {
|
||||
preserved.push({ id: el.id, node: el });
|
||||
el.parentNode.removeChild(el);
|
||||
});
|
||||
target.innerHTML = html;
|
||||
preserved.forEach(function (p) {
|
||||
var placeholder = target.querySelector("#" + CSS.escape(p.id));
|
||||
if (placeholder) placeholder.parentNode.replaceChild(p.node, placeholder);
|
||||
else target.appendChild(p.node);
|
||||
});
|
||||
break;
|
||||
case "outerHTML":
|
||||
var tgt = target;
|
||||
@@ -2043,17 +2076,49 @@
|
||||
// For links, prevent navigation
|
||||
if (eventName === "click" && el.tagName === "A") e.preventDefault();
|
||||
|
||||
// sx-validate: run validation before request
|
||||
var validateAttr = el.getAttribute("sx-validate");
|
||||
if (validateAttr === null) {
|
||||
var vForm = el.closest("[sx-validate]");
|
||||
if (vForm) validateAttr = vForm.getAttribute("sx-validate");
|
||||
}
|
||||
if (validateAttr !== null) {
|
||||
var formToValidate = el.tagName === "FORM" ? el : el.closest("form");
|
||||
if (formToValidate && !formToValidate.reportValidity()) {
|
||||
dispatch(el, "sx:validationFailed", {});
|
||||
return;
|
||||
}
|
||||
// Custom validator function
|
||||
if (validateAttr && validateAttr !== "true" && validateAttr !== "") {
|
||||
var validatorFn = window[validateAttr];
|
||||
if (typeof validatorFn === "function" && !validatorFn(el)) {
|
||||
dispatch(el, "sx:validationFailed", {});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// changed modifier: only fire if value changed
|
||||
if (mods.changed && el.value !== undefined) {
|
||||
if (el.value === lastVal) return;
|
||||
lastVal = el.value;
|
||||
}
|
||||
|
||||
// sx-optimistic: apply preview before request
|
||||
var optimisticState = _applyOptimistic(el);
|
||||
|
||||
var _execAndReconcile = function () {
|
||||
var p = executeRequest(el, verbInfo);
|
||||
if (optimisticState && p && p.catch) {
|
||||
p.catch(function () { _revertOptimistic(optimisticState); });
|
||||
}
|
||||
};
|
||||
|
||||
if (mods.delay) {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () { executeRequest(el, verbInfo); }, mods.delay);
|
||||
timer = setTimeout(_execAndReconcile, mods.delay);
|
||||
} else {
|
||||
executeRequest(el, verbInfo);
|
||||
_execAndReconcile();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2173,6 +2238,162 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sx-optimistic ----------------------------------------------------
|
||||
|
||||
function _applyOptimistic(el) {
|
||||
var directive = el.getAttribute("sx-optimistic");
|
||||
if (!directive) return null;
|
||||
var target = resolveTarget(el, null) || el;
|
||||
var state = { target: target, directive: directive };
|
||||
|
||||
if (directive === "remove") {
|
||||
state.display = target.style.display;
|
||||
state.opacity = target.style.opacity;
|
||||
target.style.opacity = "0";
|
||||
target.style.pointerEvents = "none";
|
||||
} else if (directive === "disable") {
|
||||
state.disabled = target.disabled;
|
||||
target.disabled = true;
|
||||
} else if (directive.indexOf("add-class:") === 0) {
|
||||
var cls = directive.substring(10);
|
||||
state.addClass = cls;
|
||||
target.classList.add(cls);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function _revertOptimistic(state) {
|
||||
if (!state) return;
|
||||
var target = state.target;
|
||||
if (state.directive === "remove") {
|
||||
target.style.opacity = state.opacity || "";
|
||||
target.style.pointerEvents = "";
|
||||
} else if (state.directive === "disable") {
|
||||
target.disabled = state.disabled || false;
|
||||
} else if (state.addClass) {
|
||||
target.classList.remove(state.addClass);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- sx-preload -------------------------------------------------------
|
||||
|
||||
var _preloadCache = {};
|
||||
var _PRELOAD_TTL = 30000; // 30 seconds
|
||||
|
||||
function _bindPreload(el) {
|
||||
if (!el.hasAttribute("sx-preload")) return;
|
||||
var mode = el.getAttribute("sx-preload") || "mousedown";
|
||||
var events = mode === "mouseover" ? ["mouseenter", "focusin"] : ["mousedown", "focusin"];
|
||||
var debounceTimer = null;
|
||||
var debounceMs = mode === "mouseover" ? 100 : 0;
|
||||
|
||||
events.forEach(function (evt) {
|
||||
el.addEventListener(evt, function () {
|
||||
var verb = getVerb(el);
|
||||
if (!verb) return;
|
||||
var url = verb.url;
|
||||
var cached = _preloadCache[url];
|
||||
if (cached && (Date.now() - cached.timestamp < _PRELOAD_TTL)) return; // already cached
|
||||
|
||||
if (debounceMs) {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(function () { _doPreload(url); }, debounceMs);
|
||||
} else {
|
||||
_doPreload(url);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _doPreload(url) {
|
||||
var headers = { "SX-Request": "true", "SX-Current-URL": location.href };
|
||||
var cssH = _getSxCssHeader();
|
||||
if (cssH) headers["SX-Css"] = cssH;
|
||||
var loadedN = Object.keys(_componentEnv).filter(function (k) { return k.charAt(0) === "~"; });
|
||||
if (loadedN.length) headers["SX-Components"] = loadedN.join(",");
|
||||
|
||||
fetch(url, { headers: headers }).then(function (resp) {
|
||||
if (!resp.ok) return;
|
||||
var ct = resp.headers.get("Content-Type") || "";
|
||||
return resp.text().then(function (text) {
|
||||
_preloadCache[url] = { text: text, contentType: ct, timestamp: Date.now() };
|
||||
});
|
||||
}).catch(function () { /* ignore preload errors */ });
|
||||
}
|
||||
|
||||
function _getPreloaded(url) {
|
||||
var cached = _preloadCache[url];
|
||||
if (!cached) return null;
|
||||
if (Date.now() - cached.timestamp > _PRELOAD_TTL) {
|
||||
delete _preloadCache[url];
|
||||
return null;
|
||||
}
|
||||
delete _preloadCache[url]; // consume once
|
||||
return cached;
|
||||
}
|
||||
|
||||
// ---- sx-boost ---------------------------------------------------------
|
||||
|
||||
function _processBoosted(root) {
|
||||
var boostContainers = root.querySelectorAll("[sx-boost]");
|
||||
if (root.matches && root.matches("[sx-boost]")) {
|
||||
_boostDescendants(root);
|
||||
}
|
||||
for (var i = 0; i < boostContainers.length; i++) {
|
||||
_boostDescendants(boostContainers[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function _boostDescendants(container) {
|
||||
// Boost links
|
||||
var links = container.querySelectorAll("a[href]");
|
||||
for (var i = 0; i < links.length; i++) {
|
||||
var link = links[i];
|
||||
if (link[PROCESSED] || link[PROCESSED + "boost"]) continue;
|
||||
var href = link.getAttribute("href");
|
||||
// Skip anchors, external, javascript:, mailto:, already sx-processed
|
||||
if (!href || href.charAt(0) === "#" || href.indexOf("javascript:") === 0 ||
|
||||
href.indexOf("mailto:") === 0 || !sameOrigin(href) ||
|
||||
link.hasAttribute("sx-get") || link.hasAttribute("sx-post") ||
|
||||
link.hasAttribute("sx-disable")) continue;
|
||||
link[PROCESSED + "boost"] = true;
|
||||
(function (el, url) {
|
||||
el.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
executeRequest(el, { method: "GET", url: url }).then(function () {
|
||||
try { history.pushState({ sxUrl: url, scrollY: window.scrollY }, "", url); } catch (err) {}
|
||||
});
|
||||
});
|
||||
})(link, href);
|
||||
// Default target for boosted links
|
||||
if (!link.hasAttribute("sx-target")) link.setAttribute("sx-target", "#main-panel");
|
||||
if (!link.hasAttribute("sx-swap")) link.setAttribute("sx-swap", "innerHTML");
|
||||
if (!link.hasAttribute("sx-select")) link.setAttribute("sx-select", "#main-panel");
|
||||
}
|
||||
|
||||
// Boost forms
|
||||
var forms = container.querySelectorAll("form");
|
||||
for (var j = 0; j < forms.length; j++) {
|
||||
var form = forms[j];
|
||||
if (form[PROCESSED] || form[PROCESSED + "boost"]) continue;
|
||||
if (form.hasAttribute("sx-get") || form.hasAttribute("sx-post") ||
|
||||
form.hasAttribute("sx-disable")) continue;
|
||||
form[PROCESSED + "boost"] = true;
|
||||
(function (el) {
|
||||
var method = (el.getAttribute("method") || "GET").toUpperCase();
|
||||
var action = el.getAttribute("action") || location.href;
|
||||
el.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
executeRequest(el, { method: method, url: action }).then(function () {
|
||||
try { history.pushState({ sxUrl: action, scrollY: window.scrollY }, "", action); } catch (err) {}
|
||||
});
|
||||
});
|
||||
})(form);
|
||||
if (!form.hasAttribute("sx-target")) form.setAttribute("sx-target", "#main-panel");
|
||||
if (!form.hasAttribute("sx-swap")) form.setAttribute("sx-swap", "innerHTML");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Process function -------------------------------------------------
|
||||
|
||||
function process(root) {
|
||||
@@ -2191,6 +2412,9 @@
|
||||
_processOne(elements[i]);
|
||||
}
|
||||
|
||||
// Process sx-boost containers
|
||||
_processBoosted(root);
|
||||
|
||||
// Bind sx-on:* handlers on all elements
|
||||
var allOnEls = root.querySelectorAll("[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]");
|
||||
allOnEls.forEach(function (el) {
|
||||
@@ -2210,6 +2434,7 @@
|
||||
if (!verbInfo) return;
|
||||
|
||||
bindTriggers(el, verbInfo);
|
||||
_bindPreload(el);
|
||||
}
|
||||
|
||||
// ---- Public API -------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user