From 31a8b755d9182c06246f9b8ffcb270078d4816d7 Mon Sep 17 00:00:00 2001 From: giles Date: Wed, 4 Mar 2026 11:18:31 +0000 Subject: [PATCH] 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 --- shared/static/scripts/sx.js | 235 +++++++++++++++++++++++++++++++++++- sx/content/pages.py | 156 ++++++++++++++++++++++-- sx/sxc/reference.sx | 145 ++++++++++++++++++++++ sx/sxc/sx_components.py | 10 +- 4 files changed, 525 insertions(+), 21 deletions(-) diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js index 9d48f2d..358d714 100644 --- a/shared/static/scripts/sx.js +++ b/shared/static/scripts/sx.js @@ -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 ------------------------------------------------------- diff --git a/sx/content/pages.py b/sx/content/pages.py index ba1bdd3..52b3198 100644 --- a/sx/content/pages.py +++ b/sx/content/pages.py @@ -117,20 +117,17 @@ BEHAVIOR_ATTRS = [ SX_UNIQUE_ATTRS = [ ("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-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 # --------------------------------------------------------------------------- @@ -719,4 +716,143 @@ ATTR_DETAILS: dict[str, dict] = { ' :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:" (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' + ' "")' + ), + }, } diff --git a/sx/sxc/reference.sx b/sx/sxc/reference.sx index fa4ba6e..c7f1eac 100644 --- a/sx/sxc/reference.sx +++ b/sx/sxc/reference.sx @@ -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))" :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."))) + +;; --------------------------------------------------------------------------- +;; 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."))) diff --git a/sx/sxc/sx_components.py b/sx/sxc/sx_components.py index 80ab704..bd85682 100644 --- a/sx/sxc/sx_components.py +++ b/sx/sxc/sx_components.py @@ -579,21 +579,19 @@ def _reference_attr_detail_sx(slug: str) -> 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) beh = await _attr_table_sx("Behavior Attributes", BEHAVIOR_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 ( f'(~doc-page :title "Attribute Reference"' f' (p :class "text-stone-600 mb-6"' - f' "sx attributes mirror htmx where possible. This table shows what exists, ' - f'what\'s unique to sx, and what\'s not yet implemented.")' + f' "sx attributes mirror htmx where possible. This table shows all ' + f'available attributes and their status.")' f' (div :class "space-y-8"' f' {req}' f' {beh}' - f' {uniq}' - f' {missing}))' + f' {uniq}))' )