Implement 7 missing sx attributes: boost, preload, preserve, indicator, validate, ignore, optimistic
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
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.classList.add("sx-request");
|
||||||
el.setAttribute("aria-busy", "true");
|
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 };
|
var fetchOpts = { method: method, headers: headers, signal: ctrl.signal };
|
||||||
// Cross-origin credentials for known subdomains
|
// Cross-origin credentials for known subdomains
|
||||||
try {
|
try {
|
||||||
@@ -1590,9 +1598,16 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
if (body && method !== "GET") fetchOpts.body = body;
|
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.classList.remove("sx-request");
|
||||||
el.removeAttribute("aria-busy");
|
el.removeAttribute("aria-busy");
|
||||||
|
if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; }
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
dispatch(el, "sx:responseError", { response: resp, status: resp.status });
|
dispatch(el, "sx:responseError", { response: resp, status: resp.status });
|
||||||
@@ -1709,6 +1724,7 @@
|
|||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
el.classList.remove("sx-request");
|
el.classList.remove("sx-request");
|
||||||
el.removeAttribute("aria-busy");
|
el.removeAttribute("aria-busy");
|
||||||
|
if (indicatorEl) { indicatorEl.classList.remove("sx-request"); indicatorEl.style.display = "none"; }
|
||||||
if (err.name === "AbortError") return;
|
if (err.name === "AbortError") return;
|
||||||
dispatch(el, "sx:sendError", { error: err });
|
dispatch(el, "sx:sendError", { error: err });
|
||||||
return _handleRetry(el, verbInfo, extraParams);
|
return _handleRetry(el, verbInfo, extraParams);
|
||||||
@@ -1723,6 +1739,9 @@
|
|||||||
* keyed (id) elements.
|
* keyed (id) elements.
|
||||||
*/
|
*/
|
||||||
function _morphDOM(oldNode, newNode) {
|
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
|
// Different node types or tag names → replace wholesale
|
||||||
if (oldNode.nodeType !== newNode.nodeType ||
|
if (oldNode.nodeType !== newNode.nodeType ||
|
||||||
oldNode.nodeName !== newNode.nodeName) {
|
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) {
|
while (oi < oldChildren.length) {
|
||||||
var leftover = oldChildren[oi];
|
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++;
|
oi++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1923,7 +1945,18 @@
|
|||||||
function _swapContent(target, html, strategy) {
|
function _swapContent(target, html, strategy) {
|
||||||
switch (strategy) {
|
switch (strategy) {
|
||||||
case "innerHTML":
|
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;
|
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;
|
break;
|
||||||
case "outerHTML":
|
case "outerHTML":
|
||||||
var tgt = target;
|
var tgt = target;
|
||||||
@@ -2043,17 +2076,49 @@
|
|||||||
// For links, prevent navigation
|
// For links, prevent navigation
|
||||||
if (eventName === "click" && el.tagName === "A") e.preventDefault();
|
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
|
// changed modifier: only fire if value changed
|
||||||
if (mods.changed && el.value !== undefined) {
|
if (mods.changed && el.value !== undefined) {
|
||||||
if (el.value === lastVal) return;
|
if (el.value === lastVal) return;
|
||||||
lastVal = el.value;
|
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) {
|
if (mods.delay) {
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(function () { executeRequest(el, verbInfo); }, mods.delay);
|
timer = setTimeout(_execAndReconcile, mods.delay);
|
||||||
} else {
|
} 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 -------------------------------------------------
|
// ---- Process function -------------------------------------------------
|
||||||
|
|
||||||
function process(root) {
|
function process(root) {
|
||||||
@@ -2191,6 +2412,9 @@
|
|||||||
_processOne(elements[i]);
|
_processOne(elements[i]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process sx-boost containers
|
||||||
|
_processBoosted(root);
|
||||||
|
|
||||||
// Bind sx-on:* handlers on all elements
|
// 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]");
|
var allOnEls = root.querySelectorAll("[sx-on\\:beforeRequest],[sx-on\\:afterRequest],[sx-on\\:afterSwap],[sx-on\\:afterSettle],[sx-on\\:responseError]");
|
||||||
allOnEls.forEach(function (el) {
|
allOnEls.forEach(function (el) {
|
||||||
@@ -2210,6 +2434,7 @@
|
|||||||
if (!verbInfo) return;
|
if (!verbInfo) return;
|
||||||
|
|
||||||
bindTriggers(el, verbInfo);
|
bindTriggers(el, verbInfo);
|
||||||
|
_bindPreload(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Public API -------------------------------------------------------
|
// ---- Public API -------------------------------------------------------
|
||||||
|
|||||||
@@ -117,20 +117,17 @@ BEHAVIOR_ATTRS = [
|
|||||||
|
|
||||||
SX_UNIQUE_ATTRS = [
|
SX_UNIQUE_ATTRS = [
|
||||||
("sx-retry", "Exponential backoff retry on request failure", True),
|
("sx-retry", "Exponential backoff retry on request failure", True),
|
||||||
|
("sx-boost", "Progressively enhance all links and forms in a container with AJAX navigation", True),
|
||||||
|
("sx-preload", "Preload content on hover/focus for instant response on click", True),
|
||||||
|
("sx-preserve", "Preserve element across swaps — keeps DOM state, event listeners, and scroll position", True),
|
||||||
|
("sx-indicator", "CSS selector for a loading indicator element to show/hide during requests", True),
|
||||||
|
("sx-validate", "Run browser constraint validation (or custom validator) before sending the request", True),
|
||||||
|
("sx-ignore", "Ignore element and its subtree during morph/swap — no updates applied", True),
|
||||||
|
("sx-optimistic", "Apply optimistic UI updates immediately, reconcile on server response", True),
|
||||||
("data-sx", "Client-side rendering — evaluate the sx source in this attribute and render into the element", True),
|
("data-sx", "Client-side rendering — evaluate the sx source in this attribute and render into the element", True),
|
||||||
("data-sx-env", "Provide environment variables as JSON for data-sx rendering", True),
|
("data-sx-env", "Provide environment variables as JSON for data-sx rendering", True),
|
||||||
]
|
]
|
||||||
|
|
||||||
HTMX_MISSING_ATTRS = [
|
|
||||||
("hx-boost", "Progressively enhance links and forms (not yet implemented)", False),
|
|
||||||
("hx-preload", "Preload content on hover/focus (not yet implemented)", False),
|
|
||||||
("hx-preserve", "Preserve element across swaps (not yet implemented)", False),
|
|
||||||
("hx-optimistic", "Optimistic UI updates (not yet implemented)", False),
|
|
||||||
("hx-indicator", "sx uses .sx-request CSS class instead — no dedicated attribute (not yet implemented)", False),
|
|
||||||
("hx-validate", "Custom validation (not yet implemented — sx has sx-disable)", False),
|
|
||||||
("hx-ignore", "Ignore element (not yet implemented — sx has sx-disable)", False),
|
|
||||||
]
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Reference: Headers
|
# Reference: Headers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -719,4 +716,143 @@ ATTR_DETAILS: dict[str, dict] = {
|
|||||||
' :data-sx-env \'{"title": "Dynamic", "message": "From env"}\')'
|
' :data-sx-env \'{"title": "Dynamic", "message": "From env"}\')'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
# --- New attributes ---
|
||||||
|
"sx-boost": {
|
||||||
|
"description": (
|
||||||
|
"Progressively enhance all descendant links and forms with AJAX navigation. "
|
||||||
|
"Links become sx-get requests with pushState, forms become sx-post/sx-get requests. "
|
||||||
|
"No explicit sx-* attributes needed on each link or form — just place sx-boost on a container."
|
||||||
|
),
|
||||||
|
"demo": "ref-boost-demo",
|
||||||
|
"example": (
|
||||||
|
'(nav :sx-boost "true"\n'
|
||||||
|
' (a :href "/docs/introduction" "Introduction")\n'
|
||||||
|
' (a :href "/docs/components" "Components")\n'
|
||||||
|
' (a :href "/docs/evaluator" "Evaluator"))'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"sx-preload": {
|
||||||
|
"description": (
|
||||||
|
"Preload the response in the background when the user hovers over or focuses "
|
||||||
|
"an element with sx-get. When they click, the cached response is used instantly "
|
||||||
|
"instead of making a new request. Cache entries expire after 30 seconds. "
|
||||||
|
'Values: "mousedown" (default, preloads on mousedown) or '
|
||||||
|
'"mouseover" (preloads earlier on hover with 100ms debounce).'
|
||||||
|
),
|
||||||
|
"demo": "ref-preload-demo",
|
||||||
|
"example": (
|
||||||
|
'(button :sx-get "/reference/api/time"\n'
|
||||||
|
' :sx-target "#ref-preload-result"\n'
|
||||||
|
' :sx-swap "innerHTML"\n'
|
||||||
|
' :sx-preload "mouseover"\n'
|
||||||
|
' "Hover then click (preloaded)")'
|
||||||
|
),
|
||||||
|
"handler": (
|
||||||
|
'(defhandler ref-preload-time (&key)\n'
|
||||||
|
' (let ((now (format-time (now) "%H:%M:%S.%f")))\n'
|
||||||
|
' (span :class "text-stone-800 text-sm"\n'
|
||||||
|
' "Preloaded at: " (strong now))))'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"sx-preserve": {
|
||||||
|
"description": (
|
||||||
|
"Preserve an element across morph/swap operations. The element must have an id. "
|
||||||
|
"During morphing, the element is kept in place with its full DOM state intact — "
|
||||||
|
"event listeners, scroll position, video playback, user input, and any other state "
|
||||||
|
"are preserved. The incoming version of the element is discarded."
|
||||||
|
),
|
||||||
|
"demo": "ref-preserve-demo",
|
||||||
|
"example": (
|
||||||
|
'(div :id "my-player" :sx-preserve "true"\n'
|
||||||
|
' (video :src "/media/clip.mp4" :controls "true"\n'
|
||||||
|
' "Video playback is preserved across swaps."))'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"sx-indicator": {
|
||||||
|
"description": (
|
||||||
|
"Specifies a CSS selector for a loading indicator element. "
|
||||||
|
"The indicator receives the .sx-request class during the request, "
|
||||||
|
"and the class is removed when the request completes (success or error). "
|
||||||
|
"Use CSS to show/hide the indicator based on the .sx-request class."
|
||||||
|
),
|
||||||
|
"demo": "ref-indicator-demo",
|
||||||
|
"example": (
|
||||||
|
'(button :sx-get "/reference/api/slow-echo"\n'
|
||||||
|
' :sx-target "#ref-indicator-result"\n'
|
||||||
|
' :sx-swap "innerHTML"\n'
|
||||||
|
' :sx-indicator "#ref-spinner"\n'
|
||||||
|
' "Load (slow)")\n'
|
||||||
|
'\n'
|
||||||
|
'(span :id "ref-spinner"\n'
|
||||||
|
' :class "hidden sx-request:inline text-violet-600 text-sm"\n'
|
||||||
|
' "Loading...")'
|
||||||
|
),
|
||||||
|
"handler": (
|
||||||
|
'(defhandler ref-indicator-slow (&key)\n'
|
||||||
|
' (sleep 1500)\n'
|
||||||
|
' (let ((now (format-time (now) "%H:%M:%S")))\n'
|
||||||
|
' (span "Loaded at " (strong now))))'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"sx-validate": {
|
||||||
|
"description": (
|
||||||
|
"Run browser constraint validation before sending the request. "
|
||||||
|
"If validation fails, the request is not sent and an sx:validationFailed "
|
||||||
|
"event is dispatched. Works with standard HTML5 validation attributes "
|
||||||
|
'(required, pattern, minlength, etc). Set to "true" for built-in validation, '
|
||||||
|
"or provide a function name for custom validation."
|
||||||
|
),
|
||||||
|
"demo": "ref-validate-demo",
|
||||||
|
"example": (
|
||||||
|
'(form :sx-post "/reference/api/greet"\n'
|
||||||
|
' :sx-target "#ref-validate-result"\n'
|
||||||
|
' :sx-swap "innerHTML"\n'
|
||||||
|
' :sx-validate "true"\n'
|
||||||
|
' (input :type "email" :name "email"\n'
|
||||||
|
' :required "true"\n'
|
||||||
|
' :placeholder "Enter email (required)")\n'
|
||||||
|
' (button :type "submit" "Submit"))'
|
||||||
|
),
|
||||||
|
"handler": (
|
||||||
|
'(defhandler ref-validate-greet (&key)\n'
|
||||||
|
' (let ((email (or (form-data "email") "none")))\n'
|
||||||
|
' (span "Validated: " (strong email))))'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"sx-ignore": {
|
||||||
|
"description": (
|
||||||
|
"During morph/swap, this element and its subtree are completely skipped — "
|
||||||
|
"no attribute updates, no child reconciliation, no removal. "
|
||||||
|
"Unlike sx-preserve (which requires an id and preserves by identity), "
|
||||||
|
"sx-ignore works positionally and means 'don\\'t touch this subtree at all.'"
|
||||||
|
),
|
||||||
|
"demo": "ref-ignore-demo",
|
||||||
|
"example": (
|
||||||
|
'(div :sx-ignore "true"\n'
|
||||||
|
' (p "This content is never updated by morph/swap.")\n'
|
||||||
|
' (input :type "text" :placeholder "Type here — preserved"))'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"sx-optimistic": {
|
||||||
|
"description": (
|
||||||
|
"Apply a client-side preview of the expected result immediately, "
|
||||||
|
"then reconcile when the server responds. On error, the original state "
|
||||||
|
'is restored. Values: "remove" (hide the target), '
|
||||||
|
'"add-class:<name>" (add a CSS class), "disable" (disable the element).'
|
||||||
|
),
|
||||||
|
"demo": "ref-optimistic-demo",
|
||||||
|
"example": (
|
||||||
|
'(button :sx-delete "/reference/api/item/opt1"\n'
|
||||||
|
' :sx-target "#ref-opt-item"\n'
|
||||||
|
' :sx-swap "delete"\n'
|
||||||
|
' :sx-optimistic "remove"\n'
|
||||||
|
' "Delete (optimistic)")'
|
||||||
|
),
|
||||||
|
"handler": (
|
||||||
|
'(defhandler ref-optimistic-delete (&key)\n'
|
||||||
|
' (sleep 800)\n'
|
||||||
|
' "")'
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -406,3 +406,148 @@
|
|||||||
(div :data-sx "(div :class \"p-3 bg-emerald-50 rounded\" (h3 :class \"font-semibold text-emerald-800\" title) (p :class \"text-sm text-stone-600\" message))"
|
(div :data-sx "(div :class \"p-3 bg-emerald-50 rounded\" (h3 :class \"font-semibold text-emerald-800\" title) (p :class \"text-sm text-stone-600\" message))"
|
||||||
:data-sx-env "{\"title\": \"Dynamic content\", \"message\": \"Variables passed via data-sx-env are available in the expression.\"}")
|
:data-sx-env "{\"title\": \"Dynamic content\", \"message\": \"Variables passed via data-sx-env are available in the expression.\"}")
|
||||||
(p :class "text-xs text-stone-400" "The title and message above come from the data-sx-env JSON.")))
|
(p :class "text-xs text-stone-400" "The title and message above come from the data-sx-env JSON.")))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; sx-boost
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~ref-boost-demo ()
|
||||||
|
(div :class "space-y-3"
|
||||||
|
(nav :sx-boost "true" :class "flex gap-3"
|
||||||
|
(a :href "/reference/attributes/sx-get"
|
||||||
|
:class "text-violet-600 hover:text-violet-800 underline text-sm"
|
||||||
|
"sx-get")
|
||||||
|
(a :href "/reference/attributes/sx-post"
|
||||||
|
:class "text-violet-600 hover:text-violet-800 underline text-sm"
|
||||||
|
"sx-post")
|
||||||
|
(a :href "/reference/attributes/sx-target"
|
||||||
|
:class "text-violet-600 hover:text-violet-800 underline text-sm"
|
||||||
|
"sx-target"))
|
||||||
|
(p :class "text-xs text-stone-400"
|
||||||
|
"These links use AJAX navigation via sx-boost — no sx-get needed on each link.")))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; sx-preload
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~ref-preload-demo ()
|
||||||
|
(div :class "space-y-3"
|
||||||
|
(button
|
||||||
|
:sx-get "/reference/api/time"
|
||||||
|
:sx-target "#ref-preload-result"
|
||||||
|
:sx-swap "innerHTML"
|
||||||
|
:sx-preload "mouseover"
|
||||||
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
|
"Hover then click (preloaded)")
|
||||||
|
(div :id "ref-preload-result"
|
||||||
|
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||||
|
"Hover over the button first, then click — the response is instant.")))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; sx-preserve
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~ref-preserve-demo ()
|
||||||
|
(div :class "space-y-3"
|
||||||
|
(div :class "flex gap-2 items-center"
|
||||||
|
(button
|
||||||
|
:sx-get "/reference/api/time"
|
||||||
|
:sx-target "#ref-preserve-container"
|
||||||
|
:sx-swap "innerHTML"
|
||||||
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
|
"Swap container")
|
||||||
|
(span :class "text-xs text-stone-400" "The input below keeps its value across swaps."))
|
||||||
|
(div :id "ref-preserve-container" :class "space-y-2"
|
||||||
|
(input :id "ref-preserved-input" :sx-preserve "true"
|
||||||
|
:type "text" :placeholder "Type here — preserved across swaps"
|
||||||
|
:class "w-full px-3 py-2 border border-stone-300 rounded text-sm")
|
||||||
|
(div :class "p-2 bg-stone-50 rounded text-sm text-stone-600"
|
||||||
|
"This text will be replaced on swap."))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; sx-indicator
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~ref-indicator-demo ()
|
||||||
|
(div :class "space-y-3"
|
||||||
|
(div :class "flex gap-3 items-center"
|
||||||
|
(button
|
||||||
|
:sx-get "/reference/api/slow-echo"
|
||||||
|
:sx-target "#ref-indicator-result"
|
||||||
|
:sx-swap "innerHTML"
|
||||||
|
:sx-indicator "#ref-spinner"
|
||||||
|
:sx-vals "{\"q\": \"hello\"}"
|
||||||
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
|
"Load (slow)")
|
||||||
|
(span :id "ref-spinner"
|
||||||
|
:class "text-violet-600 text-sm"
|
||||||
|
:style "display: none"
|
||||||
|
"Loading..."))
|
||||||
|
(div :id "ref-indicator-result"
|
||||||
|
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||||
|
"Click to load (indicator shows during request).")))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; sx-validate
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~ref-validate-demo ()
|
||||||
|
(div :class "space-y-3"
|
||||||
|
(form
|
||||||
|
:sx-post "/reference/api/greet"
|
||||||
|
:sx-target "#ref-validate-result"
|
||||||
|
:sx-swap "innerHTML"
|
||||||
|
:sx-validate "true"
|
||||||
|
:class "flex gap-2"
|
||||||
|
(input :type "email" :name "name" :required "true"
|
||||||
|
:placeholder "Enter email (required)"
|
||||||
|
:class "flex-1 px-3 py-2 border border-stone-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-violet-500")
|
||||||
|
(button :type "submit"
|
||||||
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
|
"Submit"))
|
||||||
|
(div :id "ref-validate-result"
|
||||||
|
:class "p-3 rounded bg-stone-50 text-stone-400 text-sm"
|
||||||
|
"Submit with invalid/empty email to see validation.")))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; sx-ignore
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~ref-ignore-demo ()
|
||||||
|
(div :class "space-y-3"
|
||||||
|
(button
|
||||||
|
:sx-get "/reference/api/time"
|
||||||
|
:sx-target "#ref-ignore-container"
|
||||||
|
:sx-swap "innerHTML"
|
||||||
|
:class "px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700 transition-colors text-sm"
|
||||||
|
"Swap container")
|
||||||
|
(div :id "ref-ignore-container" :class "space-y-2"
|
||||||
|
(div :sx-ignore "true" :class "p-2 bg-amber-50 rounded border border-amber-200"
|
||||||
|
(p :class "text-sm text-amber-800" "This subtree has sx-ignore — it won't change.")
|
||||||
|
(input :type "text" :placeholder "Type here — ignored during swap"
|
||||||
|
:class "mt-1 w-full px-2 py-1 border border-amber-300 rounded text-sm"))
|
||||||
|
(div :class "p-2 bg-stone-50 rounded text-sm text-stone-600"
|
||||||
|
"This text WILL be replaced on swap."))))
|
||||||
|
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
;; sx-optimistic
|
||||||
|
;; ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defcomp ~ref-optimistic-demo ()
|
||||||
|
(div :class "space-y-2"
|
||||||
|
(div :id "ref-opt-item-1"
|
||||||
|
:class "flex items-center justify-between p-2 border border-stone-200 rounded"
|
||||||
|
(span :class "text-sm text-stone-700" "Optimistic item A")
|
||||||
|
(button :sx-delete "/reference/api/item/opt1"
|
||||||
|
:sx-target "#ref-opt-item-1" :sx-swap "delete"
|
||||||
|
:sx-optimistic "remove"
|
||||||
|
:class "text-red-500 text-sm hover:text-red-700" "Remove"))
|
||||||
|
(div :id "ref-opt-item-2"
|
||||||
|
:class "flex items-center justify-between p-2 border border-stone-200 rounded"
|
||||||
|
(span :class "text-sm text-stone-700" "Optimistic item B")
|
||||||
|
(button :sx-delete "/reference/api/item/opt2"
|
||||||
|
:sx-target "#ref-opt-item-2" :sx-swap "delete"
|
||||||
|
:sx-optimistic "remove"
|
||||||
|
:class "text-red-500 text-sm hover:text-red-700" "Remove"))
|
||||||
|
(p :class "text-xs text-stone-400"
|
||||||
|
"Items fade out immediately on click (optimistic), then are removed when the server responds.")))
|
||||||
|
|||||||
@@ -579,21 +579,19 @@ def _reference_attr_detail_sx(slug: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def _reference_attrs_sx() -> str:
|
async def _reference_attrs_sx() -> str:
|
||||||
from content.pages import REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS, HTMX_MISSING_ATTRS
|
from content.pages import REQUEST_ATTRS, BEHAVIOR_ATTRS, SX_UNIQUE_ATTRS
|
||||||
req = await _attr_table_sx("Request Attributes", REQUEST_ATTRS)
|
req = await _attr_table_sx("Request Attributes", REQUEST_ATTRS)
|
||||||
beh = await _attr_table_sx("Behavior Attributes", BEHAVIOR_ATTRS)
|
beh = await _attr_table_sx("Behavior Attributes", BEHAVIOR_ATTRS)
|
||||||
uniq = await _attr_table_sx("Unique to sx", SX_UNIQUE_ATTRS)
|
uniq = await _attr_table_sx("Unique to sx", SX_UNIQUE_ATTRS)
|
||||||
missing = await _attr_table_sx("htmx features not yet in sx", HTMX_MISSING_ATTRS)
|
|
||||||
return (
|
return (
|
||||||
f'(~doc-page :title "Attribute Reference"'
|
f'(~doc-page :title "Attribute Reference"'
|
||||||
f' (p :class "text-stone-600 mb-6"'
|
f' (p :class "text-stone-600 mb-6"'
|
||||||
f' "sx attributes mirror htmx where possible. This table shows what exists, '
|
f' "sx attributes mirror htmx where possible. This table shows all '
|
||||||
f'what\'s unique to sx, and what\'s not yet implemented.")'
|
f'available attributes and their status.")'
|
||||||
f' (div :class "space-y-8"'
|
f' (div :class "space-y-8"'
|
||||||
f' {req}'
|
f' {req}'
|
||||||
f' {beh}'
|
f' {beh}'
|
||||||
f' {uniq}'
|
f' {uniq}))'
|
||||||
f' {missing}))'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user